/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ import DowncastDispatcher from '../../src/conversion/downcastdispatcher'; import Mapper from '../../src/conversion/mapper'; import Model from '../../src/model/model'; import ModelText from '../../src/model/text'; import ModelElement from '../../src/model/element'; import ModelRange from '../../src/model/range'; import View from '../../src/view/view'; import ViewContainerElement from '../../src/view/containerelement'; import { StylesProcessor } from '../../src/view/stylesmap'; describe( 'DowncastDispatcher', () => { let dispatcher, doc, root, differStub, model, view, mapper; beforeEach( () => { model = new Model(); view = new View( new StylesProcessor() ); doc = model.document; mapper = new Mapper(); dispatcher = new DowncastDispatcher( { mapper } ); root = doc.createRoot(); differStub = { getMarkersToRemove: () => [], getChanges: () => [], getMarkersToAdd: () => [] }; } ); describe( 'constructor()', () => { it( 'should create DowncastDispatcher with given api', () => { const apiObj = {}; const dispatcher = new DowncastDispatcher( { apiObj } ); expect( dispatcher.conversionApi.apiObj ).to.equal( apiObj ); } ); } ); describe( 'convertChanges', () => { it( 'should call convertInsert for insert change', () => { sinon.stub( dispatcher, 'convertInsert' ); const position = model.createPositionFromPath( root, [ 0 ] ); const range = ModelRange._createFromPositionAndShift( position, 1 ); differStub.getChanges = () => [ { type: 'insert', position, length: 1 } ]; view.change( writer => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); expect( dispatcher.convertInsert.calledOnce ).to.be.true; expect( dispatcher.convertInsert.firstCall.args[ 0 ].isEqual( range ) ).to.be.true; } ); it( 'should call convertRemove for remove change', () => { sinon.stub( dispatcher, 'convertRemove' ); const position = model.createPositionFromPath( root, [ 0 ] ); differStub.getChanges = () => [ { type: 'remove', position, length: 2, name: '$text' } ]; view.change( writer => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); expect( dispatcher.convertRemove.calledWith( position, 2, '$text' ) ).to.be.true; } ); it( 'should call convertAttribute for attribute change', () => { sinon.stub( dispatcher, 'convertAttribute' ); const position = model.createPositionFromPath( root, [ 0 ] ); const range = ModelRange._createFromPositionAndShift( position, 1 ); differStub.getChanges = () => [ { type: 'attribute', position, range, attributeKey: 'key', attributeOldValue: null, attributeNewValue: 'foo' } ]; view.change( writer => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); expect( dispatcher.convertAttribute.calledWith( range, 'key', null, 'foo' ) ).to.be.true; } ); it( 'should handle multiple changes', () => { sinon.stub( dispatcher, 'convertInsert' ); sinon.stub( dispatcher, 'convertRemove' ); sinon.stub( dispatcher, 'convertAttribute' ); const position = model.createPositionFromPath( root, [ 0 ] ); const range = ModelRange._createFromPositionAndShift( position, 1 ); differStub.getChanges = () => [ { type: 'insert', position, length: 1 }, { type: 'attribute', position, range, attributeKey: 'key', attributeOldValue: null, attributeNewValue: 'foo' }, { type: 'remove', position, length: 1, name: 'paragraph' }, { type: 'insert', position, length: 3 } ]; view.change( writer => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); expect( dispatcher.convertInsert.calledTwice ).to.be.true; expect( dispatcher.convertRemove.calledOnce ).to.be.true; expect( dispatcher.convertAttribute.calledOnce ).to.be.true; } ); it( 'should call convertMarkerAdd when markers are added', () => { sinon.stub( dispatcher, 'convertMarkerAdd' ); const fooRange = model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 1 ) ); const barRange = model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 6 ) ); differStub.getMarkersToAdd = () => [ { name: 'foo', range: fooRange }, { name: 'bar', range: barRange } ]; view.change( writer => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); expect( dispatcher.convertMarkerAdd.calledWith( 'foo', fooRange ) ); expect( dispatcher.convertMarkerAdd.calledWith( 'bar', barRange ) ); } ); it( 'should call convertMarkerRemove when markers are removed', () => { sinon.stub( dispatcher, 'convertMarkerRemove' ); const fooRange = model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 1 ) ); const barRange = model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 6 ) ); differStub.getMarkersToRemove = () => [ { name: 'foo', range: fooRange }, { name: 'bar', range: barRange } ]; view.change( writer => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); expect( dispatcher.convertMarkerRemove.calledWith( 'foo', fooRange ) ); expect( dispatcher.convertMarkerRemove.calledWith( 'bar', barRange ) ); } ); it( 'should re-render markers which view elements got unbound during conversion', () => { sinon.stub( dispatcher, 'convertMarkerRemove' ); sinon.stub( dispatcher, 'convertMarkerAdd' ); const fooRange = model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 1 ) ); const barRange = model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 6 ) ); model.markers._set( 'foo', fooRange ); model.markers._set( 'bar', barRange ); // Stub `Mapper#flushUnboundMarkerNames`. dispatcher.conversionApi.mapper.flushUnboundMarkerNames = () => [ 'foo', 'bar' ]; view.change( writer => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); expect( dispatcher.convertMarkerRemove.calledWith( 'foo', fooRange ) ); expect( dispatcher.convertMarkerRemove.calledWith( 'bar', barRange ) ); expect( dispatcher.convertMarkerAdd.calledWith( 'foo', fooRange ) ); expect( dispatcher.convertMarkerAdd.calledWith( 'bar', barRange ) ); } ); } ); describe( 'convertInsert', () => { it( 'should fire event with correct parameters for every item in passed range', () => { root._appendChild( [ new ModelText( 'foo', { bold: true } ), new ModelElement( 'image' ), new ModelText( 'bar' ), new ModelElement( 'paragraph', { class: 'nice' }, new ModelText( 'xx', { italic: true } ) ) ] ); const range = model.createRangeIn( root ); const loggedEvents = []; // We will check everything connected with insert event: dispatcher.on( 'insert', ( evt, data, conversionApi ) => { // Check if the item is correct. const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; // Check if the range is correct. const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; loggedEvents.push( log ); // Check if the event name is correct. expect( evt.name ).to.equal( 'insert:' + ( data.item.name || '$text' ) ); // Check if model consumable is correct. expect( conversionApi.consumable.consume( data.item, 'insert' ) ).to.be.true; } ); // Same here. dispatcher.on( 'attribute', ( evt, data, conversionApi ) => { const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; const key = data.attributeKey; const value = data.attributeNewValue; const log = 'attribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; loggedEvents.push( log ); expect( evt.name ).to.equal( 'attribute:' + key + ':' + ( data.item.name || '$text' ) ); expect( conversionApi.consumable.consume( data.item, 'attribute:' + key ) ).to.be.true; } ); dispatcher.convertInsert( range ); // Check the data passed to called events and the order of them. expect( loggedEvents ).to.deep.equal( [ 'insert:$text:foo:0:3', 'attribute:bold:true:$text:foo:0:3', 'insert:image:3:4', 'insert:$text:bar:4:7', 'insert:paragraph:7:8', 'attribute:class:nice:paragraph:7:8', 'insert:$text:xx:7,0:7,2', 'attribute:italic:true:$text:xx:7,0:7,2' ] ); } ); it( 'should not fire events for already consumed parts of model', () => { root._appendChild( [ new ModelElement( 'image', { src: 'foo.jpg', title: 'bar', bold: true }, [ new ModelElement( 'caption', {}, new ModelText( 'title' ) ) ] ) ] ); sinon.spy( dispatcher, 'fire' ); dispatcher.on( 'insert:image', ( evt, data, conversionApi ) => { conversionApi.consumable.consume( data.item.getChild( 0 ), 'insert' ); conversionApi.consumable.consume( data.item, 'attribute:bold' ); } ); const range = model.createRangeIn( root ); dispatcher.convertInsert( range ); expect( dispatcher.fire.calledWith( 'insert:image' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'attribute:src:image' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'attribute:title:image' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'insert:$text' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'attribute:bold:image' ) ).to.be.false; expect( dispatcher.fire.calledWith( 'insert:caption' ) ).to.be.false; } ); } ); describe( 'convertRemove', () => { it( 'should fire event for removed range', () => { const loggedEvents = []; dispatcher.on( 'remove:$text', ( evt, data ) => { const log = 'remove:' + data.position.path + ':' + data.length; loggedEvents.push( log ); } ); dispatcher.convertRemove( model.createPositionAt( root, 3 ), 3, '$text' ); expect( loggedEvents ).to.deep.equal( [ 'remove:3:3' ] ); } ); } ); describe( 'convertSelection', () => { beforeEach( () => { dispatcher.off( 'selection' ); root._appendChild( new ModelText( 'foobar' ) ); model.change( writer => { writer.setSelection( [ writer.createRange( writer.createPositionFromPath( root, [ 1 ] ), writer.createPositionFromPath( root, [ 3 ] ) ), writer.createRange( writer.createPositionFromPath( root, [ 4 ] ), writer.createPositionFromPath( root, [ 5 ] ) ) ] ); } ); } ); it( 'should fire selection event', () => { sinon.spy( dispatcher, 'fire' ); dispatcher.convertSelection( doc.selection, model.markers, [] ); expect( dispatcher.fire.calledWith( 'selection', { selection: sinon.match.instanceOf( doc.selection.constructor ) } ) ).to.be.true; } ); it( 'should prepare correct list of consumable values', () => { model.change( writer => { writer.setAttribute( 'bold', true, writer.createRangeIn( root ) ); writer.setAttribute( 'italic', true, writer.createRange( writer.createPositionAt( root, 4 ), writer.createPositionAt( root, 5 ) ) ); } ); dispatcher.on( 'selection', ( evt, data, conversionApi ) => { expect( conversionApi.consumable.test( data.selection, 'selection' ) ).to.be.true; expect( conversionApi.consumable.test( data.selection, 'attribute:bold' ) ).to.be.true; expect( conversionApi.consumable.test( data.selection, 'attribute:italic' ) ).to.be.null; } ); dispatcher.convertSelection( doc.selection, model.markers, [] ); } ); it( 'should not fire attributes events for non-collapsed selection', () => { model.change( writer => { writer.setAttribute( 'bold', true, writer.createRangeIn( root ) ); writer.setAttribute( 'italic', true, writer.createRange( writer.createPositionAt( root, 4 ), writer.createPositionAt( root, 5 ) ) ); } ); sinon.spy( dispatcher, 'fire' ); dispatcher.convertSelection( doc.selection, model.markers, [] ); expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.false; expect( dispatcher.fire.calledWith( 'attribute:italic' ) ).to.be.false; } ); it( 'should fire attributes events for collapsed selection', () => { model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionFromPath( root, [ 2 ] ), writer.createPositionFromPath( root, [ 2 ] ) ) ); } ); model.change( writer => { writer.setAttribute( 'bold', true, writer.createRangeIn( root ) ); } ); sinon.spy( dispatcher, 'fire' ); dispatcher.convertSelection( doc.selection, model.markers, [] ); expect( dispatcher.fire.calledWith( 'attribute:bold:$text' ) ).to.be.true; } ); it( 'should not fire attributes events if attribute has been consumed', () => { model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionFromPath( root, [ 2 ] ), writer.createPositionFromPath( root, [ 2 ] ) ) ); } ); model.change( writer => { writer.setAttribute( 'bold', true, writer.createRangeIn( root ) ); writer.setAttribute( 'italic', true, writer.createRange( writer.createPositionAt( root, 4 ), writer.createPositionAt( root, 5 ) ) ); } ); dispatcher.on( 'selection', ( evt, data, conversionApi ) => { conversionApi.consumable.consume( data.selection, 'attribute:bold' ); } ); sinon.spy( dispatcher, 'fire' ); dispatcher.convertSelection( doc.selection, model.markers, [] ); expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.false; } ); it( 'should fire events for markers for collapsed selection', () => { model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionFromPath( root, [ 1 ] ), writer.createPositionFromPath( root, [ 1 ] ) ) ); const range = writer.createRange( writer.createPositionAt( root, 0 ), writer.createPositionAt( root, 2 ) ); writer.addMarker( 'name', { range, usingOperation: false } ); } ); sinon.spy( dispatcher, 'fire' ); const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) ); dispatcher.convertSelection( doc.selection, model.markers, markers ); expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true; } ); it( 'should not fire events for markers for non-collapsed selection', () => { model.change( writer => { const range = writer.createRange( writer.createPositionAt( root, 0 ), writer.createPositionAt( root, 2 ) ); writer.addMarker( 'name', { range, usingOperation: false } ); } ); sinon.spy( dispatcher, 'fire' ); const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) ); dispatcher.convertSelection( doc.selection, model.markers, markers ); expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.false; } ); it( 'should not fire event for marker if selection is in a element with custom highlight handling', () => { // Clear after `beforeEach`. root._removeChildren( 0, root.childCount ); const text = new ModelText( 'abc' ); const caption = new ModelElement( 'caption', null, text ); const image = new ModelElement( 'image', null, caption ); root._appendChild( [ image ] ); // Create view elements that will be "mapped" to model elements. const viewCaption = new ViewContainerElement( view.document, 'caption' ); const viewFigure = new ViewContainerElement( view.document, 'figure', null, viewCaption ); // Create custom highlight handler mock. viewFigure._setCustomProperty( 'addHighlight', () => {} ); viewFigure._setCustomProperty( 'removeHighlight', () => {} ); // Create mapper mock. dispatcher.conversionApi.mapper = { toViewElement( modelElement ) { if ( modelElement == image ) { return viewFigure; } else if ( modelElement == caption ) { return viewCaption; } } }; model.change( writer => { const range = writer.createRange( writer.createPositionAt( root, 0 ), writer.createPositionAt( root, 1 ) ); writer.addMarker( 'name', { range, usingOperation: false } ); writer.setSelection( caption, 1 ); } ); sinon.spy( dispatcher, 'fire' ); const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) ); dispatcher.convertSelection( doc.selection, model.markers, markers ); expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.false; } ); it( 'should not fire events if information about marker has been consumed', () => { model.change( writer => { writer.setSelection( writer.createRange( writer.createPositionFromPath( root, [ 1 ] ), writer.createPositionFromPath( root, [ 1 ] ) ) ); const range = writer.createRange( writer.createPositionAt( root, 0 ), writer.createPositionAt( root, 2 ) ); writer.addMarker( 'foo', { range, usingOperation: false } ); writer.addMarker( 'bar', { range, usingOperation: false } ); } ); sinon.spy( dispatcher, 'fire' ); dispatcher.on( 'addMarker:foo', ( evt, data, conversionApi ) => { conversionApi.consumable.consume( data.item, 'addMarker:bar' ); } ); const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) ); dispatcher.convertSelection( doc.selection, model.markers, markers ); expect( dispatcher.fire.calledWith( 'addMarker:foo' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'addMarker:bar' ) ).to.be.false; } ); } ); describe( 'convertMarkerAdd', () => { let element, text; beforeEach( () => { text = new ModelText( 'foo bar baz' ); element = new ModelElement( 'paragraph', null, [ text ] ); root._appendChild( [ element ] ); } ); it( 'should fire addMarker event for whole collapsed marker', () => { const range = model.createRange( model.createPositionAt( element, 2 ), model.createPositionAt( element, 2 ) ); const spy = sinon.spy(); dispatcher.on( 'addMarker:name', ( evt, data ) => { spy(); expect( data.markerName ).to.equal( 'name' ); expect( data.markerRange.isEqual( range ) ).to.be.true; } ); dispatcher.convertMarkerAdd( 'name', range ); expect( spy.calledOnce ).to.be.true; } ); it( 'should not convert marker if it is in graveyard', () => { const gyRange = model.createRange( model.createPositionAt( doc.graveyard, 0 ), model.createPositionAt( doc.graveyard, 0 ) ); sinon.spy( dispatcher, 'fire' ); dispatcher.convertMarkerAdd( 'name', gyRange ); expect( dispatcher.fire.called ).to.be.false; } ); it( 'should not convert marker if it is not in model root', () => { const element = new ModelElement( 'element', null, new ModelText( 'foo' ) ); const eleRange = model.createRange( model.createPositionAt( element, 1 ), model.createPositionAt( element, 2 ) ); sinon.spy( dispatcher, 'fire' ); dispatcher.convertMarkerAdd( 'name', eleRange ); expect( dispatcher.fire.called ).to.be.false; } ); it( 'should fire addMarker event for whole non-collapsed marker and for each item in the range', () => { const range = model.createRangeIn( root ); const spyWholeRange = sinon.spy(); dispatcher.on( 'addMarker:name', ( evt, data ) => { if ( !data.item ) { spyWholeRange(); expect( data.markerName ).to.equal( 'name' ); expect( data.markerRange.isEqual( range ) ).to.be.true; } } ); const spyItems = sinon.spy(); const items = []; dispatcher.on( 'addMarker:name', ( evt, data, conversionApi ) => { if ( data.item ) { spyItems(); expect( data.markerName ).to.equal( 'name' ); expect( data.markerRange.isEqual( range ) ).to.be.true; expect( conversionApi.consumable.test( data.item, 'addMarker:name' ) ); items.push( data.item ); } } ); dispatcher.convertMarkerAdd( 'name', range ); expect( spyWholeRange.calledOnce ).to.be.true; expect( spyItems.calledTwice ).to.be.true; expect( items[ 0 ] ).to.equal( element ); expect( items[ 1 ].data ).to.equal( text.data ); } ); it( 'should not fire conversion for non-collapsed marker items if marker was consumed in earlier event', () => { const range = model.createRangeIn( root ); dispatcher.on( 'addMarker:name', ( evt, data, conversionApi ) => { if ( !data.item ) { conversionApi.consumable.consume( data.markerRange, evt.name ); } }, { priority: 'high' } ); const spyItems = sinon.spy(); dispatcher.on( 'addMarker:name', ( evt, data ) => { if ( data.item ) { spyItems(); } } ); dispatcher.convertMarkerAdd( 'name', range ); expect( spyItems.called ).to.be.false; } ); it( 'should be possible to override #1', () => { const range = model.createRangeIn( root ); const addMarkerSpy = sinon.spy(); const highAddMarkerSpy = sinon.spy(); dispatcher.on( 'addMarker:marker', ( evt, data ) => { if ( !data.item ) { addMarkerSpy(); } } ); dispatcher.on( 'addMarker:marker', ( evt, data ) => { if ( !data.item ) { highAddMarkerSpy(); evt.stop(); } }, { priority: 'high' } ); dispatcher.convertMarkerAdd( 'marker', range ); expect( addMarkerSpy.called ).to.be.false; expect( highAddMarkerSpy.calledOnce ).to.be.true; } ); it( 'should be possible to override #2', () => { const range = model.createRangeIn( root ); const addMarkerSpy = sinon.spy(); const highAddMarkerSpy = sinon.spy(); dispatcher.on( 'addMarker:marker', ( evt, data ) => { if ( data.item ) { addMarkerSpy(); } } ); dispatcher.on( 'addMarker:marker', ( evt, data ) => { if ( data.item ) { highAddMarkerSpy(); evt.stop(); } }, { priority: 'high' } ); dispatcher.convertMarkerAdd( 'marker', range ); expect( addMarkerSpy.called ).to.be.false; // Called once for each item, twice total. expect( highAddMarkerSpy.calledTwice ).to.be.true; } ); } ); describe( 'convertMarkerRemove', () => { let range, element, text; beforeEach( () => { text = new ModelText( 'foo bar baz' ); element = new ModelElement( 'paragraph', null, [ text ] ); root._appendChild( [ element ] ); range = model.createRange( model.createPositionAt( element, 0 ), model.createPositionAt( element, 4 ) ); } ); it( 'should fire removeMarker event', () => { sinon.spy( dispatcher, 'fire' ); dispatcher.convertMarkerRemove( 'name', range ); expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true; } ); it( 'should not convert marker if it is in graveyard', () => { const gyRange = model.createRange( model.createPositionAt( doc.graveyard, 0 ), model.createPositionAt( doc.graveyard, 0 ) ); sinon.spy( dispatcher, 'fire' ); dispatcher.convertMarkerRemove( 'name', gyRange ); expect( dispatcher.fire.called ).to.be.false; } ); it( 'should not convert marker if it is not in model root', () => { const element = new ModelElement( 'element', null, new ModelText( 'foo' ) ); const eleRange = model.createRange( model.createPositionAt( element, 1 ), model.createPositionAt( element, 2 ) ); sinon.spy( dispatcher, 'fire' ); dispatcher.convertMarkerRemove( 'name', eleRange ); expect( dispatcher.fire.called ).to.be.false; } ); it( 'should fire conversion for the range', () => { range = model.createRangeIn( root ); dispatcher.on( 'addMarker:name', ( evt, data ) => { expect( data.markerName ).to.equal( 'name' ); expect( data.markerRange.isEqual( range ) ).to.be.true; } ); dispatcher.convertMarkerRemove( 'name', range ); } ); it( 'should be possible to override', () => { range = model.createRangeIn( root ); const removeMarkerSpy = sinon.spy(); const highRemoveMarkerSpy = sinon.spy(); dispatcher.on( 'removeMarker:marker', removeMarkerSpy ); dispatcher.on( 'removeMarker:marker', evt => { highRemoveMarkerSpy(); evt.stop(); }, { priority: 'high' } ); dispatcher.convertMarkerRemove( 'marker', range ); expect( removeMarkerSpy.called ).to.be.false; expect( highRemoveMarkerSpy.calledOnce ).to.be.true; } ); } ); } );