/** * @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">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 attribute and marker', () => { setModelData( model, 'fo<$text bold="true">obar' ); const marker = model.markers.set( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) ); model.change( writer => { 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">obar' ); const marker = model.markers.set( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) ); model.change( writer => { 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' ); const marker = model.markers.set( 'marker2', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) ); model.change( writer => { 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' ); const marker = model.markers.set( 'marker3', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) ); model.change( writer => { 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">xy' ); 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">oobar', '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( 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 ( 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">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( 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 + '
' ); } } );