/** * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /* bender-tags: conversion */ import ModelDocument from '/ckeditor5/engine/model/document.js'; import ModelElement from '/ckeditor5/engine/model/element.js'; import ModelRange from '/ckeditor5/engine/model/range.js'; import ModelPosition from '/ckeditor5/engine/model/position.js'; import ViewDocument from '/ckeditor5/engine/view/document.js'; import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js'; import ViewAttributeElement from '/ckeditor5/engine/view/attributeelement.js'; import { mergeAt } from '/ckeditor5/engine/view/writer.js'; import Mapper from '/ckeditor5/engine/conversion/mapper.js'; import ModelConversionDispatcher from '/ckeditor5/engine/conversion/modelconversiondispatcher.js'; import { convertRangeSelection, convertCollapsedSelection, convertSelectionAttribute, clearAttributes } from '/ckeditor5/engine/conversion/model-selection-to-view-converters.js'; import { insertElement, insertText, wrap } from '/ckeditor5/engine/conversion/model-to-view-converters.js'; import { stringify as stringifyView } from '/tests/engine/_utils/view.js'; import { setData as setModelData } from '/tests/engine/_utils/model.js'; let dispatcher, mapper; let modelDoc, modelRoot, modelSelection; let viewDoc, viewRoot, viewSelection; beforeEach( () => { modelDoc = new ModelDocument(); modelRoot = modelDoc.createRoot(); modelSelection = modelDoc.selection; 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', wrap( new ViewAttributeElement( 'strong' ) ) ); // Default selection converters. dispatcher.on( 'selection', clearAttributes() ); dispatcher.on( 'selection', convertRangeSelection() ); dispatcher.on( 'selection', convertCollapsedSelection() ); } ); 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' ) ) ); } ); 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; }, null, 0 ); // 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( '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; }, null, 0 ); dispatcher.on( 'selectionAttribute:bold', ( evt, data, consumable ) => { expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true; }, null, 0 ); // 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', wrap( 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', wrap( new ViewAttributeElement( 'b' ) ) ); test( [ 3, 3 ], 'foobar', 'foo[]bar', { bold: 'true' } ); // Remove manually. mergeAt( 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( 'using element creator for attributes conversion', () => { beforeEach( () => { function styleElementCreator( styleValue ) { if ( styleValue == 'important' ) { return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } ); } else if ( styleValue == 'gold' ) { return new ViewAttributeElement( 'span', { style: 'color:yellow;' } ); } } dispatcher.on( 'selectionAttribute:style', convertSelectionAttribute( styleElementCreator ) ); dispatcher.on( 'addAttribute:style', wrap( styleElementCreator ) ); dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) ); } ); describe( 'range selection', () => { it( 'in same container, over attribute', () => { test( [ 1, 5 ], 'fo<$text style="gold">obar', 'f{ooba}r' ); } ); it( 'in same attribute', () => { test( [ 2, 4 ], 'f<$text style="gold">oobar', 'fo{ob}ar' ); } ); it( 'in same attribute, selection same as attribute', () => { test( [ 2, 4 ], 'fo<$text style="important">obar', 'fo{ob}ar' ); } ); it( 'starts in attribute, ends in text node', () => { test( [ 3, 5 ], 'fo<$text style="important">obar', 'foo{ba}r' ); } ); } ); describe( 'collapsed selection', () => { it( 'in attribute', () => { test( [ 3, 3 ], 'f<$text style="gold">oobar', 'foo{}bar' ); } ); it( 'in container with style attribute', () => { test( [ 1, 1 ], 'foobar', 'f[]oobar', { style: 'important' } ); } ); it( 'in style attribute with extra attributes #1', () => { test( [ 3, 3 ], 'f<$text style="gold">oobar', 'foo' + '[]' + 'bar', { italic: true } ); } ); it( 'in style 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 style="gold">oobar', 'foo' + '[]' + 'bar', { style: 'important' } ); } ); } ); } ); describe( 'table cell selection converter', () => { beforeEach( () => { // "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' ); } } }, null, 0 ); } ); it( 'should not be used to convert selection that is not on table cell', () => { test( [ 1, 5 ], 'fo<$text bold=true>obar', 'f{ooba}r' ); } ); it( 'should add a class to the selected table cell', () => { modelDoc.schema.registerItem( 'table', '$block' ); test( // table tr#0, table tr#1 [ [ 0, 0, 0 ], [ 0, 0, 1 ] ], '
foo
bar
', '
foo
bar
' ); } ); it( 'should not be used if selection contains more than just a table cell', () => { modelDoc.schema.registerItem( 'table', '$block' ); 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 ); // Updated selection attributes according to model. modelSelection._updateAttributes(); // 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 since we do not want to convert deletion. viewRoot.removeChildren( 0, viewRoot.childCount ); // Convert model to view. dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) ); dispatcher.convertSelection( modelSelection ); // Stringify view and check if it is same as expected. expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '
' + expectedView + '
' ); }