/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
import Model from '../../src/model/model';
import ModelElement from '../../src/model/element';
import ModelRange from '../../src/model/range';
import ModelPosition from '../../src/model/position';
import ViewDocument from '../../src/view/document';
import ViewContainerElement from '../../src/view/containerelement';
import ViewAttributeElement from '../../src/view/attributeelement';
import ViewUIElement from '../../src/view/uielement';
import { mergeAttributes } from '../../src/view/writer';
import Mapper from '../../src/conversion/mapper';
import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher';
import {
convertRangeSelection,
convertCollapsedSelection,
clearAttributes,
clearFakeSelection
} from '../../src/conversion/model-selection-to-view-converters';
import {
insertElement,
insertText,
wrap,
highlightElement,
highlightText,
removeHighlight
} from '../../src/conversion/model-to-view-converters';
import createViewRoot from '../view/_utils/createroot';
import { stringify as stringifyView } from '../../src/dev-utils/view';
import { setData as setModelData } from '../../src/dev-utils/model';
describe( 'model-selection-to-view-converters', () => {
let dispatcher, mapper, model, modelDoc, modelRoot, docSelection, viewDoc, viewRoot, viewSelection, highlightDescriptor;
beforeEach( () => {
model = new Model();
modelDoc = model.document;
modelRoot = modelDoc.createRoot();
docSelection = modelDoc.selection;
model.schema.extend( '$text', { allowIn: '$root' } );
viewDoc = new ViewDocument();
viewRoot = createViewRoot( viewDoc );
viewSelection = viewDoc.selection;
mapper = new Mapper();
mapper.bindElements( modelRoot, viewRoot );
highlightDescriptor = { class: 'marker', priority: 1 };
dispatcher = new ModelConversionDispatcher( model, { mapper, viewSelection } );
dispatcher.on( 'insert:$text', insertText() );
dispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
// Default selection converters.
dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
dispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } );
dispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
} );
afterEach( () => {
viewDoc.destroy();
} );
describe( 'default converters', () => {
describe( 'range selection', () => {
it( 'in same container', () => {
test(
[ 1, 4 ],
'foobar',
'f{oob}ar'
);
} );
it( 'in same container with unicode characters', () => {
test(
[ 2, 6 ],
'நிலைக்கு',
'நி{லைக்}கு'
);
} );
it( 'in same container, over attribute', () => {
test(
[ 1, 5 ],
'fo<$text bold="true">ob$text>ar',
'f{ooba}r'
);
} );
it( 'in same container, next to attribute', () => {
test(
[ 1, 2 ],
'fo<$text bold="true">ob$text>ar',
'f{o}obar'
);
} );
it( 'in same attribute', () => {
test(
[ 2, 4 ],
'f<$text bold="true">ooba$text>r',
'fo{ob}ar'
);
} );
it( 'in same attribute, selection same as attribute', () => {
test(
[ 2, 4 ],
'fo<$text bold="true">ob$text>ar',
'fo{ob}ar'
);
} );
it( 'starts in text node, ends in attribute #1', () => {
test(
[ 1, 3 ],
'fo<$text bold="true">ob$text>ar',
'f{oo}bar'
);
} );
it( 'starts in text node, ends in attribute #2', () => {
test(
[ 1, 4 ],
'fo<$text bold="true">ob$text>ar',
'f{oob}ar'
);
} );
it( 'starts in attribute, ends in text node', () => {
test(
[ 3, 5 ],
'fo<$text bold="true">ob$text>ar',
'foo{ba}r'
);
} );
it( 'consumes consumable values properly', () => {
// Add callback that will fire before default ones.
// This should prevent default callback doing anything.
dispatcher.on( 'selection', ( evt, data, consumable ) => {
expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
}, { priority: 'high' } );
// Similar test case as the first in this suite.
test(
[ 1, 4 ],
'foobar',
'foobar' // No selection in view.
);
} );
it( 'should convert backward selection', () => {
test(
[ 1, 3, 'backward' ],
'foobar',
'f{oo}bar'
);
expect( viewSelection.focus.offset ).to.equal( 1 );
} );
} );
describe( 'collapsed selection', () => {
let marker;
it( 'in container', () => {
test(
[ 1, 1 ],
'foobar',
'f{}oobar'
);
} );
it( 'in attribute', () => {
test(
[ 3, 3 ],
'f<$text bold="true">ooba$text>r',
'foo{}bar'
);
} );
it( 'in attribute and marker', () => {
setModelData( model, 'fo<$text bold="true">ob$text>ar' );
model.change( writer => {
marker = writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
writer.setSelection( new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) );
} );
// Remove view children manually (without firing additional conversion).
viewRoot.removeChildren( 0, viewRoot.childCount );
// Convert model to view.
dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
const markers = Array.from( model.markers.getMarkersAtPosition( docSelection.getFirstPosition() ) );
dispatcher.convertSelection( docSelection, markers );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal(
'
foo{}bar
'
);
} );
it( 'in attribute and marker - no attribute', () => {
setModelData( model, 'fo<$text bold="true">ob$text>ar' );
model.change( writer => {
marker = writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
writer.setSelection( new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) );
writer.removeSelectionAttribute( 'bold' );
} );
// Remove view children manually (without firing additional conversion).
viewRoot.removeChildren( 0, viewRoot.childCount );
// Convert model to view.
dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
const markers = Array.from( model.markers.getMarkersAtPosition( docSelection.getFirstPosition() ) );
dispatcher.convertSelection( docSelection, markers );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
.to.equal( 'foo[]bar
' );
} );
it( 'in marker - using highlight descriptor creator', () => {
dispatcher.on( 'addMarker:marker2', highlightText(
data => ( { 'class': data.markerName } )
) );
setModelData( model, 'foobar' );
model.change( writer => {
marker = writer.setMarker( 'marker2', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
writer.setSelection( new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) );
} );
// Remove view children manually (without firing additional conversion).
viewRoot.removeChildren( 0, viewRoot.childCount );
// Convert model to view.
dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
const markers = Array.from( model.markers.getMarkersAtPosition( docSelection.getFirstPosition() ) );
dispatcher.convertSelection( docSelection, markers );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
.to.equal( 'foo{}bar
' );
} );
it( 'should do nothing if creator return null', () => {
dispatcher.on( 'addMarker:marker3', highlightText( () => null ) );
setModelData( model, 'foobar' );
model.change( writer => {
marker = writer.setMarker( 'marker3', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
writer.setSelection( new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) );
} );
// Remove view children manually (without firing additional conversion).
viewRoot.removeChildren( 0, viewRoot.childCount );
// Convert model to view.
dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
const markers = Array.from( model.markers.getMarkersAtPosition( docSelection.getFirstPosition() ) );
dispatcher.convertSelection( docSelection, markers );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
.to.equal( 'foo{}bar
' );
} );
// #1072 - if the container has only ui elements, collapsed selection attribute should be rendered after those ui elements.
it( 'selection with attribute before ui element - no non-ui children', () => {
setModelData( model, '' );
// Add two ui elements to view.
viewRoot.appendChildren( [
new ViewUIElement( 'span' ),
new ViewUIElement( 'span' )
] );
model.change( writer => {
writer.setSelection( new ModelRange( new ModelPosition( modelRoot, [ 0 ] ) ) );
writer.setSelectionAttribute( 'bold', true );
} );
// Convert model to view.
dispatcher.convertSelection( docSelection, [] );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
.to.equal( '[]
' );
} );
// #1072.
it( 'selection with attribute before ui element - has non-ui children #1', () => {
setModelData( model, 'x' );
model.change( writer => {
writer.setSelection( new ModelRange( new ModelPosition( modelRoot, [ 1 ] ) ) );
writer.setSelectionAttribute( 'bold', true );
} );
// Convert model to view.
dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
// Add ui element to view.
const uiElement = new ViewUIElement( 'span' );
viewRoot.insertChildren( 1, uiElement );
dispatcher.convertSelection( docSelection, [] );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
.to.equal( 'x[]
' );
} );
// #1072.
it( 'selection with attribute before ui element - has non-ui children #2', () => {
setModelData( model, '<$text bold="true">x$text>y' );
model.change( writer => {
writer.setSelection( new ModelRange( new ModelPosition( modelRoot, [ 1 ] ) ) );
writer.setSelectionAttribute( 'bold', true );
} );
// Convert model to view.
dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
// Add ui element to view.
const uiElement = new ViewUIElement( 'span' );
viewRoot.insertChildren( 1, uiElement );
dispatcher.convertSelection( docSelection, [] );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
.to.equal( 'x{}y
' );
} );
it( 'consumes consumable values properly', () => {
// Add callbacks that will fire before default ones.
// This should prevent default callbacks doing anything.
dispatcher.on( 'selection', ( evt, data, consumable ) => {
expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
}, { priority: 'high' } );
dispatcher.on( 'attribute:bold', ( evt, data, consumable ) => {
expect( consumable.consume( data.item, 'attribute:bold' ) ).to.be.true;
}, { priority: 'high' } );
// Similar test case as above.
test(
[ 3, 3 ],
'f<$text bold="true">ooba$text>r',
'foobar' // No selection in view and no attribute.
);
} );
} );
} );
describe( 'clean-up', () => {
describe( 'convertRangeSelection', () => {
it( 'should remove all ranges before adding new range', () => {
test(
[ 0, 2 ],
'foobar',
'{fo}obar'
);
test(
[ 3, 5 ],
'foobar',
'foo{ba}r'
);
expect( viewSelection.rangeCount ).to.equal( 1 );
} );
} );
describe( 'convertCollapsedSelection', () => {
it( 'should remove all ranges before adding new range', () => {
test(
[ 2, 2 ],
'foobar',
'fo{}obar'
);
test(
[ 3, 3 ],
'foobar',
'foo{}bar'
);
expect( viewSelection.rangeCount ).to.equal( 1 );
} );
} );
describe( 'clearAttributes', () => {
it( 'should remove all ranges before adding new range', () => {
test(
[ 3, 3 ],
'foobar',
'foo[]bar',
{ bold: 'true' }
);
const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
model.change( writer => {
writer.setSelection( modelRange );
} );
dispatcher.convertSelection( modelDoc.selection, [] );
expect( viewSelection.rangeCount ).to.equal( 1 );
const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
expect( viewString ).to.equal( 'f{}oobar
' );
} );
it( 'should do nothing if the attribute element had been already removed', () => {
test(
[ 3, 3 ],
'foobar',
'foo[]bar',
{ bold: 'true' }
);
// Remove manually.
mergeAttributes( viewSelection.getFirstPosition() );
const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
model.change( writer => {
writer.setSelection( modelRange );
} );
dispatcher.convertSelection( modelDoc.selection, [] );
expect( viewSelection.rangeCount ).to.equal( 1 );
const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
expect( viewString ).to.equal( 'f{}oobar
' );
} );
} );
describe( 'clearFakeSelection', () => {
it( 'should clear fake selection', () => {
dispatcher.on( 'selection', clearFakeSelection() );
viewSelection.setFake( true );
dispatcher.convertSelection( docSelection, [] );
expect( viewSelection.isFake ).to.be.false;
} );
} );
} );
describe( 'table cell selection converter', () => {
beforeEach( () => {
model.schema.register( 'table' );
model.schema.register( 'tr' );
model.schema.register( 'td' );
model.schema.extend( 'table', { allowIn: '$root' } );
model.schema.extend( 'tr', { allowIn: 'table' } );
model.schema.extend( 'td', { allowIn: 'tr' } );
model.schema.extend( '$text', { allowIn: 'td' } );
// "Universal" converter to convert table structure.
const tableConverter = insertElement( modelItem => new ViewContainerElement( modelItem.name ) );
dispatcher.on( 'insert:table', tableConverter );
dispatcher.on( 'insert:tr', tableConverter );
dispatcher.on( 'insert:td', tableConverter );
// Special converter for table cells.
dispatcher.on( 'selection', ( evt, data, consumable, conversionApi ) => {
const selection = data.selection;
if ( !consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
return;
}
for ( const range of selection.getRanges() ) {
const node = range.start.nodeAfter;
if ( node == range.end.nodeBefore && node instanceof ModelElement && node.name == 'td' ) {
consumable.consume( selection, 'selection' );
const viewNode = conversionApi.mapper.toViewElement( node );
viewNode.addClass( 'selected' );
}
}
} );
} );
it( 'should not be used to convert selection that is not on table cell', () => {
test(
[ 1, 5 ],
'f{o<$text bold="true">ob$text>a}r',
'f{ooba}r'
);
} );
it( 'should add a class to the selected table cell', () => {
test(
// table tr#0 |td#0, table tr#0 td#0|
[ [ 0, 0, 0 ], [ 0, 0, 1 ] ],
'',
''
);
} );
it( 'should not be used if selection contains more than just a table cell', () => {
test(
// table tr td#1, table tr#2
[ [ 0, 0, 0, 1 ], [ 0, 0, 2 ] ],
'',
''
);
} );
} );
// Tests if the selection got correctly converted.
// Because `setData` might use selection converters itself to set the selection, we can't use it
// to set the selection (because then we would test converters using converters).
// Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
// that are offsets or paths of selection positions in root element.
function test( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
// Parse passed `modelInput` string and set it as current model.
setModelData( model, modelInput );
// Manually set selection ranges using passed `selectionPaths`.
const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
const startPos = new ModelPosition( modelRoot, startPath );
const endPos = new ModelPosition( modelRoot, endPath );
const isBackward = selectionPaths[ 2 ] === 'backward';
model.change( writer => {
writer.setSelection( new ModelRange( startPos, endPos ), isBackward );
// And add or remove passed attributes.
for ( const key in selectionAttributes ) {
const value = selectionAttributes[ key ];
if ( value ) {
writer.setSelectionAttribute( key, value );
} else {
writer.removeSelectionAttribute( key );
}
}
} );
// Remove view children manually (without firing additional conversion).
viewRoot.removeChildren( 0, viewRoot.childCount );
// Convert model to view.
dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
dispatcher.convertSelection( docSelection, [] );
// Stringify view and check if it is same as expected.
expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '' + expectedView + '
' );
}
} );