/** * @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 ModelText from 'ckeditor5/engine/model/text.js'; import ModelRange from 'ckeditor5/engine/model/range.js'; import ModelPosition from 'ckeditor5/engine/model/position.js'; import modelWriter from 'ckeditor5/engine/model/writer.js'; import ViewElement from 'ckeditor5/engine/view/element.js'; import ViewContainerElement from 'ckeditor5/engine/view/containerelement.js'; import ViewAttributeElement from 'ckeditor5/engine/view/attributeelement.js'; import ViewText from 'ckeditor5/engine/view/text.js'; import Mapper from 'ckeditor5/engine/conversion/mapper.js'; import ModelConversionDispatcher from 'ckeditor5/engine/conversion/modelconversiondispatcher.js'; import { insertElement, insertText, setAttribute, removeAttribute, wrap, unwrap, move, remove, rename } from 'ckeditor5/engine/conversion/model-to-view-converters.js'; import { createRangeOnElementOnly } from 'tests/engine/model/_utils/utils.js'; let dispatcher, modelDoc, modelRoot, mapper, viewRoot; beforeEach( () => { modelDoc = new ModelDocument(); modelRoot = modelDoc.createRoot(); viewRoot = new ViewContainerElement( 'div' ); mapper = new Mapper(); mapper.bindElements( modelRoot, viewRoot ); dispatcher = new ModelConversionDispatcher( { mapper } ); } ); function viewAttributesToString( item ) { let result = ''; for ( let key of item.getAttributeKeys() ) { let value = item.getAttribute( key ); if ( value ) { result += ' ' + key + '="' + value + '"'; } } return result; } function viewToString( item ) { let result = ''; if ( item instanceof ViewText ) { result = item.data; } else { // ViewElement or ViewDocumentFragment. for ( let child of item.getChildren() ) { result += viewToString( child ); } if ( item instanceof ViewElement ) { result = '<' + item.name + viewAttributesToString( item ) + '>' + result + ''; } } return result; } describe( 'insertText', () => { it( 'should convert text insertion in model to view text', () => { modelRoot.appendChildren( new ModelText( 'foobar' ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '
foobar
' ); } ); it( 'should support unicode', () => { modelRoot.appendChildren( new ModelText( 'நிலைக்கு' ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '
நிலைக்கு
' ); } ); it( 'should be possible to override it', () => { modelRoot.appendChildren( new ModelText( 'foobar' ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'insert:$text', ( evt, data, consumable ) => { consumable.consume( data.item, 'insert' ); }, { priority: 'high' } ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '
' ); } ); } ); describe( 'insertElement', () => { it( 'should convert element insertion in model to and map positions for future converting', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ); const viewElement = new ViewContainerElement( 'p' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should take view element function generator as a parameter', () => { const elementGenerator = ( data, consumable ) => { if ( consumable.consume( data.item, 'addAttribute:nice' ) ) { return new ViewContainerElement( 'div' ); } else { return new ViewContainerElement( 'p' ); } }; const niceP = new ModelElement( 'myParagraph', { nice: true }, new ModelText( 'foo' ) ); const badP = new ModelElement( 'myParagraph', null, new ModelText( 'bar' ) ); modelRoot.appendChildren( [ niceP, badP ] ); dispatcher.on( 'insert:myParagraph', insertElement( elementGenerator ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '
foo

bar

' ); } ); } ); describe( 'setAttribute/removeAttribute', () => { it( 'should convert attribute insert/change/remove on a model node', () => { const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) ); const viewElement = new ViewContainerElement( 'p' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:class', setAttribute() ); dispatcher.on( 'changeAttribute:class', setAttribute() ); dispatcher.on( 'removeAttribute:class', removeAttribute() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelElement.setAttribute( 'class', 'bar' ); dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'foo', 'bar' ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelElement.removeAttribute( 'class' ); dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should convert insert/change/remove with attribute generating function as a parameter', () => { const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) ); const modelDiv = new ModelElement( 'div', { theme: 'nice' } ); const themeConverter = ( value, key, data ) => { if ( data.item instanceof ModelElement && data.item.childCount > 0 ) { value += ' ' + 'fix-content'; } return { key: 'class', value }; }; modelRoot.appendChildren( [ modelParagraph, modelDiv ] ); dispatcher.on( 'insert:paragraph', insertElement( new ViewContainerElement( 'p' ) ) ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:theme', setAttribute( themeConverter ) ); dispatcher.on( 'changeAttribute:theme', setAttribute( themeConverter ) ); dispatcher.on( 'removeAttribute:theme', removeAttribute( themeConverter ) ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelParagraph.setAttribute( 'theme', 'awesome' ); dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'nice', 'awesome' ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelParagraph.removeAttribute( 'theme' ); dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelParagraph ), 'theme', 'awesome', null ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should be possible to override setAttribute', () => { const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) ); const viewElement = new ViewContainerElement( 'p' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:class', setAttribute() ); dispatcher.on( 'addAttribute:class', ( evt, data, consumable ) => { consumable.consume( data.item, 'addAttribute:class' ); }, { priority: 'high' } ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); // No attribute set. expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should be possible to override removeAttribute', () => { const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) ); const viewElement = new ViewContainerElement( 'p' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewElement ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:class', setAttribute() ); dispatcher.on( 'removeAttribute:class', removeAttribute() ); dispatcher.on( 'removeAttribute:class', ( evt, data, consumable ) => { consumable.consume( data.item, 'removeAttribute:class' ); }, { priority: 'high' } ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelElement.removeAttribute( 'class' ); dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'bar', null ); // Nothing changed. expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); } ); describe( 'wrap/unwrap', () => { it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) ); const viewP = new ViewContainerElement( 'p' ); const viewB = new ViewAttributeElement( 'b' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:bold', wrap( viewB ) ); dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' ); dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) ); const viewP = new ViewContainerElement( 'p' ); const elementGenerator = ( value ) => { if ( value == 'bold' ) { return new ViewAttributeElement( 'b' ); } }; modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:style', wrap( elementGenerator ) ); dispatcher.on( 'removeAttribute:style', unwrap( elementGenerator ) ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'style' ); dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'style', 'bold', null ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should update range on re-wrapping attribute (#475)', () => { const modelElement = new ModelElement( 'paragraph', null, [ new ModelText( 'x' ), new ModelText( 'foo', { link: 'http://foo.com' } ), new ModelText( 'x' ) ] ); const viewP = new ViewContainerElement( 'p' ); const elementGenerator = ( href ) => new ViewAttributeElement( 'a', { href } ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:link', wrap( elementGenerator ) ); dispatcher.on( 'changeAttribute:link', wrap( elementGenerator ) ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

xfoox

' ); modelWriter.setAttribute( ModelRange.createIn( modelElement ), 'link', 'http://foobar.com' ); dispatcher.convertAttribute( 'changeAttribute', ModelRange.createIn( modelElement ), 'link', 'http://foo.com', 'http://foobar.com' ); expect( viewToString( viewRoot ) ).to.equal( '

xfoox

' ); } ); it( 'should support unicode', () => { const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] ); const viewP = new ViewContainerElement( 'p' ); const viewB = new ViewAttributeElement( 'b' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:bold', wrap( viewB ) ); dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

நிலைக்கு

' ); modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' ); dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null ); expect( viewToString( viewRoot ) ).to.equal( '

நிலைக்கு

' ); } ); it( 'should be possible to override wrap', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) ); const viewP = new ViewContainerElement( 'p' ); const viewB = new ViewAttributeElement( 'b' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:bold', wrap( viewB ) ); dispatcher.on( 'addAttribute:bold', ( evt, data, consumable ) => { consumable.consume( data.item, 'addAttribute:bold' ); }, { priority: 'high' } ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); it( 'should be possible to override unwrap', () => { const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) ); const viewP = new ViewContainerElement( 'p' ); const viewB = new ViewAttributeElement( 'b' ); modelRoot.appendChildren( modelElement ); dispatcher.on( 'insert:paragraph', insertElement( viewP ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'addAttribute:bold', wrap( viewB ) ); dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) ); dispatcher.on( 'removeAttribute:bold', ( evt, data, consumable ) => { consumable.consume( data.item, 'removeAttribute:bold' ); }, { priority: 'high' } ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' ); dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null ); // Nothing changed. expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); } ); describe( 'move', () => { it( 'should move items in view accordingly to changes in model', () => { const modelDivA = new ModelElement( 'div', null, [ new ModelText( 'foo' ), new ModelElement( 'image' ), new ModelText( 'bar' ) ] ); const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) ); modelRoot.appendChildren( [ modelDivA, modelDivB ] ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'move', move() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); const removedNodes = modelDivA.removeChildren( 0, 2 ); modelDivB.insertChildren( 0, removedNodes ); dispatcher.convertMove( ModelPosition.createFromParentAndOffset( modelDivA, 0 ), ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 ) ); expect( viewToString( viewRoot ) ).to.equal( '
bar
fooxxyy
' ); } ); it( 'should not execute if value was already consumed', () => { const modelDivA = new ModelElement( 'div', null, new ModelText( 'foo' ) ); const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) ); modelRoot.appendChildren( [ modelDivA, modelDivB ] ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'move', move() ); dispatcher.on( 'move', ( evt, data, consumable ) => { consumable.consume( data.item, 'move' ); }, { priority: 'high' } ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '
foo
xxyy
' ); const removedNodes = modelDivA.removeChildren( 0, 1 ); modelDivB.insertChildren( 0, removedNodes ); dispatcher.convertMove( ModelPosition.createFromParentAndOffset( modelDivA, 0 ), ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 3 ) ); expect( viewToString( viewRoot ) ).to.equal( '
foo
xxyy
' ); } ); it( 'should support unicode', () => { const modelDivA = new ModelElement( 'div', null, 'நிலைக்கு' ); const modelDivB = new ModelElement( 'div' ); modelRoot.appendChildren( [ modelDivA, modelDivB ] ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'move', move() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); modelWriter.move( ModelRange.createFromParentsAndOffsets( modelDivA, 2, modelDivA, 6 ), ModelPosition.createAt( modelDivB, 'end' ) ); dispatcher.convertMove( ModelPosition.createFromParentAndOffset( modelDivA, 2 ), ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 ) ); expect( viewToString( viewRoot ) ).to.equal( '
நிகு
லைக்
' ); } ); } ); describe( 'remove', () => { it( 'should remove items from view accordingly to changes in model', () => { const modelDiv = new ModelElement( 'div', null, [ new ModelText( 'foo' ), new ModelElement( 'image' ), new ModelText( 'bar' ) ] ); modelRoot.appendChildren( modelDiv ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'remove', remove() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); const removedNodes = modelDiv.removeChildren( 0, 2 ); modelDoc.graveyard.insertChildren( 0, removedNodes ); dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( modelDiv, 0 ), ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 4 ) ); expect( viewToString( viewRoot ) ).to.equal( '
bar
' ); } ); it( 'should not execute if value was already consumed', () => { const modelDiv = new ModelElement( 'div', null, new ModelText( 'foo' ) ); modelRoot.appendChildren( modelDiv ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'remove', remove() ); dispatcher.on( 'remove', ( evt, data, consumable ) => { consumable.consume( data.item, 'remove' ); }, { priority: 'high' } ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); const removedNodes = modelDiv.removeChildren( 0, 1 ); modelDoc.graveyard.insertChildren( 0, removedNodes ); dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( modelDiv, 0 ), ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 3 ) ); expect( viewToString( viewRoot ) ).to.equal( '
foo
' ); } ); it( 'should support unicode', () => { const modelDiv = new ModelElement( 'div', null, 'நிலைக்கு' ); modelRoot.appendChildren( modelDiv ); dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) ); dispatcher.on( 'insert:$text', insertText() ); dispatcher.on( 'remove', remove() ); dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) ); modelWriter.move( ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelDiv, 6 ), ModelPosition.createAt( modelDoc.graveyard, 'end' ) ); dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( modelDiv, 0 ), ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 6 ) ); expect( viewToString( viewRoot ) ).to.equal( '
கு
' ); } ); } ); describe( 'rename', () => { const oldName = 'oldName'; const newName = 'newName'; let element, converters; beforeEach( () => { converters = { insertText: insertText(), insert: insertElement( ( data ) => new ViewContainerElement( data.item.name ) ), move: move(), remove: remove(), rename: rename() }; sinon.spy( converters, 'insert' ); sinon.spy( converters, 'move' ); sinon.spy( converters, 'remove' ); element = new ModelElement( oldName, null, new ModelText( 'foo' ) ); modelRoot.appendChildren( element ); dispatcher.on( 'insert:$text', converters.insertText ); dispatcher.on( 'insert', converters.insert ); dispatcher.on( 'move', converters.move ); dispatcher.on( 'remove', converters.remove ); dispatcher.on( 'rename', converters.rename ); dispatcher.convertInsertion( ModelRange.createOn( element ) ); element.name = newName; } ); afterEach( () => { converters.insert.restore(); converters.move.restore(); converters.remove.restore(); } ); it( 'should enable default rename conversion, that uses already registered callbacks', () => { const insertCallCount = converters.insert.callCount; expect( viewRoot.getChild( 0 ).name ).to.equal( 'oldName' ); dispatcher.convertRename( element, oldName ); expect( converters.insert.callCount - insertCallCount ).to.equal( 1 ); expect( converters.move.calledOnce ).to.be.true; expect( converters.remove.calledOnce ).to.be.true; expect( viewRoot.getChild( 0 ).name ).to.equal( 'newName' ); expect( viewRoot.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' ); } ); it( 'should not execute if converted value was already consumed', () => { dispatcher.on( 'rename', ( evt, data, consumable ) => { consumable.consume( data.element, 'rename' ); }, { priority: 'high' } ); dispatcher.on( 'rename', ( evt, data ) => { expect( data.fakeElement ).to.be.undefined; } ); dispatcher.convertRename( element, oldName ); } ); } );