/** * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ import ModelDocument from '../../src/model/document'; 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 { mergeAttributes } from '../../src/view/writer'; import Mapper from '../../src/conversion/mapper'; import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher'; import { convertRangeSelection, convertCollapsedSelection, convertSelectionAttribute, convertSelectionMarker, clearAttributes, clearFakeSelection } from '../../src/conversion/model-selection-to-view-converters'; import { insertElement, insertText, wrapItem, wrapRange, unwrapRange } from '../../src/conversion/model-to-view-converters'; import { stringify as stringifyView } from '../../src/dev-utils/view'; import { setData as setModelData } from '../../src/dev-utils/model'; const spanViewElementForMarker = new ViewAttributeElement( 'span' ); spanViewElementForMarker.priority = 1; describe( 'model-selection-to-view-converters', () => { let dispatcher, mapper; let modelDoc, modelRoot, modelSelection; let viewDoc, viewRoot, viewSelection; beforeEach( () => { modelDoc = new ModelDocument(); modelRoot = modelDoc.createRoot(); modelSelection = modelDoc.selection; modelDoc.schema.allow( { name: '$text', inside: '$root' } ); viewDoc = new ViewDocument(); viewRoot = viewDoc.createRoot( 'div' ); viewSelection = viewDoc.selection; mapper = new Mapper(); mapper.bindElements( modelRoot, viewRoot ); dispatcher = new ModelConversionDispatcher( { mapper, viewSelection } ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) ); dispatcher.on( 'addMarker:marker', wrapRange( spanViewElementForMarker ) ); dispatcher.on( 'removeMarker:marker', unwrapRange( spanViewElementForMarker ) ); // 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', () => { beforeEach( () => { // Selection converters for selection attributes. dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) ); dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) ); dispatcher.on( 'selectionMarker:marker', convertSelectionMarker( spanViewElementForMarker ) ); } ); 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">obar', 'f{ooba}r' ); } ); it( 'in same container, next to attribute', () => { test( [ 1, 2 ], 'fo<$text bold="true">obar', 'f{o}obar' ); } ); it( 'in same attribute', () => { test( [ 2, 4 ], 'f<$text bold="true">oobar', 'fo{ob}ar' ); } ); it( 'in same attribute, selection same as attribute', () => { test( [ 2, 4 ], 'fo<$text bold="true">obar', 'fo{ob}ar' ); } ); it( 'starts in text node, ends in attribute #1', () => { test( [ 1, 3 ], 'fo<$text bold="true">obar', 'f{oo}bar' ); } ); it( 'starts in text node, ends in attribute #2', () => { test( [ 1, 4 ], 'fo<$text bold="true">obar', 'f{oob}ar' ); } ); it( 'starts in attribute, ends in text node', () => { test( [ 3, 5 ], 'fo<$text bold="true">obar', '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', () => { it( 'in container', () => { test( [ 1, 1 ], 'foobar', 'f{}oobar' ); } ); it( 'in attribute', () => { test( [ 3, 3 ], 'f<$text bold="true">oobar', 'foo{}bar' ); } ); it( 'in container with extra attributes', () => { test( [ 1, 1 ], 'foobar', 'f[]oobar', { italic: true } ); } ); it( 'in attribute with extra attributes', () => { test( [ 3, 3 ], 'f<$text bold="true">oobar', 'foo[]bar', { italic: true } ); } ); it( 'in attribute and marker', () => { setModelData( modelDoc, 'fo<$text bold="true">obar' ); const marker = modelDoc.markers.set( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) ); modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] ); // Update selection attributes according to model. modelSelection.refresh(); // Remove view children manually (without firing additional conversion). viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); dispatcher.convertSelection( modelSelection ); // 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( modelDoc, 'fo<$text bold="true">obar' ); const marker = modelDoc.markers.set( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) ); modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] ); // Update selection attributes according to model. modelSelection.refresh(); modelSelection.removeAttribute( 'bold' ); // Remove view children manually (without firing additional conversion). viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); dispatcher.convertSelection( modelSelection ); // Stringify view and check if it is same as expected. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ) .to.equal( '
foo[]bar
' ); } ); it( 'in marker - using view element as element creator function for selection marker conversion', () => { dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker( ( data ) => new ViewAttributeElement( 'span', { 'class': data.name } ) ) ); setModelData( modelDoc, 'foobar' ); const marker = modelDoc.markers.set( 'marker2', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) ); modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] ); // Remove view children manually (without firing additional conversion). viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() ); dispatcher.convertSelection( modelSelection ); // Stringify view and check if it is same as expected. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ) .to.equal( '
foo[]bar
' ); } ); 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( 'selectionAttribute:bold', ( evt, data, consumable ) => { expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true; }, { priority: 'high' } ); // Similar test case as above. test( [ 3, 3 ], 'f<$text bold="true">oobar', 'foobar' // No selection in view. ); } ); } ); } ); 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', () => { dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) ); dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) ); test( [ 3, 3 ], 'foobar', 'foo[]bar', { bold: 'true' } ); const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 ); modelDoc.selection.setRanges( [ 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', () => { dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) ); dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) ); test( [ 3, 3 ], 'foobar', 'foo[]bar', { bold: 'true' } ); // Remove manually. mergeAttributes( viewSelection.getFirstPosition() ); const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 ); modelDoc.selection.setRanges( [ 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( modelSelection ); expect( viewSelection.isFake ).to.be.false; } ); } ); } ); describe( 'using element creator for attributes conversion', () => { beforeEach( () => { function themeElementCreator( themeValue ) { if ( themeValue == 'important' ) { return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } ); } else if ( themeValue == 'gold' ) { return new ViewAttributeElement( 'span', { style: 'color:yellow;' } ); } } dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) ); dispatcher.on( 'addAttribute:theme', wrapItem( themeElementCreator ) ); dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) ); } ); describe( 'range selection', () => { it( 'in same container, over attribute', () => { test( [ 1, 5 ], 'fo<$text theme="gold">obar', 'f{ooba}r' ); } ); it( 'in same attribute', () => { test( [ 2, 4 ], 'f<$text theme="gold">oobar', 'fo{ob}ar' ); } ); it( 'in same attribute, selection same as attribute', () => { test( [ 2, 4 ], 'fo<$text theme="important">obar', 'fo{ob}ar' ); } ); it( 'starts in attribute, ends in text node', () => { test( [ 3, 5 ], 'fo<$text theme="important">obar', 'foo{ba}r' ); } ); } ); describe( 'collapsed selection', () => { it( 'in attribute', () => { test( [ 3, 3 ], 'f<$text theme="gold">oobar', 'foo{}bar' ); } ); it( 'in container with theme attribute', () => { test( [ 1, 1 ], 'foobar', 'f[]oobar', { theme: 'important' } ); } ); it( 'in theme attribute with extra attributes #1', () => { test( [ 3, 3 ], 'f<$text theme="gold">oobar', 'foo' + '[]' + 'bar', { italic: true } ); } ); it( 'in theme attribute with extra attributes #2', () => { // In contrary to test above, we don't have strong + span on the selection. // This is because strong and span are both created by the same attribute. // Since style="important" overwrites style="gold" on selection, we have only strong element. // In example above, selection has both style and italic attribute. test( [ 3, 3 ], 'f<$text theme="gold">oobar', 'foo' + '[]' + 'bar', { theme: 'important' } ); } ); } ); } ); describe( 'table cell selection converter', () => { beforeEach( () => { modelDoc.schema.registerItem( 'table' ); modelDoc.schema.registerItem( 'tr' ); modelDoc.schema.registerItem( 'td' ); modelDoc.schema.allow( { name: 'table', inside: '$root' } ); modelDoc.schema.allow( { name: 'tr', inside: 'table' } ); modelDoc.schema.allow( { name: 'td', inside: 'tr' } ); modelDoc.schema.allow( { name: '$text', inside: 'td' } ); // "Universal" converter to convert table structure. const tableConverter = insertElement( ( data ) => new ViewContainerElement( data.item.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 ( let range of selection.getRanges() ) { const node = range.start.nodeAfter; if ( node == range.end.nodeBefore && node instanceof ModelElement && node.name == 'td' ) { consumable.consume( selection, 'selection' ); let 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">oba}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 ] ], '
foo
bar
', '
foo
bar
' ); } ); 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 ] ], '
foobar
', ']
f{oobar
' ); } ); } ); // 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( modelDoc, 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'; modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ], isBackward ); // Update selection attributes according to model. modelSelection.refresh(); // And add or remove passed attributes. for ( let key in selectionAttributes ) { let value = selectionAttributes[ key ]; if ( value ) { modelSelection.setAttribute( key, value ); } else { modelSelection.removeAttribute( key ); } } // Remove view children manually (without firing additional conversion). viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); dispatcher.convertSelection( modelSelection ); // Stringify view and check if it is same as expected. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '
' + expectedView + '
' ); } } );