diff --git a/docs/builds/guides/migration/migration-from-ckeditor-4.md b/docs/builds/guides/migration/migration-from-ckeditor-4.md index d043595feca..71243ea3b3b 100644 --- a/docs/builds/guides/migration/migration-from-ckeditor-4.md +++ b/docs/builds/guides/migration/migration-from-ckeditor-4.md @@ -95,7 +95,7 @@ Note: The number of options was reduced on purpose. We understood that configuri

Extending the list of HTML tags or attributes that CKEditor should support can be achieved via the {@link features/general-html-support General HTML Support feature}. The GHS allows adding HTML markup not covered by official CKEditor 5 features into the editor's content. Such elements can be loaded, pasted, or output. It does not, however, provide a dedicated UI for the extended HTML markup.

Having full-fledged HTML support can be achieved by writing a plugin that (ideally) provides also means to control (insert, edit, delete) such markup. For more information on how to create plugins check the {@link framework/guides/creating-simple-plugin Creating a simple plugin} article. Looking at the source code of CKEditor 5 plugins may also give you a lot of inspiration.

-

Note that only content that is explicitly converted between the model and the view by the editor plugins will be preserved in CKEditor 5. Check the {@link framework/guides/deep-dive/conversion-introduction conversion tutorials} to learn how to extend the conversion rules.

+

Note that only content that is explicitly converted between the model and the view by the editor plugins will be preserved in CKEditor 5. Check the {@link framework/guides/deep-dive/conversion/intro conversion tutorials} to learn how to extend the conversion rules.

diff --git a/docs/builds/guides/migration/migration-to-26.md b/docs/builds/guides/migration/migration-to-26.md index 917b35c82d3..d2a4cc50de0 100644 --- a/docs/builds/guides/migration/migration-to-26.md +++ b/docs/builds/guides/migration/migration-to-26.md @@ -216,7 +216,7 @@ Command name changes (before → after): * `forwardDelete` → `deleteForward` * `todoListCheck` → `checkTodoList` -The `TodoListCheckCommand` module was moved to {@link module:list/checktodolistcommand~CheckTodoListCommand `CheckTodoListCommand`}. +The `TodoListCheckCommand` module was moved to {@link module:list/todolist/checktodolistcommand~CheckTodoListCommand `CheckTodoListCommand`}. The `ImageInsertCommand` module was moved to {@link module:image/image/insertimagecommand~InsertImageCommand `InsertImageCommand`}. diff --git a/docs/umberto.json b/docs/umberto.json index b00ca6d7f5b..71402916b11 100644 --- a/docs/umberto.json +++ b/docs/umberto.json @@ -64,7 +64,12 @@ "framework/guides/ui/external-ui.html": "framework/guides/deep-dive/ui/external-ui.html", "framework/guides/ui/theme-customization.html": "framework/guides/deep-dive/ui/theme-customization.html", "framework/guides/creating-simple-plugin.html": "framework/guides/plugins/creating-simple-plugin.html", - "examples/builds/custom-build.html": "examples/builds-custom/full-featured-editor.html" + "examples/builds/custom-build.html": "examples/builds-custom/full-featured-editor.html", + "framework/guides/deep-dive/conversion/conversion-introduction.html": "framework/guides/deep-dive/conversion/intro.html", + "framework/guides/deep-dive/conversion/conversion-extending-output.html": "framework/guides/deep-dive/conversion/intro.html", + "framework/guides/deep-dive/conversion/conversion-preserving-custom-content.html": "framework/guides/deep-dive/conversion/intro.html", + "framework/guides/deep-dive/conversion/custom-element-conversion.html": "framework/guides/deep-dive/conversion/intro.html", + "framework/guides/deep-dive/conversion/element-reconversion.html": "framework/guides/deep-dive/conversion/intro.html" }, "scripts": { "snippet-adapter": "../scripts/docs/snippetadapter", @@ -201,7 +206,15 @@ "name": "Conversion", "id": "framework-deep-dive-conversion", "slug": "conversion", - "order": 100 + "order": 100, + "categories": [ + { + "name": "Conversion helpers", + "id": "framework-deep-dive-conversion-helpers", + "slug": "helpers", + "order": 100 + } + ] }, { "name": "User interface", diff --git a/package.json b/package.json index 9b5c88e627c..2b5cc8ab7cf 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "@ckeditor/ckeditor5-dev-webpack-plugin": "^28.0.1", "@ckeditor/ckeditor5-export-pdf": ">=1.0.0", "@ckeditor/ckeditor5-export-word": ">=1.0.0", - "@ckeditor/ckeditor5-inspector": "^2.2.2", + "@ckeditor/ckeditor5-inspector": "^3.0.0", "@ckeditor/ckeditor5-pagination": ">=1.0.0", "@ckeditor/ckeditor5-react": "^3.0.0", "@ckeditor/ckeditor5-real-time-collaboration": ">=28.0.0", diff --git a/packages/ckeditor5-alignment/tests/alignmentediting.js b/packages/ckeditor5-alignment/tests/alignmentediting.js index 2e41c96b0fc..86980eb44de 100644 --- a/packages/ckeditor5-alignment/tests/alignmentediting.js +++ b/packages/ckeditor5-alignment/tests/alignmentediting.js @@ -6,7 +6,7 @@ import AlignmentEditing from '../src/alignmentediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; diff --git a/packages/ckeditor5-autoformat/tests/autoformat.js b/packages/ckeditor5-autoformat/tests/autoformat.js index 68bf34c38ac..103aa63a226 100644 --- a/packages/ckeditor5-autoformat/tests/autoformat.js +++ b/packages/ckeditor5-autoformat/tests/autoformat.js @@ -6,8 +6,8 @@ import Autoformat from '../src/autoformat'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; -import TodoListEditing from '@ckeditor/ckeditor5-list/src/todolistediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; +import TodoListEditing from '@ckeditor/ckeditor5-list/src/todolist/todolistediting'; import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; import StrikethroughEditing from '@ckeditor/ckeditor5-basic-styles/src/strikethrough/strikethroughediting'; diff --git a/packages/ckeditor5-autoformat/tests/undointegration.js b/packages/ckeditor5-autoformat/tests/undointegration.js index c9dc537c39b..317f5e18482 100644 --- a/packages/ckeditor5-autoformat/tests/undointegration.js +++ b/packages/ckeditor5-autoformat/tests/undointegration.js @@ -6,7 +6,7 @@ import Autoformat from '../src/autoformat'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; import CodeEditing from '@ckeditor/ckeditor5-basic-styles/src/code/codeediting'; diff --git a/packages/ckeditor5-block-quote/tests/blockquoteediting.js b/packages/ckeditor5-block-quote/tests/blockquoteediting.js index f398399f310..dc10f4f8179 100644 --- a/packages/ckeditor5-block-quote/tests/blockquoteediting.js +++ b/packages/ckeditor5-block-quote/tests/blockquoteediting.js @@ -5,7 +5,7 @@ import BlockQuoteEditing from '../src/blockquoteediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; diff --git a/packages/ckeditor5-clipboard/tests/pasteplaintext.js b/packages/ckeditor5-clipboard/tests/pasteplaintext.js index cd909c8b832..3074ba43442 100644 --- a/packages/ckeditor5-clipboard/tests/pasteplaintext.js +++ b/packages/ckeditor5-clipboard/tests/pasteplaintext.js @@ -43,7 +43,7 @@ describe( 'PastePlainText', () => { isInline: true } ); - editor.conversion.for( 'upcast' ).elementToElement( { + editor.conversion.elementToElement( { model: 'softBreak', view: 'br' } ); diff --git a/packages/ckeditor5-code-block/src/converters.js b/packages/ckeditor5-code-block/src/converters.js index 85422995eda..235b3849c76 100644 --- a/packages/ckeditor5-code-block/src/converters.js +++ b/packages/ckeditor5-code-block/src/converters.js @@ -69,12 +69,12 @@ export function modelToViewCodeBlockInsertion( model, languageDefs, useLabels = preAttributes.spellcheck = 'false'; } - const pre = writer.createContainerElement( 'pre', preAttributes ); const code = writer.createContainerElement( 'code', { class: languagesToClasses[ codeBlockLanguage ] || null } ); - writer.insert( writer.createPositionAt( pre, 0 ), code ); + const pre = writer.createContainerElement( 'pre', preAttributes, code ); + writer.insert( targetViewPosition, pre ); mapper.bindElements( data.item, code ); }; diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-bold.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-bold.html new file mode 100644 index 00000000000..397850711ef --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-bold.html @@ -0,0 +1,5 @@ +
+

Text in bold

+
+ +
diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-bold.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-bold.js new file mode 100644 index 00000000000..212dd6891fc --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-bold.js @@ -0,0 +1,20 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals DecoupledEditor, MiniCKEditorInspector, console, window, document */ + +DecoupledEditor + .create( document.querySelector( '#mini-inspector-bold' ) ) + .then( editor => { + window.editor = editor; + + MiniCKEditorInspector.attach( + editor, + document.querySelector( '#mini-inspector-bold-container' ) + ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading-interactive.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading-interactive.html new file mode 100644 index 00000000000..fbc41a00122 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading-interactive.html @@ -0,0 +1,37 @@ +
+

+
+ + + +
+ + diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading-interactive.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading-interactive.js new file mode 100644 index 00000000000..3ebee5c4b1d --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading-interactive.js @@ -0,0 +1,61 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals DecoupledEditor, MiniCKEditorInspector, Essentials, console, window, document */ + +function CustomHeading( editor ) { + editor.model.schema.register( 'heading', { + allowAttributes: [ 'level' ], + inheritAllFrom: '$block' + } ); + + editor.conversion.for( 'upcast' ).elementToElement( { + view: 'h1', + model: ( viewElement, { writer } ) => { + return writer.createElement( 'heading', { level: '1' } ); + } + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: { + name: 'heading', + attributes: [ 'level' ] + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( + 'h' + modelElement.getAttribute( 'level' ) + ); + } + } ); + + const dropdown = document.getElementById( + 'mini-inspector-heading-interactive-dropdown' + ); + + dropdown.addEventListener( 'change', event => { + editor.model.change( writer => { + writer.setAttribute( + 'level', + event.target.value, + editor.model.document.getRoot().getChild( 0 ) + ); + } ); + } ); +} + +DecoupledEditor.create( document.querySelector( '#mini-inspector-heading-interactive' ), { + plugins: [ Essentials, CustomHeading ] +} ) + .then( editor => { + window.editor = editor; + + MiniCKEditorInspector.attach( + editor, + document.querySelector( '#mini-inspector-heading-interactive-container' ) + ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading.html new file mode 100644 index 00000000000..7fc4bd8a457 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading.html @@ -0,0 +1,5 @@ +
+

+
+ +
diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading.js new file mode 100644 index 00000000000..10dff621e15 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-heading.js @@ -0,0 +1,33 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals DecoupledEditor, MiniCKEditorInspector, Essentials, console, window, document */ + +function CustomHeading( editor ) { + editor.model.schema.register( 'heading', { + allowAttributes: [ 'level' ], + inheritAllFrom: '$block' + } ); + + editor.conversion.elementToElement( { + model: 'heading', + view: 'h1' + } ); +} + +DecoupledEditor.create( document.querySelector( '#mini-inspector-heading' ), { + plugins: [ Essentials, CustomHeading ] +} ) + .then( editor => { + window.editor = editor; + + MiniCKEditorInspector.attach( + editor, + document.querySelector( '#mini-inspector-heading-container' ) + ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-paragraph.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-paragraph.html new file mode 100644 index 00000000000..77ea652795b --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-paragraph.html @@ -0,0 +1,5 @@ +
+

+
+ +
diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-paragraph.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-paragraph.js new file mode 100644 index 00000000000..f613708ac03 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-paragraph.js @@ -0,0 +1,20 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals DecoupledEditor, MiniCKEditorInspector, console, window, document */ + +DecoupledEditor + .create( document.querySelector( '#mini-inspector-paragraph' ) ) + .then( editor => { + window.editor = editor; + + MiniCKEditorInspector.attach( + editor, + document.querySelector( '#mini-inspector-paragraph-container' ) + ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-structure.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-structure.html new file mode 100644 index 00000000000..68f87092ec5 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-structure.html @@ -0,0 +1,11 @@ +
+
+
+

+ Example structure +

+
+
+
+ +
diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-structure.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-structure.js new file mode 100644 index 00000000000..f62d0c9678a --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-structure.js @@ -0,0 +1,105 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals DecoupledEditor, MiniCKEditorInspector, Essentials, Paragraph, console, window, document */ + +function Structure( editor ) { + editor.model.schema.register( 'myElement', { + allowWhere: '$block', + isObject: true, + isBlock: true, + allowContentOf: '$root' + } ); + + editor.conversion.for( 'downcast' ).elementToStructure( { + model: 'myElement', + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', { class: 'wrapper' }, [ + writer.createContainerElement( 'div', { class: 'inner-wrapper' }, [ + writer.createSlot() + ] ) + ] ); + } + } ); + + editor.conversion.for( 'upcast' ).add( dispatcher => { + // Look for every view div element. + dispatcher.on( 'element:div', ( evt, data, conversionApi ) => { + // Get all the necessary items from the conversion API object. + const { + consumable, + writer, + safeInsert, + convertChildren, + updateConversionResult + } = conversionApi; + + // Get view item from data object. + const { viewItem } = data; + + // Define elements consumables. + const wrapper = { name: true, classes: 'wrapper' }; + const innerWrapper = { name: true, classes: 'inner-wrapper' }; + + // Tests if the view element can be consumed. + if ( !consumable.test( viewItem, wrapper ) ) { + return; + } + + // Check if there is only one child. + if ( viewItem.childCount !== 1 ) { + return; + } + + // Get the first child element. + const firstChildItem = viewItem.getChild( 0 ); + + // Check if the first element is a div. + if ( !firstChildItem.is( 'element', 'div' ) ) { + return; + } + + // Tests if the first child element can be consumed. + if ( !consumable.test( firstChildItem, innerWrapper ) ) { + return; + } + + // Create model element. + const modelElement = writer.createElement( 'myElement' ); + + // Insert element on a current cursor location. + if ( !safeInsert( modelElement, data.modelCursor ) ) { + return; + } + + // Consume the main outer wrapper element. + consumable.consume( viewItem, wrapper ); + // Consume the inner wrapper element. + consumable.consume( firstChildItem, innerWrapper ); + + // Handle children conversion inside inner wrapper element. + convertChildren( firstChildItem, modelElement ); + + // Necessary function call to help setting model range and cursor + // for some specific cases when elements being split. + updateConversionResult( modelElement, data ); + } ); + } ); +} + +DecoupledEditor.create( document.querySelector( '#mini-inspector-structure' ), { + plugins: [ Essentials, Paragraph, Structure ] +} ) + .then( editor => { + window.editor = editor; + + MiniCKEditorInspector.attach( + editor, + document.querySelector( '#mini-inspector-structure-container' ) + ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-attribute.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-attribute.html new file mode 100644 index 00000000000..ec543891241 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-attribute.html @@ -0,0 +1,5 @@ +
+ +
+ +
diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-attribute.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-attribute.js new file mode 100644 index 00000000000..eb7daca572d --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-attribute.js @@ -0,0 +1,39 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals DecoupledEditor, MiniCKEditorInspector, Essentials, console, document */ + +function Image( editor ) { + editor.model.schema.register( 'image', { + inheritAllFrom: '$block', + allowAttributes: [ 'source' ] + } ); + + editor.conversion.elementToElement( { + view: 'img', + model: 'image' + } ); + + editor.conversion.attributeToAttribute( { + view: { + name: 'img', + key: 'src' + }, + model: 'source' + } ); +} + +DecoupledEditor.create( document.querySelector( '#mini-inspector-upcast-attribute' ), { + plugins: [ Essentials, Image ] +} ) + .then( editor => { + MiniCKEditorInspector.attach( + editor, + document.querySelector( '#mini-inspector-upcast-attribute-container' ) + ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-element.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-element.html new file mode 100644 index 00000000000..5e81beb4297 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-element.html @@ -0,0 +1,5 @@ +
+
+
+ +
diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-element.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-element.js new file mode 100644 index 00000000000..91b76e042d6 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector-upcast-element.js @@ -0,0 +1,33 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals DecoupledEditor, MiniCKEditorInspector, Essentials, console, document */ + +function Example( editor ) { + editor.model.schema.register( 'example', { + inheritAllFrom: '$block' + } ); + + editor.conversion.elementToElement( { + view: { + name: 'div', + classes: [ 'example' ] + }, + model: 'example' + } ); +} + +DecoupledEditor.create( document.querySelector( '#mini-inspector-upcast-element' ), { + plugins: [ Essentials, Example ] +} ) + .then( editor => { + MiniCKEditorInspector.attach( + editor, + document.querySelector( '#mini-inspector-upcast-element-container' ) + ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector.html b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector.html new file mode 100644 index 00000000000..94f04b6e070 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector.html @@ -0,0 +1,77 @@ + diff --git a/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector.js b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector.js new file mode 100644 index 00000000000..2c938a82cb7 --- /dev/null +++ b/packages/ckeditor5-engine/docs/_snippets/framework/mini-inspector.js @@ -0,0 +1,16 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window */ + +import DecoupledEditor from '@ckeditor/ckeditor5-build-decoupled-document/src/ckeditor'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import MiniCKEditorInspector from '@ckeditor/ckeditor5-inspector/build/miniinspector.js'; + +window.DecoupledEditor = DecoupledEditor; +window.Essentials = Essentials; +window.Paragraph = Paragraph; +window.MiniCKEditorInspector = MiniCKEditorInspector; diff --git a/packages/ckeditor5-engine/docs/assets/img/downcast-basic.svg b/packages/ckeditor5-engine/docs/assets/img/downcast-basic.svg new file mode 100644 index 00000000000..62b0262da61 --- /dev/null +++ b/packages/ckeditor5-engine/docs/assets/img/downcast-basic.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ckeditor5-engine/docs/assets/img/downcast-pipelines.svg b/packages/ckeditor5-engine/docs/assets/img/downcast-pipelines.svg new file mode 100644 index 00000000000..0342d1776e6 --- /dev/null +++ b/packages/ckeditor5-engine/docs/assets/img/downcast-pipelines.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ckeditor5-engine/docs/assets/img/upcast-basic.svg b/packages/ckeditor5-engine/docs/assets/img/upcast-basic.svg new file mode 100644 index 00000000000..3677d7bea0b --- /dev/null +++ b/packages/ckeditor5-engine/docs/assets/img/upcast-basic.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ckeditor5-engine/docs/assets/img/upcast-pipeline.svg b/packages/ckeditor5-engine/docs/assets/img/upcast-pipeline.svg new file mode 100644 index 00000000000..d68ea8023fc --- /dev/null +++ b/packages/ckeditor5-engine/docs/assets/img/upcast-pipeline.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-extending-output.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-extending-output.md deleted file mode 100644 index f4475665f96..00000000000 --- a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-extending-output.md +++ /dev/null @@ -1,356 +0,0 @@ ---- -category: framework-deep-dive-conversion -menu-title: Extending editor output -order: 20 ---- - -{@snippet framework/build-extending-content-source} - -# Extending the editor output - -This guide focuses on customization of the one–way {@link framework/guides/architecture/editing-engine#editing-pipeline "downcast"} pipeline of CKEditor 5. This pipeline transforms the data from the model to the editing view and the output data. The following examples do not customize the model and do not process the (input) data — you can picture them as post–processors (filters) applied to the output only. - -If you want to learn how to load some extra content (element, attributes, classes) into the rich-text editor, check out the {@link framework/guides/deep-dive/conversion-preserving-custom-content next guide} of this section. - -## Before starting - -### Code architecture - -It is recommended for the code that customizes the editor data and editing pipelines to be delivered as {@link framework/guides/architecture/core-editor-architecture#plugins plugins} and all examples in this guide follow this convention. - -Also for the sake of simplicity all examples use the same {@link module:editor-classic/classiceditor~ClassicEditor `ClassicEditor`}, but keep in mind that code snippets will work with other editors, too. - -Finally, none of the converters covered in this guide requires to import any modules from CKEditor 5 Framework, hence, you can write them without rebuilding the editor. In other words, such converters can easily be added to existing {@link builds/guides/overview CKEditor 5 builds}. - -### Granular converters - -You can create separate converters for the data and editing (downcast) pipelines. The former (`dataDowncast`) will customize the data in the editor output (e.g. when {@link builds/guides/integration/saving-data#manually-retrieving-the-data obtaining the editor data}). The latter (`editingDowncast`) will only work for the content of the editor when editing. - -If you do not want to complicate your conversion, you can just add a single (`downcast`) converter which will apply both to the data and the editing view. We did that in all the examples to keep them simple but keep in mind you have several options: - -```js -// Adds a conversion dispatcher for the editing downcast pipeline only. -editor.conversion.for( 'editingDowncast' ).add( dispatcher => { - // ... -} ); - -// Adds a conversion dispatcher for the data downcast pipeline only. -editor.conversion.for( 'dataDowncast' ).add( dispatcher => { - // ... -} ); - -// Adds a conversion dispatcher for both the data and the editing downcast pipelines. -editor.conversion.for( 'downcast' ).add( dispatcher => { - // ... -} ); -``` - -### CKEditor 5 inspector - -The {@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} is an invaluable help when working with the model and view structures. It allows browsing their structure and checking selection positions like in typical browser developer tools. Make sure to enable the inspector when playing with CKEditor 5. - -## Adding a CSS class to inline elements - -In this example all links (`...`) get the `.my-green-link` CSS class. This includes all links in the editor output (`editor.getData()`) and all links in the edited content (existing and future ones). - - -Note that the same behavior can be obtained with {@link features/link#custom-link-attributes-decorators link decorators}: - -```js -ClassicEditor - .create( ..., { - // ... - link: { - decorators: { - addGreenLink: { - mode: 'automatic', - classes: 'my-green-link' - } - } - } - } ) -``` - - -{@snippet framework/extending-content-add-link-class} - -A custom CSS class is added to all links by a custom converter plugged into the downcast pipeline, following the default converters brought by the {@link features/link link} feature: - -```js -// This plugin brings customization to the downcast pipeline of the editor. -function AddClassToAllLinks( editor ) { - // Both the data and the editing pipelines are affected by this conversion. - editor.conversion.for( 'downcast' ).add( dispatcher => { - // Links are represented in the model as a "linkHref" attribute. - // Use the "low" listener priority to apply the changes after the link feature. - dispatcher.on( 'attribute:linkHref', ( evt, data, conversionApi ) => { - const viewWriter = conversionApi.writer; - const viewSelection = viewWriter.document.selection; - - // Adding a new CSS class is done by wrapping all link ranges and selection - // in a new attribute element with a class. - const viewElement = viewWriter.createAttributeElement( 'a', { - class: 'my-green-link' - }, { - priority: 5 - } ); - - if ( data.item.is( 'selection' ) ) { - viewWriter.wrap( viewSelection.getFirstRange(), viewElement ); - } else { - viewWriter.wrap( conversionApi.mapper.toViewRange( data.range ), viewElement ); - } - }, { priority: 'low' } ); - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - extraPlugins: [ AddClassToAllLinks ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -Add some CSS styles for `.my-green-link` to see the customization in action: - -```css -.my-green-link { - color: #209a25; - border: 1px solid #209a25; - border-radius: 2px; - padding: 0 3px; - box-shadow: 1px 1px 0 0 #209a25; -} -``` - -## Adding an HTML attribute to certain inline elements - -In this example all the links (`...`) that do not have "ckeditor.com" in their `href="..."` get the `target="_blank"` attribute. This includes all links in the editor output (`editor.getData()`) and all links in the edited content (existing and future ones). - - -Note that similar behavior can be obtained with {@link module:link/link~LinkConfig#addTargetToExternalLinks link decorators}: - -```js -ClassicEditor - .create( ..., { - // ... - link: { - addTargetToExternalLinks: true - } - } ) -``` - -{@snippet framework/extending-content-add-external-link-target} - -The `target` attribute is added to all "external" links by a custom converter plugged into the downcast pipeline, following the default converters brought by the {@link features/link link} feature: - -```js -// This plugin brings customization to the downcast pipeline of the editor. -function AddTargetToExternalLinks( editor ) { - // Both the data and the editing pipelines are affected by this conversion. - editor.conversion.for( 'downcast' ).add( dispatcher => { - // Links are represented in the model as a "linkHref" attribute. - // Use the "low" listener priority to apply the changes after the link feature. - dispatcher.on( 'attribute:linkHref', ( evt, data, conversionApi ) => { - const viewWriter = conversionApi.writer; - const viewSelection = viewWriter.document.selection; - - // Adding a new CSS class is done by wrapping all link ranges and selection - // in a new attribute element with the "target" attribute. - const viewElement = viewWriter.createAttributeElement( 'a', { - target: '_blank' - }, { - priority: 5 - } ); - - if ( data.attributeNewValue.match( /ckeditor\.com/ ) ) { - viewWriter.unwrap( conversionApi.mapper.toViewRange( data.range ), viewElement ); - } else { - if ( data.item.is( 'selection' ) ) { - viewWriter.wrap( viewSelection.getFirstRange(), viewElement ); - } else { - viewWriter.wrap( conversionApi.mapper.toViewRange( data.range ), viewElement ); - } - } - }, { priority: 'low' } ); - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - extraPlugins: [ AddTargetToExternalLinks ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -Add some CSS styles for links with `target="_blank"` to mark them with with the "⧉" symbol: - -```css -a[target="_blank"]::after { - content: '\29C9'; -} -``` - -## Adding a CSS class to certain inline elements - -In this example all links (`...`) that do not have `https://` in their `href="..."` attribute get the `.unsafe-link` CSS class. This includes all links in the editor output (`editor.getData()`) and all links in the edited content (existing and future ones). - - -Note that the same behavior can be obtained with {@link features/link#custom-link-attributes-decorators link decorators}: - -```js -ClassicEditor - .create( ..., { - // ... - link: { - decorators: { - markUnsafeLink: { - mode: 'automatic', - callback: url => /^(http:)?\/\//.test( url ), - classes: 'unsafe-link' - } - } - } - } ) -``` - - -{@snippet framework/extending-content-add-unsafe-link-class} - -The `.unsafe-link` CSS class is added to all "unsafe" links by a custom converter plugged into the downcast pipeline, following the default converters brought by the {@link features/link link} feature: - -```js -// This plugin brings customization to the downcast pipeline of the editor. -function AddClassToUnsafeLinks( editor ) { - // Both the data and the editing pipelines are affected by this conversion. - editor.conversion.for( 'downcast' ).add( dispatcher => { - // Links are represented in the model as a "linkHref" attribute. - // Use the "low" listener priority to apply the changes after the link feature. - dispatcher.on( 'attribute:linkHref', ( evt, data, conversionApi ) => { - const viewWriter = conversionApi.writer; - const viewSelection = viewWriter.document.selection; - - // Adding a new CSS class is done by wrapping all link ranges and selection - // in a new attribute element with the "target" attribute. - const viewElement = viewWriter.createAttributeElement( 'a', { - class: 'unsafe-link' - }, { - priority: 5 - } ); - - if ( data.attributeNewValue.match( /http:\/\// ) ) { - if ( data.item.is( 'selection' ) ) { - viewWriter.wrap( viewSelection.getFirstRange(), viewElement ); - } else { - viewWriter.wrap( conversionApi.mapper.toViewRange( data.range ), viewElement ); - } - } else { - viewWriter.unwrap( conversionApi.mapper.toViewRange( data.range ), viewElement ); - } - }, { priority: 'low' } ); - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - extraPlugins: [ AddClassToUnsafeLinks ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -Add some CSS styles for "unsafe" links to make them visible: - -```css -.unsafe-link { - padding: 0 2px; - outline: 2px dashed red; - background: #ffff00; -} -``` - -## Adding a CSS class to block elements - -In this example all second–level headings (`

...

`) get the `.my-heading` CSS class. This includes all the heading elements in the editor output (`editor.getData()`) and in the edited content (existing and future ones). - -{@snippet framework/extending-content-add-heading-class} - -A custom CSS class is added to all `

...

` elements by a custom converter plugged into the downcast pipeline, following the default converters brought by the {@link features/headings headings} feature: - - - The `heading1` element in the model corresponds to `

...

` in the output HTML because in the default {@link features/headings#configuring-heading-levels headings feature configuration} `

...

` is reserved for the top–most heading of the webpage. -
- -```js -// This plugin brings customization to the downcast pipeline of the editor. -function AddClassToAllHeading1( editor ) { - // Both the data and the editing pipelines are affected by this conversion. - editor.conversion.for( 'downcast' ).add( dispatcher => { - // Headings are represented in the model as a "heading1" element. - // Use the "low" listener priority to apply the changes after the headings feature. - dispatcher.on( 'insert:heading1', ( evt, data, conversionApi ) => { - const viewWriter = conversionApi.writer; - - viewWriter.addClass( 'my-heading', conversionApi.mapper.toViewElement( data.item ) ); - }, { priority: 'low' } ); - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - extraPlugins: [ AddClassToAllHeading1 ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -Add some CSS styles for `.my-heading` to see the customization in action: - -```css -.my-heading { - font-family: Georgia, Times, Times New Roman, serif; - border-left: 6px solid #fd0000; - padding-left: .8em; - padding: .1em .8em; -} -``` - -## What's next? - -If you would like to read more about how to make CKEditor 5 accept more content, refer to the {@link framework/guides/deep-dive/conversion-preserving-custom-content Preserving custom content} guide. - -If you want to learn how to create complex view structures or how to move from {@link module:engine/conversion/conversion~Conversion two-way} or {@link module:engine/conversion/conversion~Conversion#for one-way} converters to event-based ones, refer to the {@link framework/guides/deep-dive/custom-element-conversion Custom element conversion} guide. diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-introduction.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-introduction.md deleted file mode 100644 index d6a6ba1777e..00000000000 --- a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-introduction.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -category: framework-deep-dive-conversion -menu-title: Advanced concepts -order: 10 - -# IMPORTANT: -# This guide is meant to become "Introduction to conversion" later on, hence the file name. -# For now, due to lack of content, it is called "advanced concepts". ---- - -# Advanced conversion concepts — attributes - -This guide extends the {@link framework/guides/architecture/editing-engine Introduction to CKEditor 5 editing engine architecture guide}, which we highly recommend reading first. Also, the {@link framework/guides/tutorials/implementing-a-block-widget#defining-converters Implementing a block widget} and {@link framework/guides/tutorials/implementing-an-inline-widget#defining-converters Implementing an inline widget} tutorials explain the basics of conversion with examples, hence reading them is recommended as well. - -In this guide we will dive deeper into some of the conversion concepts. - -## Inline and block content - -Generally speaking, there are two main types of content in the editor view and data output: inline and block. - -The inline content means elements like ``, `` or ``. Unlike `

`, `

` or `
`, the inline elements do not structure the data. Instead, they format some text in a specific (visual and semantical) way. These elements are a characteristic of a text. For instance, you could say that some part of the text is bold, or is linked, etc. This concept has its reflection in the model of the rich-text editor where `` or `` are not represented as elements. Instead, they are the attributes of the text. - -For example — in the model, you might have a `` element with the "Foo bar" text, where "bar" has the `bold` attribute set to `true`. A pseudo–code of this *model* data structure could look as follows: - -```html - - "Foo " // no attributes - "bar" // bold=true - -``` - - - Throughout the rest of this guide the following, shorter convention will be used to represent model text attributes for the sake of clarity: - - ```html - Foo <$text bold="true">bar - ``` - - -Note that there is no `` or any other additional element there, it is just some text with an attribute. - -So, when does this text become wrapped with a `` element? This happens during the conversion to the view. It is also important to know what type of a view element needs to be used. In the case of the elements that represent inline formatting, this should be an {@link module:engine/view/attributeelement~AttributeElement}. - -## Conversion of multiple text attributes - -A model text node may have multiple attributes (e.g. be bolded and linked) and all of them are converted into their respective view elements by independent converters. - -Keep in mind that in the model, attributes do not have any specific order. This is contrary to the editor view or HTML output, where inline elements are nested in one another. Fortunately, the nesting happens automatically during the conversion from the model to the view. This makes working in the model simpler, as features do not need to take care of breaking or rearranging elements in the model. - -For instance, consider the following model structure: - -```html - - <$text bold="true" linkHref="url">Foo - <$text linkHref="url">bar - <$text bold="true"> baz - -``` - -During the conversion, it will be converted to the following view structure: - -```html -

- Foo bar baz -

-``` - -Note that the `` element is converted in such way that it always becomes the "topmost" element. This is intentional so that no element ever breaks a link, which would otherwise look as follows: - -```html -

- Foo bar baz -

-``` - -There are two links with the same `href` attribute next to each other in the generated view (editor output), which is semantically wrong. To make sure that it never happens, the view element that represents a link must have a *priority* defined. Most elements, like for instance ``, do not care about it and stick to the default priority (`10`). The {@link features/link link feature} ensures that all `` view elements have the priority set to `5` therefore they are kept outside other elements. - -## Merging attribute elements during conversion - -Most of the simple view inline elements like `` or `` do not have any attributes. Some of them have just one, for instance `` has its `href`. - -But it is easy to come up with features that style a part of a text in a more complex way. An example would be the {@link features/font font family feature}. When used, it adds the `fontFamily` attribute to the text in the model, which is later converted to a `` element with a corresponding `style` attribute. - -So what would happen if several attributes were set on the same part of the text? Take this model example where `fontSize` is used next to `fontFamily`: - -```html - - <$text fontFamily="Tahoma" fontSize="big">foo - -``` - -CKEditor 5 features are implemented in a granular way, which means that e.g. the font size converter is completely independent from the font family converter. This means that the above example is converted as follows: - -* `fontFamily="value"` converts to ``, -* `fontSize="value"` converts to ``. - -And, in theory, you could expect the following HTML as a result: - -```html -

- - foo - -

-``` - -But this is not the most optimal output you can get from the rich-text editor. Why not have just one `` element instead? - -```html -

- foo -

-``` - -Obviously a single `` makes more sense. And thanks to the merging mechanism built into the conversion process, this would be the actual output of the conversion. - -Why is it so? In the above scenario, two model attributes are converted to `` elements. When the first attribute (say, `fontFamily`) is converted, there is no `` in the view yet. So the first `` is added with the `style` attribute. But then, when `fontSize` is converted, the `` is already in the view. The {@link module:engine/view/downcastwriter~DowncastWriter downcast writer} recognizes it and checks whether these elements can be merged, following these 3 rules: - -1. Both elements must have the same {@link module:engine/view/element~Element#name name}. -2. Both elements must have the same {@link module:engine/view/attributeelement~AttributeElement#priority priority}. -3. Neither can have an {@link module:engine/view/attributeelement~AttributeElement#id ID}. - -## Examples - -Once you understand more about the conversion of model attributes, you can check some examples of: - -* {@link framework/guides/deep-dive/conversion-extending-output Extending the editor output} — How to extend the output of existing CKEditor 5 features. -* {@link framework/guides/deep-dive/conversion-preserving-custom-content Preserving custom content} — How to make CKEditor 5 accept more content. -* {@link framework/guides/deep-dive/custom-element-conversion Custom element conversion} — How to deal with complex view structures during the model-to-view conversion. diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-preserving-custom-content.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-preserving-custom-content.md deleted file mode 100644 index 67a333db9b0..00000000000 --- a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-preserving-custom-content.md +++ /dev/null @@ -1,491 +0,0 @@ ---- -category: framework-deep-dive-conversion -menu-title: Preserving custom content -order: 30 ---- - -{@snippet framework/build-extending-content-source} - -# Preserving custom content - -The {@link framework/guides/deep-dive/conversion-extending-output previous guide} focused on post–processing of the CKEditor 5 data output. In this one, you will also extend the editor model so custom data can be loaded into it ({@link framework/guides/architecture/editing-engine#data-pipeline "upcasted"}). This will allow you not only to "correct" the editor output but, for instance, losslessly load data unsupported by the CKEditor 5 features. - -Eventually, this knowledge will allow you to create your custom features on top of the core features of CKEditor 5. - -## Before starting - -### Code architecture - -It is recommended for the code that customizes the editor data and editing pipelines to be delivered as {@link framework/guides/architecture/core-editor-architecture#plugins plugins} and all examples in this guide follow this convention. - -Also for the sake of simplicity all examples use the same {@link module:editor-classic/classiceditor~ClassicEditor `ClassicEditor`}, but keep in mind that code snippets will work with other editors, too. - -Finally, none of the converters covered in this guide requires to import any modules from CKEditor 5 Framework, hence, you can write them without rebuilding the editor. In other words, such converters can easily be added to existing {@link builds/guides/overview CKEditor 5 builds}. - -### CKEditor 5 inspector - -The {@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} is an invaluable help when working with the model and view structures. It allows browsing their structure and checking selection positions like in typical browser developer tools. Make sure to enable the inspector when playing with CKEditor 5. - -## Loading content with a custom attribute - -In this example the links (`
...`) loaded into the editor content will preserve their `target` attribute, which is by default *not* supported by the {@link features/link Link} feature. The DOM `target` attribute will be stored in the editor model as a `linkTarget` attribute. - -Unlike the {@link framework/guides/deep-dive/conversion-extending-output#adding-an-html-attribute-to-certain-inline-elements downcast–only solution}, this approach does not change the content loaded into the editor. Any links without the `target` attribute will not get one while all the links with the attribute will preserve its value. - - -Note that the same behavior can be obtained with {@link features/link#custom-link-attributes-decorators link decorators}: - -```js -ClassicEditor - .create( ..., { - // ... - link: { - decorators: { - addGreenLink: { - mode: 'automatic', - classes: 'my-green-link' - } - } - } - } ) -``` - -{@snippet framework/extending-content-allow-link-target} - -The `target` attribute in the editor is allowed thanks to two custom converters plugged into the "downcast" and "upcast" pipelines, following the default converters brought by the {@link features/link Link} feature: - -```js -function AllowLinkTarget( editor ) { - // Allow the "linkTarget" attribute in the editor model. - editor.model.schema.extend( '$text', { allowAttributes: 'linkTarget' } ); - - // Tell the editor that the model "linkTarget" attribute converts into - editor.conversion.for( 'downcast' ).attributeToElement( { - model: 'linkTarget', - view: ( attributeValue, { writer } ) => { - const linkElement = writer.createAttributeElement( 'a', { target: attributeValue }, { priority: 5 } ); - writer.setCustomProperty( 'link', true, linkElement ); - - return linkElement; - }, - converterPriority: 'low' - } ); - - // Tell the editor that converts into the "linkTarget" attribute in the model. - editor.conversion.for( 'upcast' ).attributeToAttribute( { - view: { - name: 'a', - key: 'target' - }, - model: 'linkTarget', - converterPriority: 'low' - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - extraPlugins: [ AllowLinkTarget ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -Add some CSS styles to easily see different link targets: - -```css -a[target]::after { - content: "target=\"" attr(target) "\""; - font-size: 0.6em; - position: relative; - left: 0em; - top: -1em; - background: #00ffa6; - color: #000; - padding: 1px 3px; - border-radius: 10px; -} -``` - -## Loading content with all attributes - -In this example the `
` elements (`
...
`) loaded into the editor content will preserve their attributes. All the DOM attributes will be stored in the editor model as corresponding attributes. - -{@snippet framework/extending-content-allow-div-attributes} - -All attributes are allowed on `
` elements thanks to custom "upcast" and "downcast" converters that copy each attribute one by one. - -Allowing every possible attribute on a `
` element in the model is done by adding an {@link module:engine/model/schema~Schema#addAttributeCheck addAttributeCheck()} callback. - - - Allowing every attribute on `
` elements might introduce security issues — including XSS attacks. The production code should use only application-related attributes and/or properly encode the data. - - -Adding "upcast" and "downcast" converters for the `
` element is enough for these cases where its attributes do not change. If the attributes in the model are modified, however, these `elementToElement()` converters will not be called as the `
` is already converted. To overcome this, a lower-level API is used. - -Instead of using predefined converters, the {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event-attribute `attribute`} event listener is registered for the "downcast" dispatcher. - -```js -function ConvertDivAttributes( editor ) { - // Allow
elements in the model. - editor.model.schema.register( 'div', { - allowWhere: '$block', - allowContentOf: '$root' - } ); - - // Allow
elements in the model to have all attributes. - editor.model.schema.addAttributeCheck( context => { - if ( context.endsWith( 'div' ) ) { - return true; - } - } ); - - // The view-to-model converter converting a view
with all its attributes to the model. - editor.conversion.for( 'upcast' ).elementToElement( { - view: 'div', - model: ( viewElement, { writer: modelWriter } ) => { - return modelWriter.createElement( 'div', viewElement.getAttributes() ); - } - } ); - - // The model-to-view converter for the
element (attributes are converted separately). - editor.conversion.for( 'downcast' ).elementToElement( { - model: 'div', - view: 'div' - } ); - - // The model-to-view converter for
attributes. - // Note that a lower-level, event-based API is used here. - editor.conversion.for( 'downcast' ).add( dispatcher => { - dispatcher.on( 'attribute', ( evt, data, conversionApi ) => { - // Convert
attributes only. - if ( data.item.name != 'div' ) { - return; - } - - const viewWriter = conversionApi.writer; - const viewDiv = conversionApi.mapper.toViewElement( data.item ); - - // In the model-to-view conversion we convert changes. - // An attribute can be added or removed or changed. - // The below code handles all 3 cases. - if ( data.attributeNewValue ) { - viewWriter.setAttribute( data.attributeKey, data.attributeNewValue, viewDiv ); - } else { - viewWriter.removeAttribute( data.attributeKey, viewDiv ); - } - } ); - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - extraPlugins: [ ConvertDivAttributes ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -## Parsing attribute values - -Some features, like {@link features/font Font}, allow only specific values for inline attributes. In this example you will add a converter that will parse any `font-size` value into one of the defined values. - -{@snippet framework/extending-content-arbitrary-attribute-values} - -Parsing any font value to the model requires adding a custom "upcast" converter that will override the default converter from `FontSize`. Unlike the default one, this converter parses values set in CSS nad sets them into the model. - -As the default "downcast" converter only operates on pre-defined values, you will also add a model-to-view converter that simply outputs any model value to font size using `px` units. - -```js -function HandleFontSizeValue( editor ) { - // Add a special catch-all converter for the font size feature. - editor.conversion.for( 'upcast' ).elementToAttribute( { - view: { - name: 'span', - styles: { - 'font-size': /[\s\S]+/ - } - }, - model: { - key: 'fontSize', - value: viewElement => { - const value = parseFloat( viewElement.getStyle( 'font-size' ) ).toFixed( 0 ); - - // It might be necessary to further convert the value to meet business requirements. - // In the sample the font size is configured to handle only these sizes: - // 12, 14, 'default', 18, 20, 22, 24, 26, 28, 30 - // Other sizes will be converted to the model but the UI might not be aware of them. - - // The font size feature expects numeric values to be Number, not String. - return parseInt( value ); - } - }, - converterPriority: 'high' - } ); - - // Add a special converter for the font size feature to convert all (even the not configured) - // model attribute values. - editor.conversion.for( 'downcast' ).attributeToElement( { - model: { - key: 'fontSize' - }, - view: ( modelValue, { writer: viewWriter } ) => { - return viewWriter.createAttributeElement( 'span', { - style: `font-size:${ modelValue }px` - } ); - }, - converterPriority: 'high' - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - items: [ 'heading', '|', 'bold', 'italic', '|', 'fontSize' ], - fontSize: { - options: [ 10, 12, 14, 'default', 18, 20, 22 ] - }, - extraPlugins: [ HandleFontSizeValue ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -## Adding extra attributes to elements contained in a figure - -The {@link features/images-overview image} and {@link features/table table} features wrap view elements (`` for image and `` for table, respectively) in a `
` element. During the downcast conversion, the model element is mapped to `
` and not the inner element. In such cases the default `conversion.attributeToAttribute()` conversion helpers could lose information about the element that the attribute should be set on. - -To overcome this limitation it is sufficient to write a custom converter that adds custom attributes to elements already converted by base features. The key point is to add these converters with a lower priority than the base converters so they will be called after the base ones. - -{@snippet framework/extending-content-custom-figure-attributes} - -The sample below is extensible. To add your own attributes to preserve, just add another `setupCustomAttributeConversion()` call with desired names. - -```js -/** - * A plugin that converts custom attributes for elements that are wrapped in
in the view. - */ -class CustomFigureAttributes { - /** - * Plugin's constructor - receives an editor instance on creation. - */ - constructor( editor ) { - // Save reference to the editor. - this.editor = editor; - } - - /** - * Sets the conversion up and extends the table & image features schema. - * - * Schema extending must be done in the "afterInit()" call because plugins define their schema in "init()". - */ - afterInit() { - const editor = this.editor; - - // Define on which elements the CSS classes should be preserved: - setupCustomClassConversion( 'img', 'imageBlock', editor ); - setupCustomClassConversion( 'img', 'imageInline', editor ); - setupCustomClassConversion( 'table', 'table', editor ); - - editor.conversion.for( 'upcast' ).add( upcastCustomClasses( 'figure' ), { priority: 'low' } ); - - // Define custom attributes that should be preserved. - setupCustomAttributeConversion( 'img', 'imageBlock', 'id', editor ); - setupCustomAttributeConversion( 'img', 'imageInline', 'id', editor ); - setupCustomAttributeConversion( 'table', 'table', 'id', editor ); - } -} - -/** - * Sets up a conversion that preserves classes on and
elements. - */ -function setupCustomClassConversion( viewElementName, modelElementName, editor ) { - // The 'customClass' attribute stores custom classes from the data in the model so that schema definitions allow this attribute. - editor.model.schema.extend( modelElementName, { allowAttributes: [ 'customClass' ] } ); - - // Defines upcast converters for the and
elements with a "low" priority so they are run after the default converters. - editor.conversion.for( 'upcast' ).add( upcastCustomClasses( viewElementName ), { priority: 'low' } ); - - // Defines downcast converters for a model element with a "low" priority so they are run after the default converters. - // Use `downcastCustomClassesToFigure` if you want to keep your classes on
element or `downcastCustomClassesToChild` - // if you would like to keep your classes on a
child element, i.e. . - editor.conversion.for( 'downcast' ).add( downcastCustomClassesToFigure( modelElementName ), { priority: 'low' } ); - // editor.conversion.for( 'downcast' ).add( downcastCustomClassesToChild( viewElementName, modelElementName ), { priority: 'low' } ); -} - -/** - * Sets up a conversion for a custom attribute on the view elements contained inside a
. - * - * This method: - * - Adds proper schema rules. - * - Adds an upcast converter. - * - Adds a downcast converter. - */ -function setupCustomAttributeConversion( viewElementName, modelElementName, viewAttribute, editor ) { - // Extends the schema to store an attribute in the model. - const modelAttribute = `custom${ viewAttribute }`; - - editor.model.schema.extend( modelElementName, { allowAttributes: [ modelAttribute ] } ); - - editor.conversion.for( 'upcast' ).add( upcastAttribute( viewElementName, viewAttribute, modelAttribute ) ); - editor.conversion.for( 'downcast' ).add( downcastAttribute( modelElementName, viewElementName, viewAttribute, modelAttribute ) ); -} - -/** - * Creates an upcast converter that will pass all classes from the view element to the model element. - */ -function upcastCustomClasses( elementName ) { - return dispatcher => dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => { - const viewItem = data.viewItem; - const modelRange = data.modelRange; - - const modelElement = modelRange && modelRange.start.nodeAfter; - - if ( !modelElement ) { - return; - } - - // The upcast conversion picks up classes from the base element and from the
element so it should be extensible. - const currentAttributeValue = modelElement.getAttribute( 'customClass' ) || []; - - currentAttributeValue.push( ...viewItem.getClassNames() ); - - conversionApi.writer.setAttribute( 'customClass', currentAttributeValue, modelElement ); - } ); -} - -/** - * Creates a downcast converter that adds classes defined in the `customClass` attribute to a
element. - * - * This converter expects that the view element is nested in a
element. - */ -function downcastCustomClassesToFigure( modelElementName ) { - return dispatcher => dispatcher.on( `insert:${ modelElementName }`, ( evt, data, conversionApi ) => { - const modelElement = data.item; - - const viewFigure = conversionApi.mapper.toViewElement( modelElement ); - - if ( !viewFigure ) { - return; - } - - // The code below assumes that classes are set on the
element. - conversionApi.writer.addClass( modelElement.getAttribute( 'customClass' ), viewFigure ); - } ); -} - -/** - * Creates a downcast converter that adds classes defined in the `customClass` attribute to a
child element. - * - * This converter expects that the view element is nested in a
element. - */ -function downcastCustomClassesToChild( viewElementName, modelElementName ) { - return dispatcher => dispatcher.on( `insert:${ modelElementName }`, ( evt, data, conversionApi ) => { - const modelElement = data.item; - - const viewFigure = conversionApi.mapper.toViewElement( modelElement ); - - if ( !viewFigure ) { - return; - } - - // The code below assumes that classes are set on the element inside the
. - const viewElement = findViewChild( viewFigure, viewElementName, conversionApi ); - - conversionApi.writer.addClass( modelElement.getAttribute( 'customClass' ), viewElement ); - } ); -} - -/** - * Helper method that searches for a given view element in all children of the model element. - * - * @param {module:engine/view/item~Item} viewElement - * @param {String} viewElementName - * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi - * @return {module:engine/view/item~Item} - */ -function findViewChild( viewElement, viewElementName, conversionApi ) { - const viewChildren = Array.from( conversionApi.writer.createRangeIn( viewElement ).getItems() ); - - return viewChildren.find( item => item.is( 'element', viewElementName ) ); -} - -/** - * Returns the custom attribute upcast converter. - */ -function upcastAttribute( viewElementName, viewAttribute, modelAttribute ) { - return dispatcher => dispatcher.on( `element:${ viewElementName }`, ( evt, data, conversionApi ) => { - const viewItem = data.viewItem; - const modelRange = data.modelRange; - - const modelElement = modelRange && modelRange.start.nodeAfter; - - if ( !modelElement ) { - return; - } - - conversionApi.writer.setAttribute( modelAttribute, viewItem.getAttribute( viewAttribute ), modelElement ); - } ); -} - -/** - * Returns the custom attribute downcast converter. - */ -function downcastAttribute( modelElementName, viewElementName, viewAttribute, modelAttribute ) { - return dispatcher => dispatcher.on( `insert:${ modelElementName }`, ( evt, data, conversionApi ) => { - const modelElement = data.item; - - const viewFigure = conversionApi.mapper.toViewElement( modelElement ); - const viewElement = findViewChild( viewFigure, viewElementName, conversionApi ); - - if ( !viewElement ) { - return; - } - - conversionApi.writer.setAttribute( viewAttribute, modelElement.getAttribute( modelAttribute ), viewElement ); - } ); -} -``` - -Activate the plugin in the editor: - -```js -ClassicEditor - .create( ..., { - extraPlugins: [ CustomFigureAttributes ], - } ) - .then( editor => { - // ... - } ) - .catch( err => { - console.error( err.stack ); - } ); -``` - -## What's next? - -If you would like to read more about how to extend the output of existing CKEditor 5 features, refer to the {@link framework/guides/deep-dive/conversion-extending-output Extending the editor output} guide. - -If you want to learn how to create complex view structures or how to move from {@link module:engine/conversion/conversion~Conversion two-way} or {@link module:engine/conversion/conversion~Conversion#for one-way} converters to event-based ones, refer to the {@link framework/guides/deep-dive/custom-element-conversion Custom element conversion} guide. diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/downcast.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/downcast.md new file mode 100644 index 00000000000..2f01ca5e6d1 --- /dev/null +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/downcast.md @@ -0,0 +1,201 @@ +--- +category: framework-deep-dive-conversion +menu-title: Model to view (downcast) +order: 20 +since: 33.0.0 +--- + +# Model to view (downcast) + +## Introduction + +The process of converting the **model** to the **view** is called a **downcast**. + +{@img assets/img/downcast-basic.svg 238 Basic downcast conversion diagram.} + +The downcast process happens every time a model node or attribute needs to be converted into a view node or attribute. + +The editor engine runs the conversion process and uses converters registered by plugins. + +{@snippet framework/mini-inspector} + +## Registering a converter + +In order to tell the engine how to convert a specific model element into a view element, you need to register a **downcast converter** by using the `editor.conversion.for( 'downcast' )` method: + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'paragraph', + view: 'p' + } ); +``` + +The above converter will handle the conversion of every `` model element to a `

` view element. + +{@snippet framework/mini-inspector-paragraph} + + + This is just an example. Paragraph support is provided by the {@link api/paragraph paragraph plugin} so you don't have to write your own `` element to `

` element conversion. + + + + You just learned about the `elementToElement()` **downcast** conversion helper method! More helpers are documented in the following chapters. + + +## Downcast pipelines + +CKEditor 5 engine uses two different views: **data view** and **editing view**. + +**The data view** is used when generating editor output. This process is controlled by the data pipeline. + +**The editing view**, on the other hand, is what you see when you open the editor, which is controlled by the editing pipeline. + +{@img assets/img/downcast-pipelines.svg 444 Downcast conversion pipelines diagram.} + +The previous code example registers a converter for both pipelines at once. It means that `` model element will be converted to a `

` view element in both **data view** and **editing view**. + +Sometimes you may want to alter converter logic for a specific pipeline. For example, in editing view you want to add some additional class to the view element. + +```js +// dataDowncast for data pipeline +editor.conversion + .for( 'dataDowncast' ) + .elementToElement( { + model: 'paragraph', + view: 'p' + } ); + +// editingDowncast for editing pipeline +editor.conversion + .for( 'editingDowncast' ) + .elementToElement( { + model: 'paragraph', + view: { + name: 'p', + classes: 'paragraph-in-editing-view' + } + } ); +``` + +{@snippet framework/mini-inspector-paragraph} + +## Converting text attributes + +As you may know from the chapter about the model, an **attribute** can be applied to a model text node. + +Such text nodes attributes can be converted into view elements. + +In order to do so, you can register a converter by using `attributeToElement()` conversion helper: + +```js +editor.conversion + .for( 'downcast' ) + .attributeToElement( { + model: 'bold', + view: 'strong' + } ); +``` + +The above converter will handle the conversion of every `bold` model text node attribute to a `` view element. + +{@snippet framework/mini-inspector-bold} + + + This is just an example. Bold support is provided by the {@link features/basic-styles basic styles} plugin so you don't have to write your own bold attribute to strong element conversion. + + +## Converting element to element + +Similar to the basic example, you can convert `` model element into `

` view element with `elementToElement()` conversion helper. + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'heading', + view: 'h1' + } ); +``` + +Which is equivalent to: + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'heading', + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( + 'h1' + ); + } + } ); +``` + +You learned that the `view` property can be a simple string or an object. The example above shows it is also possible to define a custom callback function to return created element instead. + +{@snippet framework/mini-inspector-heading} + +The `` element makes the most sense if you can set the heading level. + +From the previous chapter you learned that you can apply attributes to text nodes. It is also possible to add attributes to elements. + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: { + name: 'heading', + attributes: [ 'level' ] + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( + 'h' + modelElement.getAttribute( 'level' ) + ); + } + } ); +``` + +From now on, every time level attribute updates, the whole `` element will be converted to the `` element (for example `

`, `

`, etc). + +You can check this in action by using the example below: + +{@snippet framework/mini-inspector-heading-interactive} + + + This is just an example. Heading support is provided by the {@link features/headings headings feature} so you don't have to write your own `` to `

` element conversion. + + +## Converting element to structure + +Sometimes you may want to convert a **single model** element into a more complex view structure consisting of a **single view element with children**. + +You can use `elementToStructure()` conversion helper for this purpose: + +```js +editor.conversion + .for( 'downcast' ).elementToStructure( { + model: 'myElement', + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', { class: 'wrapper' }, [ + writer.createContainerElement( 'div', { class: 'inner-wrapper' }, [ + writer.createSlot() + ] ) + ] ); + } + } ); +``` + +The above converter will convert all `` model elements to `

...

` structures. + +{@snippet framework/mini-inspector-structure} + + + Using your own custom model element requires defining it in the schema. + + +## Read next + +{@link framework/guides/deep-dive/conversion/upcast View to model (upcast)} diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/downcast.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/downcast.md new file mode 100644 index 00000000000..3c1bdeee6e7 --- /dev/null +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/downcast.md @@ -0,0 +1,376 @@ +--- +category: framework-deep-dive-conversion-helpers +menu-title: Downcast helpers (model to view) +order: 20 +since: 33.0.0 +--- + +# Downcast helpers (model to view) + +## Element to element + +Converting a model element to a view element is the most common case of conversion. It is used to create view elements like `

` or `

` (that we call container elements). + +When using the `elementToElement()` helper, a **single model element** will be converted to a **single view element**. The children of this model element have to have their own converters defined and the engine will recursively convert them and insert into the created view element. + +### Basic element to element conversion + +If you want to convert a model element to a simple view element without additional attributes, simply provide their names: + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'paragraphSeparator', + view: 'hr' +} ); +``` + +### Using view element definition + +You might want to output a view element that has more attributes, e.g. a class name. To achieve that you can provide [element definition](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_view_elementdefinition-ElementDefinition.html) in the `view` property: + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'fancyParagraph', + view: { + name: 'p', + classes: 'fancy' + } + } ); +``` + +Check out the [ElementDefinition documentation](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_view_elementdefinition-ElementDefinition.html) for more details. + +### Creating a view element using a callback + +Another way of writing a converter from the previous section using a callback would look like this: + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'fancyParagraph', + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( + 'p', { class: 'fancy' } + ); + } + } ); +``` + +The second parameter of the view callback is the [DowncastConversionApi](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_conversion_downcastdispatcher-DowncastConversionApi.html) object, that contains many properties and methods that can be useful when writing a more complex converters. + +The callback should return a single container element. That element should not contain any children except UI elements. If you want to create a richer structure, use `elementToStructure()`. + +### Handling model elements with attributes + +If the view element depends not only on the model element itself but also on its attributes you need to specify these attributes in the `model` property. + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: { + name: 'heading', + attributes: [ 'level' ] + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( + 'h' + modelElement.getAttribute( 'level' ) + ); + } + } ); +``` + + + If you forget about specifying these attributes, the converter will work for the insertion of the model element but it will not handle changes of the attribute value. + + +### Changing converter priority + +In case there are other converters with the overlapping `model` patterns already present, you can prioritize your converter in order to override t. To do that use the `converterPriority` property: + +```js +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'userComment', + view: 'div' + } ); + +editor.conversion + .for( 'downcast' ) + .elementToElement( { + model: 'userComment', + view: 'article', + converterPriority: 'high' + } ); +``` + +Above, the first converter has a default priority, `normal`. The second one overrides it by setting the priority to `high`. Using both of these converters at once will result in the `` element being converted to an `
` element. + +Another case might be when you want your converter to act as a fallback when other converters for a given element are not present (e.g. a plugin has not been loaded). Achieving this is as simple as setting the `converterProperty` to `low`. + +## Element to structure + +Convert a single model element to many view elements (a structure of view elements). + +### Handling empty model elements + +To convert a single model element `horizontalLine` to a following structure: + +```html +
+
+
+``` + +you can use a converter similar to this: + +```js +editor.conversion + .for( 'downcast' ) + .elementToStructure( { + model: 'horizontalLine', + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', { class: 'horizontal-line' }, [ + writer.createEmptyElement( 'hr' ) + ] ); + } +} ); +``` + +Note that in this example we create two elements, which is not possible by using previously mentioned `elementToElement()` helper. + +Another thing to remember is that in the real life scenario it would be recommended for this element to be {@link framework/guides/tutorials/implementing-a-block-widget a widget}. + +### Handling model element’s children + +The example above uses an empty model element. If your model element may contain children you need to specify in the view where these children should be placed. To do that use `writer.createSlot()` + +```js +editor.conversion + .for( 'downcast' ) + .elementToStructure( { + model: 'wrappedParagraph', + view: ( modelElement, conversionApi ) => { + const { writer } = conversionApi; + const paragraphViewElement = writer.createContainerElement( 'p', {}, [ + writer.createSlot() + ] ); + + return writer.createContainerElement( 'div', { class: 'wrapper' }, [ + paragraphViewElement + ] ); + } + } ); +``` +## Attribute to element + +The attribute to element conversion is used to create formatting view elements like `` or `` (that we call attribute elements). In this case, we don’t convert a model element but a text node’s attribute. It is important to note that text formatting such as bold or font size should be represented in the model as text nodes attributes. + + + In general, the model does not implement a concept of “inline elements” (in the sense in which they are defined by CSS). The only scenarios in which inline elements can be used are self-contained objects such as soft breaks (`
`) or inline images. +
+ +### Basic text attribute to model conversion + +```js +editor.conversion + .for( 'downcast' ) + .attributeToElement( { + model: 'bold', + view: 'strong' + } ); +``` + +A model text node `"CKEditor 5"` with a `bold` attribute will become a `CKEditor 5` in the view. + +### Using view element definition + +You might want to output a view element that has more attributes, e.g. a class name. To achieve that you can provide [element definition](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_view_elementdefinition-ElementDefinition.html) in the `view` property: + +```js +editor.conversion + .for( 'downcast' ) + .attributeToElement( { + model: 'invert', + view: { + name: 'span', + classes: [ 'font-light', 'bg-dark' ] + } + } ); +``` + +Check out the [ElementDefinition documentation](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_view_elementdefinition-ElementDefinition.html) for more details. + +### Creating a view element using a callback + +You can also generate the view element by a callback. This method is useful when the view element depends on the value of the model attribute. + +```js +editor.conversion + .for( 'downcast' ) + .attributeToElement( { + model: 'bold', + view: ( modelAttributeValue, conversionApi ) => { + const { writer } = conversionApi; + + return writer.createAttributeElement( 'span', { + style: 'font-weight:' + modelAttributeValue + } ); + } + } ); +``` + +The second parameter of the view callback is the [DowncastConversionApi](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_conversion_downcastdispatcher-DowncastConversionApi.html) object, that contains many properties and methods that can be useful when writing a more complex converters. + +### Changing converter priority + +In case there are other converters already present, you can prioritize your converter in order to override existing ones. To do that use the `converterPriority` property: + +```js +editor.conversion + .for( 'downcast' ) + .attributeToElement( { + model: 'bold', + view: 'strong' + } ); + +editor.conversion + .for( 'downcast' ) + .attributeToElement( { + model: 'bold', + view: 'b', + converterPriority: 'high' + } ); +``` + +Above, the first converter has a default priority, `normal`. The second one overrides it by setting the priority to `high`. Using both of these converters at once will result in the `bold` attribute being converted to a `` element. + +## Attribute to attribute + +The `attributeToAttribute()` helper allows registering a converter that handles a specific attribute and converts it to an attribute of a view element. + +Usually, when registering converters for elements (e.g. by using `elementToElement()` or `elementToStructure()`), you will want to handle their attributes while handling the element itself. + +The `attributeToAttribute()` helper comes handy when for some reason you can’t cover a specific attribute inside `elementToElement()`. For instance, you are extending someone else’s plugin. + + + This type of converter helper works only if there is already an element converter provided. Trying to convert to an attribute while there is no receiving view element will cause an error. + + +### Basic attribute to attribute conversion + +This conversion results in adding an attribute to a view node, basing on an attribute from a model node. For example, `` is converted to ``. + +```js +editor.conversion + .for( 'downcast' ) + .attributeToAttribute( { + model: 'source', + view: 'src' + } ); +``` + +### Converting specific model element and attribute + +The converter in the example above will be convert all the `source` model attributes in the document. You can limit its scope by providing the model element name. + +```js +editor.conversion + .for( 'downcast' ) + .attributeToAttribute( { + model: { + name: 'imageInline', + key: 'source' + }, + view: 'src' + } ); +``` + +The converter above will convert all the `source` model attributes, but only those present on the `imageInline` model element. + +### Creating a custom view element from a selected list of model values + +Once you provide the array in the `model.values` property, the `view` property is expected to be an object with keys matching these values. This is best explained using the example below: + +```js +editor.conversion + .for( 'downcast' ) + .attributeToAttribute( { + model: { + name: 'styled', + values: [ 'dark', 'light' ] + }, + view: { + dark: { + key: 'class', + value: [ 'styled', 'styled-dark' ] + }, + light: { + key: 'class', + value: [ 'styled', 'styled-light' ] + } + } + } ); +``` + +### Creating a view attribute with a custom value based on the model value + +The value of the view attribute can be modified in the converter. Below is a simple mapper, that sets the class attribute based on the model attribute value: + +```js +editor.conversion + .for( 'downcast' ) + .attributeToAttribute( { + model: 'styled', + view: modelAttributeValue => ( { + key: 'class', + value: 'styled-' + modelAttributeValue + } ) + } ); +``` + +It is worth noting that providing a style property in this manner requires the returned `value` to be an object: + +```js +editor.conversion + .for( 'downcast' ) + .attributeToAttribute( { + model: 'lineHeight', + view: modelAttributeValue => ( { + key: 'style', + value: { + 'line-height': modelAttributeValue, + 'border-bottom': '1px dotted #ba2' + } + } ) + } ); +``` + +### Changing converter priority + +You can override the existing converters by specifying higher priority, like in the example below: + +```js +editor.conversion + .for( 'downcast' ) + .attributeToAttribute( { + model: 'source', + view: 'href' + } ); + +editor.conversion + .for( 'downcast' ) + .attributeToAttribute( { + model: 'source', + view: 'src', + converterPriority: 'high' + } ); +``` + +First converter has the default priority, `normal`. The second converter will be called earlier because of its higher priority, thus the `source` model attribute will get converted to `src` view attribute instead of `href`. diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/intro.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/intro.md new file mode 100644 index 00000000000..7630111cf18 --- /dev/null +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/intro.md @@ -0,0 +1,20 @@ +--- +category: framework-deep-dive-conversion-helpers +menu-title: Introduction +order: 10 +since: 33.0.0 +--- + +# Introduction + +The editor is supporting out-of-the-box a vast amount of the most commonly used HTML elements via {@link features/index existing editor features}. + +If your aim is to easily enable common HTML features that are not explicitly supported by the dedicated CKEditor 5 features, use the {@link features/general-html-support General HTML Support feature}. + +Yet, there are cases where you might want to provide a rich editing experience for a custom HTML markup. The conversion helpers are the way to achieve that. + +## Helpers by category + +* **{@link framework/guides/deep-dive/conversion/helpers/downcast Downcast helpers (model to view)}** + +* **{@link framework/guides/deep-dive/conversion/helpers/upcast Upcast helpers (view to model)}** diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/upcast.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/upcast.md new file mode 100644 index 00000000000..27ed7ca9d86 --- /dev/null +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/helpers/upcast.md @@ -0,0 +1,362 @@ +--- +category: framework-deep-dive-conversion-helpers +menu-title: Upcast helpers (view to model) +order: 30 +since: 33.0.0 +--- + +# Upcast helpers (view to model) + +## Element to element + +Converting a view element to a model element is the most common case of conversion. It is used to handle view elements like `

` or `

` (which needs to be converted to model elements). + +When using the `elementToElement()` helper, a **single view element** will be converted to a **single model element**. The children of this view element have to have their own converters defined and the engine will recursively convert them and insert into the created model element. + +### Basic element to element conversion + +The simplest case of an element to element conversion, where a view element becomes a paragraph model element can be achieved by providing their names: + +```js +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: 'p', + model: 'paragraph' + } ); +``` + +The above example creates a model element `` from every `

` view element. + +### Using view element definition + +You can limit the view elements that qualify for the conversion by specifying their attributes, e.g. a class name. Provide respective element definition in the `view` property like in the example below: + +```js +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: { + name: 'p', + classes: 'fancy' + }, + model: 'fancyParagraph' + } ); +``` + +Check out the [ElementDefinition documentation](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_view_elementdefinition-ElementDefinition.html) for more details. + +### Creating a model element using a callback + +Model element resulting from the conversion can be created manually using a callback provided as a `model` property. + +```js +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: { + name: 'p', + classes: 'heading' + }, + model: ( viewElement, { writer } ) => { + return writer.createElement( 'heading' ); + } + } ); +``` + +In the example above the model element is created only from a view element `

`. The `

` elements without that class name will be omitted. + +The second parameter of the model callback is the [UpcastConversionApi](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_conversion_upcastdispatcher-UpcastConversionApi.html) object, that contains many properties and methods useful when writing more a complex converters. + +### Handling view elements with attributes + +If the model element depends not only on the view element itself but also on its attributes, you need to specify those attributes in the `view` property. + +```js +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: { + name: 'p', + attributes: [ 'data-level' ] + }, + model: ( viewElement, { writer } ) => { + return writer.createElement( 'heading', { level: viewElement.getAttribute( 'data-level' ) } ); + } + } ); +``` + + + If you forget about specifying these attributes, another converter e.g. from General HTML Support feature may also handle these attributes resulting in duplicating them in the model. + + +### Changing converter priority + +In case there are other converters with the overlapping `view` patterns already present, you can prioritize your converter in order to override them. To do so use the `converterPriority` property: + +```js +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: 'div', + model: 'mainContent', + } ); + +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: 'div', + model: 'sideContent', + converterPriority: 'high' + } ); +``` + +Above, the first converter has the default priority, `normal`. The second one override it by setting the priority to `high`. Using both of these converters at once will result in the `

` view element being converted to `sideContent`. + +Another case might be when you want your converter to act as a fallback when other converters for a given element are not present (e.g. a plugin has not been loaded) or existing converters were too specific. Achieving this is as simple as setting the `converterProperty` to `low`. + +## Element to attribute + +The element to attribute conversion is used to handle formatting view elements like `` or `` (which needs to be converted to text attributes). It is important to note that text formatting such as bold or font size should be represented in the model as text nodes attributes. + + + In general, the model does not implement a concept of “inline elements” (in the sense in which they are defined by CSS). The only scenarios in which inline elements can be used are self-contained objects such as soft breaks (`
`) or inline images. +
+ +### Basic element to attribute conversion + +```js +editor.conversion + .for( 'upcast' ) + .elementToAttribute( { + view: 'strong', + model: 'bold' + } ); +``` + +A view `CKEditor 5` will become the `"CKEditor 5"` model text node with a `bold` attribute set to `true`. + +### Converting attribute in a specific view element + +You might want to convert only view elements with a specific class name or other attribute. To achieve that you can provide [element definition](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_view_elementdefinition-ElementDefinition.html) in the `view` property. + +```js +editor.conversion + .for( 'upcast' ) + .elementToAttribute( { + view: { + name: 'span', + classes: 'bold' + }, + model: 'bold' + } ); +``` + +Check out the [ElementDefinition documentation](https://ckeditor.com/docs/ckeditor5/latest/api/module_engine_view_elementdefinition-ElementDefinition.html) for more details. + +### Setting a predefined value to model attribute + +You can specify the value model attribute will take. To achieve that provide the name of the resulting model attribute as a `key` and its value as a `value` in `model` property object: + +```js +editor.conversion + .for( 'upcast' ) + .elementToAttribute( { + view: { + name: 'span', + classes: [ 'styled', 'styled-dark' ] + }, + model: { + key: 'styled', + value: 'dark' + } + } ); +``` + +The code above will convert `CKEditor5` into a model text node `CKEditor5` with `styled` attribute set to `dark`. + +### Handling attribute values via a callback + +In case when the value of an attribute needs additional processing (like mapping, filtering, etc.) you can define the `model.value` as a callback. + +```js +editor.conversion + .for( 'upcast' ) + .elementToAttribute( { + view: { + name: 'span', + styles: { + 'font-size': /[\s\S]+/ + } + }, + model: { + key: 'fontSize', + value: ( viewElement, conversionApi ) => { + const fontSize = viewElement.getStyle( 'font-size' ); + const value = fontSize.substr( 0, fontSize.length - 2 ); + + if ( value <= 10 ) { + return 'small'; + } else if ( value > 12 ) { + return 'big'; + } + + return null; + } + } + } ); +``` + +In the above example we turn a numeric `font-size` inline style into an either `small` or `big` model attribute. + +### Changing converter priority + +You can override the existing converters by specifying higher priority, like in the example below: + +```js +editor.conversion + .for( 'upcast' ) + .elementToAttribute( { + view: 'strong', + model: 'bold' + } ); + +editor.conversion + .for( 'upcast' ) + .elementToAttribute( { + view: 'strong', + model: 'important', + converterPriority: 'high' + } ); +``` + +Above, the first converter has the default priority, `normal`. The second one overrides it by setting the priority to `high`. Using both of these converters at once will result in the `` view element being converted to an `important` model attribute. + +## Attribute to attribute + +The `attributeToAttribute()` helper allows registering a converter that handles a specific attribute and converts it to an attribute of a model element. + +Usually, when registering converters for elements (e.g. by using `elementToElement()`), you will want to handle their attributes while handling the element itself. + +The `attributeToAttribute()` helper comes handy when for some reason you can’t cover a specific attribute inside `elementToElement()`. For instance, you are extending someone else’s plugin. + + + This type of converter helper works only if there is already an element converter provided. Trying to convert to an attribute while there is no receiving model element will cause an error. + + +### Basic attribute to attribute conversion + +This conversion result in adding an attribute to a model element, based on an attribute from a view element. For example, the `src` attribute in `` will be converted to `source` in ``. + +```js +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: 'src', + model: 'source' + } ); +``` + +Another way of writing this converter is to provide a `view.key` property as in the example below: + +```js +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: { + key: 'src' + }, + model: 'source' + } ); +``` + +Both snippets will result in the creating exactly the same converter. + +### Converting specific view element’s attribute + +You can limit the element holding the attribute as well as the value of that attributes. Such a converter will be executed only in case of a full match. + +```js +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: { + name: 'p', + key: 'class', + value: 'styled-dark' + }, + model: { + key: 'styled', + value: 'dark' + } + } ); +``` + +In the example above only a `styled-dark` class of a `

` element will be converted to a model attribute `styled` with a predefined value `dark`. + +### Converting view attributes that match a more complex pattern + +The pattern provided in a `view` property can be much more elaborate. Besides a string, you can also provide a regexp or a function that takes the attribute value and returns `true` or `false`. + +```js +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: { + key: 'data-style', + value: /\S+/ + }, + model: 'styled' + } ); +``` + +In the example above we are utilizing regular expression to match only an attribute `data-style` that has no whitespace characters in its value. Attributes that match this expression will have their value assigned to a `styled` model attribute. + +### Processing attributes via callback + +In case when the value of an attribute needs additional processing (like mapping, filtering, etc.) you can define the `model.value` as a callback. + +```js +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: { + key: 'class', + value: /styled-[\S]+/ + }, + model: { + key: 'styled' + value: viewElement => { + const regexp = /styled-([\S]+)/; + const match = viewElement.getAttribute( 'class' ).match( regexp ); + + return match[ 1 ]; + } + } + } ); +``` + +The converter in the example above will extract the style name from each `class` attribute that starts with `styled-` and assign it to a model attribute `styled`. + +### Changing converter priority + +You can override the existing converters by specifying higher priority, like in the example below: + +```js +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: 'src', + model: 'source' + } ); + +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: 'src', + model: 'sourceAddress', + converterPriority: 'high' + } ); +``` + +First converter has the default priority, `normal`. The second converter will be called earlier because of its higher priority, thus the `src` view attribute will get converted to a `sourceAddress` model attribute (instead of `source`). diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/intro.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/intro.md new file mode 100644 index 00000000000..84c909ff97f --- /dev/null +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/intro.md @@ -0,0 +1,32 @@ +--- +category: framework-deep-dive-conversion +menu-title: Introduction +order: 10 +since: 33.0.0 +--- + +# Introduction + +## What is the conversion? + +As you may know, the editor works on two layers - model and view. The process of transforming one into the other is called conversion. + +When you load data into the editor, the view is created out of the markup, then, with the help of the upcast converters, the model is created. Once that is done, the model becomes the editor state. + +All changes (e.g. typing or pasting from the clipboard) are then applied directly to the model. In order to update the editing view (the one being displayed to the user), the engine transforms changes in the model to the view. The same process is executed when data needs to be generated (e.g. when you copy editor content or use `editor.getData()`). + +You can think about upcast and downcast as about processes working in opposite directions (symmetrical to each other). + +Following chapters will teach you how to create the right converter for each case, when creating your very own CKEditor 5 plugin. + +* **{@link framework/guides/deep-dive/conversion/downcast Model to view (downcast)}** + + Model has to be transformed into the view. Learn how to achieve that by creating downcast converters. + +* **{@link framework/guides/deep-dive/conversion/upcast View to model (upcast)}** + + Raw data coming into the editor has to be transformed into the model. Learn how to achieve that by creating upcast converters. + +* **{@link framework/guides/deep-dive/conversion/helpers/intro Conversion helpers}** + + There are plenty of ways to transform data between model and view. To help you do this as efficiently as possible we provided many functions speeding up this process. This chapter will help you choose the right helper for the job. diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/upcast.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/upcast.md new file mode 100644 index 00000000000..f478c5bc840 --- /dev/null +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion/upcast.md @@ -0,0 +1,209 @@ +--- +category: framework-deep-dive-conversion +menu-title: View to model (upcast) +order: 30 +since: 33.0.0 +--- + +# View to model (upcast) + +## Introduction + +The process of converting the **view** to the **model** is called an **upcast**. + +{@img assets/img/upcast-basic.svg 214 Basic upcast conversion diagram.} + +The upcast process conversion happens every time any data is being loaded into the editor. + +Incoming data becomes the view which is then converted into the model via registered converters. + +{@snippet framework/mini-inspector} + +## Registering a converter + +In order to tell the engine how to convert a specific view element into a model element, you need to register an **upcast converter** by using the `editor.conversion.for( 'upcast' )` method: + +```js +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: 'p', + model: 'paragraph' + } ); +``` + +The above converter will handle the conversion of every `

` view element to a `` model element. + +{@snippet framework/mini-inspector-paragraph} + + + This is just an example. Paragraph support is provided by the {@link api/paragraph paragraph plugin} so you don't have to write your own `

` element to `` element conversion. + + + + You just learned about the `elementToElement()` **upcast** conversion helper method! More helpers are documented in the following chapters. + + +## Upcast pipeline + +Contrary to the downcast, the upcast process happens only in the data pipeline and is called **data upcast.** + +The editing view may be changed only via changing the model first, hence editing pipeline needs only the downcast process. + +{@img assets/img/upcast-pipeline.svg 612 Upcast conversion pipeline diagram.} + +The previous code example registers a converter for both pipelines at once. It means that `` model element will be converted to a `

` view element in both **data view** and **editing view**. + +## Converting to text attribute + +View elements representing inline text formatting (such as `` or ``) need to be converted to an attribute on a model text node. + +To register such a converter, use `elementToAttribute()`: + +```js +editor.conversion + .for( 'upcast' ) + .elementToAttribute( { + view: 'strong', + model: 'bold' + } ); +``` + +Text wrapped with the `` tag will be converted to a model text node with a `bold` attribute applied to it. + +{@snippet framework/mini-inspector-bold} + + + This is just an example. Bold support is provided by the {@link features/basic-styles basic styles} plugin so you don't have to write your own strong element to bold attribute conversion. + + +If you need to “copy” an attribute from a view element to a model element, use `attributeToAttribute()`. + +Keep in mind that the model element must have its own converter registered, otherwise there is nothing the attribute can be copied to. + +```js +editor.conversion + .for( 'upcast' ) + .attributeToAttribute( { + view: 'src', + model: 'source' + } ); +``` + +Assuming that in the editor some other feature did register the `` to `` model element upcast converter, you can extend this feature to allow `src` attribute. This attribute will be converted into `source` attribute on a model element. + +{@snippet framework/mini-inspector-upcast-attribute} + + + This is just an example. Image elements and source attributes support is provided by the {@link features/images-overview images feature} so you don't have to write your own `` to `` element conversion. + + +## Converting to element + +Converting a view element to a corresponding model element can be achieved by registering the converter by using the `elementToElement()` method: + +```js +editor.conversion + .for( 'upcast' ) + .elementToElement( { + view: { + name: 'div', + classes: [ 'example' ] + }, + model: 'example' + } ); +``` + +The above converter will handle the conversion of every `

` view element into an `` model element. + +{@snippet framework/mini-inspector-upcast-element} + + + Using your own custom model element requires defining it in the schema. + + +## Converting structures + +As you may learned in the {@link framework/guides/deep-dive/conversion/downcast previous chapter}, a single model element can be downcasted into a structure of multiple view elements. + +The opposite process will have to detect that structure (e.g. the main element) and convert that into a simple model element. + +There is no `structureToElement()` helper available for the upcast conversion. In order to register upcast converter for the entire structure and create just one model element, you must use the event based API like in the following example: + +```js +editor.conversion.for( 'upcast' ).add( dispatcher => { + // Look for every view div element. + dispatcher.on( 'element:div', ( evt, data, conversionApi ) => { + // Get all the necessary items from the conversion API object. + const { + consumable, + writer, + safeInsert, + convertChildren, + updateConversionResult + } = conversionApi; + + // Get view item from data object. + const { viewItem } = data; + + // Define elements consumables. + const wrapper = { name: true, classes: 'wrapper' }; + const innerWrapper = { name: true, classes: 'inner-wrapper' }; + + // Tests if the view element can be consumed. + if ( !consumable.test( viewItem, wrapper ) ) { + return; + } + + // Check if there is only one child. + if ( viewItem.childCount !== 1 ) { + return; + } + + // Get the first child element. + const firstChildItem = viewItem.getChild( 0 ); + + // Check if the first element is a div. + if ( !firstChildItem.is( 'element', 'div' ) ) { + return; + } + + // Tests if the first child element can be consumed. + if ( !consumable.test( firstChildItem, innerWrapper ) ) { + return; + } + + // Create model element. + const modelElement = writer.createElement( 'myElement' ); + + // Insert element on a current cursor location. + if ( !safeInsert( modelElement, data.modelCursor ) ) { + return; + } + + // Consume the main outer wrapper element. + consumable.consume( viewItem, wrapper ); + // Consume the inner wrapper element. + consumable.consume( firstChildItem, innerWrapper ); + + // Handle children conversion inside inner wrapper element. + convertChildren( firstChildItem, modelElement ); + + // Necessary function call to help setting model range and cursor + // for some specific cases when elements being split. + updateConversionResult( modelElement, data ); + } ); +} ); +``` + +The above converter will detect all `

...

` structures (by scanning for the outer `
` and turn those into a single `` model element). + +{@snippet framework/mini-inspector-structure} + + + Using your own custom model element requires defining it in the schema. + + +## Read next + +{@link framework/guides/deep-dive/conversion/helpers/intro Conversion helpers} diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/custom-element-conversion.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/custom-element-conversion.md deleted file mode 100644 index 940eecdd503..00000000000 --- a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/custom-element-conversion.md +++ /dev/null @@ -1,397 +0,0 @@ ---- -category: framework-deep-dive-conversion -menu-title: Custom element conversion -order: 40 ---- - -{@snippet framework/build-custom-element-converter-source} - -There are three levels on which elements can be converted: - -* By using the two-way converter: {@link module:engine/conversion/conversion~Conversion#elementToElement `conversion.elementToElement()`}. - This is a fully declarative API. It is the least powerful option but it is the easiest one to use. -* By using one-way converters: for example {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `conversion.for( 'downcast' ).elementToElement()`} and {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToElement `conversion.for( 'upcast' ).elementToElement()`}. - In this case, you need to define at least two converters (for upcast and downcast), but the "how" part becomes a callback, and hence you gain more control over it. -* Finally, by using event-based converters. - In this case, you need to listen to events fired by {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} and {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}. This method has full access to every bit of logic that a converter needs to implement and therefore it can be used to write the most complex conversion methods. - -This guide explains how to migrate from a simple two-way converter to an event-based converter as the requirements regarding the feature get more complex. - -## Introduction - -Let us assume that the content in your application contains "info boxes". As for now, it was only required to wrap a part of the content in a `
` element that would look like this in the data and editing views: - -```html -
- -

This is important!

-
-``` - -The data is represented in the model as the following structure: - -```html - - - <$text>This is <$text bold="true">important! - -``` - -This can be easily done with the below schema and converters in a simple `InfoBox` plugin: - -```js -class InfoBox { - constructor( editor ) { - // 1. Define infoBox as an object that can contain any other content. - editor.model.schema.register( 'infoBox', { - allowWhere: '$block', - allowContentOf: '$root', - isObject: true - } ); - - // 2. The conversion is straightforward: - editor.conversion.elementToElement( { - model: 'infoBox', - view: { - name: 'div', - classes: 'info-box' - } - } ); - } -} -``` - -## Migrating to an event-based converter - -Let us now assume that the requirements have changed and there is a need for adding an additional element in the data and editing views that will display the type of the info box (warning, error, info, etc.). - -The new info box structure: - -```html -
-
Warning
-
- -

This is important!

-
-
-``` - -The "Warning" part should not be editable. It defines the type of the info box so you can store this bit of information as an attribute of the `` element: - -```html - - - <$text>This is <$text bold="true">important! - -``` - -Let us see how to update the basic implementation to cover these requirements. - -### Demo - -Below is a demo of the editor with a sample info box. - -{@snippet framework/extending-content-custom-element-converter} - -### Schema - -The type of the box is defined by an additional class on the main `
` but it is also represented as text in `
`. All the info box content must now be placed inside `
` instead of the main wrapper. - -For the above requirements you can see that the model structure of the `infoBox` does not need to change much. You can still use a single element in the model. The only addition to the model is an attribute that will store information about the info box type: - -```js -editor.model.schema.register( 'infoBox', { - allowWhere: '$block', - allowContentOf: '$root', - isObject: true, - allowAttributes: [ 'infoBoxType' ] // Added. -} ); -``` - -### Event-based upcast converter - -The conversion of the type of the box itself can be achieved by using {@link module:engine/conversion/conversion~Conversion#attributeToAttribute `attributeToAttribute()`} (`info-box-*` CSS classes to the `infoBoxType` model attribute). However, two more changes were made to the data format that you need to handle: - -* There is a new `
` element that should be ignored during the upcast conversion as it duplicates the information conveyed by the main element's CSS class. -* The content of the info box is now located inside another element. Previously it was located directly in the main wrapper. - -Neither two-way nor one-way converters can handle such conversion. Therefore, you need to use an event-based converter with the following behavior: - -1. Create a model `` element with the `infoBoxType` attribute. -1. Skip the conversion of `
` as the information about type can be obtained from the wrapper's CSS classes. -1. Convert the children of `
` and insert them directly into ``. - -```js -function upcastConverter( event, data, conversionApi ) { - const viewInfoBox = data.viewItem; - - // Check whether the view element is an info box
. - // Otherwise, it should be handled by another converter. - if ( !viewInfoBox.hasClass( 'info-box' ) ) { - return; - } - - // Create the model structure. - const modelElement = conversionApi.writer.createElement( 'infoBox', { - infoBoxType: getTypeFromViewElement( viewInfoBox ) - } ); - - // Try to safely insert the element into the model structure. - // If `safeInsert()` returns `false`, the element cannot be safely inserted - // into the content and the conversion process must stop. - // This may happen if the data that you are converting has an incorrect structure - // (e.g. it was copied from an external website). - if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) { - return; - } - - // Mark the info box
as handled by this converter. - conversionApi.consumable.consume( viewInfoBox, { name: true } ); - - // Let us assume that the HTML structure is always the same. - // Note: For full bulletproofing this converter, you should also check - // whether these elements are the right ones. - const viewInfoBoxTitle = viewInfoBox.getChild( 0 ); - const viewInfoBoxContent = viewInfoBox.getChild( 1 ); - - // Mark info box inner elements (title and content
s) as handled by this converter. - conversionApi.consumable.consume( viewInfoBoxTitle, { name: true } ); - conversionApi.consumable.consume( viewInfoBoxContent, { name: true } ); - - // Let the editor handle the children of
. - conversionApi.convertChildren( viewInfoBoxContent, modelElement ); - - // Finally, update the conversion's modelRange and modelCursor. - conversionApi.updateConversionResult( modelElement, data ); -} - -// A helper function to read the type from the view classes. -function getTypeFromViewElement( viewElement ) { - if ( viewElement.hasClass( 'info-box-info' ) ) { - return 'Info'; - } - - if ( viewElement.hasClass( 'info-box-warning' ) ) { - return 'Warning'; - } - - return 'None'; -} -``` - -This upcast converter callback can now be plugged by adding a listener to the {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#element `UpcastDispatcher#element` event}. You will listen to `element:div` to ensure that the callback is called only for `
` elements. - -```js -editor.conversion.for( 'upcast' ) - .add( dispatcher => dispatcher.on( 'element:div', upcastConverter ) ); -``` - -### Event-based downcast converter - -The missing bits are the downcast converters for the editing and data pipelines. - -You will want to use the widget system to make the info box behave like an "object". Another aspect that you need to take care of is the fact that the view structure has more elements than the model structure. In this case, you could actually use one-way converters. However, this tutorial will showcase how an event-based converter would look. - - - See the {@link framework/guides/tutorials/implementing-a-block-widget Implementing a block widget guide} to learn about the widget system. - - -The remaining downcast converters: - -```js -function editingDowncastConverter( event, data, conversionApi ) { - let { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi ); - - // Decorate view items as a widget and widget editable area. - infoBox = toWidget( infoBox, conversionApi.writer, { label: 'info box widget' } ); - infoBoxContent = toWidgetEditable( infoBoxContent, conversionApi.writer ); - - insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent ); -} - -function dataDowncastConverter( event, data, conversionApi ) { - const { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi ); - - insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent ); -} - -function createViewElements( data, conversionApi ) { - const type = data.item.getAttribute( 'infoBoxType' ); - - const infoBox = conversionApi.writer.createContainerElement( 'div', { - class: `info-box info-box-${ type.toLowerCase() }` - } ); - const infoBoxContent = conversionApi.writer.createEditableElement( 'div', { - class: 'info-box-content' - } ); - - const infoBoxTitle = conversionApi.writer.createUIElement( 'div', - { class: 'info-box-title' }, - function( domDocument ) { - const domElement = this.toDomElement( domDocument ); - - domElement.innerText = type; - - return domElement; - } ); - - return { infoBox, infoBoxContent, infoBoxTitle }; -} - -function insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent ) { - conversionApi.consumable.consume( data.item, 'insert' ); - - conversionApi.writer.insert( - conversionApi.writer.createPositionAt( infoBox, 0 ), - infoBoxTitle - ); - conversionApi.writer.insert( - conversionApi.writer.createPositionAt( infoBox, 1 ), - infoBoxContent - ); - - // The mapping between the model and its view representation. - conversionApi.mapper.bindElements( data.item, infoBox ); - - conversionApi.writer.insert( - conversionApi.mapper.toViewPosition( data.range.start ), - infoBox - ); -} -``` - -These two converters need to be plugged as listeners into the {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#insert `DowncastDispatcher#insert` event}: - -```js -editor.conversion.for( 'editingDowncast' ) - .add( dispatcher => dispatcher.on( 'insert:infoBox', editingDowncastConverter ) ); -editor.conversion.for( 'dataDowncast' ) - .add( dispatcher => dispatcher.on( 'insert:infoBox', dataDowncastConverter ) ); -``` - -Due to the fact that the info box's view structure is more complex than its model structure, you need to take care of one additional aspect to make the converters work — position mapping. - -### The model-to-view position mapping - -The downcast converters shown in the previous section will not work correctly yet. This is what the given model would look like, after being downcasted: - -``` - ->
- ->

- Foobar -> Foobar - ->

-
Info
-
- ->
-``` - -This is not a correct view structure. The content of the model's `` element ended up directly inside the outer `
`. The ``'s content should be inside the `
`. - -You defined downcast conversion for `` itself, but you need to specify where its content should land in its view structure. By default, it is converted as direct children of `
` (as shown in the above snippet) but it should go into `
`. To achieve this, you need to register a callback for the {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition `Mapper#modelToViewPosition`} event, so the positions inside the model `` element would map to the positions inside the `
` view element. - -``` - ->
-
Info
-
- ->

- Foobar -> Foobar - ->

-
- ->
-``` - -Such a mapping can be achieved by registering this callback to the {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition `Mapper#modelToViewPosition`} event: - -```js -function createModelToViewPositionMapper( view ) { - return ( evt, data ) => { - const modelPosition = data.modelPosition; - const parent = modelPosition.parent; - - // Only the mapping of positions that are directly in - // the model element should be modified. - if ( !parent.is( 'element', 'infoBox' ) ) { - return; - } - - // Get the mapped view element
. - const viewElement = data.mapper.toViewElement( parent ); - - // Find the
in it. - const viewContentElement = findContentViewElement( view, viewElement ); - - // Translate the model position offset to the view position offset. - data.viewPosition = data.mapper.findPositionIn( viewContentElement, modelPosition.offset ); - }; -} - -// Returns the
nested in the info box view structure. -function findContentViewElement( editingView, viewElement ) { - for ( const value of editingView.createRangeIn( viewElement ) ) { - if ( value.item.is( 'element', 'div' ) && value.item.hasClass( 'info-box-content' ) ) { - return value.item; - } - } -} -``` - -It needs to be plugged into the {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition `Mapper#modelToViewPosition`} event for both downcast pipelines: - -```js -editor.editing.mapper.on( 'modelToViewPosition', createModelToViewPositionMapper( editor.editing.view ) ); -editor.data.mapper.on( 'modelToViewPosition', createModelToViewPositionMapper( editor.editing.view ) ); -``` - - - **Note**: You do not need the reverse position mapping ({@link module:engine/conversion/mapper~Mapper#event:viewToModelPosition from the view to the model}) because the default view-to-model position mapping looks for the {@link module:engine/conversion/mapper~Mapper#findMappedViewAncestor mapped view ancestor} and maps the offset in respect to the model element. - - -### Updated plugin code - -The updated `InfoBox` plugin that glues the event-based converters together: - -```js -class InfoBox { - constructor( editor ) { - // Schema definition. - editor.model.schema.register( 'infoBox', { - allowWhere: '$block', - allowContentOf: '$root', - isObject: true, - allowAttributes: [ 'infoBoxType' ] - } ); - - // Upcast converter. - editor.conversion.for( 'upcast' ) - .add( dispatcher => dispatcher.on( 'element:div', upcastConverter ) ); - - // The downcast conversion must be split as you need a widget in the editing pipeline. - editor.conversion.for( 'editingDowncast' ) - .add( dispatcher => dispatcher.on( 'insert:infoBox', editingDowncastConverter ) ); - editor.conversion.for( 'dataDowncast' ) - .add( dispatcher => dispatcher.on( 'insert:infoBox', dataDowncastConverter ) ); - - // The model-to-view position mapper is needed since the model content needs to end up in the inner - //
. - editor.editing.mapper.on( 'modelToViewPosition', createModelToViewPositionMapper( editor.editing.view ) ); - editor.data.mapper.on( 'modelToViewPosition', createModelToViewPositionMapper( editor.editing.view ) ); - } -} - -function upcastConverter() { - // ... -} - -function editingDowncastConverter() { - // ... -} - -function dataDowncastConverter() { - // ... -} - -function createModelToViewPositionMapper() { - // ... -} -``` diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/element-reconversion.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/element-reconversion.md deleted file mode 100644 index b278be05bbc..00000000000 --- a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/element-reconversion.md +++ /dev/null @@ -1,626 +0,0 @@ ---- -category: framework-deep-dive-conversion -order: 50 -since: 24.0.0 ---- - -# Element reconversion - -{@snippet framework/build-element-reconversion-source} - - - Element reconversion is currently in beta version. The API will be extended to support more cases and will be changing with time. - - -This guide introduces the concept of the _reconversion of model elements_ during the downcast (model-to-view) {@link framework/guides/architecture/editing-engine#conversion conversion}. - -Reconversion allows simplifying downcast converters for model elements by merging multiple separate converters into a single converter that reacts to more types of model changes. - -## Prerequisites - -To better understand the concepts used in this guide, it is recommended to familiarize yourself with other conversion guides, too: - -* {@link framework/guides/tutorials/implementing-a-block-widget Implementing a block widget} -* {@link framework/guides/deep-dive/custom-element-conversion Custom element conversion} - -## Atomic converters vs element reconversion - -In order to convert a model element to its view representation, you often write the following converters: - -* An `elementToElement()` converter. This converter reacts to the insertion of a model element specified in the `model` field. -* If the model element has attributes and these attributes may change with time, you need to add the `attributeToAttribute()` converters for each attribute. These converters react to changes in the model element attributes and update the view accordingly. - -This granular approach to conversion is used by many editor features as it ensures extensibility of the base features and provides a separation of concerns. For example, the {@link features/images-overview base image feature} provides conversion for a simple `` model element, while the {@link features/images-resizing image resize feature} adds support for the `width` and `height` attributes, the {@link features/images-captions image caption feature} for the `
` HTML element, and so on. - -Apart from the extensibility aspect, the above approach ensures that a change of a model attribute or structure requires minimal changes in the view. - -However, in some cases where granularity is not necessary this approach may be an overkill. Consider a case in which you need to create a multi-layer view structure for one model element, or a case in which the view structure depends on a value of a model attribute. In such cases, writing a separate converter for a model element and separate converters for each attribute becomes cumbersome. - -Thankfully, element reconversion allows merging these converters into a single converter that reacts to multiple types of model changes (element insertion, its attribute changes and changes in its direct children). This approach can be considered more "functional" as the `view` callback executed on any of these changes should produce the entire view structure (down to a certain level) without taking into account what state changes have just happened. - -An additional perk of using element reconversion is that the parts of the model tree that have not been changed, like paragraphs and text inside your feature element, will not be reconverted. In other words, their view elements are kept in memory and re-used inside the changed parent. - -To sum up, element reconversion comes in handy for cases where you need to convert a relatively simple model to a complex view structure. And also, writing a single functional converter is easier to grasp in your project. - -## Enabling element reconversion - -Element reconversion is enabled by setting the reconversion trigger configuration (`triggerBy`) for the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} downcast helper. - -The model element can be reconverted when: - -* one or many attributes change (using `triggerBy.attributes`) or -* a child is inserted or removed (using `triggerBy.children`) - - - Note that when using the `children` configuration option, the current implementation assumes that the downcast converter will either: - * handle an element and its children conversion at once, - * have a "flat" structure. - - -A simple example of an element reconversion configuration is demonstrated below: - -```js -editor.conversion.for( 'downcast' ).elementToElement( { - model: 'myElement', - view: ( modelElement, { writer } ) => { - return writer.createContainerElement( 'div', { - 'data-owner-id': modelElement.getAttribute( 'ownerId' ), - class: `my-element my-element-${ modelElement.getAttribute( 'type' ) }` - } ); - }, - triggerBy: { - attributes: [ 'ownerId', 'type' ] - } -} ) -``` - -In this example: - -* The downcast converter for `myElement` creates a `
` with a `data-owner-id` attribute and a set of CSS classes. -* The value of `data-owner-id` is set from the `ownerId` model element's attribute. -* The second CSS class is constructed off the `type` model element's attribute. -* The `triggerBy.attributes` configuration defines that the element will be converted upon changes of the `onwerId` or `type` attributes. - -Before CKEditor version `23.1.0` you would have to define a set of atomic converters for the element and for each attribute: - -```js -editor.conversion.for( 'downcast' ) - .elementToElement( { - model: 'myElement', - view: 'div' - } ) - .attributeToAttribute( { - model: 'owner-id', - view: 'data-owner-id' - } ) - .attributeToAttribute( { - model: 'type', - view: modelAttributeValue => ( { - key: 'class', - value: `my-element my-element-${ modelAttributeValue }` - } ) - } ); -``` - -## Example implementation - -In this example implementation you will implement a "card" box which is displayed beside the main article content. The card will contain a text-only title, one to four content sections and an optional URL. Additionally, the user can choose the type of the card. - -### Demo - -{@snippet framework/element-reconversion-demo} - -### Model and view structure - -A simplified model markup for the side card looks as follows: - -```html - - The title - - The content - - -``` - -This will be converted to the below view structure: - -```html - -``` - -In the above example you can observe that the `'cardURL'` model attribute is converted as a view element inside the main view container while the type attribute is translated to a CSS class. Additionally, the UI controls are injected to the view after all other child views of the main container. Describing it using atomic converters would introduce a convoluted complexity. - -### Schema - -The side card model structure is represented in the editor's {@link framework/guides/deep-dive/schema schema} as follows: - -```js -// The main element with attributes for type and URL: -schema.register( 'sideCard', { - allowWhere: '$block', - isObject: true, - allowAttributes: [ 'cardType', 'cardURL' ] -} ); -// Disallow side card nesting. -schema.addChildCheck( ( context, childDefinition ) => { - if ( [ ...context.getNames() ].includes( 'sideCard' ) && childDefinition.name === 'sideCard' ) { - return false; - } -} ); - -// A text-only title. -schema.register( 'sideCardTitle', { - isLimit: true, - allowIn: 'sideCard' -} ); -// Allow text in title... -schema.extend( '$text', { allowIn: 'sideCardTitle' } ); -// ...but disallow any text attribute inside. -schema.addAttributeCheck( context => { - if ( context.endsWith( 'sideCardTitle $text' ) ) { - return false; - } -} ); - -// A content block which can have any content allowed in $root. -schema.register( 'sideCardSection', { - isLimit: true, - allowIn: 'sideCard', - allowContentOf: '$root' -} ); -``` - -### Reconversion definition - -To enable element reconversion, define for which attribute and children modifications the main element will be converted: - -```js -conversion.for( 'editingDowncast' ).elementToElement( { - model: 'sideCard', - view: downcastSideCard( editor, { asWidget: true } ), - triggerBy: { - attributes: [ 'cardType', 'cardURL' ], - children: [ 'sideCardSection' ] - } -} ); -``` - -The above definition will use the `downcastSideCard()` function to re-create the view when: - -* The `sideCard` element is inserted into the model. -* One of `cardType` or `cardURL` has changed. -* A child `sideCardSection` is added or removed from the parent `sideCard`. - -### Downcast converter details - -The function that creates a complete view for the model element: - -```js -const downcastSideCard = ( editor, { asWidget } ) => { - return ( modelElement, { writer, consumable, mapper } ) => { - const type = modelElement.getAttribute( 'cardType' ) || 'default'; - - // The main view element for the side card. - const sideCardView = writer.createContainerElement( 'aside', { - class: `side-card side-card-${ type }` - } ); - - // Create inner views from the side card children. - for ( const child of modelElement.getChildren() ) { - const childView = writer.createEditableElement( 'div' ); - - // Child is either a "title" or "section". - if ( child.is( 'element', 'sideCardTitle' ) ) { - writer.addClass( 'side-card-title', childView ); - } else { - writer.addClass( 'side-card-section', childView ); - } - - // It is important to consume and bind converted elements. - consumable.consume( child, 'insert' ); - mapper.bindElements( child, childView ); - - // Make it an editable part of the widget. - if ( asWidget ) { - toWidgetEditable( childView, writer ); - } - - writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView ); - } - - const urlAttribute = modelElement.getAttribute( 'cardURL' ); - - // Do not render an empty URL field - if ( urlAttribute ) { - const urlBox = writer.createRawElement( 'div', { - class: 'side-card-url' - }, function( domElement ) { - domElement.innerText = `URL: "${ urlAttribute }"`; - } ); - - writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox ); - } - - // Inner element used to render a simple UI that allows to change the side card's attributes. - // It will only be needed in the editing view inside the widgetized element. - // The data output should not contain this section. - if ( asWidget ) { - const actionsView = writer.createRawElement( 'div', { - class: 'side-card-actions', - contenteditable: 'false', // Prevents editing of the element. - 'data-cke-ignore-events': 'true' // Allows using custom UI elements inside the editing view. - }, createActionsView( editor, modelElement ) ); // See the full code for details. - - writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView ); - - toWidget( sideCardView, writer, { widgetLabel: 'Side card', hasSelectionHandle: true } ); - } - - return sideCardView; - }; -}; -``` - -By using `mapper.bindElements( child, childView )` for `` and `` you define which view elements correspond to which model elements. This allows the editor's conversion to re-use the existing view elements for the title and section children, so they will not be re-converted without a need. - -### Upcast conversion - -The upcast conversion uses standard element-to-element converters for the box and title, and a custom converter for the side card to extract metadata from the data. - -```js -editor.conversion.for( 'upcast' ) - .elementToElement( { - view: { name: 'aside', classes: [ 'side-card' ] }, - model: upcastCard // Details in the full source code. - } ) - .elementToElement( { - view: { name: 'div', classes: [ 'side-card-title' ] }, - model: 'sideCardTitle' - } ) - .elementToElement( { - view: { name: 'div', classes: [ 'side-card-section' ] }, - model: 'sideCardSection' - } ); -``` - -You can see the details of the upcast converter function (`upcastCard()`) in the full source code at the end of this guide. - -### Full source code - -```js -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import Command from '@ckeditor/ckeditor5-core/src/command'; -import { toWidget, toWidgetEditable, findOptimalInsertionRange } from '@ckeditor/ckeditor5-widget/src/utils'; -import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement'; - -/** - * Helper for extracting the side card type from a view element based on its CSS class. - */ -const getTypeFromViewElement = viewElement => { - for ( const type of [ 'default', 'alternate' ] ) { - if ( viewElement.hasClass( `side-card-${ type }` ) ) { - return type; - } - } - - return 'default'; -}; - -/** - * Single upcast converter to the element with all its attributes. - */ -const upcastCard = ( viewElement, { writer } ) => { - const sideCard = writer.createElement( 'sideCard' ); - - const type = getTypeFromViewElement( viewElement ); - writer.setAttribute( 'cardType', type, sideCard ); - - const urlWrapper = [ ...viewElement.getChildren() ].find( child => { - return child.is( 'element', 'div' ) && child.hasClass( 'side-card-url' ); - } ); - - if ( urlWrapper ) { - writer.setAttribute( 'cardURL', urlWrapper.getChild( 0 ).data, sideCard ); - } - - return sideCard; -}; - -/** - * Helper for creating a DOM button with an editor callback. - */ -const addActionButton = ( text, callback, domElement, editor ) => { - const domDocument = domElement.ownerDocument; - - const button = createElement( domDocument, 'button', {}, [ text ] ); - - button.addEventListener( 'click', () => { - editor.model.change( callback ); - } ); - - domElement.appendChild( button ); - - return button; -}; - -/** - * Helper function that creates the card editing UI inside the card. - */ -const createActionsView = ( editor, modelElement ) => function( domElement ) { - // - // Set the URL action button. - // - addActionButton( 'Set URL', writer => { - // eslint-disable-next-line no-alert - const newURL = prompt( 'Set URL', modelElement.getAttribute( 'cardURL' ) || '' ); - - writer.setAttribute( 'cardURL', newURL, modelElement ); - }, domElement, editor ); - - const currentType = modelElement.getAttribute( 'cardType' ); - const newType = currentType === 'default' ? 'alternate' : 'default'; - - // - // Change the card action button. - // - addActionButton( 'Change type', writer => { - writer.setAttribute( 'cardType', newType, modelElement ); - }, domElement, editor ); - - const childCount = modelElement.childCount; - - // - // Add the content section to the card action button. - // - const addButton = addActionButton( 'Add section', writer => { - writer.insertElement( 'sideCardSection', modelElement, 'end' ); - }, domElement, editor ); - - // Disable the button so only 1-3 content boxes are in the card (there will always be a title). - if ( childCount > 4 ) { - addButton.setAttribute( 'disabled', 'disabled' ); - } - - // - // Remove the content section from the card action button. - // - const removeButton = addActionButton( 'Remove section', writer => { - writer.remove( modelElement.getChild( childCount - 1 ) ); - }, domElement, editor ); - - // Disable the button so only 1-3 content boxes are in the card (there will always be a title). - if ( childCount < 3 ) { - removeButton.setAttribute( 'disabled', 'disabled' ); - } -}; - -/** - * The downcast converter for the element. - * - * It returns the full view structure based on the current state of the model element. - */ -const downcastSideCard = ( editor, { asWidget } ) => { - return ( modelElement, { writer, consumable, mapper } ) => { - const type = modelElement.getAttribute( 'cardType' ) || 'default'; - - // The main view element for the side card. - const sideCardView = writer.createContainerElement( 'aside', { - class: `side-card side-card-${ type }` - } ); - - // Create inner views from the side card children. - for ( const child of modelElement.getChildren() ) { - const childView = writer.createEditableElement( 'div' ); - - // Child is either a "title" or "section". - if ( child.is( 'element', 'sideCardTitle' ) ) { - writer.addClass( 'side-card-title', childView ); - } else { - writer.addClass( 'side-card-section', childView ); - } - - // It is important to consume and bind converted elements. - consumable.consume( child, 'insert' ); - mapper.bindElements( child, childView ); - - // Make it an editable part of the widget. - if ( asWidget ) { - toWidgetEditable( childView, writer ); - } - - writer.insert( writer.createPositionAt( sideCardView, 'end' ), childView ); - } - - const urlAttribute = modelElement.getAttribute( 'cardURL' ); - - // Do not render an empty URL field. - if ( urlAttribute ) { - const urlBox = writer.createRawElement( 'div', { - class: 'side-card-url' - }, function( domElement ) { - domElement.innerText = `URL: "${ urlAttribute }"`; - } ); - - writer.insert( writer.createPositionAt( sideCardView, 'end' ), urlBox ); - } - - // Inner element used to render a simple UI that allows to change the side card's attributes. - // It will only be needed in the editing view inside the widgetized element. - // The data output should not contain this section. - if ( asWidget ) { - const actionsView = writer.createRawElement( 'div', { - class: 'side-card-actions', - contenteditable: 'false', // Prevents editing of the element. - 'data-cke-ignore-events': 'true' // Allows using custom UI elements inside the editing view. - }, createActionsView( editor, modelElement ) ); // See the full code for details. - - writer.insert( writer.createPositionAt( sideCardView, 'end' ), actionsView ); - - toWidget( sideCardView, writer, { widgetLabel: 'Side card' } ); - } - - return sideCardView; - }; -}; - -class InsertCardCommand extends Command { - /** - * Refresh used schema definition to check if a side card can be inserted in the current selection. - */ - refresh() { - const model = this.editor.model; - const range = findOptimalInsertionRange( model.document.selection, model ); - - this.isEnabled = model.schema.checkChild( validParent, 'sideCard' ); - } - - /** - * Creates a full side card element with all required children and attributes. - */ - execute() { - const model = this.editor.model; - const selection = model.document.selection; - - const insertionRange = findOptimalInsertionRange( selection, model ); - - model.change( writer => { - const sideCard = writer.createElement( 'sideCard', { cardType: 'default' } ); - const title = writer.createElement( 'sideCardTitle' ); - const section = writer.createElement( 'sideCardSection' ); - const paragraph = writer.createElement( 'paragraph' ); - - writer.insert( title, sideCard, 0 ); - writer.insert( section, sideCard, 1 ); - writer.insert( paragraph, section, 0 ); - - model.insertContent( sideCard, insertionRange ); - - writer.setSelection( writer.createPositionAt( title, 0 ) ); - } ); - } -} - -class ComplexBox extends Plugin { - constructor( editor ) { - super( editor ); - - this._defineSchema(); - this._defineConversion(); - - editor.commands.add( 'insertCard', new InsertCardCommand( editor ) ); - - this._defineUI(); - } - - _defineConversion() { - const editor = this.editor; - const conversion = editor.conversion; - - conversion.for( 'upcast' ) - .elementToElement( { - view: { name: 'aside', classes: [ 'side-card' ] }, - model: upcastCard - } ) - .elementToElement( { - view: { name: 'div', classes: [ 'side-card-title' ] }, - model: 'sideCardTitle' - } ) - .elementToElement( { - view: { name: 'div', classes: [ 'side-card-section' ] }, - model: 'sideCardSection' - } ); - - // The downcast conversion must be split as you need a widget in the editing pipeline. - conversion.for( 'editingDowncast' ).elementToElement( { - model: 'sideCard', - view: downcastSideCard( editor, { asWidget: true } ), - triggerBy: { - attributes: [ 'cardType', 'cardURL' ], - children: [ 'sideCardSection' ] - } - } ); - // The data downcast is always executed from the current model stat, so `triggerBy` will take no effect. - conversion.for( 'dataDowncast' ).elementToElement( { - model: 'sideCard', - view: downcastSideCard( editor, { asWidget: false } ) - } ); - } - - _defineSchema() { - const schema = this.editor.model.schema; - - // The main element with attributes for type and URL: - schema.register( 'sideCard', { - allowWhere: '$block', - isObject: true, - allowAttributes: [ 'cardType', 'cardURL' ] - } ); - // Disallow side card nesting. - schema.addChildCheck( ( context, childDefinition ) => { - if ( [ ...context.getNames() ].includes( 'sideCard' ) && childDefinition.name === 'sideCard' ) { - return false; - } - } ); - - // A text-only title. - schema.register( 'sideCardTitle', { - isLimit: true, - allowIn: 'sideCard' - } ); - // Allow text in title... - schema.extend( '$text', { allowIn: 'sideCardTitle' } ); - // ...but disallow any text attribute inside. - schema.addAttributeCheck( context => { - if ( context.endsWith( 'sideCardTitle $text' ) ) { - return false; - } - } ); - - // A content block which can have any content allowed in $root. - schema.register( 'sideCardSection', { - isLimit: true, - allowIn: 'sideCard', - allowContentOf: '$root' - } ); - } - - _defineUI() { - const editor = this.editor; - - // Defines a simple text button. - editor.ui.componentFactory.add( 'complexBox', locale => { - const button = new ButtonView( locale ); - - const command = editor.commands.get( 'insertComplexBox' ); - - button.set( { - withText: true, - icon: false, - label: 'Complex Box' - } ); - - button.bind( 'isEnabled' ).to( command ); - - button.on( 'execute', () => { - editor.execute( 'insertComplexBox' ); - editor.editing.view.focus(); - } ); - - return button; - } ); - } -} -``` diff --git a/packages/ckeditor5-engine/src/controller/datacontroller.js b/packages/ckeditor5-engine/src/controller/datacontroller.js index 29013fdf483..de4a357a1da 100644 --- a/packages/ckeditor5-engine/src/controller/datacontroller.js +++ b/packages/ckeditor5-engine/src/controller/datacontroller.js @@ -14,7 +14,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import Mapper from '../conversion/mapper'; import DowncastDispatcher from '../conversion/downcastdispatcher'; -import { insertText } from '../conversion/downcasthelpers'; +import { insertAttributesAndChildren, insertText } from '../conversion/downcasthelpers'; import UpcastDispatcher from '../conversion/upcastdispatcher'; import { convertText, convertToModelFragment } from '../conversion/upcasthelpers'; @@ -81,6 +81,7 @@ export default class DataController { schema: model.schema } ); this.downcastDispatcher.on( 'insert:$text', insertText(), { priority: 'lowest' } ); + this.downcastDispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); /** * Upcast dispatcher used by the {@link #set set method}. Upcast converters should be attached to it. @@ -243,27 +244,16 @@ export default class DataController { this.mapper.bindElements( modelElementOrFragment, viewDocumentFragment ); - // Make additional options available during conversion process through `conversionApi`. - this.downcastDispatcher.conversionApi.options = options; - - // We have no view controller and rendering to DOM in DataController so view.change() block is not used here. - this.downcastDispatcher.convertInsert( modelRange, viewWriter ); - - // Convert markers. + // Prepare list of markers. // For document fragment, simply take the markers assigned to this document fragment. // For model root, all markers in that root will be taken. // For model element, we need to check which markers are intersecting with this element and relatively modify the markers' ranges. // Collapsed markers at element boundary, although considered as not intersecting with the element, will also be returned. const markers = modelElementOrFragment.is( 'documentFragment' ) ? - Array.from( modelElementOrFragment.markers ) : + modelElementOrFragment.markers : _getMarkersRelativeToElement( modelElementOrFragment ); - for ( const [ name, range ] of markers ) { - this.downcastDispatcher.convertMarkerAdd( name, range, viewWriter ); - } - - // Clean `conversionApi`. - delete this.downcastDispatcher.conversionApi.options; + this.downcastDispatcher.convert( modelRange, markers, viewWriter, options ); return viewDocumentFragment; } @@ -547,7 +537,7 @@ function _getMarkersRelativeToElement( element ) { const doc = element.root.document; if ( !doc ) { - return []; + return new Map(); } const elementRange = ModelRange._createIn( element ); @@ -581,7 +571,7 @@ function _getMarkersRelativeToElement( element ) { // reverse DOM order, and intersecting ranges are in something approximating // reverse DOM order (since reverse DOM order doesn't have a precise meaning // when working with intersecting ranges). - return result.sort( ( [ n1, r1 ], [ n2, r2 ] ) => { + result.sort( ( [ n1, r1 ], [ n2, r2 ] ) => { if ( r1.end.compareWith( r2.start ) !== 'after' ) { // m1.end <= m2.start -- m1 is entirely <= m2 return 1; @@ -608,4 +598,6 @@ function _getMarkersRelativeToElement( element ) { } } } ); + + return new Map( result ); } diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.js b/packages/ckeditor5-engine/src/controller/editingcontroller.js index 32bb537e006..5d7a4436329 100644 --- a/packages/ckeditor5-engine/src/controller/editingcontroller.js +++ b/packages/ckeditor5-engine/src/controller/editingcontroller.js @@ -11,10 +11,18 @@ import RootEditableElement from '../view/rooteditableelement'; import View from '../view/view'; import Mapper from '../conversion/mapper'; import DowncastDispatcher from '../conversion/downcastdispatcher'; -import { clearAttributes, convertCollapsedSelection, convertRangeSelection, insertText, remove } from '../conversion/downcasthelpers'; +import { + clearAttributes, + convertCollapsedSelection, + convertRangeSelection, + insertAttributesAndChildren, + insertText, + remove +} from '../conversion/downcasthelpers'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import { convertSelectionChange } from '../conversion/upcasthelpers'; // @if CK_DEBUG_ENGINE // const { dumpTrees, initDocumentDumping } = require( '../dev-utils/utils' ); @@ -101,6 +109,7 @@ export default class EditingController { // Attach default model converters. this.downcastDispatcher.on( 'insert:$text', insertText(), { priority: 'lowest' } ); + this.downcastDispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); this.downcastDispatcher.on( 'remove', remove(), { priority: 'low' } ); // Attach default model selection converters. @@ -144,6 +153,74 @@ export default class EditingController { this.view.destroy(); this.stopListening(); } + + /** + * Calling this method will refresh the marker by triggering the downcast conversion for it. + * + * Reconverting the marker is useful when you want to change its {@link module:engine/view/element~Element view element} + * without changing any marker data. For instance: + * + * let isCommentActive = false; + * + * model.conversion.markerToHighlight( { + * model: 'comment', + * view: data => { + * const classes = [ 'comment-marker' ]; + * + * if ( isCommentActive ) { + * classes.push( 'comment-marker--active' ); + * } + * + * return { classes }; + * } + * } ); + * + * // ... + * + * // Change the property that indicates if marker is displayed as active or not. + * isCommentActive = true; + * + * // Reconverting will downcast and synchronize the marker with the new isCommentActive state value. + * editor.editing.reconvertMarker( 'comment' ); + * + * **Note**: If you want to reconvert a model item, use {@link #reconvertItem} instead. + * + * @param {String|module:engine/model/markercollection~Marker} markerOrName Name of a marker to update, or a marker instance. + */ + reconvertMarker( markerOrName ) { + const markerName = typeof markerOrName == 'string' ? markerOrName : markerOrName.name; + const currentMarker = this.model.markers.get( markerName ); + + if ( !currentMarker ) { + /** + * The marker with provided name does not exist and cannot be reconverted. + * + * @error editingcontroller-reconvertmarker-marker-not-exist + * @param {String} markerName The name of the reconverted marker. + */ + throw new CKEditorError( 'editingcontroller-reconvertmarker-marker-not-exist', this, { markerName } ); + } + + this.model.change( () => { + this.model.markers._refresh( currentMarker ); + } ); + } + + /** + * Calling this method will downcast a model item on demand (by requesting a refresh in the {@link module:engine/model/differ~Differ}). + * + * You can use it if you want the view representation of a specific item updated as a response to external modifications. For instance, + * when the view structure depends not only on the associated model data but also on some external state. + * + * **Note**: If you want to reconvert a model marker, use {@link #reconvertMarker} instead. + * + * @param {module:engine/model/item~Item} item Item to refresh. + */ + reconvertItem( item ) { + this.model.change( () => { + this.model.document.differ._refreshItem( item ); + } ); + } } mix( EditingController, ObservableMixin ); diff --git a/packages/ckeditor5-engine/src/conversion/conversion.js b/packages/ckeditor5-engine/src/conversion/conversion.js index 57a28e8fac3..57def8a4f97 100644 --- a/packages/ckeditor5-engine/src/conversion/conversion.js +++ b/packages/ckeditor5-engine/src/conversion/conversion.js @@ -140,6 +140,7 @@ export default class Conversion { * * downcast (model-to-view) conversion helpers: * * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`}, + * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure `elementToStructure()`}, * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement `attributeToElement()`}, * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToAttribute `attributeToAttribute()`}. * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToElement `markerToElement()`}. diff --git a/packages/ckeditor5-engine/src/conversion/downcastdispatcher.js b/packages/ckeditor5-engine/src/conversion/downcastdispatcher.js index 989266d0ce2..f33739cb24b 100644 --- a/packages/ckeditor5-engine/src/conversion/downcastdispatcher.js +++ b/packages/ckeditor5-engine/src/conversion/downcastdispatcher.js @@ -9,7 +9,6 @@ import Consumable from './modelconsumable'; import Range from '../model/range'; -import Position, { getNodeAfterPosition, getTextNodeAtPosition } from '../model/position'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; @@ -49,8 +48,9 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix'; * * Additionally, downcast dispatcher fires events for {@link module:engine/model/markercollection~Marker marker} changes: * - * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker} – If a marker was added. - * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:removeMarker} – If a marker was removed. + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker `addMarker`} – If a marker was added. + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:removeMarker `removeMarker`} – If a marker was + * removed. * * Note that changing a marker is done through removing the marker from the old range and adding it to the new range, * so both events are fired. @@ -58,11 +58,11 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix'; * Finally, downcast dispatcher also handles firing events for the {@link module:engine/model/selection model selection} * conversion: * - * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:selection} + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:selection `selection`} * – Converts the selection from the model to the view. - * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute} + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} * – Fired for every selection attribute. - * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker} + * * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker `addMarker`} * – Fired for every marker that contains a selection. * * Unlike the model tree and the markers, the events for selection are not fired for changes but for a selection state. @@ -70,18 +70,15 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix'; * When providing custom listeners for a downcast dispatcher, remember to check whether a given change has not been * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} yet. * - * When providing custom listeners for downcast dispatcher, keep in mind that any callback that has - * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} a value from a consumable and - * converted the change should also stop the event (for efficiency purposes). + * When providing custom listeners for a downcast dispatcher, keep in mind that you **should not** stop the event. If you stop it, + * then the default converter at the `lowest` priority will not trigger the conversion of this node's attributes and child nodes. * - * When providing custom listeners for downcast dispatcher, remember to use the provided + * When providing custom listeners for a downcast dispatcher, remember to use the provided * {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer} to apply changes to the view document. * - * You can read more about conversion in the following guides: + * You can read more about conversion in the following guide: * - * * {@glink framework/guides/deep-dive/conversion/conversion-introduction Advanced conversion concepts — attributes} - * * {@glink framework/guides/deep-dive/conversion/conversion-extending-output Extending the editor output } - * * {@glink framework/guides/deep-dive/conversion/custom-element-conversion Custom element conversion} + * * {@glink framework/guides/deep-dive/conversion/downcast Downcast conversion} * * An example of a custom converter for the downcast dispatcher: * @@ -103,9 +100,6 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix'; * * // Add the newly created view element to the view. * conversionApi.writer.insert( viewPosition, viewElement ); - * - * // Remember to stop the event propagation. - * evt.stop(); * } ); */ export default class DowncastDispatcher { @@ -118,206 +112,102 @@ export default class DowncastDispatcher { */ constructor( conversionApi ) { /** - * An interface passed by the dispatcher to the event callbacks. + * A template for an interface passed by the dispatcher to the event callbacks. * + * @protected * @member {module:engine/conversion/downcastdispatcher~DowncastConversionApi} */ - this.conversionApi = Object.assign( { dispatcher: this }, conversionApi ); + this._conversionApi = { dispatcher: this, ...conversionApi }; /** - * Maps conversion event names that will trigger element reconversion for a given element name. + * A map of already fired events for a given `ModelConsumable`. * - * @type {Map} * @private + * @member {WeakMap.} */ - this._reconversionEventsMapping = new Map(); + this._firedEventsMap = new WeakMap(); } /** - * Takes a {@link module:engine/model/differ~Differ model differ} object with buffered changes and fires conversion basing on it. + * Converts changes buffered in the given {@link module:engine/model/differ~Differ model differ} + * and fires conversion events based on it. * + * @fires insert + * @fires remove + * @fires attribute + * @fires addMarker + * @fires removeMarker + * @fires reduceChanges * @param {module:engine/model/differ~Differ} differ The differ object with buffered changes. - * @param {module:engine/model/markercollection~MarkerCollection} markers Markers connected with the converted model. + * @param {module:engine/model/markercollection~MarkerCollection} markers Markers related to the model fragment to convert. * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document. */ convertChanges( differ, markers, writer ) { + const conversionApi = this._createConversionApi( writer, differ.getRefreshedItems() ); + // Before the view is updated, remove markers which have changed. for ( const change of differ.getMarkersToRemove() ) { - this.convertMarkerRemove( change.name, change.range, writer ); + this._convertMarkerRemove( change.name, change.range, conversionApi ); } - const changes = this._mapChangesWithAutomaticReconversion( differ ); + // Let features modify the change list (for example to allow reconversion). + const changes = this._reduceChanges( differ.getChanges() ); // Convert changes that happened on model tree. for ( const entry of changes ) { if ( entry.type === 'insert' ) { - this.convertInsert( Range._createFromPositionAndShift( entry.position, entry.length ), writer ); + this._convertInsert( Range._createFromPositionAndShift( entry.position, entry.length ), conversionApi ); + } else if ( entry.type === 'reinsert' ) { + this._convertReinsert( Range._createFromPositionAndShift( entry.position, entry.length ), conversionApi ); } else if ( entry.type === 'remove' ) { - this.convertRemove( entry.position, entry.length, entry.name, writer ); - } else if ( entry.type === 'reconvert' ) { - this.reconvertElement( entry.element, writer ); + this._convertRemove( entry.position, entry.length, entry.name, conversionApi ); } else { // Defaults to 'attribute' change. - this.convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue, writer ); + this._convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue, conversionApi ); } } - for ( const markerName of this.conversionApi.mapper.flushUnboundMarkerNames() ) { + for ( const markerName of conversionApi.mapper.flushUnboundMarkerNames() ) { const markerRange = markers.get( markerName ).getRange(); - this.convertMarkerRemove( markerName, markerRange, writer ); - this.convertMarkerAdd( markerName, markerRange, writer ); + this._convertMarkerRemove( markerName, markerRange, conversionApi ); + this._convertMarkerAdd( markerName, markerRange, conversionApi ); } // After the view is updated, convert markers which have changed. for ( const change of differ.getMarkersToAdd() ) { - this.convertMarkerAdd( change.name, change.range, writer ); - } - } - - /** - * Starts a conversion of a range insertion. - * - * For each node in the range, {@link #event:insert `insert` event is fired}. For each attribute on each node, - * {@link #event:attribute `attribute` event is fired}. - * - * @fires insert - * @fires attribute - * @param {module:engine/model/range~Range} range The inserted range. - * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document. - */ - convertInsert( range, writer ) { - this.conversionApi.writer = writer; - - // Create a list of things that can be consumed, consisting of nodes and their attributes. - this.conversionApi.consumable = this._createInsertConsumable( range ); - - // Fire a separate insert event for each node and text fragment contained in the range. - for ( const data of Array.from( range ).map( walkerValueToEventData ) ) { - this._convertInsertWithAttributes( data ); + this._convertMarkerAdd( change.name, change.range, conversionApi ); } - this._clearConversionApi(); - } - - /** - * Fires conversion of a single node removal. Fires {@link #event:remove remove event} with provided data. - * - * @param {module:engine/model/position~Position} position Position from which node was removed. - * @param {Number} length Offset size of removed node. - * @param {String} name Name of removed node. - * @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify view document. - */ - convertRemove( position, length, name, writer ) { - this.conversionApi.writer = writer; - - this.fire( 'remove:' + name, { position, length }, this.conversionApi ); - - this._clearConversionApi(); - } - - /** - * Starts a conversion of an attribute change on a given `range`. - * - * For each node in the given `range`, {@link #event:attribute attribute event} is fired with the passed data. - * - * @fires attribute - * @param {module:engine/model/range~Range} range Changed range. - * @param {String} key Key of the attribute that has changed. - * @param {*} oldValue Attribute value before the change or `null` if the attribute has not been set before. - * @param {*} newValue New attribute value or `null` if the attribute has been removed. - * @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify view document. - */ - convertAttribute( range, key, oldValue, newValue, writer ) { - this.conversionApi.writer = writer; - - // Create a list with attributes to consume. - this.conversionApi.consumable = this._createConsumableForRange( range, `attribute:${ key }` ); - - // Create a separate attribute event for each node in the range. - for ( const value of range ) { - const item = value.item; - const itemRange = Range._createFromPositionAndShift( value.previousPosition, value.length ); - const data = { - item, - range: itemRange, - attributeKey: key, - attributeOldValue: oldValue, - attributeNewValue: newValue - }; - - this._testAndFire( `attribute:${ key }`, data ); - } + // Remove mappings for all removed view elements. + conversionApi.mapper.flushDeferredBindings(); - this._clearConversionApi(); + // Verify if all insert consumables were consumed. + conversionApi.consumable.verifyAllConsumed( 'insert' ); } /** - * Starts the reconversion of an element. It will: - * - * * Fire an {@link #event:insert `insert` event} for the element to reconvert. - * * Fire an {@link #event:attribute `attribute` event} for element attributes. - * - * This will not reconvert children of the element if they have existing (already converted) views. For newly inserted child elements - * it will behave the same as {@link #convertInsert}. - * - * Element reconversion is defined by the `triggerBy` configuration for the - * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} conversion helper. + * Starts a conversion of a model range and the provided markers. * * @fires insert * @fires attribute - * @param {module:engine/model/element~Element} element The element to be reconverted. + * @fires addMarker + * @param {module:engine/model/range~Range} range The inserted range. + * @param {Map} markers The map of markers that should be down-casted. * @param {module:engine/view/downcastwriter~DowncastWriter} writer The view writer that should be used to modify the view document. + * @param {Object} [options] Optional options object passed to `convertionApi.options`. */ - reconvertElement( element, writer ) { - const elementRange = Range._createOn( element ); - - this.conversionApi.writer = writer; - - // Create a list of things that can be consumed, consisting of nodes and their attributes. - this.conversionApi.consumable = this._createInsertConsumable( elementRange ); - - const mapper = this.conversionApi.mapper; - const currentView = mapper.toViewElement( element ); - - // Remove the old view but do not remove mapper mappings - those will be used to revive existing elements. - writer.remove( currentView ); - - // Convert the element - without converting children. - this._convertInsertWithAttributes( { - item: element, - range: elementRange - } ); + convert( range, markers, writer, options = {} ) { + const conversionApi = this._createConversionApi( writer, undefined, options ); - const convertedViewElement = mapper.toViewElement( element ); + this._convertInsert( range, conversionApi ); - // Iterate over children of reconverted element in order to... - for ( const value of Range._createIn( element ) ) { - const { item } = value; - - const view = elementOrTextProxyToView( item, mapper ); - - // ...either bring back previously converted view... - if ( view ) { - // Do not move views that are already in converted element - those might be created by the main element converter in case - // when main element converts also its direct children. - if ( view.root !== convertedViewElement.root ) { - writer.move( - writer.createRangeOn( view ), - mapper.toViewPosition( Position._createBefore( item ) ) - ); - } - } - // ... or by converting newly inserted elements. - else { - this._convertInsertWithAttributes( walkerValueToEventData( value ) ); - } + for ( const [ name, range ] of markers ) { + this._convertMarkerAdd( name, range, conversionApi ); } - // After reconversion is done we can unbind the old view. - mapper.unbindViewElement( currentView ); - - this._clearConversionApi(); + // Verify if all insert consumables were consumed. + conversionApi.consumable.verifyAllConsumed( 'insert' ); } /** @@ -335,21 +225,20 @@ export default class DowncastDispatcher { convertSelection( selection, markers, writer ) { const markersAtSelection = Array.from( markers.getMarkersAtPosition( selection.getFirstPosition() ) ); - this.conversionApi.writer = writer; - this.conversionApi.consumable = this._createSelectionConsumable( selection, markersAtSelection ); + const conversionApi = this._createConversionApi( writer ); - this.fire( 'selection', { selection }, this.conversionApi ); + this._addConsumablesForSelection( conversionApi.consumable, selection, markersAtSelection ); - if ( !selection.isCollapsed ) { - this._clearConversionApi(); + this.fire( 'selection', { selection }, conversionApi ); + if ( !selection.isCollapsed ) { return; } for ( const marker of markersAtSelection ) { const markerRange = marker.getRange(); - if ( !shouldMarkerChangeBeConverted( selection.getFirstPosition(), marker, this.conversionApi.mapper ) ) { + if ( !shouldMarkerChangeBeConverted( selection.getFirstPosition(), marker, conversionApi.mapper ) ) { continue; } @@ -359,8 +248,8 @@ export default class DowncastDispatcher { markerRange }; - if ( this.conversionApi.consumable.test( selection, 'addMarker:' + marker.name ) ) { - this.fire( 'addMarker:' + marker.name, data, this.conversionApi ); + if ( conversionApi.consumable.test( selection, 'addMarker:' + marker.name ) ) { + this.fire( 'addMarker:' + marker.name, data, conversionApi ); } } @@ -374,130 +263,218 @@ export default class DowncastDispatcher { }; // Do not fire event if the attribute has been consumed. - if ( this.conversionApi.consumable.test( selection, 'attribute:' + data.attributeKey ) ) { - this.fire( 'attribute:' + data.attributeKey + ':$text', data, this.conversionApi ); + if ( conversionApi.consumable.test( selection, 'attribute:' + data.attributeKey ) ) { + this.fire( 'attribute:' + data.attributeKey + ':$text', data, conversionApi ); } } + } + + /** + * Fires insertion conversion of a range of nodes. + * + * For each node in the range, {@link #event:insert `insert` event is fired}. For each attribute on each node, + * {@link #event:attribute `attribute` event is fired}. + * + * @protected + * @fires insert + * @fires attribute + * @param {module:engine/model/range~Range} range The inserted range. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + * @param {Object} [options] + * @param {Boolean} [options.doNotAddConsumables=false] Whether the ModelConsumable should not get populated + * for items in the provided range. + */ + _convertInsert( range, conversionApi, options = {} ) { + if ( !options.doNotAddConsumables ) { + // Collect a list of things that can be consumed, consisting of nodes and their attributes. + this._addConsumablesForInsert( conversionApi.consumable, Array.from( range ) ); + } + + // Fire a separate insert event for each node and text fragment contained in the range. + for ( const data of Array.from( range.getWalker( { shallow: true } ) ).map( walkerValueToEventData ) ) { + this._testAndFire( 'insert', data, conversionApi ); + } + } + + /** + * Fires conversion of a single node removal. Fires {@link #event:remove remove event} with provided data. + * + * @protected + * @param {module:engine/model/position~Position} position Position from which node was removed. + * @param {Number} length Offset size of removed node. + * @param {String} name Name of removed node. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + _convertRemove( position, length, name, conversionApi ) { + this.fire( 'remove:' + name, { position, length }, conversionApi ); + } + + /** + * Starts a conversion of an attribute change on a given `range`. + * + * For each node in the given `range`, {@link #event:attribute attribute event} is fired with the passed data. + * + * @protected + * @fires attribute + * @param {module:engine/model/range~Range} range Changed range. + * @param {String} key Key of the attribute that has changed. + * @param {*} oldValue Attribute value before the change or `null` if the attribute has not been set before. + * @param {*} newValue New attribute value or `null` if the attribute has been removed. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + _convertAttribute( range, key, oldValue, newValue, conversionApi ) { + // Create a list with attributes to consume. + this._addConsumablesForRange( conversionApi.consumable, range, `attribute:${ key }` ); + + // Create a separate attribute event for each node in the range. + for ( const value of range ) { + const data = { + item: value.item, + range: Range._createFromPositionAndShift( value.previousPosition, value.length ), + attributeKey: key, + attributeOldValue: oldValue, + attributeNewValue: newValue + }; + + this._testAndFire( `attribute:${ key }`, data, conversionApi ); + } + } + + /** + * Fires re-insertion conversion (with a `reconversion` flag passed to `insert` events) + * of a range of elements (only elements on the range depth, without children). + * + * For each node in the range on its depth (without children), {@link #event:insert `insert` event} is fired. + * For each attribute on each node, {@link #event:attribute `attribute` event} is fired. + * + * @protected + * @fires insert + * @fires attribute + * @param {module:engine/model/range~Range} range The range to reinsert. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. + */ + _convertReinsert( range, conversionApi ) { + // Convert the elements - without converting children. + const walkerValues = Array.from( range.getWalker( { shallow: true } ) ); + + // Collect a list of things that can be consumed, consisting of nodes and their attributes. + this._addConsumablesForInsert( conversionApi.consumable, walkerValues ); - this._clearConversionApi(); + // Fire a separate insert event for each node and text fragment contained shallowly in the range. + for ( const data of walkerValues.map( walkerValueToEventData ) ) { + this._testAndFire( 'insert', { ...data, reconversion: true }, conversionApi ); + } } /** * Converts the added marker. Fires the {@link #event:addMarker `addMarker`} event for each item * in the marker's range. If the range is collapsed, a single event is dispatched. See the event description for more details. * + * @protected * @fires addMarker * @param {String} markerName Marker name. * @param {module:engine/model/range~Range} markerRange The marker range. - * @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify the view document. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. */ - convertMarkerAdd( markerName, markerRange, writer ) { + _convertMarkerAdd( markerName, markerRange, conversionApi ) { // Do not convert if range is in graveyard. if ( markerRange.root.rootName == '$graveyard' ) { return; } - this.conversionApi.writer = writer; - // In markers' case, event name == consumable name. const eventName = 'addMarker:' + markerName; // // First, fire an event for the whole marker. // - const consumable = new Consumable(); - consumable.add( markerRange, eventName ); + conversionApi.consumable.add( markerRange, eventName ); - this.conversionApi.consumable = consumable; - - this.fire( eventName, { markerName, markerRange }, this.conversionApi ); + this.fire( eventName, { markerName, markerRange }, conversionApi ); // // Do not fire events for each item inside the range if the range got consumed. + // Also consume the whole marker consumable if it wasn't consumed. // - if ( !consumable.test( markerRange, eventName ) ) { - this._clearConversionApi(); - + if ( !conversionApi.consumable.consume( markerRange, eventName ) ) { return; } // // Then, fire an event for each item inside the marker range. // - this.conversionApi.consumable = this._createConsumableForRange( markerRange, eventName ); + this._addConsumablesForRange( conversionApi.consumable, markerRange, eventName ); for ( const item of markerRange.getItems() ) { // Do not fire event for already consumed items. - if ( !this.conversionApi.consumable.test( item, eventName ) ) { + if ( !conversionApi.consumable.test( item, eventName ) ) { continue; } const data = { item, range: Range._createOn( item ), markerName, markerRange }; - this.fire( eventName, data, this.conversionApi ); + this.fire( eventName, data, conversionApi ); } - - this._clearConversionApi(); } /** * Fires the conversion of the marker removal. Fires the {@link #event:removeMarker `removeMarker`} event with the provided data. * + * @protected * @fires removeMarker * @param {String} markerName Marker name. * @param {module:engine/model/range~Range} markerRange The marker range. - * @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify the view document. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. */ - convertMarkerRemove( markerName, markerRange, writer ) { + _convertMarkerRemove( markerName, markerRange, conversionApi ) { // Do not convert if range is in graveyard. if ( markerRange.root.rootName == '$graveyard' ) { return; } - this.conversionApi.writer = writer; - - this.fire( 'removeMarker:' + markerName, { markerName, markerRange }, this.conversionApi ); - - this._clearConversionApi(); + this.fire( 'removeMarker:' + markerName, { markerName, markerRange }, conversionApi ); } /** - * Maps the model element "insert" reconversion for given event names. The event names must be fully specified: + * Fires the reduction of changes buffered in the {@link module:engine/model/differ~Differ `Differ`}. * - * * For "attribute" change event, it should include the main element name, i.e: `'attribute:attributeName:elementName'`. - * * For child node change events, these should use the child event name as well, i.e: - * * For adding a node: `'insert:childElementName'`. - * * For removing a node: `'remove:childElementName'`. + * Features can replace selected {@link module:engine/model/differ~DiffItem `DiffItem`}s with `reinsert` entries to trigger + * reconversion. The {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * `DowncastHelpers.elementToStructure()`} is using this event to trigger reconversion. * - * **Note**: This method should not be used directly. The reconversion is defined by the `triggerBy()` configuration of the - * `elementToElement()` conversion helper. - * - * @protected - * @param {String} modelName The name of the main model element for which the events will trigger the reconversion. - * @param {String} eventName The name of an event that would trigger conversion for a given model element. + * @private + * @fires reduceChanges + * @param {Iterable.} changes + * @returns {Iterable.} */ - _mapReconversionTriggerEvent( modelName, eventName ) { - this._reconversionEventsMapping.set( eventName, modelName ); + _reduceChanges( changes ) { + const data = { changes }; + + this.fire( 'reduceChanges', data ); + + return data.changes; } /** - * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from a given range, + * Populates provided {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from a given range, * assuming that the range has just been inserted to the model. * * @private - * @param {module:engine/model/range~Range} range The inserted range. + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The consumable. + * @param {Iterable.} walkerValues The walker values for the inserted range. * @returns {module:engine/conversion/modelconsumable~ModelConsumable} The values to consume. */ - _createInsertConsumable( range ) { - const consumable = new Consumable(); - - for ( const value of range ) { + _addConsumablesForInsert( consumable, walkerValues ) { + for ( const value of walkerValues ) { const item = value.item; - consumable.add( item, 'insert' ); + // Add consumable if it wasn't there yet. + if ( consumable.test( item, 'insert' ) === null ) { + consumable.add( item, 'insert' ); - for ( const key of item.getAttributeKeys() ) { - consumable.add( item, 'attribute:' + key ); + for ( const key of item.getAttributeKeys() ) { + consumable.add( item, 'attribute:' + key ); + } } } @@ -505,16 +482,15 @@ export default class DowncastDispatcher { } /** - * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume for a given range. + * Populates provided {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume for a given range. * * @private + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The consumable. * @param {module:engine/model/range~Range} range The affected range. * @param {String} type Consumable type. * @returns {module:engine/conversion/modelconsumable~ModelConsumable} The values to consume. */ - _createConsumableForRange( range, type ) { - const consumable = new Consumable(); - + _addConsumablesForRange( consumable, range, type ) { for ( const item of range.getItems() ) { consumable.add( item, type ); } @@ -523,16 +499,15 @@ export default class DowncastDispatcher { } /** - * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with selection consumable values. + * Populates provided {@link module:engine/conversion/modelconsumable~ModelConsumable} with selection consumable values. * * @private + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The consumable. * @param {module:engine/model/selection~Selection} selection The selection to create the consumable from. * @param {Iterable.} markers Markers that contain the selection. * @returns {module:engine/conversion/modelconsumable~ModelConsumable} The values to consume. */ - _createSelectionConsumable( selection, markers ) { - const consumable = new Consumable(); - + _addConsumablesForSelection( consumable, selection, markers ) { consumable.add( selection, 'selection' ); for ( const marker of markers ) { @@ -547,152 +522,97 @@ export default class DowncastDispatcher { } /** - * Tests passed `consumable` to check whether given event can be fired and if so, fires it. + * Tests whether given event wasn't already fired and if so, fires it. * * @private * @fires insert * @fires attribute * @param {String} type Event type. * @param {Object} data Event data. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. */ - _testAndFire( type, data ) { - if ( !this.conversionApi.consumable.test( data.item, type ) ) { - // Do not fire event if the item was consumed. + _testAndFire( type, data, conversionApi ) { + const eventName = getEventName( type, data ); + const itemKey = data.item.is( '$textProxy' ) ? conversionApi.consumable._getSymbolForTextProxy( data.item ) : data.item; + + const eventsFiredForConversion = this._firedEventsMap.get( conversionApi ); + const eventsFiredForItem = eventsFiredForConversion.get( itemKey ); + + if ( !eventsFiredForItem ) { + eventsFiredForConversion.set( itemKey, new Set( [ eventName ] ) ); + } else if ( !eventsFiredForItem.has( eventName ) ) { + eventsFiredForItem.add( eventName ); + } else { return; } - this.fire( getEventName( type, data ), data, this.conversionApi ); - } - - /** - * Clears the conversion API object. - * - * @private - */ - _clearConversionApi() { - delete this.conversionApi.writer; - delete this.conversionApi.consumable; + this.fire( eventName, data, conversionApi ); } /** - * Internal method for converting element insertion. It will fire events for the inserted element and events for its attributes. + * Fires not already fired events for setting attributes on just inserted item. * * @private - * @fires insert - * @fires attribute - * @param {Object} data Event data. + * @param {module:engine/model/item~Item} item The model item to convert attributes for. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion API object. */ - _convertInsertWithAttributes( data ) { - this._testAndFire( 'insert', data ); + _testAndFireAddAttributes( item, conversionApi ) { + const data = { + item, + range: Range._createOn( item ) + }; - // Fire a separate addAttribute event for each attribute that was set on inserted items. - // This is important because most attributes converters will listen only to add/change/removeAttribute events. - // If we would not add this part, attributes on inserted nodes would not be converted. for ( const key of data.item.getAttributeKeys() ) { data.attributeKey = key; data.attributeOldValue = null; data.attributeNewValue = data.item.getAttribute( key ); - this._testAndFire( `attribute:${ key }`, data ); + this._testAndFire( `attribute:${ key }`, data, conversionApi ); } } /** - * Returns differ changes together with added "reconvert" type changes for {@link #reconvertElement}. These are defined by - * a the `triggerBy()` configuration for the - * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} conversion helper. - * - * This method will remove every mapped insert or remove change with a single "reconvert" change. - * - * For instance: Having a `triggerBy()` configuration defined for the `` element that issues this element reconversion on - * `foo` and `bar` attributes change, and a set of changes for this element: - * - * const differChanges = [ - * { type: 'attribute', attributeKey: 'foo', ... }, - * { type: 'attribute', attributeKey: 'bar', ... }, - * { type: 'attribute', attributeKey: 'baz', ... } - * ]; + * Builds an instance of the {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi} from a template and a given + * {@link module:engine/view/downcastwriter~DowncastWriter `DowncastWriter`} and options object. * - * This method will return: - * - * const updatedChanges = [ - * { type: 'reconvert', element: complexElementInstance }, - * { type: 'attribute', attributeKey: 'baz', ... } - * ]; - * - * In the example above, the `'baz'` attribute change will fire an {@link #event:attribute attribute event} - * - * @param {module:engine/model/differ~Differ} differ The differ object with buffered changes. - * @returns {Array.} Updated set of changes. * @private + * @param {module:engine/view/downcastwriter~DowncastWriter} writer View writer that should be used to modify the view document. + * @param {Set.} [refreshedItems] A set of model elements that should not reuse their + * previous view representations. + * @param {Object} [options] Optional options passed to `convertionApi.options`. + * @return {module:engine/conversion/downcastdispatcher~DowncastConversionApi} The conversion API object. */ - _mapChangesWithAutomaticReconversion( differ ) { - const itemsToReconvert = new Set(); - const updated = []; - - for ( const entry of differ.getChanges() ) { - const position = entry.position || entry.range.start; - // Cached parent - just in case. See https://github.com/ckeditor/ckeditor5/issues/6579. - const positionParent = position.parent; - const textNode = getTextNodeAtPosition( position, positionParent ); - - // Reconversion is done only on elements so skip text changes. - if ( textNode ) { - updated.push( entry ); - - continue; - } - - const element = entry.type === 'attribute' ? getNodeAfterPosition( position, positionParent, null ) : positionParent; - - // Case of text node set directly in root. For now used only in tests but can be possible when enabled in paragraph-like roots. - // See: https://github.com/ckeditor/ckeditor5/issues/762. - if ( element.is( '$text' ) ) { - updated.push( entry ); - - continue; - } - - let eventName; - - if ( entry.type === 'attribute' ) { - eventName = `attribute:${ entry.attributeKey }:${ element.name }`; - } else { - eventName = `${ entry.type }:${ entry.name }`; - } - - if ( this._isReconvertTriggerEvent( eventName, element.name ) ) { - if ( itemsToReconvert.has( element ) ) { - // Element is already reconverted, so skip this change. - continue; - } - - itemsToReconvert.add( element ); - - // Add special "reconvert" change. - updated.push( { type: 'reconvert', element } ); - } else { - updated.push( entry ); - } - } - - return updated; + _createConversionApi( writer, refreshedItems = new Set(), options = {} ) { + const conversionApi = { + ...this._conversionApi, + consumable: new Consumable(), + writer, + options, + convertItem: item => this._convertInsert( Range._createOn( item ), conversionApi ), + convertChildren: element => this._convertInsert( Range._createIn( element ), conversionApi, { doNotAddConsumables: true } ), + convertAttributes: item => this._testAndFireAddAttributes( item, conversionApi ), + canReuseView: viewElement => !refreshedItems.has( conversionApi.mapper.toModelElement( viewElement ) ) + }; + + this._firedEventsMap.set( conversionApi, new Map() ); + + return conversionApi; } /** - * Checks if the resulting change should trigger element reconversion. - * - * These are defined by a `triggerBy()` configuration for the - * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} conversion helper. - * - * @private - * @param {String} eventName The event name to check. - * @param {String} elementName The element name to check. - * @returns {Boolean} + * Fired to enable reducing (transforming) changes buffered in the {@link module:engine/model/differ~Differ `Differ`} before + * {@link #convertChanges `convertChanges()`} will fire any conversion events. + * + * For instance, a feature can replace selected {@link module:engine/model/differ~DiffItem `DiffItem`}s with a `reinsert` entry + * to trigger reconversion of an element when e.g. its attribute has changes. + * The {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * `DowncastHelpers.elementToStructure()`} helper is using this event to trigger reconversion of an element when the element, + * its attributes or direct children changed. + * + * @param {Object} data + * @param {Iterable.} data.changes A buffered changes to get reduced. + * @event reduceChanges */ - _isReconvertTriggerEvent( eventName, elementName ) { - return this._reconversionEventsMapping.get( eventName ) === elementName; - } /** * Fired for inserted nodes. @@ -857,17 +777,6 @@ function walkerValueToEventData( value ) { }; } -function elementOrTextProxyToView( item, mapper ) { - if ( item.is( 'textProxy' ) ) { - const mappedPosition = mapper.toViewPosition( Position._createBefore( item ) ); - const positionParent = mappedPosition.parent; - - return positionParent.is( '$text' ) ? positionParent : null; - } - - return mapper.toViewElement( item ); -} - /** * Conversion interface that is registered for given {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} * and is passed as one of parameters when {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher dispatcher} @@ -907,6 +816,28 @@ function elementOrTextProxyToView( item, mapper ) { * @member {module:engine/view/downcastwriter~DowncastWriter} #writer */ +/** + * Triggers conversion of a specified item. + * This conversion is triggered within (as a separate process of) the parent conversion. + * + * @method #convertItem + * @param {module:engine/model/item~Item} item The model item to trigger nested insert conversion on. + */ + +/** + * Triggers conversion of children of a specified element. + * + * @method #convertChildren + * @param {module:engine/model/element~Element} element The model element to trigger children insert conversion on. + */ + +/** + * Triggers conversion of attributes of a specified item. + * + * @method #convertAttributes + * @param {module:engine/model/item~Item} item The model item to trigger attribute conversion on. + */ + /** * An object with an additional configuration which can be used during the conversion process. Available only for data downcast conversion. * diff --git a/packages/ckeditor5-engine/src/conversion/downcasthelpers.js b/packages/ckeditor5-engine/src/conversion/downcasthelpers.js index 56275a78b3c..afab06b5e07 100644 --- a/packages/ckeditor5-engine/src/conversion/downcasthelpers.js +++ b/packages/ckeditor5-engine/src/conversion/downcasthelpers.js @@ -12,6 +12,7 @@ import ModelRange from '../model/range'; import ModelSelection from '../model/selection'; import ModelElement from '../model/element'; +import ModelPosition from '../model/position'; import ViewAttributeElement from '../view/attributeelement'; import DocumentSelection from '../model/documentselection'; @@ -60,16 +61,78 @@ export default class DowncastHelpers extends ConversionHelpers { * } * } ); * - * The element-to-element conversion supports the reconversion mechanism. This is helpful in the conversion to complex view structures - * where multiple atomic element-to-element and attribute-to-attribute or attribute-to-element could be used. By specifying - * `triggerBy()` events you can trigger reconverting the model to full view tree structures at once. + * The element-to-element conversion supports the reconversion mechanism. It can be enabled by using either `attributes` or `children` + * props on a model description. Couple examples below. + * + * In order to reconvert element if any of its direct children have been added or removed use `children` property on a `model` + * description. For example, model: + * + * + * Some text. + * + * + * will be converted into this structure in the view: + * + *
+ *

Some text.

+ *
+ * + * But if more items inserted in the model: + * + * + * Some text. + * Other item. + * + * + * it will be converted into this structure in the view (note the element `data-type` change): + * + *
+ *

Some text.

+ *

Other item.

+ *
+ * + * Such a converter would look like this (note that `paragraph` elements are converted separately): * * editor.conversion.for( 'downcast' ).elementToElement( { - * model: 'complex', - * view: ( modelElement, conversionApi ) => createComplexViewFromModel( modelElement, conversionApi ), - * triggerBy: { - * attributes: [ 'foo', 'bar' ], - * children: [ 'slot' ] + * model: { + * name: 'box', + * children: true + * }, + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createContainerElement( 'div', { + * class: 'box', + * 'data-type': modelElement.childCount == 1 ? 'single' : 'multiple' + * } ); + * } + * } ); + * + * In order to reconvert element if any of its attributes have been updated use `attributes` property on a `model` + * description. For example, model: + * + * Some text. + * + * will be converted into this structure in the view: + * + *

Some text.

+ * + * But if `heading` element `level` attribute has been updated to `3` for example, then + * it will be converted into this structure in the view: + * + *

Some text.

+ * + * Such a converter would look like this: + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: { + * name: 'heading', + * attributes: 'level' + * }, + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) ); * } * } ); * @@ -77,25 +140,163 @@ export default class DowncastHelpers extends ConversionHelpers { * to the conversion process. * * You can read more about element-to-element conversion in the - * {@glink framework/guides/deep-dive/conversion/custom-element-conversion Custom element conversion} guide. + * {@glink framework/guides/deep-dive/conversion/downcast downcast conversion} guide. * * @method #elementToElement * @param {Object} config Conversion configuration. - * @param {String} config.model The name of the model element to convert. + * @param {String|Object} config.model The description or a name of the model element to convert. + * @param {String|Array.} [config.model.attributes] The list of attribute names that should be consumed while creating + * the view element. Note that the view will be reconverted if any of the listed attributes will change. + * @param {Boolean} [config.model.children] Specifies whether the view element requires reconversion if the list + * of model child nodes changed. * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function * that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} * as parameters and returns a view container element. - * @param {Object} [config.triggerBy] Reconversion triggers. At least one trigger must be defined. - * @param {Array.} config.triggerBy.attributes The name of the element's attributes whose change will trigger element - * reconversion. - * @param {Array.} config.triggerBy.children The name of direct children whose adding or removing will trigger element - * reconversion. * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} */ elementToElement( config ) { return this.add( downcastElementToElement( config ) ); } + /** + * Model element to view structure (several elements) conversion helper. + * + * This conversion results in creating a view structure with defined one or more slots for the child nodes. + * For example, a model `

` may become this structure in the view: + * + *
+ *
+ * ${ slot for table rows } + *
+ * + * + * The children of the model's `` element will be inserted into the `` element. + * If a `elementToElement()` helper was used, the children would be inserted into the `
`. + * + * An example converter that converts the following model structure: + * + * Some text. + * + * into this structure in the view: + * + *
+ *

Some text.

+ *
+ * + * would look like this: + * + * editor.conversion.for( 'downcast' ).elementToStructure( { + * model: 'wrappedParagraph', + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * const wrapperViewElement = writer.createContainerElement( 'div', { class: 'wrapper' } ); + * const paragraphViewElement = writer.createContainerElement( 'p' ); + * + * writer.insert( writer.createPositionAt( wrapperViewElement, 0 ), paragraphViewElement ); + * writer.insert( writer.createPositionAt( paragraphViewElement, 0 ), writer.createSlot() ); + * + * return wrapperViewElement; + * } + * } ); + * + * The `slorFor()` function can also take a callback that allows filtering which children of the model element + * should be converted into this slot. + * + * Imagine a table feature where for this model structure: + * + *
+ * ... table cells 1 ... + * ... table cells 2 ... + * ... table cells 3 ... + * + *
Caption text
+ * + * We want to generate this view structure: + * + *
+ * + * + * ... table cells 1 ... + * + * + * ... table cells 2 ... + * ... table cells 3 ... + * + *
+ *
Caption text
+ *
+ * + * The converter has to take `headingRows` attribute into consideration when allocating `` elements + * into the `` and `` elements. Hence, we need two slots and define proper filter callbacks for them. + * + * Additionally, all other elements than `` should be placed outside ``. In the example above, this will + * handle the table caption. + * + * Such a converter would look like this: + * + * editor.conversion.for( 'downcast' ).elementToStructure( { + * model: { + * name: 'table', + * attributes: [ 'headingRows' ] + * }, + * view: ( modelElement, conversionApi ) => { + * const { writer } = conversionApi; + * + * const figureElement = writer.createContainerElement( 'figure', { class: 'table' } ); + * const tableElement = writer.createContainerElement( 'table' ); + * + * writer.insert( writer.createPositionAt( figureElement, 0 ), tableElement ); + * + * const headingRows = modelElement.getAttribute( 'headingRows' ) || 0; + * + * if ( headingRows > 0 ) { + * const tableHead = writer.createContainerElement( 'thead' ); + * + * const headSlot = writer.createSlot( node => node.is( 'element', 'tableRow' ) && node.index < headingRows ); + * + * writer.insert( writer.createPositionAt( tableElement, 'end' ), tableHead ); + * writer.insert( writer.createPositionAt( tableHead, 0 ), headSlot ); + * } + * + * if ( headingRows < tableUtils.getRows( table ) ) { + * const tableBody = writer.createContainerElement( 'tbody' ); + * + * const bodySlot = writer.createSlot( node => node.is( 'element', 'tableRow' ) && node.index >= headingRows ); + * + * writer.insert( writer.createPositionAt( tableElement, 'end' ), tableBody ); + * writer.insert( writer.createPositionAt( tableBody, 0 ), bodySlot ); + * } + * + * const restSlot = writer.createSlot( node => !node.is( 'element', 'tableRow' ) ); + * + * writer.insert( writer.createPositionAt( figureElement, 'end' ), restSlot ); + * + * return figureElement; + * } + * } ); + * + * Note: The children of a model element that's being converted must be allocated in the same order in the view + * in which they are placed in the model. + * + * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter + * to the conversion process. + * + * @method #elementToStructure + * @param {Object} config Conversion configuration. + * @param {String|Object} config.model The description or a name of the model element to convert. + * @param {String} [config.model.name] The name of the model element to convert. + * @param {String|Array.} [config.model.attributes] The list of attribute names that should be consumed while creating + * the view structure. Note that the view will be reconverted if any of the listed attributes will change. + * @param {module:engine/conversion/downcasthelpers~StructureCreatorFunction} config.view A function + * that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast + * conversion API} as parameters and returns a view container element with slots for model child nodes to be converted into. + * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} + */ + elementToStructure( config ) { + return this.add( downcastElementToStructure( config ) ); + } + /** * Model attribute to view element conversion helper. * @@ -464,7 +665,7 @@ export default class DowncastHelpers extends ConversionHelpers { * // Model: *
[]Foo
* - * // View: + * // View: *

Foo

* * Similarly, when a marker is collapsed after the last element: @@ -530,7 +731,7 @@ export default class DowncastHelpers extends ConversionHelpers { */ export function insertText() { return ( evt, data, conversionApi ) => { - if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) { + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { return; } @@ -542,6 +743,23 @@ export function insertText() { }; } +/** + * Function factory that creates a default downcast converter for triggering attributes and children conversion. + * + * @returns {Function} The converter. + */ +export function insertAttributesAndChildren() { + return ( evt, data, conversionApi ) => { + conversionApi.convertAttributes( data.item ); + + // Start converting children of the current item. + // In case of reconversion children were already re-inserted or converted separately. + if ( !data.reconversion && data.item.is( 'element' ) && !data.item.isEmpty ) { + conversionApi.convertChildren( data.item ); + } + }; +} + /** * Function factory that creates a default downcast converter for node remove changes. * @@ -565,7 +783,7 @@ export function remove() { // After the range is removed, unbind all view elements from the model. // Range inside view document fragment is used to unbind deeply. for ( const child of conversionApi.writer.createRangeIn( removed ).getItems() ) { - conversionApi.mapper.unbindViewElement( child ); + conversionApi.mapper.unbindViewElement( child, { defer: true } ); } }; } @@ -745,6 +963,10 @@ export function clearAttributes() { */ export function wrap( elementCreator ) { return ( evt, data, conversionApi ) => { + if ( !conversionApi.consumable.test( data.item, evt.name ) ) { + return; + } + // Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed // or the attribute was removed. const oldViewElement = elementCreator( data.attributeOldValue, conversionApi ); @@ -756,9 +978,7 @@ export function wrap( elementCreator ) { return; } - if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { - return; - } + conversionApi.consumable.consume( data.item, evt.name ); const viewWriter = conversionApi.writer; const viewSelection = viewWriter.document.selection; @@ -789,8 +1009,7 @@ export function wrap( elementCreator ) { * It is expected that the function returns an {@link module:engine/view/element~Element}. * The result of the function will be inserted into the view. * - * The converter automatically consumes the corresponding value from the consumables list, stops the event (see - * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}) and binds the model and view elements. + * The converter automatically consumes the corresponding value from the consumables list and binds the model and view elements. * * downcastDispatcher.on( * 'insert:myElem', @@ -806,24 +1025,88 @@ export function wrap( elementCreator ) { * * @protected * @param {Function} elementCreator Function returning a view element, which will be inserted. + * @param {module:engine/conversion/downcasthelpers~ConsumerFunction} [consumer] Function defining element consumption process. + * By default this function just consume passed item insertion. * @returns {Function} Insert element event converter. */ -export function insertElement( elementCreator ) { +export function insertElement( elementCreator, consumer = defaultConsumer ) { return ( evt, data, conversionApi ) => { + if ( !consumer( data.item, conversionApi.consumable, { preflight: true } ) ) { + return; + } + const viewElement = elementCreator( data.item, conversionApi ); if ( !viewElement ) { return; } - if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) { + // Consume an element insertion and all present attributes that are specified as a reconversion triggers. + consumer( data.item, conversionApi.consumable ); + + const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); + + conversionApi.mapper.bindElements( data.item, viewElement ); + conversionApi.writer.insert( viewPosition, viewElement ); + + // Convert attributes before converting children. + conversionApi.convertAttributes( data.item ); + + // Convert children or reinsert previous view elements. + reinsertOrConvertNodes( viewElement, data.item.getChildren(), conversionApi, { reconversion: data.reconversion } ); + }; +} + +/** + * Function factory that creates a converter which converts a single model node insertion to a view structure. + * + * It is expected that the passed element creator function returns an {@link module:engine/view/element~Element} with attached slots + * created with `writer.createSlot()` to indicate where child nodes should be converted. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * + * @protected + * @param {module:engine/conversion/downcasthelpers~StructureCreatorFunction} elementCreator Function returning a view structure, + * which will be inserted. + * @param {module:engine/conversion/downcasthelpers~ConsumerFunction} consumer A callback that is expected to consume all the consumables + * that were used by the element creator. + * @returns {Function} Insert element event converter. +*/ +export function insertStructure( elementCreator, consumer ) { + return ( evt, data, conversionApi ) => { + if ( !consumer( data.item, conversionApi.consumable, { preflight: true } ) ) { + return; + } + + const slotsMap = new Map(); + + conversionApi.writer._registerSlotFactory( createSlotFactory( data.item, slotsMap, conversionApi ) ); + + // View creation. + const viewElement = elementCreator( data.item, conversionApi ); + + conversionApi.writer._clearSlotFactory(); + + if ( !viewElement ) { return; } + // Check if all children are covered by slots and there is no child that landed in multiple slots. + validateSlotsChildren( data.item, slotsMap, conversionApi ); + + // Consume an element insertion and all present attributes that are specified as a reconversion triggers. + consumer( data.item, conversionApi.consumable ); + const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); conversionApi.mapper.bindElements( data.item, viewElement ); conversionApi.writer.insert( viewPosition, viewElement ); + + // Convert attributes before converting children. + conversionApi.convertAttributes( data.item ); + + // Fill view slots with previous view elements or create new ones. + fillSlots( viewElement, slotsMap, conversionApi, { reconversion: data.reconversion } ); }; } @@ -1087,6 +1370,10 @@ function removeMarkerData( viewCreator ) { // @returns {Function} Set/change attribute converter. function changeAttribute( attributeCreator ) { return ( evt, data, conversionApi ) => { + if ( !conversionApi.consumable.test( data.item, evt.name ) ) { + return; + } + const oldAttribute = attributeCreator( data.attributeOldValue, conversionApi ); const newAttribute = attributeCreator( data.attributeNewValue, conversionApi ); @@ -1094,9 +1381,7 @@ function changeAttribute( attributeCreator ) { return; } - if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { - return; - } + conversionApi.consumable.consume( data.item, evt.name ); const viewElement = conversionApi.mapper.toViewElement( data.item ); const viewWriter = conversionApi.writer; @@ -1138,10 +1423,7 @@ function changeAttribute( attributeCreator ) { * * @error conversion-attribute-to-attribute-on-text */ - throw new CKEditorError( - 'conversion-attribute-to-attribute-on-text', - [ data, conversionApi ] - ); + throw new CKEditorError( 'conversion-attribute-to-attribute-on-text', conversionApi.dispatcher, data ); } // First remove the old attribute if there was one. @@ -1366,34 +1648,106 @@ function removeHighlight( highlightDescriptor ) { // See {@link ~DowncastHelpers#elementToElement `.elementToElement()` downcast helper} for examples and config params description. // // @param {Object} config Conversion configuration. -// @param {String} config.model +// @param {String|Object} config.model The description or a name of the model element to convert. +// @param {String|Array.} [config.model.attributes] List of attributes triggering element reconversion. +// @param {Boolean} [config.model.children] Should reconvert element if the list of model child nodes changed. // @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view -// @param {Object} [config.triggerBy] -// @param {Array.} [config.triggerBy.attributes] -// @param {Array.} [config.triggerBy.children] // @returns {Function} Conversion helper. function downcastElementToElement( config ) { config = cloneDeep( config ); + config.model = normalizeModelElementConfig( config.model ); config.view = normalizeToElementConfig( config.view, 'container' ); + // Trigger reconversion on children list change if element is a subject to any reconversion. + // This is required to be able to trigger Differ#refreshItem() on a direct child of the reconverted element. + if ( config.model.attributes.length ) { + config.model.children = true; + } + return dispatcher => { - dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.converterPriority || 'normal' } ); + dispatcher.on( + 'insert:' + config.model.name, + insertElement( config.view, createConsumer( config.model ) ), + { priority: config.converterPriority || 'normal' } + ); + + if ( config.model.children || config.model.attributes.length ) { + dispatcher.on( 'reduceChanges', createChangeReducer( config.model ), { priority: 'low' } ); + } + }; +} - if ( config.triggerBy ) { - if ( config.triggerBy.attributes ) { - for ( const attributeKey of config.triggerBy.attributes ) { - dispatcher._mapReconversionTriggerEvent( config.model, `attribute:${ attributeKey }:${ config.model }` ); - } - } +// Model element to view structure conversion helper. +// +// See {@link ~DowncastHelpers#elementToStructure `.elementToStructure()` downcast helper} for examples and config params description. +// +// @param {Object} config Conversion configuration. +// @param {String|Object} config.model +// @param {String} [config.model.name] +// @param {Array.} [config.model.attributes] +// @param {module:engine/conversion/downcasthelpers~StructureCreatorFunction} config.view +// @returns {Function} Conversion helper. +function downcastElementToStructure( config ) { + config = cloneDeep( config ); - if ( config.triggerBy.children ) { - for ( const childName of config.triggerBy.children ) { - dispatcher._mapReconversionTriggerEvent( config.model, `insert:${ childName }` ); - dispatcher._mapReconversionTriggerEvent( config.model, `remove:${ childName }` ); - } - } + config.model = normalizeModelElementConfig( config.model ); + config.view = normalizeToElementConfig( config.view, 'container' ); + + // Trigger reconversion on children list change because it always needs to use slots to put children in proper places. + // This is required to be able to trigger Differ#refreshItem() on a direct child of the reconverted element. + config.model.children = true; + + return dispatcher => { + if ( dispatcher._conversionApi.schema.checkChild( config.model.name, '$text' ) ) { + /** + * This error occurs when a {@link module:engine/model/element~Element model element} is downcasted + * via {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure} helper but the element was + * allowed to host `$text` by the {@link module:engine/model/schema~Schema model schema}. + * + * For instance, this may be the result of `myElement` allowing the content of + * {@glink framework/guides/deep-dive/schema#generic-items `$block`} in its schema definition: + * + * // Element definition in schema. + * schema.register( 'myElement', { + * allowContentOf: '$block', + * + * // ... + * } ); + * + * // ... + * + * // Conversion of myElement with the use of elementToStructure(). + * editor.conversion.for( 'downcast' ).elementToStructure( { + * model: 'myElement', + * view: ( modelElement, { writer } ) => { + * // ... + * } + * } ); + * + * In such case, {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `elementToElement()`} helper + * can be used instead to get around this problem: + * + * editor.conversion.for( 'downcast' ).elementToElement( { + * model: 'myElement', + * view: ( modelElement, { writer } ) => { + * // ... + * } + * } ); + * + * @error conversion-element-to-structure-disallowed-text + * @param {String} elementName The name of the element the structure is to be created for. + */ + throw new CKEditorError( 'conversion-element-to-structure-disallowed-text', dispatcher, { elementName: config.model.name } ); } + + dispatcher.on( + 'insert:' + config.model.name, + insertStructure( config.view, createConsumer( config.model ) ), + { priority: config.converterPriority || 'normal' } + ); + + dispatcher.on( 'reduceChanges', createChangeReducer( config.model ), { priority: 'low' } ); }; } @@ -1541,6 +1895,31 @@ function downcastMarkerToHighlight( config ) { }; } +// Takes `config.model`, and converts it to an object with normalized structure. +// +// @param {String|Object} model Model configuration or element name. +// @param {String} model.name +// @param {Array.} [model.attributes] +// @param {Boolean} [model.children] +// @returns {Object} +function normalizeModelElementConfig( model ) { + if ( typeof model == 'string' ) { + model = { name: model }; + } + + // List of attributes that should trigger reconversion. + if ( !model.attributes ) { + model.attributes = []; + } else if ( !Array.isArray( model.attributes ) ) { + model.attributes = [ model.attributes ]; + } + + // Whether a children insertion/deletion should trigger reconversion. + model.children = !!model.children; + + return model; +} + // Takes `config.view`, and if it is an {@link module:engine/view/elementdefinition~ElementDefinition}, converts it // to a function (because lower level converters accept only element creator functions). // @@ -1670,6 +2049,291 @@ function prepareDescriptor( highlightDescriptor, data, conversionApi ) { return descriptor; } +// Creates a function that checks a single differ diff item whether it should trigger reconversion. +// +// @param {Object} model A normalized `config.model` converter configuration. +// @param {String} model.name The name of element. +// @param {Array.} model.attributes The list of attribute names that should trigger reconversion. +// @param {Boolean} [model.children] Whether the child list change should trigger reconversion. +// @returns {Function} +function createChangeReducerCallback( model ) { + return ( node, change ) => { + if ( !node.is( 'element', model.name ) ) { + return false; + } + + if ( change.type == 'attribute' ) { + if ( model.attributes.includes( change.attributeKey ) ) { + return true; + } + } else { + /* istanbul ignore else: This is always true because otherwise it would not register a reducer callback. */ + if ( model.children ) { + return true; + } + } + + return false; + }; +} + +// Creates a `reduceChanges` event handler for reconversion. +// +// @param {Object} model A normalized `config.model` converter configuration. +// @param {String} model.name The name of element. +// @param {Array.} model.attributes The list of attribute names that should trigger reconversion. +// @param {Boolean} [model.children] Whether the child list change should trigger reconversion. +// @returns {Function} +function createChangeReducer( model ) { + const shouldReplace = createChangeReducerCallback( model ); + + return ( evt, data ) => { + const reducedChanges = []; + + if ( !data.reconvertedElements ) { + data.reconvertedElements = new Set(); + } + + for ( const change of data.changes ) { + // For attribute use node affected by the change. + // For insert or remove use parent element because we need to check if it's added/removed child. + const node = change.position ? change.position.parent : change.range.start.nodeAfter; + + if ( !node || !shouldReplace( node, change ) ) { + reducedChanges.push( change ); + + continue; + } + + // If it's already marked for reconversion, so skip this change, otherwise add the diff items. + if ( !data.reconvertedElements.has( node ) ) { + data.reconvertedElements.add( node ); + + const position = ModelPosition._createBefore( node ); + + reducedChanges.push( { + type: 'remove', + name: node.name, + position, + length: 1 + }, { + type: 'reinsert', + name: node.name, + position, + length: 1 + } ); + } + } + + data.changes = reducedChanges; + }; +} + +// Creates a function that checks if an element and its watched attributes can be consumed and consumes them. +// +// @param {Object} model A normalized `config.model` converter configuration. +// @param {String} model.name The name of element. +// @param {Array.} model.attributes The list of attribute names that should trigger reconversion. +// @param {Boolean} [model.children] Whether the child list change should trigger reconversion. +// @returns {module:engine/conversion/downcasthelpers~ConsumerFunction} +function createConsumer( model ) { + return ( node, consumable, options = {} ) => { + const events = [ 'insert' ]; + + // Collect all set attributes that are triggering conversion. + for ( const attributeName of model.attributes ) { + if ( node.hasAttribute( attributeName ) ) { + events.push( `attribute:${ attributeName }` ); + } + } + + if ( !events.every( event => consumable.test( node, event ) ) ) { + return false; + } + + if ( !options.preflight ) { + events.forEach( event => consumable.consume( node, event ) ); + } + + return true; + }; +} + +// Creates a function that create view slots. +// +// @param {module:engine/model/element~Element} element +// @param {Map.>} slotsMap +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @returns {Function} Exposed by writer as createSlot(). +function createSlotFactory( element, slotsMap, conversionApi ) { + return ( writer, modeOrFilter = 'children' ) => { + const slot = writer.createContainerElement( '$slot' ); + + let children = null; + + if ( modeOrFilter === 'children' ) { + children = Array.from( element.getChildren() ); + } else if ( typeof modeOrFilter == 'function' ) { + children = Array.from( element.getChildren() ).filter( element => modeOrFilter( element ) ); + } else { + /** + * Unknown slot mode was provided to `writer.createSlot()` in downcast converter. + * + * @error conversion-slot-mode-unknown + */ + throw new CKEditorError( 'conversion-slot-mode-unknown', conversionApi.dispatcher, { modeOrFilter } ); + } + + slotsMap.set( slot, children ); + + return slot; + }; +} + +// Checks if all children are covered by slots and there is no child that landed in multiple slots. +// +// @param {module:engine/model/element~Element} +// @param {Map.>} slotsMap +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +function validateSlotsChildren( element, slotsMap, conversionApi ) { + const childrenInSlots = Array.from( slotsMap.values() ).flat(); + const uniqueChildrenInSlots = new Set( childrenInSlots ); + + if ( uniqueChildrenInSlots.size != childrenInSlots.length ) { + /** + * Filters provided to `writer.createSlot()` overlap (at least two filters accept the same child element). + * + * @error conversion-slot-filter-overlap + * @param {module:engine/model/element~Element} element The element of which children would not be properly + * allocated to multiple slots. + */ + throw new CKEditorError( 'conversion-slot-filter-overlap', conversionApi.dispatcher, { element } ); + } + + if ( uniqueChildrenInSlots.size != element.childCount ) { + /** + * Filters provided to `writer.createSlot()` are incomplete and exclude at least one children element (one of + * the children elements would not be assigned to any of the slots). + * + * @error conversion-slot-filter-incomplete + * @param {module:engine/model/element~Element} element The element of which children would not be properly + * allocated to multiple slots. + */ + throw new CKEditorError( 'conversion-slot-filter-incomplete', conversionApi.dispatcher, { element } ); + } +} + +// Fill slots with appropriate view elements. +// +// @param {module:engine/view/element~Element} viewElement +// @param {Map.>} slotsMap +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @param {Object} options +// @param {Boolean} [options.reconversion] +function fillSlots( viewElement, slotsMap, conversionApi, options ) { + // Set temporary position mapping to redirect child view elements into a proper slots. + conversionApi.mapper.on( 'modelToViewPosition', toViewPositionMapping, { priority: 'highest' } ); + + let currentSlot = null; + let currentSlotNodes = null; + + // Fill slots with nested view nodes. + for ( [ currentSlot, currentSlotNodes ] of slotsMap ) { + reinsertOrConvertNodes( viewElement, currentSlotNodes, conversionApi, options ); + + conversionApi.writer.move( + conversionApi.writer.createRangeIn( currentSlot ), + conversionApi.writer.createPositionBefore( currentSlot ) + ); + conversionApi.writer.remove( currentSlot ); + } + + conversionApi.mapper.off( 'modelToViewPosition', toViewPositionMapping ); + + function toViewPositionMapping( evt, data ) { + const element = data.modelPosition.nodeAfter; + + // Find the proper offset within the slot. + const index = currentSlotNodes.indexOf( element ); + + if ( index < 0 ) { + return; + } + + data.viewPosition = data.mapper.findPositionIn( currentSlot, index ); + } +} + +// Inserts view representation of `nodes` into the `viewElement` either by bringing back just removed view nodes +// or by triggering conversion for them. +// +// @param {module:engine/view/element~Element} viewElement +// @param {Iterable.} modelNodes +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @param {Object} options +// @param {Boolean} [options.reconversion] +function reinsertOrConvertNodes( viewElement, modelNodes, conversionApi, options ) { + // Fill with nested view nodes. + for ( const modelChildNode of modelNodes ) { + // Try reinserting the view node for the specified model node... + if ( !reinsertNode( viewElement.root, modelChildNode, conversionApi, options ) ) { + // ...or else convert the model element to the view. + conversionApi.convertItem( modelChildNode ); + } + } +} + +// Checks if the view for the given model element could be reused and reinserts it to the view. +// +// @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} viewRoot +// @param {module:engine/model/element~Element} modelElement +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi +// @param {Object} options +// @param {Boolean} [options.reconversion] +// @returns {Boolean} `false` if view element can't be reused. +function reinsertNode( viewRoot, modelElement, conversionApi, options ) { + const { writer, mapper } = conversionApi; + + // Don't reinsert if this is not a reconversion... + if ( !options.reconversion ) { + return false; + } + + const viewChildNode = mapper.toViewElement( modelElement ); + + // ...or there is no view to reinsert or it was already inserted to the view structure... + if ( !viewChildNode || viewChildNode.root == viewRoot ) { + return false; + } + + // ...or it was strictly marked as not to be reused. + if ( !conversionApi.canReuseView( viewChildNode ) ) { + return false; + } + + // Otherwise reinsert the view node. + writer.move( + writer.createRangeOn( viewChildNode ), + mapper.toViewPosition( ModelPosition._createBefore( modelElement ) ) + ); + + return true; +} + +// The default consumer for insert events. +// @param {module:engine/model/item~Item} item Model item. +// @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The model consumable. +// @param {Object} [options] +// @param {Boolean} [options.preflight=false] Whether should consume or just check if can be consumed. +// @returns {Boolean} +function defaultConsumer( item, consumable, { preflight } = {} ) { + if ( preflight ) { + return consumable.test( item, 'insert' ); + } else { + return consumable.consume( item, 'insert' ); + } +} + /** * An object describing how the marker highlight should be represented in the view. * @@ -1704,3 +2368,45 @@ function prepareDescriptor( highlightDescriptor, data, conversionApi ) { * attribute element. If the descriptor is applied to an element, usually these attributes will be set on that element, however, * this depends on how the element converts the descriptor. */ + +/** + * A filtering function used to choose model child nodes to be downcasted into the specific view + * {@link module:engine/view/downcastwriter~DowncastWriter#createSlot "slot"} while executing the + * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure `elementToStructure()`} converter. + * + * @callback module:engine/conversion/downcasthelpers~SlotFilter + * + * @param {module:engine/model/node~Node} node A model node. + * @returns {Boolean} Whether provided model node should be downcasted into this slot. + * + * @see module:engine/view/downcastwriter~DowncastWriter#createSlot + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * @see module:engine/conversion/downcasthelpers~insertStructure + */ + +/** + * A function that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast + * conversion API} as parameters and returns a view container element with slots for model child nodes to be converted into. + * + * @callback module:engine/conversion/downcasthelpers~StructureCreatorFunction + * @param {module:engine/model/element~Element} element The model element to be converted to the view structure. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @returns {module:engine/view/element~Element} The view structure with slots for model child nodes. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * @see module:engine/conversion/downcasthelpers~insertStructure + */ + +/** + * A function that is expected to consume all the consumables that were used by the element creator. + * + * @callback module:engine/conversion/downcasthelpers~ConsumerFunction + * @param {module:engine/model/element~Element} element The model element to be converted to the view structure. + * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable The `ModelConsumable` same as in + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi#consumable `DowncastConversionApi.consumable`}. + * @param {Object} [options] + * @param {Boolean} [options.preflight=false] Whether should consume or just check if can be consumed. + * @returns {Boolean} `true` if all consumable values were available and were consumed, `false` otherwise. + * + * @see module:engine/conversion/downcasthelpers~insertStructure + */ diff --git a/packages/ckeditor5-engine/src/conversion/mapper.js b/packages/ckeditor5-engine/src/conversion/mapper.js index 7a0afcbad0c..4478f2154ae 100644 --- a/packages/ckeditor5-engine/src/conversion/mapper.js +++ b/packages/ckeditor5-engine/src/conversion/mapper.js @@ -15,6 +15,7 @@ import ViewRange from '../view/range'; import ViewText from '../view/text'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; /** @@ -88,6 +89,14 @@ export default class Mapper { */ this._elementToMarkerNames = new Map(); + /** + * The map of removed view elements with their current root (used for deferred unbinding). + * + * @private + * @member {Map.} + */ + this._deferredBindingRemovals = new Map(); + /** * Stores marker names of markers which has changed due to unbinding a view element (so it is assumed that the view element * has been removed, moved or renamed). @@ -105,6 +114,18 @@ export default class Mapper { const viewContainer = this._modelToViewMapping.get( data.modelPosition.parent ); + if ( !viewContainer ) { + /** + * A model position could not be mapped to the view because the parent of the model position + * does not have a mapped view element (might have not been converted yet or it has no converter). + * + * Make sure that the model element is correctly converted to the view. + * + * @error mapping-view-position-parent-not-found + */ + throw new CKEditorError( 'mapping-view-position-parent-not-found', this, { modelPosition: data.modelPosition } ); + } + data.viewPosition = this.findPositionIn( viewContainer, data.modelPosition.offset ); }, { priority: 'low' } ); @@ -137,29 +158,36 @@ export default class Mapper { } /** - * Unbinds given {@link module:engine/view/element~Element view element} from the map. + * Unbinds the given {@link module:engine/view/element~Element view element} from the map. * * **Note:** view-to-model binding will be removed, if it existed. However, corresponding model-to-view binding - * will be removed only if model element is still bound to passed `viewElement`. + * will be removed only if model element is still bound to the passed `viewElement`. * * This behavior lets for re-binding model element to another view element without fear of losing the new binding * when the previously bound view element is unbound. * * @param {module:engine/view/element~Element} viewElement View element to unbind. + * @param {Object} [options={}] The options object. + * @param {Boolean} [options.defer=false] Controls whether the binding should be removed immediately or deferred until a + * {@link #flushDeferredBindings `flushDeferredBindings()`} call. */ - unbindViewElement( viewElement ) { + unbindViewElement( viewElement, options = {} ) { const modelElement = this.toModelElement( viewElement ); - this._viewToModelMapping.delete( viewElement ); - if ( this._elementToMarkerNames.has( viewElement ) ) { for ( const markerName of this._elementToMarkerNames.get( viewElement ) ) { this._unboundMarkerNames.add( markerName ); } } - if ( this._modelToViewMapping.get( modelElement ) == viewElement ) { - this._modelToViewMapping.delete( modelElement ); + if ( options.defer ) { + this._deferredBindingRemovals.set( viewElement, viewElement.root ); + } else { + this._viewToModelMapping.delete( viewElement ); + + if ( this._modelToViewMapping.get( modelElement ) == viewElement ) { + this._modelToViewMapping.delete( modelElement ); + } } } @@ -244,6 +272,22 @@ export default class Mapper { return markerNames; } + /** + * Unbinds all deferred binding removals of view elements that were not re-attached in the meantime to some root or document fragment. + * + * See: {@link #unbindViewElement `unbindViewElement()`}. + */ + flushDeferredBindings() { + for ( const [ viewElement, root ] of this._deferredBindingRemovals ) { + // Unbind it only if it wasn't re-attached to some root or document fragment. + if ( viewElement.root == root ) { + this.unbindViewElement( viewElement ); + } + } + + this._deferredBindingRemovals = new Map(); + } + /** * Removes all model to view and view to model bindings. */ @@ -253,6 +297,7 @@ export default class Mapper { this._markerNameToElements = new Map(); this._elementToMarkerNames = new Map(); this._unboundMarkerNames = new Set(); + this._deferredBindingRemovals = new Map(); } /** diff --git a/packages/ckeditor5-engine/src/conversion/modelconsumable.js b/packages/ckeditor5-engine/src/conversion/modelconsumable.js index 1ae9a9a42c9..f20bfd87096 100644 --- a/packages/ckeditor5-engine/src/conversion/modelconsumable.js +++ b/packages/ckeditor5-engine/src/conversion/modelconsumable.js @@ -8,6 +8,7 @@ */ import TextProxy from '../model/textproxy'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; /** * Manages a list of consumable values for {@link module:engine/model/item~Item model items}. @@ -246,13 +247,55 @@ export default class ModelConsumable { return null; } + /** + * Verifies if all events from specified group were consumed. + * + * @param {String} eventGroup The events group to verify. + */ + verifyAllConsumed( eventGroup ) { + const items = []; + + for ( const [ item, consumables ] of this._consumable ) { + for ( const [ event, canConsume ] of consumables ) { + const eventPrefix = event.split( ':' )[ 0 ]; + + if ( canConsume && eventGroup == eventPrefix ) { + items.push( { + event, + item: item.name || item.description + } ); + } + } + } + + if ( items.length ) { + /** + * Some of {@link module:engine/model/item~Item model items} were not consumed while downcasting the model to view. + * + * This might be an effect of: + * + * * Missing converter for some model elements. Make sure that you registered downcast converters for all model elements. + * * Custom converter that does not consume converted items. Make sure that you + * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} all model elements that you converted + * from the model to the view. + * * Custom converter that called `event.stop()`. When providing a custom converter, keep in mind that you should not stop + * the event. If you stop it then the default converter at the `lowest` priority will not trigger the conversion of this node's + * attributes and child nodes. + * + * @error conversion-model-consumable-not-consumed + * @param {Array.} items Items that were not consumed. + */ + throw new CKEditorError( 'conversion-model-consumable-not-consumed', null, { items } ); + } + } + /** * Gets a unique symbol for passed {@link module:engine/model/textproxy~TextProxy} instance. All `TextProxy` instances that * have same parent, same start index and same end index will get the same symbol. * * Used internally to correctly consume `TextProxy` instances. * - * @private + * @protected * @param {module:engine/model/textproxy~TextProxy} textProxy `TextProxy` instance to get a symbol for. * @returns {Symbol} Symbol representing all equal instances of `TextProxy`. */ @@ -270,25 +313,27 @@ export default class ModelConsumable { } if ( !symbol ) { - symbol = this._addSymbolForTextProxy( textProxy.startOffset, textProxy.endOffset, textProxy.parent ); + symbol = this._addSymbolForTextProxy( textProxy ); } return symbol; } /** - * Adds a symbol for given properties that characterizes a {@link module:engine/model/textproxy~TextProxy} instance. + * Adds a symbol for given {@link module:engine/model/textproxy~TextProxy} instance. * * Used internally to correctly consume `TextProxy` instances. * * @private - * @param {Number} startIndex Text proxy start index in it's parent. - * @param {Number} endIndex Text proxy end index in it's parent. - * @param {module:engine/model/element~Element} parent Text proxy parent. - * @returns {Symbol} Symbol generated for given properties. + * @param {module:engine/model/textproxy~TextProxy} textProxy Text proxy instance. + * @returns {Symbol} Symbol generated for given `TextProxy`. */ - _addSymbolForTextProxy( start, end, parent ) { - const symbol = Symbol( 'textProxySymbol' ); + _addSymbolForTextProxy( textProxy ) { + const start = textProxy.startOffset; + const end = textProxy.endOffset; + const parent = textProxy.parent; + + const symbol = Symbol( '$textProxy:' + textProxy.data ); let startMap, endMap; startMap = this._textProxyRegistry.get( start ); @@ -320,6 +365,11 @@ export default class ModelConsumable { function _normalizeConsumableType( type ) { const parts = type.split( ':' ); + // For inserts allow passing event name, it's stored in the context of a specified element so the element name is not needed. + if ( parts[ 0 ] == 'insert' ) { + return parts[ 0 ]; + } + // Markers are identified by the whole name (otherwise we would consume the whole markers group). if ( parts[ 0 ] == 'addMarker' || parts[ 0 ] == 'removeMarker' ) { return type; diff --git a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js index 7a170c490dd..4eee1f210ee 100644 --- a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js +++ b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js @@ -42,8 +42,7 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix'; * * You can read more about conversion in the following guides: * - * * {@glink framework/guides/deep-dive/conversion/conversion-introduction Advanced conversion concepts — attributes} - * * {@glink framework/guides/deep-dive/conversion/custom-element-conversion Custom element conversion} + * * {@glink framework/guides/deep-dive/conversion/upcast Upcast conversion} * * Examples of event-based converters: * diff --git a/packages/ckeditor5-engine/src/dev-utils/model.js b/packages/ckeditor5-engine/src/dev-utils/model.js index aa8d84ab793..07eb843c9b2 100644 --- a/packages/ckeditor5-engine/src/dev-utils/model.js +++ b/packages/ckeditor5-engine/src/dev-utils/model.js @@ -31,6 +31,7 @@ import Mapper from '../conversion/mapper'; import { convertCollapsedSelection, convertRangeSelection, + insertAttributesAndChildren, insertElement, insertText, insertUIElement, @@ -233,6 +234,7 @@ export function stringify( node, selectionOrPositionOrRange = null, markers = nu mapper.bindElements( node.root, viewRoot ); downcastDispatcher.on( 'insert:$text', insertText() ); + downcastDispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); downcastDispatcher.on( 'attribute', ( evt, data, conversionApi ) => { if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection || data.item.is( '$textProxy' ) ) { const converter = wrap( ( modelAttributeValue, { writer } ) => { @@ -260,28 +262,28 @@ export function stringify( node, selectionOrPositionOrRange = null, markers = nu return writer.createUIElement( name ); } ) ); + const markersMap = new Map(); + + if ( markers ) { + // To provide stable results, sort markers by name. + for ( const marker of Array.from( markers ).sort( ( a, b ) => a.name < b.name ? 1 : -1 ) ) { + markersMap.set( marker.name, marker.getRange() ); + } + } + // Convert model to view. const writer = view._writer; - downcastDispatcher.convertInsert( range, writer ); + downcastDispatcher.convert( range, markersMap, writer ); // Convert model selection to view selection. if ( selection ) { downcastDispatcher.convertSelection( selection, markers || model.markers, writer ); } - if ( markers ) { - // To provide stable results, sort markers by name. - markers = Array.from( markers ).sort( ( a, b ) => a.name < b.name ? 1 : -1 ); - - for ( const marker of markers ) { - downcastDispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer ); - } - } - // Parse view to data string. let data = viewStringify( viewRoot, viewDocument.selection, { sameSelectionCharacters: true } ); - // Removing unneccessary
and
added because `viewRoot` was also stringified alongside input data. + // Removing unnecessary
and
added because `viewRoot` was also stringified alongside input data. data = data.substr( 5, data.length - 11 ); view.destroy(); diff --git a/packages/ckeditor5-engine/src/model/differ.js b/packages/ckeditor5-engine/src/model/differ.js index ea8d7f43aec..aa3fd6ef191 100644 --- a/packages/ckeditor5-engine/src/model/differ.js +++ b/packages/ckeditor5-engine/src/model/differ.js @@ -98,6 +98,14 @@ export default class Differ { * @type {Array.|null} */ this._cachedChangesWithGraveyard = null; + + /** + * Set of model items that were marked to get refreshed in {@link #_refreshItem}. + * + * @private + * @type {Set.} + */ + this._refreshedItems = new Set(); } /** @@ -110,32 +118,6 @@ export default class Differ { return this._changesInElement.size == 0 && this._changedMarkers.size == 0; } - /** - * Marks given `item` in differ to be "refreshed". It means that the item will be marked as removed and inserted in the differ changes - * set, so it will be effectively re-converted when differ changes will be handled by a dispatcher. - * - * @param {module:engine/model/item~Item} item Item to refresh. - */ - refreshItem( item ) { - if ( this._isInInsertedElement( item.parent ) ) { - return; - } - - this._markRemove( item.parent, item.startOffset, item.offsetSize ); - this._markInsert( item.parent, item.startOffset, item.offsetSize ); - - const range = Range._createOn( item ); - - for ( const marker of this._markerCollection.getMarkersIntersectingRange( range ) ) { - const markerRange = marker.getRange(); - - this.bufferMarkerChange( marker.name, markerRange, markerRange, marker.affectsData ); - } - - // Clear cache after each buffered operation as it is no longer valid. - this._cachedChanges = null; - } - /** * Buffers the given operation. An operation has to be buffered before it is executed. * @@ -540,16 +522,25 @@ export default class Differ { this._changeCount = 0; // Cache changes. - this._cachedChangesWithGraveyard = diffSet.slice(); + this._cachedChangesWithGraveyard = diffSet; this._cachedChanges = diffSet.filter( _changesInGraveyardFilter ); if ( options.includeChangesInGraveyard ) { - return this._cachedChangesWithGraveyard; + return this._cachedChangesWithGraveyard.slice(); } else { - return this._cachedChanges; + return this._cachedChanges.slice(); } } + /** + * Returns a set of model items that were marked to get refreshed. + * + * @return {Set.} + */ + getRefreshedItems() { + return new Set( this._refreshedItems ); + } + /** * Resets `Differ`. Removes all buffered changes. */ @@ -557,6 +548,36 @@ export default class Differ { this._changesInElement.clear(); this._elementSnapshots.clear(); this._changedMarkers.clear(); + this._refreshedItems = new Set(); + this._cachedChanges = null; + } + + /** + * Marks given `item` in differ to be "refreshed". It means that the item will be marked as removed and inserted in the differ changes + * set, so it will be effectively re-converted when differ changes will be handled by a dispatcher. + * + * @protected + * @param {module:engine/model/item~Item} item Item to refresh. + */ + _refreshItem( item ) { + if ( this._isInInsertedElement( item.parent ) ) { + return; + } + + this._markRemove( item.parent, item.startOffset, item.offsetSize ); + this._markInsert( item.parent, item.startOffset, item.offsetSize ); + + this._refreshedItems.add( item ); + + const range = Range._createOn( item ); + + for ( const marker of this._markerCollection.getMarkersIntersectingRange( range ) ) { + const markerRange = marker.getRange(); + + this.bufferMarkerChange( marker.name, markerRange, markerRange, marker.affectsData ); + } + + // Clear cache after each buffered operation as it is no longer valid. this._cachedChanges = null; } diff --git a/packages/ckeditor5-engine/src/model/writer.js b/packages/ckeditor5-engine/src/model/writer.js index 5ac07eec5c7..351711b97e8 100644 --- a/packages/ckeditor5-engine/src/model/writer.js +++ b/packages/ckeditor5-engine/src/model/writer.js @@ -27,7 +27,7 @@ import DocumentSelection from './documentselection'; import toMap from '@ckeditor/ckeditor5-utils/src/tomap'; -import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import CKEditorError, { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; /** * The model can only be modified by using the writer. It should be used whenever you want to create a node, modify @@ -968,30 +968,8 @@ export default class Writer { * As the first parameter you can set marker name or instance. If none of them is provided, new marker, with a unique * name is created and returned. * - * As the second parameter you can set the new marker data or leave this parameter as empty which will just refresh - * the marker by triggering downcast conversion for it. Refreshing the marker is useful when you want to change - * the marker {@link module:engine/view/element~Element view element} without changing any marker data. - * - * let isCommentActive = false; - * - * model.conversion.markerToHighlight( { - * model: 'comment', - * view: data => { - * const classes = [ 'comment-marker' ]; - * - * if ( isCommentActive ) { - * classes.push( 'comment-marker--active' ); - * } - * - * return { classes }; - * } - * } ); - * - * // Change the property that indicates if marker is displayed as active or not. - * isCommentActive = true; - * - * // And refresh the marker to convert it with additional class. - * model.change( writer => writer.updateMarker( 'comment' ) ); + * **Note**: If you want to change the {@link module:engine/view/element~Element view element} of the marker while its data in the model + * remains the same, use the dedicated {@link module:engine/controller/editingcontroller~EditingController#reconvertMarker} method. * * The `options.usingOperation` parameter lets you change if the marker should be managed by operations or not. See * {@link module:engine/model/markercollection~Marker marker class description} to learn about the difference between @@ -1037,7 +1015,7 @@ export default class Writer { if ( !currentMarker ) { /** - * Marker with provided name does not exists. + * Marker with provided name does not exist and will not be updated. * * @error writer-updatemarker-marker-not-exists */ @@ -1045,6 +1023,18 @@ export default class Writer { } if ( !options ) { + /** + * Usage of `writer.updateMarker()` only to reconvert (refresh) a + * {@link module:engine/model/markercollection~Marker model marker} was deprecated and may not work in the future. + * Please update your code to use + * {@link module:engine/controller/editingcontroller~EditingController#reconvertMarker `editor.editing.reconvertMarker()`} + * instead. + * + * @error writer-updatemarker-reconvert-using-editingcontroller + * @param {String} markerName The name of the updated marker. + */ + logWarning( 'writer-updatemarker-reconvert-using-editingcontroller', { markerName } ); + this.model.markers._refresh( currentMarker ); return; diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/src/view/document.js index 2edf1244b39..aa70a66456d 100644 --- a/packages/ckeditor5-engine/src/view/document.js +++ b/packages/ckeditor5-engine/src/view/document.js @@ -141,7 +141,8 @@ export default class Document { * * * adding or removing attribute from elements, * * changes inside of {@link module:engine/view/uielement~UIElement UI elements}, - * * {@link module:engine/model/differ~Differ#refreshItem marking some of the model elements to be re-converted}. + * * {@link module:engine/controller/editingcontroller~EditingController#reconvertItem marking some of the model elements to be + * re-converted}. * * Try to avoid changes which touch view structure: * diff --git a/packages/ckeditor5-engine/src/view/downcastwriter.js b/packages/ckeditor5-engine/src/view/downcastwriter.js index 63087b7a207..cac01b1e54d 100644 --- a/packages/ckeditor5-engine/src/view/downcastwriter.js +++ b/packages/ckeditor5-engine/src/view/downcastwriter.js @@ -58,6 +58,14 @@ export default class DowncastWriter { * @type {Map.} */ this._cloneGroups = new Map(); + + /** + * The slot factory used by the `elementToStructure` downcast helper. + * + * @private + * @type {Function|null} + */ + this._slotFactory = null; } /** @@ -222,8 +230,20 @@ export default class DowncastWriter { * // Create element with custom classes. * writer.createContainerElement( 'p', { class: 'foo bar baz' } ); * + * // Create element with children. + * writer.createContainerElement( 'figure', { class: 'image' }, [ + * writer.createEmptyElement( 'img' ), + * writer.createContainerElement( 'figcaption' ) + * ] ); + * + * // Create element with specific options. + * writer.createContainerElement( 'span', { class: 'placeholder' }, { isAllowedInsideAttributeElement: true } ); + * * @param {String} name Name of the element. * @param {Object} [attributes] Elements attributes. + * @param {module:engine/view/node~Node|Iterable.|Object} [childrenOrOptions] + * A node or a list of nodes to be inserted into the created element. If no children were specified, element's `options` + * can be passed in this argument. * @param {Object} [options] Element's options. * @param {Boolean} [options.isAllowedInsideAttributeElement=false] Whether an element is * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped @@ -232,8 +252,16 @@ export default class DowncastWriter { * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms. * @returns {module:engine/view/containerelement~ContainerElement} Created element. */ - createContainerElement( name, attributes, options = {} ) { - const containerElement = new ContainerElement( this.document, name, attributes ); + createContainerElement( name, attributes, childrenOrOptions = {}, options = {} ) { + let children = null; + + if ( isPlainObject( childrenOrOptions ) ) { + options = childrenOrOptions; + } else { + children = childrenOrOptions; + } + + const containerElement = new ContainerElement( this.document, name, attributes, children ); if ( options.isAllowedInsideAttributeElement !== undefined ) { containerElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement; @@ -1167,7 +1195,7 @@ export default class DowncastWriter { } /** - Creates new {@link module:engine/view/selection~Selection} instance. + * Creates new {@link module:engine/view/selection~Selection} instance. * * // Creates empty selection without ranges. * const selection = writer.createSelection(); @@ -1230,6 +1258,63 @@ export default class DowncastWriter { return new Selection( selectable, placeOrOffset, options ); } + /** + * Creates placeholders for child elements of {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure + * `elementToStructure()`} conversion helper. + * + * const viewSlot = conversionApi.writer.createSlot(); + * const viewPosition = conversionApi.writer.createPositionAt( viewElement, 0 ); + * + * conversionApi.writer.insert( viewPosition, viewSlot ); + * + * It could be filtered to a specific subset of children (only `` model elements in this case): + * + * const viewSlot = conversionApi.writer.createSlot( node => node.is( 'element', 'foo' ) ); + * const viewPosition = conversionApi.writer.createPositionAt( viewElement, 0 ); + * + * conversionApi.writer.insert( viewPosition, viewSlot ); + * + * While providing a filtered slot make sure to provide slots for all child nodes. A single node can not be downcasted into + * multiple slots. + * + * **Note**: You should not change the order of nodes. View elements should be in the same order as model nodes. + * + * @param {'children'|module:engine/conversion/downcasthelpers~SlotFilter} [modeOrFilter='children'] The filter for child nodes. + * @returns {module:engine/view/element~Element} The slot element to be placed in to the view structure while processing + * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure `elementToStructure()`}. + */ + createSlot( modeOrFilter ) { + if ( !this._slotFactory ) { + /** + * The `createSlot()` method is allowed only inside the `elementToStructure` downcast helper callback. + * + * @error view-writer-invalid-create-slot-context + */ + throw new CKEditorError( 'view-writer-invalid-create-slot-context', this.document ); + } + + return this._slotFactory( this, modeOrFilter ); + } + + /** + * Registers a slot factory. + * + * @protected + * @param {Function} slotFactory The slot factory. + */ + _registerSlotFactory( slotFactory ) { + this._slotFactory = slotFactory; + } + + /** + * Clears the registered slot factory. + * + * @protected + */ + _clearSlotFactory() { + this._slotFactory = null; + } + /** * Inserts a node or nodes at the specified position. Takes care of breaking attributes before insertion * and merging them afterwards if requested by the breakAttributes param. diff --git a/packages/ckeditor5-engine/tests/controller/editingcontroller.js b/packages/ckeditor5-engine/tests/controller/editingcontroller.js index 72660740389..cea10b373ee 100644 --- a/packages/ckeditor5-engine/tests/controller/editingcontroller.js +++ b/packages/ckeditor5-engine/tests/controller/editingcontroller.js @@ -24,6 +24,8 @@ import { getData as getModelData, parse } from '../../src/dev-utils/model'; import { getData as getViewData } from '../../src/dev-utils/view'; import { StylesProcessor } from '../../src/view/stylesmap'; +import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; + describe( 'EditingController', () => { describe( 'constructor()', () => { let model, editing; @@ -511,4 +513,117 @@ describe( 'EditingController', () => { expect( spy.called ).to.be.true; } ); } ); + + describe( 'reconvertMarker()', () => { + let model, editing; + + beforeEach( () => { + model = new Model(); + model.document.createRoot(); + editing = new EditingController( model, new StylesProcessor() ); + } ); + + it( 'should call MarkerCollection#_refresh()', () => { + model.change( writer => { + writer.insert( writer.createText( 'x' ), model.document.getRoot(), 0 ); + + writer.addMarker( 'foo', { + range: writer.createRangeIn( model.document.getRoot() ), + usingOperation: true + } ); + } ); + + const refreshSpy = sinon.stub( model.markers, '_refresh' ); + + editing.reconvertMarker( 'foo' ); + sinon.assert.calledOnce( refreshSpy ); + sinon.assert.calledWith( refreshSpy, model.markers.get( 'foo' ) ); + } ); + + it( 'should use a model.change() block to reconvert a marker', () => { + const changeSpy = sinon.spy(); + + model.change( writer => { + writer.insert( writer.createText( 'x' ), model.document.getRoot(), 0 ); + + writer.addMarker( 'foo', { + range: writer.createRangeIn( model.document.getRoot() ), + usingOperation: true + } ); + } ); + + model.document.on( 'change', changeSpy ); + sinon.assert.notCalled( changeSpy ); + + editing.reconvertMarker( 'foo' ); + sinon.assert.calledOnce( changeSpy ); + } ); + + it( 'should work when a marker instance was passed', () => { + let marker; + + model.change( writer => { + writer.insert( writer.createText( 'x' ), model.document.getRoot(), 0 ); + + marker = writer.addMarker( 'foo', { + range: writer.createRangeIn( model.document.getRoot() ), + usingOperation: true + } ); + } ); + + const refreshSpy = sinon.stub( model.markers, '_refresh' ); + + editing.reconvertMarker( marker ); + sinon.assert.calledOnce( refreshSpy ); + } ); + + it( 'should throw when marker was not found in the collection', () => { + expectToThrowCKEditorError( + () => { + editing.reconvertMarker( 'foo' ); + }, + 'editingcontroller-reconvertmarker-marker-not-exist', + editing, + { + markerName: 'foo' + } + ); + } ); + } ); + + describe( 'reconvertItem()', () => { + let model, editing; + + beforeEach( () => { + model = new Model(); + model.document.createRoot(); + editing = new EditingController( model, new StylesProcessor() ); + } ); + + it( 'should call Differ#_refreshItem()', () => { + model.change( writer => { + writer.insert( writer.createText( 'x' ), model.document.getRoot(), 0 ); + } ); + + const refreshSpy = sinon.stub( model.document.differ, '_refreshItem' ); + + editing.reconvertItem( model.document.getRoot().getChild( 0 ) ); + sinon.assert.calledOnce( refreshSpy ); + sinon.assert.calledWith( refreshSpy, model.document.getRoot().getChild( 0 ) ); + } ); + + it( 'should use a model.change() block to reconvert an item', () => { + const changeSpy = sinon.spy(); + + model.change( writer => { + writer.insert( writer.createText( 'x' ), model.document.getRoot(), 0 ); + } ); + + model.document.on( 'change', changeSpy ); + sinon.assert.notCalled( changeSpy ); + + editing.reconvertItem( model.document.getRoot().getChild( 0 ) ); + sinon.assert.calledOnce( changeSpy ); + } ); + } ); } ); diff --git a/packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js b/packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js index 8865b296cf6..7fb69d73ded 100644 --- a/packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js +++ b/packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js @@ -3,6 +3,8 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + import DowncastDispatcher from '../../src/conversion/downcastdispatcher'; import Mapper from '../../src/conversion/mapper'; @@ -11,41 +13,53 @@ import ModelText from '../../src/model/text'; import ModelElement from '../../src/model/element'; import ModelDocumentFragment from '../../src/model/documentfragment'; import ModelRange from '../../src/model/range'; +import ModelConsumable from '../../src/conversion/modelconsumable'; import View from '../../src/view/view'; import ViewContainerElement from '../../src/view/containerelement'; +import DowncastWriter from '../../src/view/downcastwriter'; import { StylesProcessor } from '../../src/view/stylesmap'; +import { insertAttributesAndChildren } from '../../src/conversion/downcasthelpers'; describe( 'DowncastDispatcher', () => { - let dispatcher, doc, root, differStub, model, view, mapper; + let dispatcher, doc, root, differStub, model, view, mapper, apiObj; beforeEach( () => { model = new Model(); view = new View( new StylesProcessor() ); doc = model.document; mapper = new Mapper(); - dispatcher = new DowncastDispatcher( { mapper } ); + apiObj = {}; + dispatcher = new DowncastDispatcher( { mapper, apiObj } ); root = doc.createRoot(); + dispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); + differStub = { getMarkersToRemove: () => [], getChanges: () => [], - getMarkersToAdd: () => [] + getMarkersToAdd: () => [], + getRefreshedItems: () => [] }; } ); describe( 'constructor()', () => { - it( 'should create DowncastDispatcher with given api', () => { + it( 'should create DowncastDispatcher with given api template', () => { const apiObj = {}; const dispatcher = new DowncastDispatcher( { apiObj } ); - expect( dispatcher.conversionApi.apiObj ).to.equal( apiObj ); + expect( dispatcher._conversionApi.apiObj ).to.equal( apiObj ); } ); } ); describe( 'convertChanges', () => { - it( 'should call convertInsert for insert change', () => { - sinon.stub( dispatcher, 'convertInsert' ); + it( 'should call _convertInsert for insert change', () => { + let spyConsumableVerify; + + sinon.stub( dispatcher, '_convertInsert' ).callsFake( ( range, conversionApi ) => { + spyConsumableVerify = spyConsumableVerify || sinon.spy( conversionApi.consumable, 'verifyAllConsumed' ); + } ); + sinon.stub( mapper, 'flushDeferredBindings' ); const position = model.createPositionFromPath( root, [ 0 ] ); const range = ModelRange._createFromPositionAndShift( position, 1 ); @@ -56,15 +70,44 @@ describe( 'DowncastDispatcher', () => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); - expect( dispatcher.convertInsert.calledOnce ).to.be.true; - expect( dispatcher.convertInsert.firstCall.args[ 0 ].isEqual( range ) ).to.be.true; + expect( dispatcher._convertInsert.calledOnce ).to.be.true; + expect( dispatcher._convertInsert.firstCall.args[ 0 ].isEqual( range ) ).to.be.true; + + assertConversionApi( dispatcher._convertInsert.firstCall.args[ 1 ] ); + + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( spyConsumableVerify.calledOnce ).to.be.true; + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should call _convertReinsert for reinsert change', () => { + sinon.stub( dispatcher, '_convertReinsert' ); + sinon.stub( mapper, 'flushDeferredBindings' ); + + const position = model.createPositionFromPath( root, [ 0 ] ); + const range = ModelRange._createFromPositionAndShift( position, 1 ); + + differStub.getChanges = () => [ { type: 'reinsert', position, length: 1 } ]; + + view.change( writer => { + dispatcher.convertChanges( differStub, model.markers, writer ); + } ); + + expect( dispatcher._convertReinsert.calledOnce ).to.be.true; + expect( dispatcher._convertReinsert.firstCall.args[ 0 ].isEqual( range ) ).to.be.true; + + assertConversionApi( dispatcher._convertReinsert.firstCall.args[ 1 ] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); - it( 'should call convertRemove for remove change', () => { - sinon.stub( dispatcher, 'convertRemove' ); + it( 'should call _convertRemove for remove change', () => { + sinon.stub( dispatcher, '_convertRemove' ); + sinon.stub( mapper, 'flushDeferredBindings' ); const position = model.createPositionFromPath( root, [ 0 ] ); @@ -74,15 +117,18 @@ describe( 'DowncastDispatcher', () => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); - expect( dispatcher.convertRemove.calledWith( position, 2, '$text' ) ).to.be.true; + expect( dispatcher._convertRemove.calledWith( position, 2, '$text' ) ).to.be.true; + + assertConversionApi( dispatcher._convertRemove.firstCall.args[ 3 ] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); - it( 'should call convertAttribute for attribute change', () => { - sinon.stub( dispatcher, 'convertAttribute' ); - sinon.stub( dispatcher, '_mapChangesWithAutomaticReconversion' ).callsFake( differ => differ.getChanges() ); + it( 'should call _convertAttribute for attribute change', () => { + sinon.stub( dispatcher, '_convertAttribute' ); + sinon.stub( mapper, 'flushDeferredBindings' ); const position = model.createPositionFromPath( root, [ 0 ] ); const range = ModelRange._createFromPositionAndShift( position, 1 ); @@ -95,23 +141,28 @@ describe( 'DowncastDispatcher', () => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); - expect( dispatcher.convertAttribute.calledWith( range, 'key', null, 'foo' ) ).to.be.true; + expect( dispatcher._convertAttribute.calledWith( range, 'key', null, 'foo' ) ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + assertConversionApi( dispatcher._convertAttribute.firstCall.args[ 4 ] ); + + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should handle multiple changes', () => { - sinon.stub( dispatcher, 'convertInsert' ); - sinon.stub( dispatcher, 'convertRemove' ); - sinon.stub( dispatcher, 'convertAttribute' ); - sinon.stub( dispatcher, '_mapChangesWithAutomaticReconversion' ).callsFake( differ => differ.getChanges() ); + sinon.stub( dispatcher, '_convertInsert' ); + sinon.stub( dispatcher, '_convertReinsert' ); + sinon.stub( dispatcher, '_convertRemove' ); + sinon.stub( dispatcher, '_convertAttribute' ); + sinon.stub( mapper, 'flushDeferredBindings' ); const position = model.createPositionFromPath( root, [ 0 ] ); const range = ModelRange._createFromPositionAndShift( position, 1 ); differStub.getChanges = () => [ { type: 'insert', position, length: 1 }, + { type: 'reinsert', 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 } @@ -121,16 +172,56 @@ describe( 'DowncastDispatcher', () => { 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; + expect( dispatcher._convertInsert.calledTwice ).to.be.true; + expect( dispatcher._convertReinsert.calledOnce ).to.be.true; + expect( dispatcher._convertRemove.calledOnce ).to.be.true; + expect( dispatcher._convertAttribute.calledOnce ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); - it( 'should call convertMarkerAdd when markers are added', () => { - sinon.stub( dispatcher, 'convertMarkerAdd' ); + it( 'should fire "reduceChanges" event and use replaced changes', () => { + sinon.stub( dispatcher, '_convertInsert' ); + sinon.stub( dispatcher, '_convertReinsert' ); + sinon.stub( dispatcher, '_convertRemove' ); + sinon.stub( dispatcher, '_convertAttribute' ); + sinon.stub( mapper, 'flushDeferredBindings' ); + + 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' } + ]; + + dispatcher.on( 'reduceChanges', ( evt, data ) => { + data.changes = [ + { type: 'insert', position, length: 1 }, + { type: 'remove', position, length: 1 }, + { type: 'reinsert', position, length: 1 } + ]; + } ); + + view.change( writer => { + dispatcher.convertChanges( differStub, model.markers, writer ); + } ); + + expect( dispatcher._convertInsert.calledOnce ).to.be.true; + expect( dispatcher._convertReinsert.calledOnce ).to.be.true; + expect( dispatcher._convertRemove.calledOnce ).to.be.true; + expect( dispatcher._convertAttribute.notCalled ).to.be.true; + + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should call _convertMarkerAdd when markers are added', () => { + sinon.stub( dispatcher, '_convertMarkerAdd' ); + sinon.stub( mapper, 'flushDeferredBindings' ); const fooRange = model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 1 ) ); const barRange = model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 6 ) ); @@ -144,15 +235,20 @@ describe( 'DowncastDispatcher', () => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); - expect( dispatcher.convertMarkerAdd.calledWith( 'foo', fooRange ) ); - expect( dispatcher.convertMarkerAdd.calledWith( 'bar', barRange ) ); + expect( dispatcher._convertMarkerAdd.calledWith( 'foo', fooRange ) ); + expect( dispatcher._convertMarkerAdd.calledWith( 'bar', barRange ) ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + assertConversionApi( dispatcher._convertMarkerAdd.firstCall.args[ 2 ] ); + assertConversionApi( dispatcher._convertMarkerAdd.secondCall.args[ 2 ] ); + + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); - it( 'should call convertMarkerRemove when markers are removed', () => { - sinon.stub( dispatcher, 'convertMarkerRemove' ); + it( 'should call _convertMarkerRemove when markers are removed', () => { + sinon.stub( dispatcher, '_convertMarkerRemove' ); + sinon.stub( mapper, 'flushDeferredBindings' ); const fooRange = model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 1 ) ); const barRange = model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 6 ) ); @@ -166,16 +262,21 @@ describe( 'DowncastDispatcher', () => { dispatcher.convertChanges( differStub, model.markers, writer ); } ); - expect( dispatcher.convertMarkerRemove.calledWith( 'foo', fooRange ) ); - expect( dispatcher.convertMarkerRemove.calledWith( 'bar', barRange ) ); + expect( dispatcher._convertMarkerRemove.calledWith( 'foo', fooRange ) ); + expect( dispatcher._convertMarkerRemove.calledWith( 'bar', barRange ) ); + + assertConversionApi( dispatcher._convertMarkerRemove.firstCall.args[ 2 ] ); + assertConversionApi( dispatcher._convertMarkerRemove.secondCall.args[ 2 ] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should re-render markers which view elements got unbound during conversion', () => { - sinon.stub( dispatcher, 'convertMarkerRemove' ); - sinon.stub( dispatcher, 'convertMarkerAdd' ); + sinon.stub( dispatcher, '_convertMarkerRemove' ); + sinon.stub( dispatcher, '_convertMarkerAdd' ); + sinon.stub( mapper, 'flushDeferredBindings' ); const fooRange = model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 1 ) ); const barRange = model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 6 ) ); @@ -184,23 +285,29 @@ describe( 'DowncastDispatcher', () => { model.markers._set( 'bar', barRange ); // Stub `Mapper#flushUnboundMarkerNames`. - dispatcher.conversionApi.mapper.flushUnboundMarkerNames = () => [ 'foo', 'bar' ]; + 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 ) ); + expect( dispatcher._convertMarkerRemove.calledWith( 'foo', fooRange ) ); + expect( dispatcher._convertMarkerRemove.calledWith( 'bar', barRange ) ); + expect( dispatcher._convertMarkerAdd.calledWith( 'foo', fooRange ) ); + expect( dispatcher._convertMarkerAdd.calledWith( 'bar', barRange ) ); + + assertConversionApi( dispatcher._convertMarkerRemove.firstCall.args[ 2 ] ); + assertConversionApi( dispatcher._convertMarkerRemove.secondCall.args[ 2 ] ); + assertConversionApi( dispatcher._convertMarkerAdd.firstCall.args[ 2 ] ); + assertConversionApi( dispatcher._convertMarkerAdd.secondCall.args[ 2 ] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( mapper.flushDeferredBindings.calledOnce ).to.be.true; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); } ); - describe( 'convertInsert', () => { + describe( 'convert', () => { it( 'should fire event with correct parameters for every item in passed range', () => { root._appendChild( [ new ModelText( 'foo', { bold: true } ), @@ -240,7 +347,7 @@ describe( 'DowncastDispatcher', () => { expect( conversionApi.consumable.consume( data.item, 'attribute:' + key ) ).to.be.true; } ); - dispatcher.convertInsert( range ); + dispatcher.convert( range, [] ); // Check the data passed to called events and the order of them. expect( loggedEvents ).to.deep.equal( [ @@ -254,42 +361,555 @@ describe( 'DowncastDispatcher', () => { 'attribute:italic:true:$text:xx:7,0:7,2' ] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); - it( 'should not fire events for already consumed parts of model', () => { + it( 'should not fire same events multiple times', () => { root._appendChild( [ new ModelElement( 'imageBlock', { src: 'foo.jpg', title: 'bar', bold: true }, [ new ModelElement( 'caption', {}, new ModelText( 'title' ) ) ] ) ] ); - sinon.spy( dispatcher, 'fire' ); + const loggedEvents = []; + + dispatcher.on( 'insert', ( evt, data, conversionApi ) => { + const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; + const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; + + loggedEvents.push( log ); + + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + 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 ); + + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + conversionApi.convertAttributes( data.item ); + conversionApi.convertChildren( data.item ); + } ); + + const range = model.createRangeIn( root ); + + dispatcher.convert( range, [] ); + + expect( loggedEvents ).to.deep.equal( [ + 'insert:imageBlock:0:1', + 'attribute:src:foo.jpg:imageBlock:0:1', + 'attribute:title:bar:imageBlock:0:1', + 'attribute:bold:true:imageBlock:0:1', + 'insert:caption:0,0:0,1', + 'insert:$text:title:0,0,0:0,0,5' + ] ); + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should call _convertMarkerAdd for all provided markers', () => { + 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 ) ); + + const markers = new Map( [ + [ 'foo', fooRange ], + [ 'bar', barRange ] + ] ); + + const range = model.createRangeIn( root ); + + view.change( writer => { + dispatcher.convert( range, markers, writer ); + } ); + + expect( dispatcher._convertMarkerAdd.calledWith( 'foo', fooRange ) ); + expect( dispatcher._convertMarkerAdd.calledWith( 'bar', barRange ) ); + + assertConversionApi( dispatcher._convertMarkerAdd.firstCall.args[ 2 ] ); + assertConversionApi( dispatcher._convertMarkerAdd.secondCall.args[ 2 ] ); + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should pass options object to conversionApi', () => { + sinon.stub( dispatcher, '_convertInsert' ); + sinon.stub( dispatcher, '_convertMarkerAdd' ); + + const position = model.createPositionFromPath( root, [ 0 ] ); + const range = ModelRange._createFromPositionAndShift( position, 1 ); + + const fooRange = model.createRange( model.createPositionAt( root, 0 ), model.createPositionAt( root, 1 ) ); + const barRange = model.createRange( model.createPositionAt( root, 3 ), model.createPositionAt( root, 6 ) ); + + const markers = new Map( [ + [ 'foo', fooRange ], + [ 'bar', barRange ] + ] ); + + const options = {}; + + view.change( writer => { + dispatcher.convert( range, markers, writer, options ); + } ); + + expect( dispatcher._convertInsert.calledOnce ).to.be.true; + expect( dispatcher._convertInsert.firstCall.args[ 0 ].isEqual( range ) ).to.be.true; + + expect( dispatcher._convertMarkerAdd.calledWith( 'foo', fooRange ) ); + expect( dispatcher._convertMarkerAdd.calledWith( 'bar', barRange ) ); + + assertConversionApi( dispatcher._convertInsert.firstCall.args[ 1 ] ); + assertConversionApi( dispatcher._convertMarkerAdd.firstCall.args[ 2 ] ); + assertConversionApi( dispatcher._convertMarkerAdd.secondCall.args[ 2 ] ); + + expect( dispatcher._convertInsert.firstCall.args[ 1 ].options ).to.equal( options ); + expect( dispatcher._convertMarkerAdd.firstCall.args[ 2 ].options ).to.equal( options ); + expect( dispatcher._convertMarkerAdd.firstCall.args[ 2 ].options ).to.equal( options ); + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should be possible to listen for the insert event with the lowest priority to get subtree converted', () => { + root._appendChild( [ + new ModelElement( 'imageBlock', { src: 'foo.jpg' }, [ + new ModelElement( 'caption', {}, new ModelText( 'title' ) ) + ] ) + ] ); + + const range = model.createRangeIn( root ); + const spyBefore = sinon.spy(); + const spyAfter = sinon.spy(); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'insert:caption', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'insert:$text', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'attribute:src:imageBlock', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + spyBefore(); + + expect( conversionApi.consumable.test( data.item, 'insert' ) ).to.be.true; + expect( conversionApi.consumable.test( data.item, 'attribute:src' ) ).to.be.true; + expect( conversionApi.consumable.test( data.item.getChild( 0 ), 'insert' ) ).to.be.true; + }, { priority: 'highest' } ); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + spyAfter(); + + expect( conversionApi.consumable.test( data.item, 'insert' ) ).to.be.false; + expect( conversionApi.consumable.test( data.item, 'attribute:src' ) ).to.be.false; + expect( conversionApi.consumable.test( data.item.getChild( 0 ), 'insert' ) ).to.be.false; + }, { priority: 'lowest' } ); + + dispatcher.convert( range, [] ); + + expect( spyBefore.calledOnce ).to.be.true; + expect( spyAfter.calledOnce ).to.be.true; + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should throw if not all insert events were consumed', () => { + root._appendChild( [ + new ModelElement( 'imageBlock', { src: 'foo.jpg' }, [ + new ModelElement( 'caption', {}, new ModelText( 'title' ) ) + ] ) + ] ); + + const range = model.createRangeIn( root ); + let spy; + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + spy = sinon.spy( conversionApi.consumable, 'verifyAllConsumed' ); + } ); + + expect( () => { + dispatcher.convert( range, [] ); + } ).to.throw( CKEditorError, 'conversion-model-consumable-not-consumed' ); + + expect( spy.calledOnce ).to.be.true; + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + } ); + + describe( '_convertInsert', () => { + it( 'should fire event with correct parameters for every item in passed range', () => { + root._appendChild( [ + new ModelText( 'foo', { bold: true } ), + new ModelElement( 'imageBlock', null, new ModelElement( 'caption' ) ), + 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; + expect( data ).to.not.have.property( 'reconversion' ); + } ); + + // 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; + } ); + + view.change( writer => { + dispatcher._convertInsert( range, dispatcher._createConversionApi( writer ) ); + } ); + + // 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:imageBlock:3:4', + 'insert:caption:3,0:3,1', + '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' + ] ); + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should fire events only for shallow range', () => { + // Set new dispatcher without convert attributes and children handler. + dispatcher = new DowncastDispatcher( { mapper, apiObj } ); + + root._appendChild( [ + new ModelText( 'foo', { bold: true } ), + new ModelElement( 'imageBlock', null, new ModelElement( 'caption' ) ), + new ModelText( 'bar' ), + new ModelElement( 'paragraph', { class: 'nice' }, new ModelText( 'xx', { italic: true } ) ) + ] ); + + const range = model.createRangeIn( root ); + const loggedEvents = []; + let consumable; + + // 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; + expect( data ).to.not.have.property( 'reconversion' ); + + consumable = conversionApi.consumable; + } ); + + // 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; + } ); + + view.change( writer => { + dispatcher._convertInsert( range, dispatcher._createConversionApi( writer ) ); + } ); + + // Check the data passed to called events and the order of them. + expect( loggedEvents ).to.deep.equal( [ + 'insert:$text:foo:0:3', + 'insert:imageBlock:3:4', + 'insert:$text:bar:4:7', + 'insert:paragraph:7:8' + ] ); + + // Consumable should be populated with all the events (even those nested). + expect( consumable.test( root.getChild( 1 ).getChild( 0 ), 'insert' ) ).to.be.true; + expect( consumable.test( root.getChild( 3 ), 'attribute:class:paragraph' ) ).to.be.true; + expect( consumable.test( root.getChild( 1 ), 'insert' ) ).to.be.false; + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should not fire same events multiple times', () => { + root._appendChild( [ + new ModelElement( 'imageBlock', { src: 'foo.jpg', title: 'bar', bold: true }, [ + new ModelElement( 'caption', {}, new ModelText( 'title' ) ) + ] ) + ] ); + + const loggedEvents = []; + + dispatcher.on( 'insert', ( evt, data ) => { + const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; + const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; + + loggedEvents.push( log ); + } ); + + dispatcher.on( 'attribute', ( evt, data ) => { + 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 ); + } ); dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { - conversionApi.consumable.consume( data.item.getChild( 0 ), 'insert' ); - conversionApi.consumable.consume( data.item, 'attribute:bold' ); + conversionApi.convertAttributes( data.item ); + conversionApi.convertChildren( data.item ); + } ); + + dispatcher.on( 'insert:caption', ( evt, data, conversionApi ) => { + conversionApi.convertAttributes( data.item ); + conversionApi.convertChildren( data.item ); } ); const range = model.createRangeIn( root ); - dispatcher.convertInsert( range ); + view.change( writer => { + dispatcher._convertInsert( range, dispatcher._createConversionApi( writer ) ); + } ); - expect( dispatcher.fire.calledWith( 'insert:imageBlock' ) ).to.be.true; - expect( dispatcher.fire.calledWith( 'attribute:src:imageBlock' ) ).to.be.true; - expect( dispatcher.fire.calledWith( 'attribute:title:imageBlock' ) ).to.be.true; - expect( dispatcher.fire.calledWith( 'insert:$text' ) ).to.be.true; + expect( loggedEvents ).to.deep.equal( [ + 'insert:imageBlock:0:1', + 'attribute:src:foo.jpg:imageBlock:0:1', + 'attribute:title:bar:imageBlock:0:1', + 'attribute:bold:true:imageBlock:0:1', + 'insert:caption:0,0:0,1', + 'insert:$text:title:0,0,0:0,0,5' + ] ); - expect( dispatcher.fire.calledWith( 'attribute:bold:imageBlock' ) ).to.be.false; - expect( dispatcher.fire.calledWith( 'insert:caption' ) ).to.be.false; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + + it( 'should not add consumable item if it was added already in consumable', () => { + root._appendChild( [ + new ModelElement( 'imageBlock', {}, [ + new ModelElement( 'caption', {}, new ModelText( 'title' ) ) + ] ) + ] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + 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.test( data.item, 'insert' ) ).to.be.true; + expect( data ).to.not.have.property( 'reconversion' ); + } ); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + if ( conversionApi.consumable.consume( data.item, 'insert' ) ) { + conversionApi.convertItem( data.item ); + } + } ); + + dispatcher.on( 'insert:caption', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, 'insert' ); + } ); + + dispatcher.on( 'insert:$text', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, 'insert' ); + } ); + + const range = model.createRangeIn( root ); + + view.change( writer => { + dispatcher._convertInsert( range, dispatcher._createConversionApi( writer ) ); + } ); + + expect( loggedEvents ).to.deep.equal( [ + 'insert:imageBlock:0:1', + 'insert:caption:0,0:0,1', + 'insert:$text:title:0,0,0:0,0,5' + ] ); + } ); + + it( 'should be possible to listen for the insert event with the lowest priority to get subtree converted', () => { + root._appendChild( [ + new ModelElement( 'imageBlock', { src: 'foo.jpg' }, [ + new ModelElement( 'caption', {}, new ModelText( 'title' ) ) + ] ) + ] ); + + const range = model.createRangeIn( root ); + const spyBefore = sinon.spy(); + const spyAfter = sinon.spy(); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'insert:caption', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'insert:$text', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'attribute:src:imageBlock', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + spyBefore(); + + expect( conversionApi.consumable.test( data.item, 'insert' ) ).to.be.true; + expect( conversionApi.consumable.test( data.item, 'attribute:src' ) ).to.be.true; + expect( conversionApi.consumable.test( data.item.getChild( 0 ), 'insert' ) ).to.be.true; + }, { priority: 'highest' } ); + + dispatcher.on( 'insert:imageBlock', ( evt, data, conversionApi ) => { + spyAfter(); + + expect( conversionApi.consumable.test( data.item, 'insert' ) ).to.be.false; + expect( conversionApi.consumable.test( data.item, 'attribute:src' ) ).to.be.false; + expect( conversionApi.consumable.test( data.item.getChild( 0 ), 'insert' ) ).to.be.false; + }, { priority: 'lowest' } ); + + view.change( writer => { + dispatcher._convertInsert( range, dispatcher._createConversionApi( writer ) ); + } ); + + expect( spyBefore.calledOnce ).to.be.true; + expect( spyAfter.calledOnce ).to.be.true; + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; + } ); + } ); + + describe( '_convertReinsert', () => { + it( 'should fire event with correct parameters for every item in passed range (shallow)', () => { + root._appendChild( [ + new ModelText( 'foo', { bold: true } ), + new ModelElement( 'imageBlock', null, new ModelElement( 'caption' ) ), + 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; + expect( data ).to.have.property( 'reconversion' ).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; + } ); + + view.change( writer => { + dispatcher._convertReinsert( range, dispatcher._createConversionApi( writer ) ); + } ); + + // 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:imageBlock:3:4', + 'insert:$text:bar:4:7', + 'insert:paragraph:7:8', + 'attribute:class:nice:paragraph:7:8' + ] ); + + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); } ); - describe( 'convertRemove', () => { + describe( '_convertRemove', () => { it( 'should fire event for removed range', () => { const loggedEvents = []; @@ -298,12 +918,12 @@ describe( 'DowncastDispatcher', () => { loggedEvents.push( log ); } ); - dispatcher.convertRemove( model.createPositionAt( root, 3 ), 3, '$text' ); + dispatcher._convertRemove( model.createPositionAt( root, 3 ), 3, '$text' ); expect( loggedEvents ).to.deep.equal( [ 'remove:3:3' ] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); } ); @@ -330,8 +950,8 @@ describe( 'DowncastDispatcher', () => { { selection: sinon.match.instanceOf( doc.selection.constructor ) } ) ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should prepare correct list of consumable values', () => { @@ -350,8 +970,8 @@ describe( 'DowncastDispatcher', () => { dispatcher.convertSelection( doc.selection, model.markers, [] ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should not fire attributes events for non-collapsed selection', () => { @@ -369,8 +989,8 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.false; expect( dispatcher.fire.calledWith( 'attribute:italic' ) ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should fire attributes events for collapsed selection', () => { @@ -390,8 +1010,8 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'attribute:bold:$text' ) ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should not fire attributes events if attribute has been consumed', () => { @@ -418,8 +1038,8 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should fire events for markers for collapsed selection', () => { @@ -438,8 +1058,8 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should fire events for all markers of the same group for collapsed selection', () => { @@ -467,8 +1087,8 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'addMarker:name:1' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'addMarker:name:2' ) ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should not fire events for markers for non-collapsed selection', () => { @@ -484,8 +1104,8 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should not fire event for marker if selection is in a element with custom highlight handling', () => { @@ -506,7 +1126,7 @@ describe( 'DowncastDispatcher', () => { viewFigure._setCustomProperty( 'removeHighlight', () => {} ); // Create mapper mock. - dispatcher.conversionApi.mapper = { + dispatcher._conversionApi.mapper = { toViewElement( modelElement ) { if ( modelElement == image ) { return viewFigure; @@ -529,8 +1149,8 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should not fire events if information about marker has been consumed', () => { @@ -556,12 +1176,12 @@ describe( 'DowncastDispatcher', () => { expect( dispatcher.fire.calledWith( 'addMarker:foo' ) ).to.be.true; expect( dispatcher.fire.calledWith( 'addMarker:bar' ) ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); } ); - describe( 'convertMarkerAdd', () => { + describe( '_convertMarkerAdd', () => { let element, text; beforeEach( () => { @@ -582,12 +1202,12 @@ describe( 'DowncastDispatcher', () => { expect( data.markerRange.isEqual( range ) ).to.be.true; } ); - dispatcher.convertMarkerAdd( 'name', range ); + dispatcher._convertMarkerAdd( 'name', range, dispatcher._createConversionApi() ); expect( spy.calledOnce ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should convert marker in document fragment', () => { @@ -596,24 +1216,24 @@ describe( 'DowncastDispatcher', () => { const eleRange = model.createRange( model.createPositionAt( docFrag, 1 ), model.createPositionAt( docFrag, 2 ) ); sinon.spy( dispatcher, 'fire' ); - dispatcher.convertMarkerAdd( 'name', eleRange ); + dispatcher._convertMarkerAdd( 'name', eleRange, dispatcher._createConversionApi() ); expect( dispatcher.fire.called ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); 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 ); + dispatcher._convertMarkerAdd( 'name', gyRange, dispatcher._createConversionApi() ); expect( dispatcher.fire.called ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should fire addMarker event for whole non-collapsed marker and for each item in the range', () => { @@ -645,7 +1265,7 @@ describe( 'DowncastDispatcher', () => { } } ); - dispatcher.convertMarkerAdd( 'name', range ); + dispatcher._convertMarkerAdd( 'name', range, dispatcher._createConversionApi() ); expect( spyWholeRange.calledOnce ).to.be.true; expect( spyItems.calledTwice ).to.be.true; @@ -653,8 +1273,8 @@ describe( 'DowncastDispatcher', () => { expect( items[ 0 ] ).to.equal( element ); expect( items[ 1 ].data ).to.equal( text.data ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should not fire conversion for non-collapsed marker items if marker was consumed in earlier event', () => { @@ -674,12 +1294,12 @@ describe( 'DowncastDispatcher', () => { } } ); - dispatcher.convertMarkerAdd( 'name', range ); + dispatcher._convertMarkerAdd( 'name', range, dispatcher._createConversionApi() ); expect( spyItems.called ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should be possible to override #1', () => { @@ -702,13 +1322,13 @@ describe( 'DowncastDispatcher', () => { } }, { priority: 'high' } ); - dispatcher.convertMarkerAdd( 'marker', range ); + dispatcher._convertMarkerAdd( 'marker', range, dispatcher._createConversionApi() ); expect( addMarkerSpy.called ).to.be.false; expect( highAddMarkerSpy.calledOnce ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should be possible to override #2', () => { @@ -731,19 +1351,19 @@ describe( 'DowncastDispatcher', () => { } }, { priority: 'high' } ); - dispatcher.convertMarkerAdd( 'marker', range ); + dispatcher._convertMarkerAdd( 'marker', range, dispatcher._createConversionApi() ); expect( addMarkerSpy.called ).to.be.false; // Called once for each item, twice total. expect( highAddMarkerSpy.calledTwice ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); } ); - describe( 'convertMarkerRemove', () => { + describe( '_convertMarkerRemove', () => { let range, element, text; beforeEach( () => { @@ -757,24 +1377,24 @@ describe( 'DowncastDispatcher', () => { it( 'should fire removeMarker event', () => { sinon.spy( dispatcher, 'fire' ); - dispatcher.convertMarkerRemove( 'name', range ); + dispatcher._convertMarkerRemove( 'name', range, dispatcher._createConversionApi() ); expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); 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 ); + dispatcher._convertMarkerRemove( 'name', gyRange, dispatcher._createConversionApi() ); expect( dispatcher.fire.called ).to.be.false; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should convert marker in document fragment', () => { @@ -783,12 +1403,12 @@ describe( 'DowncastDispatcher', () => { const eleRange = model.createRange( model.createPositionAt( docFrag, 1 ), model.createPositionAt( docFrag, 2 ) ); sinon.spy( dispatcher, 'fire' ); - dispatcher.convertMarkerRemove( 'name', eleRange ); + dispatcher._convertMarkerRemove( 'name', eleRange, dispatcher._createConversionApi() ); expect( dispatcher.fire.called ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should fire conversion for the range', () => { @@ -799,10 +1419,10 @@ describe( 'DowncastDispatcher', () => { expect( data.markerRange.isEqual( range ) ).to.be.true; } ); - dispatcher.convertMarkerRemove( 'name', range ); + dispatcher._convertMarkerRemove( 'name', range, dispatcher._createConversionApi() ); - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); it( 'should be possible to override', () => { @@ -819,13 +1439,20 @@ describe( 'DowncastDispatcher', () => { evt.stop(); }, { priority: 'high' } ); - dispatcher.convertMarkerRemove( 'marker', range ); + dispatcher._convertMarkerRemove( 'marker', range, dispatcher._createConversionApi() ); expect( removeMarkerSpy.called ).to.be.false; expect( highRemoveMarkerSpy.calledOnce ).to.be.true; - expect( dispatcher.conversionApi.writer ).to.be.undefined; - expect( dispatcher.conversionApi.consumable ).to.be.undefined; + expect( dispatcher._conversionApi.writer ).to.be.undefined; + expect( dispatcher._conversionApi.consumable ).to.be.undefined; } ); } ); + + function assertConversionApi( conversionApi ) { + expect( conversionApi ).to.have.property( 'writer' ).that.is.instanceof( DowncastWriter ); + expect( conversionApi ).to.have.property( 'consumable' ).that.is.instanceof( ModelConsumable ); + expect( conversionApi ).to.have.property( 'mapper' ).that.is.equal( mapper ); + expect( conversionApi ).to.have.property( 'apiObj' ).that.is.equal( apiObj ); + } } ); diff --git a/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js b/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js index 2ce695a613a..a0766219136 100644 --- a/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js +++ b/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js @@ -26,6 +26,7 @@ import DowncastHelpers, { convertCollapsedSelection, convertRangeSelection, createViewElementFromHighlightDescriptor, + insertAttributesAndChildren, insertText } from '../../src/conversion/downcasthelpers'; @@ -44,6 +45,8 @@ import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; describe( 'DowncastHelpers', () => { let model, modelRoot, viewRoot, downcastHelpers, controller, modelRootStart; + testUtils.createSinonSandbox(); + beforeEach( () => { model = new Model(); const modelDoc = model.document; @@ -117,1192 +120,2200 @@ describe( 'DowncastHelpers', () => { expectResult( '

' ); } ); - describe( 'config.triggerBy', () => { - describe( 'with simple block view structure (without children)', () => { - beforeEach( () => { - model.schema.register( 'simpleBlock', { - allowIn: '$root', - allowAttributes: [ 'toStyle', 'toClass' ] - } ); - downcastHelpers.elementToElement( { - model: 'simpleBlock', - view: ( modelElement, { writer } ) => { - return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); - }, - triggerBy: { - attributes: [ 'toStyle', 'toClass' ] - } - } ); + it( 'config.view is a function that does not return view element', () => { + downcastHelpers.elementToElement( { + model: 'heading', + view: () => null + } ); + + controller.downcastDispatcher.on( 'insert:heading', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'lowest' } ); + + model.change( writer => { + writer.insertElement( 'heading', {}, modelRoot, 0 ); + } ); + + expectResult( '' ); + } ); + + describe( 'converting element', () => { + beforeEach( () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] } ); - it( 'should convert on insert', () => { - model.change( writer => { - writer.insertElement( 'simpleBlock', modelRoot, 0 ); - } ); + downcastHelpers.elementToElement( { + model: 'simpleBlock', + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', { class: 'simple' } ); + } + } ); + + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'simpleBlock' + } ); - expectResult( '
' ); + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' } ); + } ); - it( 'should convert on attribute set', () => { - setModelData( model, '' ); + it( 'should convert element insert', () => { + setModelData( model, '' ); - const [ viewBefore ] = getNodes(); + expectResult( '
' ); + } ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); - } ); + it( 'should not reconvert on adding a child', () => { + setModelData( model, 'foo' ); - const [ viewAfter ] = getNodes(); + const spy = sinon.spy(); - expectResult( '
' ); - expect( viewAfter ).to.not.equal( viewBefore ); + controller.downcastDispatcher.on( 'insert:simpleBlock', () => { + spy(); } ); - it( 'should convert on attribute change', () => { - setModelData( model, '' ); + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); - const [ viewBefore ] = getNodes(); + writer.insert( paragraph, modelRoot.getChild( 0 ), 0 ); + writer.insert( text, paragraph, 0 ); + } ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); - } ); + const [ viewAfter, /* insertedPara */, /* insertedText */, paraAfter, textAfter ] = getNodes(); - const [ viewAfter ] = getNodes(); + expectResult( '

bar

foo

' ); - expectResult( '
' ); + expect( viewAfter, 'simpleBlock' ).to.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.notCalled ).to.be.true; + } ); + } ); + + describe( 'converting element together with selected attributes', () => { + it( 'should allow passing a list of attributes to convert and consume', () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); - expect( viewAfter ).to.not.equal( viewBefore ); + downcastHelpers.elementToElement( { + model: { + name: 'simpleBlock', + attributes: [ 'toStyle', 'toClass' ] + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + } } ); - it( 'should convert on attribute remove', () => { - setModelData( model, '' ); + let consumable; - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - } ); + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data, conversionApi ) => { + consumable = conversionApi.consumable; + }, { priority: 'low' } ); - expectResult( '
' ); - } ); + setModelData( model, '' ); - it( 'should convert on one attribute add and other remove', () => { - setModelData( model, '' ); + expectResult( '
' ); - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); - } ); + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toStyle' ) ).to.be.false; + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toClass' ) ).to.be.null; + } ); - expectResult( '
' ); + it( 'should allow passing a single attribute name to convert and consume', () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] } ); - it( 'should properly re-bind mapper mappings and retain markers', () => { - downcastHelpers.elementToElement( { - model: 'simpleBlock', - view: ( modelElement, { writer } ) => { - const viewElement = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + downcastHelpers.elementToElement( { + model: { + name: 'simpleBlock', + attributes: 'toStyle' + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + } + } ); - return toWidget( viewElement, writer ); - }, - triggerBy: { - attributes: [ 'toStyle', 'toClass' ] - }, - converterPriority: 'high' - } ); + let consumable; - const mapper = controller.mapper; + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data, conversionApi ) => { + consumable = conversionApi.consumable; + }, { priority: 'low' } ); - downcastHelpers.markerToHighlight( { - model: 'myMarker', - view: { classes: 'foo' } - } ); + setModelData( model, '' ); - setModelData( model, '' ); + expectResult( '
' ); - const modelElement = modelRoot.getChild( 0 ); - const [ viewBefore ] = getNodes(); + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toStyle' ) ).to.be.false; + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toClass' ) ).to.be.null; + } ); + } ); - model.change( writer => { - writer.addMarker( 'myMarker', { range: writer.createRangeOn( modelElement ), usingOperation: false } ); - } ); + describe( 'with simple block view structure (without reconversion on children list change)', () => { + beforeEach( () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); - expect( mapper.toViewElement( modelElement ) ).to.equal( viewBefore ); - expect( mapper.toModelElement( viewBefore ) ).to.equal( modelElement ); - expect( mapper.markerNameToElements( 'myMarker' ).has( viewBefore ) ).to.be.true; + downcastHelpers.elementToElement( { + model: { + name: 'simpleBlock', + attributes: [ 'toStyle', 'toClass' ] + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + } + } ); + } ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:block', modelElement ); - } ); + it( 'should convert on insert', () => { + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); - const [ viewAfter ] = getNodes(); + model.change( writer => { + writer.insertElement( 'simpleBlock', modelRoot, 0 ); + } ); + + expectResult( '
' ); + } ); - expect( mapper.toViewElement( modelElement ) ).to.equal( viewAfter ); - expect( mapper.toModelElement( viewBefore ) ).to.be.undefined; - expect( mapper.toModelElement( viewAfter ) ).to.equal( modelElement ); - expect( mapper.markerNameToElements( 'myMarker' ).has( viewAfter ) ).to.be.true; - expect( mapper.markerNameToElements( 'myMarker' ).has( viewBefore ) ).to.be.false; + it( 'should convert on attribute set', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; } ); - it( 'should do nothing if non-triggerBy attribute has changed', () => { - setModelData( model, '' ); + const [ viewBefore ] = getNodes(); - const [ viewBefore ] = getNodes(); + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); + } ); - model.change( writer => { - writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); - } ); + const [ viewAfter ] = getNodes(); + + expectResult( '
' ); + expect( viewAfter ).to.not.equal( viewBefore ); + } ); + + it( 'should convert on attribute change', () => { + setModelData( model, '' ); - const [ viewAfter ] = getNodes(); + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); - expectResult( '
' ); + const [ viewBefore ] = getNodes(); - expect( viewAfter ).to.equal( viewBefore ); + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); } ); + + const [ viewAfter ] = getNodes(); + + expectResult( '
' ); + + expect( viewAfter ).to.not.equal( viewBefore ); } ); - describe( 'with simple block view structure (with children)', () => { - beforeEach( () => { - model.schema.register( 'simpleBlock', { - allowIn: '$root', - allowAttributes: [ 'toStyle', 'toClass' ] - } ); - downcastHelpers.elementToElement( { - model: 'simpleBlock', - view: ( modelElement, { writer } ) => { - return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); - }, - triggerBy: { - attributes: [ 'toStyle', 'toClass' ] - } - } ); + it( 'should convert on attribute remove', () => { + setModelData( model, '' ); - model.schema.register( 'paragraph', { - inheritAllFrom: '$block', - allowIn: 'simpleBlock' - } ); - downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; } ); - it( 'should convert on insert', () => { - model.change( writer => { - const simpleBlock = writer.createElement( 'simpleBlock' ); - const paragraph = writer.createElement( 'paragraph' ); + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + } ); - writer.insert( simpleBlock, modelRoot, 0 ); - writer.insert( paragraph, simpleBlock, 0 ); - writer.insertText( 'foo', paragraph, 0 ); - } ); + expectResult( '
' ); + } ); + + it( 'should convert on one attribute add and other remove', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); - expectResult( '

foo

' ); + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); } ); - it( 'should convert on attribute set', () => { - setModelData( model, 'foo' ); + expectResult( '
' ); + } ); - const [ viewBefore, paraBefore, textBefore ] = getNodes(); + it( 'should reuse child view element', () => { + model.schema.register( 'paragraph', { inheritAllFrom: '$block', allowIn: 'simpleBlock' } ); + downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); - } ); + setModelData( model, 'foo' ); - const [ viewAfter, paraAfter, textAfter ] = getNodes(); + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); - expectResult( '

foo

' ); + const [ viewBefore, paraBefore, textBefore ] = getNodes(); - expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); - expect( paraAfter, 'para' ).to.equal( paraBefore ); - expect( textAfter, 'text' ).to.equal( textBefore ); + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); } ); - it( 'should convert on attribute change', () => { - setModelData( model, 'foo' ); + const [ viewAfter, paraAfter, textAfter ] = getNodes(); - const [ viewBefore, paraBefore, textBefore ] = getNodes(); + expectResult( '

foo

' ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); - } ); + expect( viewAfter ).to.not.equal( viewBefore ); + expect( paraAfter ).to.equal( paraBefore ); + expect( textAfter ).to.equal( textBefore ); + } ); + + it( 'should not reuse child view element if marked by Differ#_refreshItem()', () => { + model.schema.register( 'paragraph', { inheritAllFrom: '$block', allowIn: 'simpleBlock' } ); + downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); - const [ viewAfter, paraAfter, textAfter ] = getNodes(); + setModelData( model, 'foo' ); - expectResult( '

foo

' ); + const [ viewBefore, paraBefore, textBefore ] = getNodes(); - expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); - expect( paraAfter, 'para' ).to.equal( paraBefore ); - expect( textAfter, 'text' ).to.equal( textBefore ); + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); + model.document.differ._refreshItem( modelRoot.getChild( 0 ).getChild( 0 ) ); } ); - it( 'should convert on attribute remove', () => { - setModelData( model, 'foo' ); + const [ viewAfter, paraAfter, textAfter ] = getNodes(); - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - } ); + expectResult( '

foo

' ); - expectResult( '

foo

' ); + expect( viewAfter ).to.not.equal( viewBefore ); + expect( paraAfter ).to.not.equal( paraBefore ); + expect( textAfter ).to.not.equal( textBefore ); + } ); + + it( 'should properly re-bind mapper mappings and retain markers', () => { + downcastHelpers.elementToElement( { + model: 'simpleBlock', + view: ( modelElement, { writer } ) => { + const viewElement = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + + return toWidget( viewElement, writer ); + }, + triggerBy: { + attributes: [ 'toStyle', 'toClass' ] + }, + converterPriority: 'high' } ); - it( 'should convert on one attribute add and other remove', () => { - setModelData( model, 'foo' ); + const mapper = controller.mapper; - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); - } ); + downcastHelpers.markerToHighlight( { + model: 'myMarker', + view: { classes: 'foo' } + } ); + + setModelData( model, '' ); - expectResult( '

foo

' ); + const modelElement = modelRoot.getChild( 0 ); + const [ viewBefore ] = getNodes(); + + model.change( writer => { + writer.addMarker( 'myMarker', { range: writer.createRangeOn( modelElement ), usingOperation: false } ); } ); - it( 'should do nothing if non-triggerBy attribute has changed', () => { - setModelData( model, 'foo' ); + expect( mapper.toViewElement( modelElement ) ).to.equal( viewBefore ); + expect( mapper.toModelElement( viewBefore ) ).to.equal( modelElement ); + expect( mapper.markerNameToElements( 'myMarker' ).has( viewBefore ) ).to.be.true; - const [ viewBefore, paraBefore, textBefore ] = getNodes(); + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:block', modelElement ); + } ); - model.change( writer => { - writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); - } ); + const [ viewAfter ] = getNodes(); - const [ viewAfter, paraAfter, textAfter ] = getNodes(); + expect( mapper.toViewElement( modelElement ) ).to.equal( viewAfter ); + expect( mapper.toModelElement( viewBefore ) ).to.be.undefined; + expect( mapper.toModelElement( viewAfter ) ).to.equal( modelElement ); + expect( mapper.markerNameToElements( 'myMarker' ).has( viewAfter ) ).to.be.true; + expect( mapper.markerNameToElements( 'myMarker' ).has( viewBefore ) ).to.be.false; + } ); + + it( 'should not reconvert if non watched attribute has changed', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); - expectResult( '

foo

' ); + const [ viewBefore ] = getNodes(); - expect( viewAfter, 'simpleBlock' ).to.equal( viewBefore ); - expect( paraAfter, 'para' ).to.equal( paraBefore ); - // TODO - is text always re-converted? - expect( textAfter, 'text' ).to.equal( textBefore ); + model.change( writer => { + writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); } ); + + const [ viewAfter ] = getNodes(); + + expectResult( '
' ); + + expect( viewAfter ).to.equal( viewBefore ); } ); - describe( 'with simple block view structure (with children - reconvert on child add)', () => { - beforeEach( () => { - model.schema.register( 'simpleBlock', { - allowIn: '$root' - } ); - downcastHelpers.elementToElement( { - model: 'simpleBlock', - view: 'div', - triggerBy: { - children: [ 'paragraph' ] - } - } ); + it( 'should reconvert on child element added (implicit reconversion because of attributes watch)', () => { + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'simpleBlock' + } ); - model.schema.register( 'paragraph', { - inheritAllFrom: '$block', - allowIn: 'simpleBlock' - } ); - downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' } ); - it( 'should convert on insert', () => { - model.change( writer => { - const simpleBlock = writer.createElement( 'simpleBlock' ); - const paragraph = writer.createElement( 'paragraph' ); + setModelData( model, '' ); - writer.insert( simpleBlock, modelRoot, 0 ); - writer.insert( paragraph, simpleBlock, 0 ); - writer.insertText( 'foo', paragraph, 0 ); - } ); + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + const [ viewBefore ] = getNodes(); - expectResult( '

foo

' ); + model.change( writer => { + writer.insertElement( 'paragraph', modelRoot.getChild( 0 ), 0 ); } ); - it( 'should convert on adding a child (at the beginning)', () => { - setModelData( model, 'foo' ); + const [ viewAfter ] = getNodes(); - const [ viewBefore, paraBefore, textBefore ] = getNodes(); + expectResult( '

' ); - model.change( writer => { - const paragraph = writer.createElement( 'paragraph' ); - const text = writer.createText( 'bar' ); + expect( viewAfter ).to.not.equal( viewBefore ); + } ); + } ); - writer.insert( paragraph, modelRoot.getChild( 0 ), 0 ); - writer.insert( text, paragraph, 0 ); - } ); + describe( 'with simple block view structure (with reconversion on child add)', () => { + beforeEach( () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); - const [ viewAfter, /* insertedPara */, /* insertedText */, paraAfter, textAfter ] = getNodes(); + downcastHelpers.elementToElement( { + model: { + name: 'simpleBlock', + children: true + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + } + } ); - expectResult( '

bar

foo

' ); + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'simpleBlock' + } ); - expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); - expect( paraAfter, 'para' ).to.equal( paraBefore ); - expect( textAfter, 'text' ).to.equal( textBefore ); + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' } ); + } ); - it( 'should convert on adding a child (in the middle)', () => { - setModelData( model, - '' + - 'foobar' + - '' ); + it( 'should convert on insert', () => { + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); - const [ viewBefore, paraFooBefore, textFooBefore, paraBarBefore, textBarBefore ] = getNodes(); + model.change( writer => { + const simpleBlock = writer.createElement( 'simpleBlock' ); + const paragraph = writer.createElement( 'paragraph' ); - model.change( writer => { - const paragraph = writer.createElement( 'paragraph' ); - const text = writer.createText( 'baz' ); + writer.insert( simpleBlock, modelRoot, 0 ); + writer.insert( paragraph, simpleBlock, 0 ); + writer.insertText( 'foo', paragraph, 0 ); + } ); - writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); - writer.insert( text, paragraph, 0 ); - } ); + expectResult( '

foo

' ); + } ); - const [ viewAfter, - paraFooAfter, textFooAfter, /* insertedPara */, /* insertedText */, paraBarAfter, textBarAfter - ] = getNodes(); + it( 'should convert on adding a child (at the beginning)', () => { + setModelData( model, 'foo' ); - expectResult( '

foo

baz

bar

' ); + const spy = sinon.spy(); - expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); - expect( paraFooAfter, 'para foo' ).to.equal( paraFooBefore ); - expect( textFooAfter, 'text foo' ).to.equal( textFooBefore ); - expect( paraBarAfter, 'para bar' ).to.equal( paraBarBefore ); - expect( textBarAfter, 'text bar' ).to.equal( textBarBefore ); + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - it( 'should convert on adding a child (at the end)', () => { - setModelData( model, 'foo' ); + const [ viewBefore, paraBefore, textBefore ] = getNodes(); - const [ viewBefore, paraBefore, textBefore ] = getNodes(); + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); - model.change( writer => { - const paragraph = writer.createElement( 'paragraph' ); - const text = writer.createText( 'bar' ); + writer.insert( paragraph, modelRoot.getChild( 0 ), 0 ); + writer.insert( text, paragraph, 0 ); + } ); - writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); - writer.insert( text, paragraph, 0 ); - } ); + const [ viewAfter, /* insertedPara */, /* insertedText */, paraAfter, textAfter ] = getNodes(); - const [ viewAfter, paraAfter, textAfter ] = getNodes(); + expectResult( '

bar

foo

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on adding a child (in the middle)', () => { + setModelData( model, + '' + + 'foo' + + 'bar' + + '' + ); - expectResult( '

foo

bar

' ); + const spy = sinon.spy(); - expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); - expect( paraAfter, 'para' ).to.equal( paraBefore ); - expect( textAfter, 'text' ).to.equal( textBefore ); + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - it( 'should convert on removing a child', () => { - setModelData( model, - 'foobar' ); + const [ viewBefore, paraFooBefore, textFooBefore, paraBarBefore, textBarBefore ] = getNodes(); - const [ viewBefore, paraBefore, textBefore ] = getNodes(); + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'baz' ); - model.change( writer => { - writer.remove( modelRoot.getNodeByPath( [ 0, 1 ] ) ); - } ); + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); - const [ viewAfter, paraAfter, textAfter ] = getNodes(); + const [ viewAfter, + paraFooAfter, textFooAfter, /* insertedPara */, /* insertedText */, paraBarAfter, textBarAfter + ] = getNodes(); - expectResult( '

foo

' ); + expectResult( '

foo

baz

bar

' ); - expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); - expect( paraAfter, 'para' ).to.equal( paraBefore ); - expect( textAfter, 'text' ).to.equal( textBefore ); - } ); + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraFooAfter, 'para foo' ).to.equal( paraFooBefore ); + expect( textFooAfter, 'text foo' ).to.equal( textFooBefore ); + expect( paraBarAfter, 'para bar' ).to.equal( paraBarBefore ); + expect( textBarAfter, 'text bar' ).to.equal( textBarBefore ); + expect( spy.called ).to.be.true; } ); - describe( 'with complex view structure - no children allowed', () => { - beforeEach( () => { - model.schema.register( 'complex', { - allowIn: '$root', - allowAttributes: [ 'toStyle', 'toClass' ] - } ); - downcastHelpers.elementToElement( { - model: 'complex', - view: ( modelElement, { writer } ) => { - const outer = writer.createContainerElement( 'div', { class: 'complex-outer' } ); - const inner = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + it( 'should convert on adding a child (at the end)', () => { + setModelData( model, 'foo' ); - writer.insert( writer.createPositionAt( outer, 0 ), inner ); + const spy = sinon.spy(); - return outer; - }, - triggerBy: { - attributes: [ 'toStyle', 'toClass' ] - } - } ); + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - it( 'should convert on insert', () => { - model.change( writer => { - writer.insertElement( 'complex', modelRoot, 0 ); - } ); + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); - expectResult( '
' ); + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); } ); - it( 'should convert on attribute set', () => { - setModelData( model, '' ); + const [ viewAfter, paraAfter, textAfter ] = getNodes(); - const [ outerDivBefore, innerDivBefore ] = getNodes(); + expectResult( '

foo

bar

' ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); - } ); + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on removing a child', () => { + setModelData( model, + 'foobar' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); - const [ outerDivAfter, innerDivAfter ] = getNodes(); + const [ viewBefore, paraBefore, textBefore ] = getNodes(); - expectResult( '
' ); - expect( outerDivAfter, 'outer div' ).to.not.equal( outerDivBefore ); - expect( innerDivAfter, 'inner div' ).to.not.equal( innerDivBefore ); + model.change( writer => { + writer.remove( modelRoot.getNodeByPath( [ 0, 1 ] ) ); } ); - it( 'should convert on attribute change', () => { - setModelData( model, '' ); + const [ viewAfter, paraAfter, textAfter ] = getNodes(); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); - } ); + expectResult( '

foo

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); + + // https://github.com/ckeditor/ckeditor5/issues/9641 + it( 'should convert on multiple similar child hooks', () => { + model.schema.register( 'simpleBlock2', { + allowIn: '$root', + allowChildren: 'paragraph' + } ); - expectResult( '
' ); + downcastHelpers.elementToElement( { + model: { + name: 'simpleBlock2', + children: true + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', { class: 'second' } ); + } } ); - it( 'should convert on attribute remove', () => { - setModelData( model, '' ); + setModelData( model, + 'foo' + + 'bar' + ); - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - } ); + const [ viewBefore0, paraBefore0, textBefore0 ] = getNodes( 0 ); + const [ viewBefore1, paraBefore1, textBefore1 ] = getNodes( 1 ); - expectResult( '
' ); + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'abc' ); + + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); } ); - it( 'should convert on one attribute add and other remove', () => { - setModelData( model, '' ); + const [ viewAfter0, paraAfter0, textAfter0 ] = getNodes( 0 ); + const [ viewAfter1, paraAfter1, textAfter1 ] = getNodes( 1 ); - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); - } ); + expectResult( + '

foo

abc

' + + '

bar

' + ); + + expect( viewAfter0, 'simpleBlock' ).to.not.equal( viewBefore0 ); + expect( paraAfter0, 'para' ).to.equal( paraBefore0 ); + expect( textAfter0, 'text' ).to.equal( textBefore0 ); + + expect( viewAfter1, 'simpleBlock' ).to.equal( viewBefore1 ); + expect( paraAfter1, 'para' ).to.equal( paraBefore1 ); + expect( textAfter1, 'text' ).to.equal( textBefore1 ); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( '123' ); - expectResult( '
' ); + writer.insert( paragraph, modelRoot.getChild( 1 ), 1 ); + writer.insert( text, paragraph, 0 ); } ); - it( 'should do nothing if non-triggerBy attribute has changed', () => { - setModelData( model, '' ); + const [ viewAfterAfter0, paraAfterAfter0, textAfterAfter0 ] = getNodes( 0 ); + const [ viewAfterAfter1, paraAfterAfter1, textAfterAfter1 ] = getNodes( 1 ); - const [ outerDivBefore, innerDivBefore ] = getNodes(); + expectResult( + '

foo

abc

' + + '

bar

123

' + ); - model.change( writer => { - writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); - } ); + expect( viewAfter0, 'simpleBlock' ).to.not.equal( viewBefore0 ); + expect( paraAfter0, 'para' ).to.equal( paraBefore0 ); + expect( textAfter0, 'text' ).to.equal( textBefore0 ); + + expect( viewAfter1, 'simpleBlock' ).to.equal( viewBefore1 ); + expect( paraAfter1, 'para' ).to.equal( paraBefore1 ); + expect( textAfter1, 'text' ).to.equal( textBefore1 ); + + expect( viewAfterAfter0, 'simpleBlock' ).to.equal( viewAfter0 ); + expect( paraAfterAfter0, 'para' ).to.equal( paraAfter0 ); + expect( textAfterAfter0, 'text' ).to.equal( textAfter0 ); + + expect( viewAfterAfter1, 'simpleBlock' ).to.not.equal( viewAfter1 ); + expect( paraAfterAfter1, 'para' ).to.equal( paraAfter1 ); + expect( textAfterAfter1, 'text' ).to.equal( textAfter1 ); + } ); - const [ outerDivAfter, innerDivAfter ] = getNodes(); + it( 'should not reuse child view element if marked by Differ#_refreshItem()', () => { + setModelData( model, 'foo' ); - expectResult( '
' ); + const [ viewBefore, paraBefore, textBefore ] = getNodes(); - expect( outerDivAfter, 'outer div' ).to.equal( outerDivBefore ); - expect( innerDivAfter, 'inner div' ).to.equal( innerDivBefore ); + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); + model.document.differ._refreshItem( modelRoot.getChild( 0 ).getChild( 0 ) ); } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

' ); + + expect( viewAfter ).to.not.equal( viewBefore ); + expect( paraAfter ).to.not.equal( paraBefore ); + expect( textAfter ).to.not.equal( textBefore ); } ); - describe( 'with complex view structure (without slots)', () => { - beforeEach( () => { - model.schema.register( 'complex', { - allowIn: '$root', - allowAttributes: [ 'toStyle', 'toClass' ] - } ); - downcastHelpers.elementToElement( { - model: 'complex', - view: ( modelElement, { writer, mapper } ) => { - const outer = writer.createContainerElement( 'c-outer' ); - const inner = writer.createContainerElement( 'c-inner', getViewAttributes( modelElement ) ); + it( 'should fire conversion events in proper order', () => { + const loggedEvents = []; - writer.insert( writer.createPositionAt( outer, 0 ), inner ); - mapper.bindElements( modelElement, outer ); // Need for nested mapping - mapper.bindElements( modelElement, inner ); + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; + const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; - return outer; - }, - triggerBy: { - attributes: [ 'toStyle', 'toClass' ] - } - } ); + loggedEvents.push( log ); + }, { priority: 'highest' } ); - model.schema.register( 'paragraph', { - inheritAllFrom: '$block', - allowIn: 'complex' - } ); - downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); - } ); + controller.downcastDispatcher.on( 'attribute', ( evt, data ) => { + 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; - it( 'should convert on insert', () => { - model.change( writer => { - writer.insertElement( 'complex', modelRoot, 0 ); - } ); + loggedEvents.push( log ); + }, { priority: 'highest' } ); - expectResult( '' ); - } ); + setModelData( model, 'foo' ); + + expectResult( '

foo

' ); + + expect( loggedEvents ).to.deep.equal( [ + 'insert:simpleBlock:0:1', + 'attribute:toStyle:display:block:simpleBlock:0:1', + 'insert:paragraph:0,0:0,1', + 'insert:$text:foo:0,0,0:0,0,3' + ] ); + } ); + } ); + + describe( 'with multiple child elements', () => { + it( 'does not warn if multiple child elements are created', () => { + let viewElement; + + testUtils.sinon.stub( console, 'warn' ); + + downcastHelpers.elementToElement( { + model: 'multiItemBox', + view: ( modelElement, { writer } ) => { + viewElement = writer.createContainerElement( 'div' ); + + writer.insert( writer.createPositionAt( viewElement, 0 ), writer.createEmptyElement( 'p' ) ); + + return viewElement; + } + } ); + + model.change( writer => { + writer.insertElement( 'multiItemBox', null, modelRoot, 0 ); + } ); + + sinon.assert.notCalled( console.warn ); + } ); + + it( 'does not warn if multiple child UI elements are created', () => { + let viewElement; + + testUtils.sinon.stub( console, 'warn' ); + + downcastHelpers.elementToElement( { + model: 'multiItemBox', + view: ( modelElement, { writer } ) => { + viewElement = writer.createContainerElement( 'div' ); + + writer.insert( writer.createPositionAt( viewElement, 0 ), writer.createUIElement( 'div' ) ); + writer.insert( writer.createPositionAt( viewElement, 1 ), writer.createUIElement( 'span' ) ); + + return viewElement; + } + } ); + + model.change( writer => { + writer.insertElement( 'multiItemBox', null, modelRoot, 0 ); + } ); + + sinon.assert.notCalled( console.warn ); + } ); + } ); + } ); + + describe( 'elementToStructure()', () => { + it( 'should be chainable', () => { + expect( downcastHelpers.elementToStructure( { model: 'paragraph', view: 'p' } ) ).to.equal( downcastHelpers ); + } ); + + it( 'config.view is a string', () => { + downcastHelpers.elementToStructure( { model: 'paragraph', view: 'p' } ); + + model.change( writer => { + writer.insertElement( 'paragraph', modelRoot, 0 ); + } ); + + expectResult( '

' ); + } ); + + it( 'can be overwritten using converterPriority', () => { + downcastHelpers.elementToStructure( { model: 'paragraph', view: 'p' } ); + downcastHelpers.elementToStructure( { model: 'paragraph', view: 'foo', converterPriority: 'high' } ); + + model.change( writer => { + writer.insertElement( 'paragraph', modelRoot, 0 ); + } ); + + expectResult( '' ); + } ); + + it( 'config.view is a view element definition', () => { + downcastHelpers.elementToStructure( { + model: 'fancyParagraph', + view: { + name: 'p', + classes: 'fancy' + } + } ); + + model.change( writer => { + writer.insertElement( 'fancyParagraph', modelRoot, 0 ); + } ); + + expectResult( '

' ); + } ); + + it( 'config.view is a function', () => { + downcastHelpers.elementToStructure( { + model: 'heading', + view: ( modelElement, { writer } ) => writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) ) + } ); + + model.change( writer => { + writer.insertElement( 'heading', { level: 2 }, modelRoot, 0 ); + } ); + + expectResult( '

' ); + } ); + + it( 'config.view is a function that does not return view element', () => { + downcastHelpers.elementToStructure( { + model: 'heading', + view: () => null + } ); + + controller.downcastDispatcher.on( 'insert:heading', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'lowest' } ); + + model.change( writer => { + writer.insertElement( 'heading', {}, modelRoot, 0 ); + } ); + + expectResult( '' ); + } ); + + describe( 'converting element together with selected attributes', () => { + it( 'should allow passing a list of attributes to convert and consume', () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); + + downcastHelpers.elementToStructure( { + model: { + name: 'simpleBlock', + attributes: [ 'toStyle', 'toClass' ] + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + } + } ); + + let consumable; + + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data, conversionApi ) => { + consumable = conversionApi.consumable; + }, { priority: 'low' } ); + + setModelData( model, '' ); + + expectResult( '
' ); + + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toStyle' ) ).to.be.false; + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toClass' ) ).to.be.null; + } ); + + it( 'should allow passing a single attribute name to convert and consume', () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); + + downcastHelpers.elementToStructure( { + model: { + name: 'simpleBlock', + attributes: 'toStyle' + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + } + } ); + + let consumable; + + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data, conversionApi ) => { + consumable = conversionApi.consumable; + }, { priority: 'low' } ); + + setModelData( model, '' ); + + expectResult( '
' ); + + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toStyle' ) ).to.be.false; + expect( consumable.test( modelRoot.getChild( 0 ), 'attribute:toClass' ) ).to.be.null; + } ); + } ); + + describe( 'with simple block view structure (without slots)', () => { + beforeEach( () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); + + downcastHelpers.elementToStructure( { + model: { + name: 'simpleBlock', + attributes: [ 'toStyle', 'toClass' ] + }, + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + } + } ); + } ); + + it( 'should convert on insert', () => { + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); + + model.change( writer => { + writer.insertElement( 'simpleBlock', modelRoot, 0 ); + } ); + + expectResult( '
' ); + } ); + + it( 'should convert on attribute set', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + const [ viewBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); + } ); + + const [ viewAfter ] = getNodes(); + + expectResult( '
' ); + expect( viewAfter ).to.not.equal( viewBefore ); + } ); + + it( 'should convert on attribute change', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + const [ viewBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); + } ); + + const [ viewAfter ] = getNodes(); + + expectResult( '
' ); + + expect( viewAfter ).to.not.equal( viewBefore ); + } ); + + it( 'should convert on attribute remove', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + } ); + + expectResult( '
' ); + } ); + + it( 'should convert on one attribute add and other remove', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); + } ); + + expectResult( '
' ); + } ); + + it( 'should properly re-bind mapper mappings and retain markers', () => { + downcastHelpers.elementToElement( { + model: 'simpleBlock', + view: ( modelElement, { writer } ) => { + const viewElement = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + + return toWidget( viewElement, writer ); + }, + triggerBy: { + attributes: [ 'toStyle', 'toClass' ] + }, + converterPriority: 'high' + } ); + + const mapper = controller.mapper; + + downcastHelpers.markerToHighlight( { + model: 'myMarker', + view: { classes: 'foo' } + } ); + + setModelData( model, '' ); + + const modelElement = modelRoot.getChild( 0 ); + const [ viewBefore ] = getNodes(); + + model.change( writer => { + writer.addMarker( 'myMarker', { range: writer.createRangeOn( modelElement ), usingOperation: false } ); + } ); + + expect( mapper.toViewElement( modelElement ) ).to.equal( viewBefore ); + expect( mapper.toModelElement( viewBefore ) ).to.equal( modelElement ); + expect( mapper.markerNameToElements( 'myMarker' ).has( viewBefore ) ).to.be.true; + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:block', modelElement ); + } ); + + const [ viewAfter ] = getNodes(); + + expect( mapper.toViewElement( modelElement ) ).to.equal( viewAfter ); + expect( mapper.toModelElement( viewBefore ) ).to.be.undefined; + expect( mapper.toModelElement( viewAfter ) ).to.equal( modelElement ); + expect( mapper.markerNameToElements( 'myMarker' ).has( viewAfter ) ).to.be.true; + expect( mapper.markerNameToElements( 'myMarker' ).has( viewBefore ) ).to.be.false; + } ); + + it( 'should not reconvert if non watched attribute has changed', () => { + setModelData( model, '' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); + + const [ viewBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); + } ); + + const [ viewAfter ] = getNodes(); + + expectResult( '
' ); + + expect( viewAfter ).to.equal( viewBefore ); + } ); + } ); + + describe( 'with simple block view structure (with slots, changing attributes)', () => { + beforeEach( () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); + + downcastHelpers.elementToStructure( { + model: { + name: 'simpleBlock', + attributes: [ 'toStyle', 'toClass' ] + }, + view: ( modelElement, { writer } ) => { + const viewElement = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + + writer.insert( writer.createPositionAt( viewElement, 0 ), writer.createSlot() ); + + return viewElement; + } + } ); + + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'simpleBlock' + } ); + + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' + } ); + } ); + + it( 'should convert on insert', () => { + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); + + model.change( writer => { + const simpleBlock = writer.createElement( 'simpleBlock' ); + const paragraph = writer.createElement( 'paragraph' ); + + writer.insert( simpleBlock, modelRoot, 0 ); + writer.insert( paragraph, simpleBlock, 0 ); + writer.insertText( 'foo', paragraph, 0 ); + } ); + + expectResult( '

foo

' ); + } ); + + it( 'should convert on attribute set', () => { + setModelData( model, 'foo' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); + } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + } ); + + it( 'should convert on attribute change', () => { + setModelData( model, 'foo' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); + } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + } ); + + it( 'should convert on attribute remove', () => { + setModelData( model, 'foo' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + } ); + + expectResult( '

foo

' ); + } ); + + it( 'should convert on one attribute add and other remove', () => { + setModelData( model, 'foo' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + } ); + + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); + } ); + + expectResult( '

foo

' ); + } ); + + it( 'should not reconvert if non watched attribute has changed', () => { + setModelData( model, 'foo' ); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); + } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

' ); + + expect( viewAfter, 'simpleBlock' ).to.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + } ); + + it( 'should not reuse child view element if marked by Differ#_refreshItem()', () => { + setModelData( model, 'foo' ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); + model.document.differ._refreshItem( modelRoot.getChild( 0 ).getChild( 0 ) ); + } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

' ); + + expect( viewAfter ).to.not.equal( viewBefore ); + expect( paraAfter ).to.not.equal( paraBefore ); + expect( textAfter ).to.not.equal( textBefore ); + } ); + } ); + + describe( 'with simple block view structure (reconvert on child add)', () => { + beforeEach( () => { + model.schema.register( 'simpleBlock', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); + + downcastHelpers.elementToStructure( { + model: 'simpleBlock', + view: ( modelElement, { writer } ) => { + const viewElement = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + + writer.insert( writer.createPositionAt( viewElement, 0 ), writer.createSlot() ); + + return viewElement; + } + } ); + + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'simpleBlock' + } ); + + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' + } ); + } ); + + it( 'should convert on insert', () => { + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + } ); + + model.change( writer => { + const simpleBlock = writer.createElement( 'simpleBlock' ); + const paragraph = writer.createElement( 'paragraph' ); + + writer.insert( simpleBlock, modelRoot, 0 ); + writer.insert( paragraph, simpleBlock, 0 ); + writer.insertText( 'foo', paragraph, 0 ); + } ); + + expectResult( '

foo

' ); + } ); + + it( 'should convert on adding a child (at the beginning)', () => { + setModelData( model, 'foo' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); + + writer.insert( paragraph, modelRoot.getChild( 0 ), 0 ); + writer.insert( text, paragraph, 0 ); + } ); + + const [ viewAfter, /* insertedPara */, /* insertedText */, paraAfter, textAfter ] = getNodes(); + + expectResult( '

bar

foo

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on adding a child (in the middle)', () => { + setModelData( model, + '' + + 'foo' + + 'bar' + + '' + ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + const [ viewBefore, paraFooBefore, textFooBefore, paraBarBefore, textBarBefore ] = getNodes(); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'baz' ); + + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); + + const [ viewAfter, + paraFooAfter, textFooAfter, /* insertedPara */, /* insertedText */, paraBarAfter, textBarAfter + ] = getNodes(); + + expectResult( '

foo

baz

bar

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraFooAfter, 'para foo' ).to.equal( paraFooBefore ); + expect( textFooAfter, 'text foo' ).to.equal( textFooBefore ); + expect( paraBarAfter, 'para bar' ).to.equal( paraBarBefore ); + expect( textBarAfter, 'text bar' ).to.equal( textBarBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on adding a child (at the end)', () => { + setModelData( model, 'foo' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:simpleBlock', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); + + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

bar

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on removing a child', () => { + setModelData( model, + 'foobar' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + writer.remove( modelRoot.getNodeByPath( [ 0, 1 ] ) ); + } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

' ); + + expect( viewAfter, 'simpleBlock' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); + + // https://github.com/ckeditor/ckeditor5/issues/9641 + it( 'should convert on multiple similar child hooks', () => { + model.schema.register( 'simpleBlock2', { + allowIn: '$root', + allowChildren: 'paragraph' + } ); + + downcastHelpers.elementToStructure( { + model: { + name: 'simpleBlock2', + children: true + }, + view: ( modelElement, { writer } ) => { + const viewElement = writer.createContainerElement( 'div', { class: 'second' } ); + + writer.insert( writer.createPositionAt( viewElement, 0 ), writer.createSlot() ); + + return viewElement; + } + } ); + + setModelData( model, + 'foo' + + 'bar' + ); + + const [ viewBefore0, paraBefore0, textBefore0 ] = getNodes( 0 ); + const [ viewBefore1, paraBefore1, textBefore1 ] = getNodes( 1 ); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'abc' ); + + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); + + const [ viewAfter0, paraAfter0, textAfter0 ] = getNodes( 0 ); + const [ viewAfter1, paraAfter1, textAfter1 ] = getNodes( 1 ); + + expectResult( + '

foo

abc

' + + '

bar

' + ); + + expect( viewAfter0, 'simpleBlock' ).to.not.equal( viewBefore0 ); + expect( paraAfter0, 'para' ).to.equal( paraBefore0 ); + expect( textAfter0, 'text' ).to.equal( textBefore0 ); + + expect( viewAfter1, 'simpleBlock' ).to.equal( viewBefore1 ); + expect( paraAfter1, 'para' ).to.equal( paraBefore1 ); + expect( textAfter1, 'text' ).to.equal( textBefore1 ); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( '123' ); + + writer.insert( paragraph, modelRoot.getChild( 1 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); + + const [ viewAfterAfter0, paraAfterAfter0, textAfterAfter0 ] = getNodes( 0 ); + const [ viewAfterAfter1, paraAfterAfter1, textAfterAfter1 ] = getNodes( 1 ); + + expectResult( + '

foo

abc

' + + '

bar

123

' + ); + + expect( viewAfter0, 'simpleBlock' ).to.not.equal( viewBefore0 ); + expect( paraAfter0, 'para' ).to.equal( paraBefore0 ); + expect( textAfter0, 'text' ).to.equal( textBefore0 ); + + expect( viewAfter1, 'simpleBlock' ).to.equal( viewBefore1 ); + expect( paraAfter1, 'para' ).to.equal( paraBefore1 ); + expect( textAfter1, 'text' ).to.equal( textBefore1 ); + + expect( viewAfterAfter0, 'simpleBlock' ).to.equal( viewAfter0 ); + expect( paraAfterAfter0, 'para' ).to.equal( paraAfter0 ); + expect( textAfterAfter0, 'text' ).to.equal( textAfter0 ); + + expect( viewAfterAfter1, 'simpleBlock' ).to.not.equal( viewAfter1 ); + expect( paraAfterAfter1, 'para' ).to.equal( paraAfter1 ); + expect( textAfterAfter1, 'text' ).to.equal( textAfter1 ); + } ); + + it( 'should not reuse child view element if marked by Differ#_refreshItem()', () => { + setModelData( model, 'foo' ); + + const [ viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); + model.document.differ._refreshItem( modelRoot.getChild( 0 ).getChild( 0 ) ); + } ); + + const [ viewAfter, paraAfter, textAfter ] = getNodes(); + + expectResult( '

foo

' ); + + expect( viewAfter ).to.not.equal( viewBefore ); + expect( paraAfter ).to.not.equal( paraBefore ); + expect( textAfter ).to.not.equal( textBefore ); + } ); + } ); + + describe( 'with complex view structure - single slot for all children', () => { + beforeEach( () => { + model.schema.register( 'complex', { + allowIn: '$root', + allowAttributes: [ 'toStyle', 'toClass' ] + } ); + + downcastHelpers.elementToStructure( { + model: { + name: 'complex', + attributes: [ 'toStyle', 'toClass' ], + children: true + }, + view: ( modelElement, { writer } ) => { + const outer = writer.createContainerElement( 'div', { class: 'complex-outer' } ); + const inner = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); + + writer.insert( writer.createPositionAt( outer, 0 ), inner ); + writer.insert( writer.createPositionAt( inner, 0 ), writer.createSlot() ); + + return outer; + } + } ); + + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'complex' + } ); + + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' + } ); + } ); + + it( 'should convert on insert', () => { + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + spy(); + } ); + + model.change( writer => { + writer.insertElement( 'complex', modelRoot, 0 ); + } ); + + expectResult( '
' ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on attribute set', () => { + setModelData( model, '' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + const [ outerDivBefore, innerDivBefore ] = getNodes(); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); + } ); + + const [ outerDivAfter, innerDivAfter ] = getNodes(); + + expectResult( '
' ); + expect( outerDivAfter, 'outer div' ).to.not.equal( outerDivBefore ); + expect( innerDivAfter, 'inner div' ).to.not.equal( innerDivBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on attribute change', () => { + setModelData( model, '' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + model.change( writer => { + writer.setAttribute( 'toStyle', 'display:inline', modelRoot.getChild( 0 ) ); + } ); + + expectResult( '
' ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on attribute remove', () => { + setModelData( model, '' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + } ); + + expectResult( '
' ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on one attribute add and other remove', () => { + setModelData( model, '' ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); + + model.change( writer => { + writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); + writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); + } ); + + expectResult( '
' ); + expect( spy.called ).to.be.true; + } ); - it( 'should convert on attribute set', () => { - setModelData( model, '' ); + it( 'should convert on adding a child (at the beginning)', () => { + setModelData( model, 'foo' ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); - } ); + const spy = sinon.spy(); - expectResult( '' ); + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - it( 'should convert on attribute remove', () => { - setModelData( model, '' ); + const [ outerBefore, viewBefore, paraBefore, textBefore ] = getNodes(); - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - } ); + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); - expectResult( '' ); + writer.insert( paragraph, modelRoot.getChild( 0 ), 0 ); + writer.insert( text, paragraph, 0 ); } ); - it( 'should convert on one attribute add and other remove', () => { - setModelData( model, '' ); + const [ outerAfter, viewAfter, /* insertedPara */, /* insertedText */, paraAfter, textAfter ] = getNodes(); - model.change( writer => { - writer.removeAttribute( 'toStyle', modelRoot.getChild( 0 ) ); - writer.setAttribute( 'toClass', true, modelRoot.getChild( 0 ) ); - } ); + expectResult( '

bar

foo

' ); - expectResult( '' ); - } ); + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( viewAfter, 'inner' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); - it( 'should do nothing if non-triggerBy attribute has changed', () => { - setModelData( model, '' ); + it( 'should convert on adding a child (in the middle)', () => { + setModelData( model, + '' + + 'foo' + + 'bar' + + '' + ); - model.change( writer => { - writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); - } ); + const spy = sinon.spy(); - expectResult( '' ); + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - describe( 'memoization', () => { - it( 'should create new element on re-converting element', () => { - setModelData( model, '' ); + const [ outerBefore, viewBefore, paraFooBefore, textFooBefore, paraBarBefore, textBarBefore ] = getNodes(); - const [ outerBefore, innerBefore ] = getNodes(); - - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); - } ); + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'baz' ); - const [ outerAfter, innerAfter ] = getNodes(); + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); - expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); - expect( innerAfter, 'inner' ).to.not.equal( innerBefore ); - } ); + const [ outerAfter, viewAfter, + paraFooAfter, textFooAfter, /* insertedPara */, /* insertedText */, paraBarAfter, textBarAfter + ] = getNodes(); - // Skipped, as it would require two-level mapping. See https://github.com/ckeditor/ckeditor5/issues/1589. - // Doable as a similar case works in table scenario for table cells (table is refreshed). - it.skip( 'should not re-create child elements on re-converting element', () => { - setModelData( model, 'Foo bar baz' ); + expectResult( '

foo

baz

bar

' ); - expectResult( '

Foo bar baz

' ); - const renderedViewView = viewRoot.getChild( 0 ).getChild( 0 ); + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( viewAfter, 'inner' ).to.not.equal( viewBefore ); + expect( paraFooAfter, 'para foo' ).to.equal( paraFooBefore ); + expect( textFooAfter, 'text foo' ).to.equal( textFooBefore ); + expect( paraBarAfter, 'para bar' ).to.equal( paraBarBefore ); + expect( textBarAfter, 'text bar' ).to.equal( textBarBefore ); + expect( spy.called ).to.be.true; + } ); - model.change( writer => { - writer.setAttribute( 'toStyle', 'display:block', modelRoot.getChild( 0 ) ); - } ); + it( 'should convert on adding a child (at the end)', () => { + setModelData( model, 'foo' ); - const viewAfterReRender = viewRoot.getChild( 0 ).getChild( 0 ); + const spy = sinon.spy(); - expect( viewAfterReRender ).to.equal( renderedViewView ); - } ); + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - } ); - describe( 'with complex view structure (slot conversion)', () => { - beforeEach( () => { - model.schema.register( 'complex', { - allowIn: '$root', - allowAttributes: [ 'classForMain', 'classForWrap', 'attributeToElement' ] - } ); - downcastHelpers.elementToElement( { - model: 'complex', - view: ( modelElement, { writer, mapper } ) => { - const classForMain = !!modelElement.getAttribute( 'classForMain' ); - const classForWrap = !!modelElement.getAttribute( 'classForWrap' ); - const attributeToElement = !!modelElement.getAttribute( 'attributeToElement' ); - - const outer = writer.createContainerElement( 'div', { - class: `complex-slots${ classForMain ? ' with-class' : '' }` - } ); - const inner = writer.createContainerElement( 'div', { - class: `slots${ classForWrap ? ' with-class' : '' }` - } ); + const [ outerBefore, viewBefore, paraBefore, textBefore ] = getNodes(); + + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); - if ( attributeToElement ) { - const optional = writer.createEmptyElement( 'div', { class: 'optional' } ); - writer.insert( writer.createPositionAt( outer, 0 ), optional ); - } + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); - writer.insert( writer.createPositionAt( outer, 'end' ), inner ); - mapper.bindElements( modelElement, inner ); + const [ outerAfter, viewAfter, paraAfter, textAfter ] = getNodes(); - for ( const slot of modelElement.getChildren() ) { - const viewSlot = writer.createContainerElement( 'div', { class: 'slot' } ); + expectResult( '

foo

bar

' ); - writer.insert( writer.createPositionAt( inner, slot.index ), viewSlot ); - mapper.bindElements( slot, viewSlot ); - } + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( viewAfter, 'inner' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); - return outer; - }, - triggerBy: { - attributes: [ 'classForMain', 'classForWrap', 'attributeToElement' ], - children: [ 'slot' ] - } - } ); + it( 'should convert on removing a child', () => { + setModelData( model, 'foobar' ); - model.schema.register( 'slot', { - allowIn: 'complex' - } ); + const spy = sinon.spy(); - model.schema.register( 'paragraph', { - inheritAllFrom: '$block', - allowIn: 'slot' - } ); - downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - it( 'should convert on insert', () => { - model.change( writer => { - writer.insertElement( 'complex', modelRoot, 0 ); - } ); + const [ outerBefore, viewBefore, paraBefore, textBefore ] = getNodes(); - expectResult( '
' ); + model.change( writer => { + writer.remove( modelRoot.getNodeByPath( [ 0, 1 ] ) ); } ); - it( 'should convert on attribute set (main element)', () => { - setModelData( model, '' ); + const [ outerAfter, viewAfter, paraAfter, textAfter ] = getNodes(); - model.change( writer => { - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); + expectResult( '

foo

' ); - expectResult( '
' ); - } ); + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( viewAfter, 'inner' ).to.not.equal( viewBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); - it( 'should convert on attribute set (other element)', () => { - setModelData( model, '' ); + it( 'should not reconvert if non watched attribute has changed', () => { + setModelData( model, '' ); - model.change( writer => { - writer.setAttribute( 'classForWrap', true, modelRoot.getChild( 0 ) ); - } ); + const spy = sinon.spy(); - expectResult( '
' ); + controller.downcastDispatcher.on( 'insert:complex', () => { + spy(); } ); - it( 'should convert on attribute set (insert new view element)', () => { - setModelData( model, '' ); - - model.change( writer => { - writer.setAttribute( 'attributeToElement', true, modelRoot.getChild( 0 ) ); - } ); + const [ outerDivBefore, innerDivBefore ] = getNodes(); - expectResult( '
' ); + model.change( writer => { + writer.setAttribute( 'notTriggered', true, modelRoot.getChild( 0 ) ); } ); - it( 'should convert element with slots', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + const [ outerDivAfter, innerDivAfter ] = getNodes(); - expectResult( - '
' + - '
' + - '

foo

' + - '

bar

' + - '
' + - '
' - ); - } ); + expectResult( '
' ); - it( 'should convert element on adding slot', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + expect( outerDivAfter, 'outer div' ).to.equal( outerDivBefore ); + expect( innerDivAfter, 'inner div' ).to.equal( innerDivBefore ); + expect( spy.notCalled ).to.be.true; + } ); - model.change( writer => { - insertBazSlot( writer, modelRoot ); - } ); + it( 'should fire conversion events in proper order', () => { + const loggedEvents = []; - expectResult( - '
' + - '
' + - '

foo

' + - '

bar

' + - '

baz

' + - '
' + - '
' - ); - } ); + controller.downcastDispatcher.on( 'insert', ( evt, data ) => { + const itemId = data.item.name ? data.item.name : '$text:' + data.item.data; + const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path; - it( 'should convert element on removing slot', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + loggedEvents.push( log ); + }, { priority: 'highest' } ); - model.change( writer => { - writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) ); - } ); + controller.downcastDispatcher.on( 'attribute', ( evt, data ) => { + 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; - expectResult( - '
' + - '
' + - '

bar

' + - '
' + - '
' - ); - } ); + loggedEvents.push( log ); + }, { priority: 'highest' } ); - it( 'should convert element on multiple triggers (remove + insert)', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + setModelData( model, 'foobar' ); - model.change( writer => { - writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) ); - insertBazSlot( writer, modelRoot ); - } ); + expectResult( '

foo

bar

' ); - expectResult( - '
' + - '
' + - '

bar

' + - '

baz

' + - '
' + - '
' - ); + expect( loggedEvents ).to.deep.equal( [ + 'insert:complex:0:1', + 'attribute:toClass:true:complex:0:1', + 'insert:paragraph:0,0:0,1', + 'insert:$text:foo:0,0,0:0,0,3', + 'insert:paragraph:0,1:0,2', + 'insert:$text:bar:0,1,0:0,1,3' + ] ); + } ); + } ); + + describe( 'with complex view structure - multiple slots', () => { + beforeEach( () => { + model.schema.register( 'complex', { + allowIn: '$root' } ); - it( 'should convert element on multiple triggers (remove + attribute)', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + downcastHelpers.elementToStructure( { + model: { + name: 'complex', + children: true + }, + view: ( modelElement, { writer } ) => { + const outer = writer.createContainerElement( 'div', { class: 'complex-outer' } ); + const inner1 = writer.createContainerElement( 'div', { class: 'inner-first' } ); + const inner2 = writer.createContainerElement( 'div', { class: 'inner-second' } ); - model.change( writer => { - writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) ); - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); + writer.insert( writer.createPositionAt( outer, 'end' ), inner1 ); + writer.insert( writer.createPositionAt( outer, 'end' ), inner2 ); - expectResult( - '
' + - '
' + - '

bar

' + - '
' + - '
' - ); + writer.insert( writer.createPositionAt( inner1, 0 ), writer.createSlot( element => element.index < 2 ) ); + writer.insert( writer.createPositionAt( inner2, 0 ), writer.createSlot( element => element.index >= 2 ) ); + + return outer; + } + } ); + + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'complex' } ); - it( 'should convert element on multiple triggers (insert + attribute)', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' + } ); + } ); - model.change( writer => { - insertBazSlot( writer, modelRoot ); - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); + it( 'should convert on insert', () => { + const spy = sinon.spy(); - expectResult( - '
' + - '
' + - '

foo

' + - '

bar

' + - '

baz

' + - '
' + - '
' - ); + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.not.have.property( 'reconversion' ); + spy(); } ); - it( 'should not trigger refresh on adding a slot to an element without triggerBy conversion', () => { - model.schema.register( 'other', { - allowIn: '$root' - } ); - model.schema.extend( 'slot', { - allowIn: 'other' - } ); - downcastHelpers.elementToElement( { - model: 'other', - view: { - name: 'div', - classes: 'other' - } - } ); - downcastHelpers.elementToElement( { - model: 'slot', - view: { - name: 'div', - classes: 'slot' - } - } ); + model.change( writer => { + writer.insertElement( 'complex', modelRoot, 0 ); + } ); - setModelData( model, - '' + - 'foo' + - 'bar' + - '' - ); - const otherView = viewRoot.getChild( 0 ); + expectResult( '
' ); + expect( spy.called ).to.be.true; + } ); - model.change( writer => { - insertBazSlot( writer, modelRoot ); - } ); + it( 'should convert on adding a child (at the beginning)', () => { + setModelData( model, 'foo' ); - expectResult( - '
' + - '

foo

' + - '

bar

' + - '

baz

' + - '
' - ); - const otherViewAfter = viewRoot.getChild( 0 ); + const spy = sinon.spy(); - expect( otherView, 'the view should not be refreshed' ).to.equal( otherViewAfter ); + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - describe( 'memoization', () => { - it( 'should create new element on re-converting element', () => { - setModelData( model, '' + - 'foo' + - 'bar' + - '' - ); + const [ outerBefore, firstBefore, paraBefore, textBefore, secondBefore ] = getNodes(); - const [ complexView ] = getNodes(); + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'bar' ); - model.change( writer => { - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); + writer.insert( paragraph, modelRoot.getChild( 0 ), 0 ); + writer.insert( text, paragraph, 0 ); + } ); - const [ viewAfterReRender ] = getNodes(); + const [ outerAfter, firstAfter, /* insertedPara */, /* insertedText */, paraAfter, textAfter, secondAfter ] = getNodes(); - expect( viewAfterReRender, 'the view should be refreshed' ).to.not.equal( complexView ); - } ); + expectResult( + '
' + + '

bar

foo

' + + '
' + + '
' + ); - it( 'should not re-create slot\'s child elements on re-converting main element (attribute changed)', () => { - setModelData( model, '' + - 'foo' + - 'bar' + - '' - ); - - const [ main, /* unused */, - slotOne, paraOne, textNodeOne, - slotTwo, paraTwo, textNodeTwo ] = getNodes(); - - model.change( writer => { - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); - - const [ mainAfter, /* unused */, - slotOneAfter, paraOneAfter, textNodeOneAfter, - slotTwoAfter, paraTwoAfter, textNodeTwoAfter ] = getNodes(); - - expect( mainAfter, 'main view' ).to.not.equal( main ); - expect( slotOneAfter, 'first slot view' ).to.not.equal( slotOne ); - expect( slotTwoAfter, 'second slot view' ).to.not.equal( slotTwo ); - expect( paraOneAfter, 'first slot paragraph view' ).to.equal( paraOne ); - expect( textNodeOneAfter, 'first slot text node view' ).to.equal( textNodeOne ); - expect( paraTwoAfter, 'second slot paragraph view' ).to.equal( paraTwo ); - expect( textNodeTwoAfter, 'second slot text node view' ).to.equal( textNodeTwo ); - } ); + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( firstAfter, 'inner first' ).to.not.equal( firstBefore ); + expect( secondAfter, 'inner second' ).to.not.equal( secondBefore ); + expect( paraAfter, 'para' ).to.equal( paraBefore ); + expect( textAfter, 'text' ).to.equal( textBefore ); + expect( spy.called ).to.be.true; + } ); - it( 'should not re-create slot\'s child elements on re-converting main element (slot added)', () => { - setModelData( model, '' + - 'foo' + - 'bar' + - '' - ); - - const [ main, /* unused */, - slotOne, paraOne, textNodeOne, - slotTwo, paraTwo, textNodeTwo ] = getNodes(); - - model.change( writer => { - const slot = writer.createElement( 'slot' ); - const paragraph = writer.createElement( 'paragraph' ); - writer.insertText( 'baz', paragraph, 0 ); - writer.insert( paragraph, slot, 0 ); - writer.insert( slot, modelRoot.getChild( 0 ), 'end' ); - } ); - - const [ mainAfter, /* unused */, - slotOneAfter, paraOneAfter, textNodeOneAfter, - slotTwoAfter, paraTwoAfter, textNodeTwoAfter, - slotThreeAfter, paraThreeAfter, textNodeThreeAfter - ] = getNodes(); - - expect( mainAfter, 'main view' ).to.not.equal( main ); - expect( slotOneAfter, 'first slot view' ).to.not.equal( slotOne ); - expect( slotTwoAfter, 'second slot view' ).to.not.equal( slotTwo ); - expect( paraOneAfter, 'first slot paragraph view' ).to.equal( paraOne ); - expect( textNodeOneAfter, 'first slot text node view' ).to.equal( textNodeOne ); - expect( paraTwoAfter, 'second slot paragraph view' ).to.equal( paraTwo ); - expect( textNodeTwoAfter, 'second slot text node view' ).to.equal( textNodeTwo ); - expect( slotThreeAfter, 'third slot view' ).to.not.be.undefined; - expect( paraThreeAfter, 'third slot paragraph view' ).to.not.be.undefined; - expect( textNodeThreeAfter, 'third slot text node view' ).to.not.be.undefined; - } ); + it( 'should convert on adding a child (in the middle)', () => { + setModelData( model, + '' + + 'foo' + + 'bar' + + '' + ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - } ); - // Skipped, as it would require two-level mapping. See https://github.com/ckeditor/ckeditor5/issues/1589. - describe.skip( 'with complex view structure (slot conversion atomic converters for some changes)', () => { - beforeEach( () => { - model.schema.register( 'complex', { - allowIn: '$root', - allowAttributes: [ 'classForMain', 'classForWrap', 'attributeToElement' ] - } ); + const [ + outerBefore, + firstBefore, paraFooBefore, textFooBefore, paraBarBefore, textBarBefore, + secondBefore + ] = getNodes(); - function createViewSlot( slot, { writer, mapper } ) { - const viewSlot = writer.createContainerElement( 'div', { class: 'slot' } ); + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'baz' ); - mapper.bindElements( slot, viewSlot ); + writer.insert( paragraph, modelRoot.getChild( 0 ), 1 ); + writer.insert( text, paragraph, 0 ); + } ); - return viewSlot; - } + const [ + outerAfter, + firstAfter, paraFooAfter, textFooAfter, /* insertedPara */, /* insertedText */, + secondAfter, paraBarAfter, textBarAfter + ] = getNodes(); + + expectResult( + '
' + + '

foo

baz

' + + '

bar

' + + '
' + ); - downcastHelpers.elementToElement( { - model: 'complex', - view: ( modelElement, { writer, mapper, consumable } ) => { - const classForMain = !!modelElement.getAttribute( 'classForMain' ); - const classForWrap = !!modelElement.getAttribute( 'classForWrap' ); - const attributeToElement = !!modelElement.getAttribute( 'attributeToElement' ); - - const outer = writer.createContainerElement( 'div', { - class: `complex-slots${ classForMain ? ' with-class' : '' }` - } ); - const inner = writer.createContainerElement( 'div', { - class: `slots${ classForWrap ? ' with-class' : '' }` - } ); + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( firstAfter, 'inner first' ).to.not.equal( firstBefore ); + expect( secondAfter, 'inner second' ).to.not.equal( secondBefore ); + expect( paraFooAfter, 'para foo' ).to.equal( paraFooBefore ); + expect( textFooAfter, 'text foo' ).to.equal( textFooBefore ); + expect( paraBarAfter, 'para bar' ).to.equal( paraBarBefore ); + expect( textBarAfter, 'text bar' ).to.equal( textBarBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on adding a child (at the end)', () => { + setModelData( model, + '' + + 'foo' + + 'bar' + + '' + ); - if ( attributeToElement ) { - const optional = writer.createEmptyElement( 'div', { class: 'optional' } ); - writer.insert( writer.createPositionAt( outer, 0 ), optional ); - } + const spy = sinon.spy(); - writer.insert( writer.createPositionAt( outer, 'end' ), inner ); - mapper.bindElements( modelElement, outer ); - mapper.bindElements( modelElement, inner ); + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); + } ); - for ( const slot of modelElement.getChildren() ) { - const viewSlot = createViewSlot( slot, { writer, mapper } ); + const [ + outerBefore, + firstBefore, paraFooBefore, textFooBefore, paraBarBefore, textBarBefore, + secondBefore + ] = getNodes(); - writer.insert( writer.createPositionAt( inner, slot.index ), viewSlot ); - consumable.consume( slot, 'insert' ); - } + model.change( writer => { + const paragraph = writer.createElement( 'paragraph' ); + const text = writer.createText( 'baz' ); - return outer; - }, - triggerBy: { - attributes: [ 'classForMain', 'classForWrap', 'attributeToElement' ] - // Contrary to the previous test - do not act on child changes. - // children: [ 'slot' ] - } - } ); - downcastHelpers.elementToElement( { - model: 'slot', - view: createViewSlot - } ); + writer.insert( paragraph, modelRoot.getChild( 0 ), 'end' ); + writer.insert( text, paragraph, 0 ); + } ); - model.schema.register( 'slot', { - allowIn: 'complex' - } ); + const [ + outerAfter, + firstAfter, paraFooAfter, textFooAfter, paraBarAfter, textBarAfter, + secondAfter, /* insertedPara */, /* insertedText */ + ] = getNodes(); + + expectResult( + '
' + + '

foo

bar

' + + '

baz

' + + '
' + ); - model.schema.register( 'paragraph', { - inheritAllFrom: '$block', - allowIn: 'slot' - } ); - downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( firstAfter, 'inner first' ).to.not.equal( firstBefore ); + expect( secondAfter, 'inner second' ).to.not.equal( secondBefore ); + expect( paraFooAfter, 'para foo' ).to.equal( paraFooBefore ); + expect( textFooAfter, 'text foo' ).to.equal( textFooBefore ); + expect( paraBarAfter, 'para bar' ).to.equal( paraBarBefore ); + expect( textBarAfter, 'text bar' ).to.equal( textBarBefore ); + expect( spy.called ).to.be.true; + } ); + + it( 'should convert on removing a child', () => { + setModelData( model, + '' + + 'foo' + + 'bar' + + 'baz' + + 'abc' + + '' + ); + + const spy = sinon.spy(); + + controller.downcastDispatcher.on( 'insert:complex', ( evt, data ) => { + expect( data ).to.have.property( 'reconversion' ).to.be.true; + spy(); } ); - it( 'should convert on insert', () => { - model.change( writer => { - writer.insertElement( 'complex', modelRoot, 0 ); - } ); + const [ + outerBefore, + firstBefore, paraFooBefore, textFooBefore, /* removedPara */, /* removedText */, + secondBefore, paraBazBefore, textBazBefore, paraAbcBefore, textAbcBefore + ] = getNodes(); - expectResult( '
' ); + model.change( writer => { + writer.remove( modelRoot.getNodeByPath( [ 0, 1 ] ) ); } ); - it( 'should convert on attribute set (main element)', () => { - setModelData( model, '' ); + const [ + outerAfter, + firstAfter, paraFooAfter, textFooAfter, paraBazAfter, textBazAfter, + secondAfter, paraAbcAfter, textAbcAfter + ] = getNodes(); + + expectResult( + '
' + + '

foo

baz

' + + '

abc

' + + '
' + ); - model.change( writer => { - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); + expect( outerAfter, 'outer' ).to.not.equal( outerBefore ); + expect( firstAfter, 'inner first' ).to.not.equal( firstBefore ); + expect( secondAfter, 'inner second' ).to.not.equal( secondBefore ); + expect( paraFooAfter, 'para foo' ).to.equal( paraFooBefore ); + expect( textFooAfter, 'text foo' ).to.equal( textFooBefore ); + expect( paraBazAfter, 'para baz' ).to.equal( paraBazBefore ); + expect( textBazAfter, 'text baz' ).to.equal( textBazBefore ); + expect( paraAbcAfter, 'para abc' ).to.equal( paraAbcBefore ); + expect( textAbcAfter, 'text abc' ).to.equal( textAbcBefore ); + expect( spy.called ).to.be.true; + } ); + } ); - expectResult( '
' ); - } ); + it( 'should throw an exception if invalid slot mode or filter was provided', () => { + model.schema.register( 'simple', { + allowIn: '$root' + } ); + + downcastHelpers.elementToStructure( { + model: { + name: 'simple', + children: true + }, + view: ( modelElement, { writer } ) => { + const element = writer.createContainerElement( 'div' ); - it( 'should convert on attribute set (other element)', () => { - setModelData( model, '' ); + writer.insert( writer.createPositionAt( element, 0 ), writer.createSlot( 'foo' ) ); - model.change( writer => { - writer.setAttribute( 'classForWrap', true, modelRoot.getChild( 0 ) ); - } ); + return element; + } + } ); - expectResult( '
' ); - } ); + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'simple' + } ); - it( 'should convert on attribute set (insert new view element)', () => { - setModelData( model, '' ); + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' + } ); - model.change( writer => { - writer.setAttribute( 'attributeToElement', true, modelRoot.getChild( 0 ) ); - } ); + expectToThrowCKEditorError( () => { + model.change( writer => { + const simple = writer.createElement( 'simple' ); + const paragraph = writer.createElement( 'paragraph' ); - expectResult( '
' ); + writer.insert( paragraph, simple, 0 ); + writer.insert( simple, modelRoot, 0 ); } ); + }, /^conversion-slot-mode-unknown/, controller.downcastDispatcher, { modeOrFilter: 'foo' } ); + } ); - it( 'should convert element with slots', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + it( 'should throw an exception if slot filter results overlap', () => { + model.schema.register( 'complex', { + allowIn: '$root' + } ); - expectResult( - '
' + - '
' + - '

foo

' + - '

bar

' + - '
' + - '
' - ); - } ); + downcastHelpers.elementToStructure( { + model: { + name: 'complex', + children: true + }, + view: ( modelElement, { writer } ) => { + const outer = writer.createContainerElement( 'div' ); + const inner1 = writer.createContainerElement( 'div', { class: 'inner-first' } ); + const inner2 = writer.createContainerElement( 'div', { class: 'inner-second' } ); - it( 'should not convert element on adding slot', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + writer.insert( writer.createPositionAt( outer, 'end' ), inner1 ); + writer.insert( writer.createPositionAt( outer, 'end' ), inner2 ); - model.change( writer => { - const slot = writer.createElement( 'slot' ); - const paragraph = writer.createElement( 'paragraph' ); - writer.insertText( 'baz', paragraph, 0 ); - writer.insert( paragraph, slot, 0 ); - writer.insert( slot, modelRoot.getChild( 0 ), 'end' ); - } ); + writer.insert( writer.createPositionAt( inner1, 0 ), writer.createSlot( element => element.index <= 1 ) ); + writer.insert( writer.createPositionAt( inner2, 0 ), writer.createSlot( element => element.index >= 1 ) ); - expectResult( - '
' + - '
' + - '

foo

' + - '

bar

' + - '

baz

' + - '
' + - '
' - ); - } ); + return outer; + } + } ); - it( 'should not convert element on removing slot', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'complex' + } ); - model.change( writer => { - writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) ); - } ); + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' + } ); - expectResult( - '
' + - '
' + - '

bar

' + - '
' + - '
' - ); + expectToThrowCKEditorError( () => { + model.change( writer => { + const complex = writer.createElement( 'complex' ); + + writer.insertElement( 'paragraph', complex, 0 ); + writer.insertElement( 'paragraph', complex, 0 ); + writer.insertElement( 'paragraph', complex, 0 ); + writer.insert( complex, modelRoot, 0 ); } ); + }, /^conversion-slot-filter-overlap/, controller.downcastDispatcher ); + } ); - it( 'should convert element on a trigger and block atomic converters (remove + attribute)', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + it( 'should throw an exception if slot filter not include all children', () => { + model.schema.register( 'complex', { + allowIn: '$root' + } ); - model.change( writer => { - writer.remove( modelRoot.getChild( 0 ).getChild( 0 ) ); - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); + downcastHelpers.elementToStructure( { + model: { + name: 'complex', + children: true + }, + view: ( modelElement, { writer } ) => { + const outer = writer.createContainerElement( 'div' ); + const inner1 = writer.createContainerElement( 'div', { class: 'inner-first' } ); + const inner2 = writer.createContainerElement( 'div', { class: 'inner-second' } ); - expectResult( - '
' + - '
' + - '

bar

' + - '
' + - '
' - ); - } ); + writer.insert( writer.createPositionAt( outer, 'end' ), inner1 ); + writer.insert( writer.createPositionAt( outer, 'end' ), inner2 ); - it( 'should convert element on a trigger and block atomic converters (insert + attribute)', () => { - setModelData( model, - '' + - 'foo' + - 'bar' + - '' ); + writer.insert( writer.createPositionAt( inner1, 0 ), writer.createSlot( element => element.index < 1 ) ); + writer.insert( writer.createPositionAt( inner2, 0 ), writer.createSlot( element => element.index > 1 ) ); - model.change( writer => { - writer.insert( modelRoot.getChild( 0 ).getChild( 0 ) ); - writer.setAttribute( 'classForMain', true, modelRoot.getChild( 0 ) ); - } ); + return outer; + } + } ); - expectResult( - '
' + - '
' + - '

foo

' + - '

bar

' + - '

baz

' + - '
' + - '
' - ); - } ); + model.schema.register( 'paragraph', { + inheritAllFrom: '$block', + allowIn: 'complex' } ); - function getViewAttributes( modelElement ) { - const toStyle = modelElement.hasAttribute( 'toStyle' ) && { style: modelElement.getAttribute( 'toStyle' ) }; - const toClass = modelElement.hasAttribute( 'toClass' ) && { class: 'is-classy' }; + downcastHelpers.elementToElement( { + model: 'paragraph', + view: 'p' + } ); - return { - ...toStyle, - ...toClass - }; - } + expectToThrowCKEditorError( () => { + model.change( writer => { + const complex = writer.createElement( 'complex' ); - function insertBazSlot( writer, modelRoot ) { - const slot = writer.createElement( 'slot' ); - const paragraph = writer.createElement( 'paragraph' ); - writer.insertText( 'baz', paragraph, 0 ); - writer.insert( paragraph, slot, 0 ); - writer.insert( slot, modelRoot.getChild( 0 ), 'end' ); - } + writer.insertElement( 'paragraph', complex, 0 ); + writer.insertElement( 'paragraph', complex, 0 ); + writer.insertElement( 'paragraph', complex, 0 ); + writer.insert( complex, modelRoot, 0 ); + } ); + }, /^conversion-slot-filter-incomplete/, controller.downcastDispatcher ); + } ); + + // https://github.com/ckeditor/ckeditor5/issues/11163 + it( 'should throw an exception when invoked for a model element that allows $text', () => { + model.schema.register( 'myElement', { + allowIn: '$root', - function* getNodes() { - const main = viewRoot.getChild( 0 ); - yield main; + // This makes it accept $text. + allowContentOf: '$block' + } ); - for ( const { item } of controller.view.createRangeIn( main ) ) { - if ( item.is( 'textProxy' ) ) { - // TreeWalker always create a new instance of a TextProxy so use referenced textNode. - yield item.textNode; - } else { - yield item; + expectToThrowCKEditorError( () => { + downcastHelpers.elementToStructure( { + model: 'myElement', + view: ( modelElement, { writer } ) => { + return writer.createContainerElement( 'div' ); } - } - } + } ); + }, /^conversion-element-to-structure-disallowed-text/, controller.downcastDispatcher, { elementName: 'myElement' } ); } ); } ); @@ -1460,6 +2471,10 @@ describe( 'DowncastHelpers', () => { it( 'should not convert if creator returned null', () => { downcastHelpers.elementToElement( { model: 'div', view: () => null } ); + controller.downcastDispatcher.on( 'insert:div', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'lowest' } ); + const modelElement = new ModelElement( 'div' ); model.change( writer => { @@ -1848,7 +2863,7 @@ describe( 'DowncastHelpers', () => { writer.insertText( 'Foo', { test: '2' }, modelRoot.getChild( 0 ), 0 ); writer.insertText( 'Bar', { test: '3' }, modelRoot.getChild( 1 ), 0 ); } ); - }, /^conversion-attribute-to-attribute-on-text/ ); + }, /^conversion-attribute-to-attribute-on-text/, controller.downcastDispatcher ); } ); it( 'should convert attribute insert/change/remove on a model node', () => { @@ -2623,6 +3638,7 @@ describe( 'DowncastHelpers', () => { const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); conversionApi.writer.insert( viewPosition, viewText ); + conversionApi.consumable.consume( data.item, evt.name ); } ); // added so it can store selection, otherwise it throws. @@ -3177,6 +4193,30 @@ describe( 'DowncastHelpers', () => { function expectResult( string ) { expect( stringifyView( viewRoot, null, { ignoreRoot: true } ) ).to.equal( string ); } + + function getViewAttributes( modelElement ) { + const toStyle = modelElement.hasAttribute( 'toStyle' ) && { style: modelElement.getAttribute( 'toStyle' ) }; + const toClass = modelElement.hasAttribute( 'toClass' ) && { class: 'is-classy' }; + + return { + ...toStyle, + ...toClass + }; + } + + function* getNodes( childIndex = 0 ) { + const main = viewRoot.getChild( childIndex ); + yield main; + + for ( const { item } of controller.view.createRangeIn( main ) ) { + if ( item.is( 'textProxy' ) ) { + // TreeWalker always create a new instance of a TextProxy so use referenced textNode. + yield item.textNode; + } else { + yield item; + } + } + } } ); describe( 'downcast converters', () => { @@ -3547,6 +4587,7 @@ describe( 'downcast selection converters', () => { dispatcher = new DowncastDispatcher( { mapper, viewSelection } ); dispatcher.on( 'insert:$text', insertText() ); + dispatcher.on( 'insert', insertAttributesAndChildren(), { priority: 'lowest' } ); downcastHelpers = new DowncastHelpers( [ dispatcher ] ); downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); @@ -3699,8 +4740,9 @@ describe( 'downcast selection converters', () => { // Convert model to view. view.change( writer => { - dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer ); - dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer ); + const markers = [ [ marker.name, marker.getRange() ] ]; + + dispatcher.convert( model.createRangeIn( modelRoot ), markers, writer ); dispatcher.convertSelection( docSelection, model.markers, writer ); } ); @@ -3725,8 +4767,9 @@ describe( 'downcast selection converters', () => { // Convert model to view. view.change( writer => { - dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer ); - dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer ); + const markers = [ [ marker.name, marker.getRange() ] ]; + + dispatcher.convert( model.createRangeIn( modelRoot ), markers, writer ); dispatcher.convertSelection( docSelection, model.markers, writer ); } ); @@ -3754,8 +4797,9 @@ describe( 'downcast selection converters', () => { // Convert model to view. view.change( writer => { - dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer ); - dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer ); + const markers = [ [ marker.name, marker.getRange() ] ]; + + dispatcher.convert( model.createRangeIn( modelRoot ), markers, writer ); dispatcher.convertSelection( docSelection, model.markers, writer ); } ); @@ -3783,8 +4827,9 @@ describe( 'downcast selection converters', () => { // Convert model to view. view.change( writer => { - dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer ); - dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer ); + const markers = [ [ marker.name, marker.getRange() ] ]; + + dispatcher.convert( model.createRangeIn( modelRoot ), markers, writer ); dispatcher.convertSelection( docSelection, model.markers, writer ); } ); @@ -3829,7 +4874,7 @@ describe( 'downcast selection converters', () => { // Convert model to view. view.change( writer => { - dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer ); + dispatcher.convert( model.createRangeIn( modelRoot ), [], writer ); // Add ui element to view. const uiElement = new ViewUIElement( viewDocument, 'span' ); @@ -3854,7 +4899,7 @@ describe( 'downcast selection converters', () => { // Convert model to view. view.change( writer => { - dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer ); + dispatcher.convert( model.createRangeIn( modelRoot ), [], writer ); // Add ui element to view. const uiElement = new ViewUIElement( viewDocument, 'span' ); @@ -4135,7 +5180,7 @@ describe( 'downcast selection converters', () => { // Convert model to view. view.change( writer => { - dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer ); + dispatcher.convert( model.createRangeIn( modelRoot ), model.markers, writer ); dispatcher.convertSelection( docSelection, model.markers, writer ); } ); diff --git a/packages/ckeditor5-engine/tests/conversion/mapper.js b/packages/ckeditor5-engine/tests/conversion/mapper.js index f0a64fe5d37..ea54c0d2684 100644 --- a/packages/ckeditor5-engine/tests/conversion/mapper.js +++ b/packages/ckeditor5-engine/tests/conversion/mapper.js @@ -17,8 +17,11 @@ import ViewUIElement from '../../src/view/uielement'; import ViewText from '../../src/view/text'; import ViewPosition from '../../src/view/position'; import ViewRange from '../../src/view/range'; +import ViewDocumentFragment from '../../src/view/documentfragment'; import { StylesProcessor } from '../../src/view/stylesmap'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + describe( 'Mapper', () => { let viewDocument; @@ -145,6 +148,60 @@ describe( 'Mapper', () => { expect( mapper.toModelElement( viewA ) ).to.be.undefined; expect( mapper.toViewElement( modelA ) ).to.equal( viewB ); } ); + + it( 'should allow deferred unbinding', () => { + const viewA = new ViewElement( viewDocument, 'a' ); + const modelA = new ModelElement( 'a' ); + + const mapper = new Mapper(); + mapper.bindElements( modelA, viewA ); + + expect( mapper.toModelElement( viewA ) ).to.equal( modelA ); + expect( mapper.toViewElement( modelA ) ).to.equal( viewA ); + + mapper.unbindViewElement( viewA, { defer: true } ); + + expect( mapper.toModelElement( viewA ) ).to.equal( modelA ); + expect( mapper.toViewElement( modelA ) ).to.equal( viewA ); + + mapper.flushDeferredBindings(); + + expect( mapper.toModelElement( viewA ) ).to.be.undefined; + expect( mapper.toViewElement( modelA ) ).to.be.undefined; + } ); + + it( 'should not unbind if element was reused after deferred unbinding', () => { + const viewA = new ViewElement( viewDocument, 'a' ); + const viewFragmentA = new ViewDocumentFragment( viewDocument, [ viewA ] ); + const viewFragmentB = new ViewDocumentFragment( viewDocument ); + + const modelA = new ModelElement( 'a' ); + + const mapper = new Mapper(); + mapper.bindElements( modelA, viewA ); + + expect( mapper.toModelElement( viewA ) ).to.equal( modelA ); + expect( mapper.toViewElement( modelA ) ).to.equal( viewA ); + expect( viewA.root ).to.equal( viewFragmentA ); + + mapper.unbindViewElement( viewA, { defer: true } ); + + expect( mapper.toModelElement( viewA ) ).to.equal( modelA ); + expect( mapper.toViewElement( modelA ) ).to.equal( viewA ); + expect( viewA.root ).to.equal( viewFragmentA ); + + viewFragmentB._appendChild( viewA ); + + expect( mapper.toModelElement( viewA ) ).to.equal( modelA ); + expect( mapper.toViewElement( modelA ) ).to.equal( viewA ); + expect( viewA.root ).to.equal( viewFragmentB ); + + mapper.flushDeferredBindings(); + + expect( mapper.toModelElement( viewA ) ).to.equal( modelA ); + expect( mapper.toViewElement( modelA ) ).to.equal( viewA ); + expect( viewA.root ).to.equal( viewFragmentB ); + } ); } ); describe( 'Standard mapping', () => { @@ -378,6 +435,19 @@ describe( 'Mapper', () => { expect( result ).to.equal( stub ); } ); + it( 'should throw an error on missing position parent view element', () => { + // The foo element was not downcasted to view. + const modelElement = new ModelElement( 'foo' ); + + modelDiv._appendChild( modelElement ); + + const modelPosition = new ModelPosition( modelElement, [ 0 ] ); + + expect( () => { + mapper.toViewPosition( modelPosition ); + } ).to.throw( CKEditorError, 'mapping-view-position-parent-not-found' ); + } ); + // Default algorithm tests. it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) ); it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) ); diff --git a/packages/ckeditor5-engine/tests/conversion/modelconsumable.js b/packages/ckeditor5-engine/tests/conversion/modelconsumable.js index 7e3fb90b8d2..63e2a6a4d8f 100644 --- a/packages/ckeditor5-engine/tests/conversion/modelconsumable.js +++ b/packages/ckeditor5-engine/tests/conversion/modelconsumable.js @@ -8,6 +8,8 @@ import ModelElement from '../../src/model/element'; import ModelTextProxy from '../../src/model/textproxy'; import ModelText from '../../src/model/text'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + describe( 'ModelConsumable', () => { let modelConsumable, modelElement; @@ -16,7 +18,7 @@ describe( 'ModelConsumable', () => { modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ); } ); - describe( 'add', () => { + describe( 'add()', () => { it( 'should add consumable value from given element of given type', () => { modelConsumable.add( modelElement, 'type' ); @@ -52,6 +54,13 @@ describe( 'ModelConsumable', () => { expect( modelConsumable.test( modelElement, 'foo:xxx' ) ).to.be.null; } ); + it( 'should normalize type name for inserts', () => { + modelConsumable.add( modelElement, 'insert:foo' ); + + expect( modelConsumable.test( modelElement, 'insert:foo' ) ).to.be.true; + expect( modelConsumable.test( modelElement, 'insert' ) ).to.be.true; + } ); + it( 'should not normalize type name for markers', () => { modelConsumable.add( modelElement, 'addMarker:foo:bar:baz:abc' ); modelConsumable.add( modelElement, 'removeMarker:foo:bar:baz:abc' ); @@ -70,7 +79,7 @@ describe( 'ModelConsumable', () => { } ); } ); - describe( 'consume', () => { + describe( 'consume()', () => { it( 'should remove consumable value of given type for given element and return true', () => { modelConsumable.add( modelElement, 'type' ); @@ -114,6 +123,16 @@ describe( 'ModelConsumable', () => { expect( modelConsumable.test( modelElement, 'foo:bar' ) ).to.be.false; } ); + it( 'should normalize type name for inserts', () => { + modelConsumable.add( modelElement, 'insert' ); + const result = modelConsumable.consume( modelElement, 'insert:foo' ); + + expect( result ).to.be.true; + + expect( modelConsumable.test( modelElement, 'insert:foo' ) ).to.be.false; + expect( modelConsumable.test( modelElement, 'insert' ) ).to.be.false; + } ); + it( 'should not normalize type names for markers', () => { modelConsumable.add( modelElement, 'addMarker:foo:bar:baz' ); modelConsumable.add( modelElement, 'removeMarker:foo:bar:baz' ); @@ -136,7 +155,7 @@ describe( 'ModelConsumable', () => { } ); } ); - describe( 'revert', () => { + describe( 'revert()', () => { it( 'should re-add consumable value if it was already consumed and return true', () => { modelConsumable.add( modelElement, 'type' ); modelConsumable.consume( modelElement, 'type' ); @@ -187,7 +206,7 @@ describe( 'ModelConsumable', () => { } ); } ); - describe( 'test', () => { + describe( 'test()', () => { it( 'should return null if consumable value of given type has never been added for given element', () => { expect( modelConsumable.test( modelElement, 'typeA' ) ).to.be.null; @@ -211,4 +230,57 @@ describe( 'ModelConsumable', () => { expect( modelConsumable.test( equalProxy1To4, 'type' ) ).to.be.true; } ); } ); + + describe( 'verifyAllConsumed()', () => { + it( 'should not throw if all events were consumed', () => { + modelConsumable.add( modelElement, 'insert:paragraph' ); + modelConsumable.add( modelElement, 'attribute:foo:paragraph' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 0, 6 ), 'insert:$text' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 0, 3 ), 'insert:$text' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 3, 3 ), 'insert:$text' ); + + modelConsumable.consume( modelElement, 'insert:paragraph' ); + modelConsumable.consume( modelElement, 'attribute:foo:paragraph' ); + modelConsumable.consume( new ModelTextProxy( modelElement.getChild( 0 ), 0, 6 ), 'insert:$text' ); + modelConsumable.consume( new ModelTextProxy( modelElement.getChild( 0 ), 0, 3 ), 'insert:$text' ); + modelConsumable.consume( new ModelTextProxy( modelElement.getChild( 0 ), 3, 3 ), 'insert:$text' ); + + expect( () => modelConsumable.verifyAllConsumed( 'insert' ) ).to.not.throw(); + } ); + + it( 'should not throw if all events from specified group were consumed', () => { + modelConsumable.add( modelElement, 'insert:paragraph' ); + modelConsumable.add( modelElement, 'attribute:foo:paragraph' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 0, 6 ), 'insert:$text' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 0, 3 ), 'insert:$text' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 3, 3 ), 'insert:$text' ); + + modelConsumable.consume( modelElement, 'insert:paragraph' ); + modelConsumable.consume( new ModelTextProxy( modelElement.getChild( 0 ), 0, 6 ), 'insert:$text' ); + modelConsumable.consume( new ModelTextProxy( modelElement.getChild( 0 ), 0, 3 ), 'insert:$text' ); + modelConsumable.consume( new ModelTextProxy( modelElement.getChild( 0 ), 3, 3 ), 'insert:$text' ); + + expect( () => modelConsumable.verifyAllConsumed( 'insert' ) ).to.not.throw(); + } ); + + it( 'should throw if some element event was not consumed', () => { + modelConsumable.add( modelElement, 'insert:paragraph' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 0, 6 ), 'insert:$text' ); + + modelConsumable.consume( new ModelTextProxy( modelElement.getChild( 0 ), 0, 6 ), 'insert:$text' ); + + expect( () => modelConsumable.verifyAllConsumed( 'insert' ) ) + .to.throw( CKEditorError, 'conversion-model-consumable-not-consumed' ); + } ); + + it( 'should throw if some text node event was not consumed', () => { + modelConsumable.add( modelElement, 'insert:paragraph' ); + modelConsumable.add( new ModelTextProxy( modelElement.getChild( 0 ), 0, 6 ), 'insert:$text' ); + + modelConsumable.consume( modelElement, 'insert:paragraph' ); + + expect( () => modelConsumable.verifyAllConsumed( 'insert' ) ) + .to.throw( CKEditorError, 'conversion-model-consumable-not-consumed' ); + } ); + } ); } ); diff --git a/packages/ckeditor5-engine/tests/manual/element-reconversion.html b/packages/ckeditor5-engine/tests/manual/element-reconversion.html new file mode 100644 index 00000000000..1a6bfcdf0aa --- /dev/null +++ b/packages/ckeditor5-engine/tests/manual/element-reconversion.html @@ -0,0 +1,93 @@ + + + + +
+ Mode: + + + +
+ +
+
+
+
+
diff --git a/packages/ckeditor5-engine/tests/manual/element-reconversion.js b/packages/ckeditor5-engine/tests/manual/element-reconversion.js new file mode 100644 index 00000000000..b7c991b402f --- /dev/null +++ b/packages/ckeditor5-engine/tests/manual/element-reconversion.js @@ -0,0 +1,146 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; + +const thresholds = new Map(); + +thresholds.set( 15, 'huge' ); +thresholds.set( 10, 'high' ); +thresholds.set( 7, 'reasonable' ); +thresholds.set( 4, 'few' ); +thresholds.set( 2, 'little' ); +thresholds.set( 1, 'single' ); + +const getThreshold = value => { + for ( const [ thresholdValue, name ] of thresholds ) { + if ( value >= thresholdValue ) { + return name; + } + } +}; + +function Items( editor ) { + editor.model.schema.register( 'items', { + allowIn: '$root', + allowAttributes: [ 'mode' ], + allowChildren: [ 'item' ] + } ); + + editor.model.schema.register( 'item', { + allowChildren: [ '$text' ] + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: { + name: 'items', + attributes: [ 'mode' ] + }, + view: ( modelElement, { writer } ) => { + const mode = modelElement.getAttribute( 'mode' ); + const attributes = { class: 'items ' }; + + if ( mode === 'threshold' ) { + return writer.createContainerElement( 'div', { + 'data-amount': getThreshold( modelElement.childCount ), + ...attributes + } ); + } + + if ( mode === 'hsl' ) { + return writer.createContainerElement( 'div', { + style: `background-color: hsl(${ modelElement.childCount * 5 }, 100%, 50%)`, + ...attributes + } ); + } + + return writer.createContainerElement( 'div', attributes ); + } + } ); + + editor.conversion.for( 'upcast' ).elementToElement( { + view: { name: 'div', classes: 'items' }, + model: ( viewElement, { writer } ) => { + return writer.createElement( 'items', { + mode: viewElement.getAttribute( 'data-mode' ) + } ); + } + } ); + + editor.conversion.elementToElement( { + view: { name: 'div', classes: 'item' }, + model: 'item' + } ); +} + +function AddRenderCount( editor ) { + let insertCount = 0; + + const nextInsert = () => insertCount++; + + editor.conversion.for( 'downcast' ).add( dispatcher => dispatcher.on( 'insert', ( event, data, conversionApi ) => { + const view = conversionApi.mapper.toViewElement( data.item ); + + if ( view ) { + const insertCount = nextInsert(); + + conversionApi.writer.setAttribute( 'data-insert-count', `${ insertCount }`, view ); + conversionApi.writer.setAttribute( 'title', `Insertion counter: ${ insertCount }`, view ); + } + }, { priority: 'lowest' } ) ); +} + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ ArticlePluginSet, Items, AddRenderCount ], + toolbar: [ + 'heading', + 'bold', + 'italic', + 'link', + 'bulletedList', + 'numberedList', + '|', + 'outdent', + 'indent', + '|', + 'blockQuote', + 'insertTable', + 'mediaEmbed', + 'undo', + 'redo' + ], + image: { + toolbar: [ 'imageStyle:block', 'imageStyle:side', '|', 'imageTextAlternative' ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + } + } ) + .then( editor => { + window.editor = editor; + + for ( const option of document.querySelectorAll( 'input[name=mode]' ) ) { + option.addEventListener( 'change', event => { + editor.model.change( writer => { + writer.setAttribute( + 'mode', + event.target.value, + editor.model.document.getRoot().getChild( 0 ) + ); + } ); + } ); + } + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-engine/tests/manual/element-reconversion.md b/packages/ckeditor5-engine/tests/manual/element-reconversion.md new file mode 100644 index 00000000000..c707aec6e2e --- /dev/null +++ b/packages/ckeditor5-engine/tests/manual/element-reconversion.md @@ -0,0 +1,38 @@ +# Element to element reconversion + +The editor should be loaded with `items` element that contains one `item` element in which user can edit content. + +Adding new items (by pressing Enter key) or removing (by pressing backspace) should: + +### In threshold mode + +Update `items` view element's `data-amount` attribute and change the list background color accordingly. + +List of thresholds: + +``` +| Items | Amount | Color | +|----------|------------|-----------------| +| 1 item | single | aquamarine | +| 2 items | little | cadetblue | +| 4 items | few | darkkhaki | +| 7 items | reasonable | darksalmon | +| 10 items | high | deeppink | +| 15 items | huge | mediumvioletred | +``` + +### In HSL mode + +Update `items` view element's inline style background color to hsl value with hue shifted by 5 every time an `item` is added or removed. + +### In none mode + +Remove background color and update nothing. + +## Reconversion counter + +In every mode you should be able to inspect number of times each item has been inserted. + +This way you can observe increasing insertion counter on the main `items` element every time a child is either inserted or removed. + +Main `items` element counter should also be increased when you change mode. diff --git a/packages/ckeditor5-engine/tests/manual/slotconversion.html b/packages/ckeditor5-engine/tests/manual/slot-conversion.html similarity index 80% rename from packages/ckeditor5-engine/tests/manual/slotconversion.html rename to packages/ckeditor5-engine/tests/manual/slot-conversion.html index 7ecc38a4691..fd504d810fd 100644 --- a/packages/ckeditor5-engine/tests/manual/slotconversion.html +++ b/packages/ckeditor5-engine/tests/manual/slot-conversion.html @@ -16,6 +16,12 @@ background: hsl(0, 0%, 60%); } + .box-footer { + border: 1px solid hsl(0, 0%, 80%); + background: hsl(0, 0%, 40%); + color: hsl(0, 0%, 80%); + } + .box-content-field { padding: .5em; background: hsl(0, 0%, 100%); @@ -39,6 +45,11 @@ +
+ + +
+
diff --git a/packages/ckeditor5-engine/tests/manual/slotconversion.js b/packages/ckeditor5-engine/tests/manual/slot-conversion.js similarity index 64% rename from packages/ckeditor5-engine/tests/manual/slotconversion.js rename to packages/ckeditor5-engine/tests/manual/slot-conversion.js index 1939613bc8a..2130dcf1a5d 100644 --- a/packages/ckeditor5-engine/tests/manual/slotconversion.js +++ b/packages/ckeditor5-engine/tests/manual/slot-conversion.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals console, window, document */ +/* globals window, document */ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; @@ -41,7 +41,7 @@ function mapMeta( editor ) { } function getChildren( editor, viewElement ) { - return [ ...( editor.editing.view.createRangeIn( viewElement ) ) ] + return Array.from( editor.editing.view.createRangeIn( viewElement ) ) .filter( ( { type } ) => type === 'elementStart' ) .map( ( { item } ) => item ); } @@ -73,84 +73,67 @@ function getBoxUpcastConverter( editor ) { for ( const field of fields ) { const boxField = writer.createElement( 'boxField' ); - conversionApi.safeInsert( boxField, writer.createPositionAt( box, field.index ) ); + conversionApi.safeInsert( boxField, writer.createPositionAt( box, 'end' ) ); conversionApi.convertChildren( field, boxField ); } conversionApi.consumable.consume( viewElement, { name: true } ); - elements.map( element => { - conversionApi.consumable.consume( element, { name: true } ); - } ); + elements.forEach( element => conversionApi.consumable.consume( element, { name: true } ) ); conversionApi.updateConversionResult( box, data ); } ); } -function downcastBox( modelElement, conversionApi ) { - const { writer } = conversionApi; +function getBoxDowncastCreator( multiSlot ) { + return ( modelElement, conversionApi ) => { + const { writer } = conversionApi; - const viewBox = writer.createContainerElement( 'div', { class: 'box' } ); - conversionApi.mapper.bindElements( modelElement, viewBox ); + const viewBox = writer.createContainerElement( 'div', { class: 'box' } ); + const contentWrap = writer.createContainerElement( 'div', { class: 'box-content' } ); - const contentWrap = writer.createContainerElement( 'div', { class: 'box-content' } ); - writer.insert( writer.createPositionAt( viewBox, 0 ), contentWrap ); + writer.insert( writer.createPositionAt( viewBox, 0 ), contentWrap ); - for ( const [ meta, metaValue ] of Object.entries( modelElement.getAttribute( 'meta' ) ) ) { - if ( meta === 'header' ) { - const header = writer.createRawElement( 'div', { - class: 'box-meta box-meta-header' - }, domElement => { - domElement.innerHTML = `

${ metaValue.title }

`; - } ); + for ( const [ meta, metaValue ] of Object.entries( modelElement.getAttribute( 'meta' ) ) ) { + if ( meta === 'header' ) { + const header = writer.createRawElement( 'div', { + class: 'box-meta box-meta-header' + }, domElement => { + domElement.innerHTML = `

${ metaValue.title }

`; + } ); + + writer.insert( writer.createPositionBefore( contentWrap ), header ); + } - writer.insert( writer.createPositionBefore( contentWrap ), header ); + if ( meta === 'author' ) { + const author = writer.createRawElement( 'div', { + class: 'box-meta box-meta-author' + }, domElement => { + domElement.innerHTML = `${ metaValue.name }`; + } ); + + writer.insert( writer.createPositionAfter( contentWrap ), author ); + } } - if ( meta === 'author' ) { - const author = writer.createRawElement( 'div', { - class: 'box-meta box-meta-author' - }, domElement => { - domElement.innerHTML = `${ metaValue.name }`; + if ( !multiSlot ) { + writer.insert( writer.createPositionAt( contentWrap, 0 ), writer.createSlot() ); + } else { + writer.insert( writer.createPositionAt( contentWrap, 0 ), writer.createSlot( element => element.index < 2 ) ); + + const contentWrap2 = writer.createContainerElement( 'div', { class: 'box-content' } ); + + writer.insert( writer.createPositionAt( viewBox, 'end' ), contentWrap2 ); + writer.insert( writer.createPositionAt( contentWrap2, 0 ), writer.createSlot( element => element.index >= 2 ) ); + + const footer = writer.createRawElement( 'div', { class: 'box-footer' }, domElement => { + domElement.innerHTML = 'Footer'; } ); - writer.insert( writer.createPositionAfter( contentWrap ), author ); + writer.insert( writer.createPositionAfter( contentWrap2 ), footer ); } - } - for ( const field of modelElement.getChildren() ) { - const viewField = writer.createContainerElement( 'div', { class: 'box-content-field' } ); - - writer.insert( writer.createPositionAt( contentWrap, field.index ), viewField ); - conversionApi.mapper.bindElements( field, viewField ); - conversionApi.consumable.consume( field, 'insert' ); - - // Might be simplified to: - // - // writer.defineSlot( field, viewField, field.index ); - // - // but would require a converter: - // - // editor.conversion.for( 'downcast' ).elementToElement( { // .slotToElement()? - // model: 'viewField', - // view: { name: 'div', class: 'box-content-field' } - // } ); - } - - // At this point we're inserting whole "component". Equivalent to (JSX-like notation): - // - // "rendered" view Mapping/source - // - // <-- top-level box - // ... box[meta.header] - // - // ... <-- this is "slot" boxField - // ... many - // ... <-- this is "slot" boxField - // - // ... box[meta.author] - // - - return viewBox; + return viewBox; + }; } function addButton( editor, uiName, label, callback ) { @@ -188,7 +171,7 @@ function Box( editor ) { allowIn: '$root', isObject: true, isSelectable: true, - allowAttributes: [ 'infoBoxMeta' ] + allowAttributes: [ 'meta' ] } ); editor.model.schema.register( 'boxField', { @@ -199,13 +182,18 @@ function Box( editor ) { editor.conversion.for( 'upcast' ).add( getBoxUpcastConverter( editor ) ); - editor.conversion.for( 'downcast' ).elementToElement( { - model: 'box', - view: downcastBox, - triggerBy: { + editor.conversion.for( 'downcast' ).elementToStructure( { + model: { + name: 'box', attributes: [ 'meta' ], - children: [ 'boxField' ] - } + children: true + }, + view: getBoxDowncastCreator( editor.config.get( 'box.multiSlot' ) ) + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'boxField', + view: { name: 'div', classes: 'box-content-field' } } ); addBoxMetaButton( editor, 'boxTitle', 'Box title', () => ( { @@ -247,8 +235,8 @@ function AddRenderCount( editor ) { }, { priority: 'lowest' } ) ); } -ClassicEditor - .create( document.querySelector( '#editor' ), { +async function createEditor( multiSlot ) { + const editor = await ClassicEditor.create( document.querySelector( '#editor' ), { plugins: [ ArticlePluginSet, Box, AddRenderCount ], toolbar: [ 'heading', @@ -282,11 +270,23 @@ ClassicEditor 'tableRow', 'mergeTableCells' ] + }, + box: { multiSlot } + } ); + + window.editor = editor; +} + +for ( const option of document.querySelectorAll( 'input[name=box-mode]' ) ) { + option.addEventListener( 'change', async event => { + if ( window.editor ) { + await window.editor.destroy(); } - } ) - .then( editor => { - window.editor = editor; - } ) - .catch( err => { - console.error( err.stack ); + + await createEditor( event.target.value == 'multi' ); } ); + + if ( option.checked ) { + createEditor( option.value == 'multi' ); + } +} diff --git a/packages/ckeditor5-engine/tests/manual/slot-conversion.md b/packages/ckeditor5-engine/tests/manual/slot-conversion.md new file mode 100644 index 00000000000..a8ac8f99231 --- /dev/null +++ b/packages/ckeditor5-engine/tests/manual/slot-conversion.md @@ -0,0 +1,14 @@ +# Slot conversion + +The editor should be loaded with a "box" element that contains multiple fields in which user can edit content. + +An additional converter adds `"data-insert-count"` attribute to view elements to show when it was rendered. It is displayed with a CSS in the top-right corner of rendered element. If a view element was not re-rendered this attribute should not change. *Note*: it only acts on "insert" changes, so it can omit attribute-to-element changes or insertions not passed through dispatcher. + +Observe which view elements are re-rendered when using UI-buttons: + +* `Box title` - updates title attribute which triggers re-rendering of a "box". +* `Box author` - updates author attribute which triggers re-rendering of a "box". +* `+` - adds field to box which triggers re-rendering of a "box". +* `-` - removes field from box which triggers re-rendering of a "box". + +There is a switch above the editor to load single slot version of the plugin (where all fields are in a single wrapper), and a multi-slot version (where first 2 fields are in one wrapper and the rest in the other wrapper). diff --git a/packages/ckeditor5-engine/tests/manual/slotconversion.md b/packages/ckeditor5-engine/tests/manual/slotconversion.md deleted file mode 100644 index bab4d7202b1..00000000000 --- a/packages/ckeditor5-engine/tests/manual/slotconversion.md +++ /dev/null @@ -1,12 +0,0 @@ -# Slot conversion - -The editor should be loaded with a "box" element that contains multiple "slots" in which user can edit content. - -An additional converter adds `"data-insert-count"` attribute to view elements to show when it was rendered. It is displayed with a CSS at the top-right corner of rendered element. If a view element was not re-rendered this attribute should not change. *Note*: it only acts on "insert" changes so it can omit attribute-to-element changes or insertions not passed through dispatcher. - -Observe which view elements are re-rendered when using UI-buttons: - -* `Box title` - updates title attribute which triggers re-rendering of a "box". -* `Box author` - updates author attribute which triggers re-rendering of a "box". -* `+` - adds "slot" to box" which triggers re-rendering of a "box". -* `-` - removes "slot" from box" which triggers re-rendering of a "box". diff --git a/packages/ckeditor5-engine/tests/model/differ.js b/packages/ckeditor5-engine/tests/model/differ.js index e69be8f0b62..e699c5cfe75 100644 --- a/packages/ckeditor5-engine/tests/model/differ.js +++ b/packages/ckeditor5-engine/tests/model/differ.js @@ -1791,11 +1791,11 @@ describe( 'Differ', () => { } ); } ); - describe( 'refreshItem()', () => { + describe( '#_refreshItem()', () => { it( 'should mark given element to be removed and added again', () => { const p = root.getChild( 0 ); - differ.refreshItem( p ); + differ._refreshItem( p ); expectChanges( [ { type: 'remove', name: 'paragraph', length: 1, position: model.createPositionBefore( p ) }, @@ -1808,7 +1808,7 @@ describe( 'Differ', () => { const range = model.createRangeIn( p ); const textProxy = [ ...range.getItems() ][ 0 ]; - differ.refreshItem( textProxy ); + differ._refreshItem( textProxy ); expectChanges( [ { type: 'remove', name: '$text', length: 3, position: model.createPositionAt( p, 0 ) }, @@ -1821,7 +1821,7 @@ describe( 'Differ', () => { model.change( () => { insert( new Element( 'blockQuote', null, new Element( 'paragraph' ) ), new Position( root, [ 2 ] ) ); - differ.refreshItem( root.getChild( 2 ).getChild( 0 ) ); + differ._refreshItem( root.getChild( 2 ).getChild( 0 ) ); expectChanges( [ { type: 'insert', name: 'blockQuote', length: 1, position: new Position( root, [ 2 ] ) } @@ -1846,7 +1846,7 @@ describe( 'Differ', () => { const markersToRefresh = [ 'markerA', 'markerB', 'markerC' ]; - differ.refreshItem( root.getChild( 1 ) ); + differ._refreshItem( root.getChild( 1 ) ); expectChanges( [ { type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 1 ] ) }, diff --git a/packages/ckeditor5-engine/tests/model/operation/transform/utils.js b/packages/ckeditor5-engine/tests/model/operation/transform/utils.js index ea32125970a..714a48fcbcc 100644 --- a/packages/ckeditor5-engine/tests/model/operation/transform/utils.js +++ b/packages/ckeditor5-engine/tests/model/operation/transform/utils.js @@ -5,7 +5,7 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; diff --git a/packages/ckeditor5-engine/tests/model/writer.js b/packages/ckeditor5-engine/tests/model/writer.js index c31d40a1106..440b4e1ab34 100644 --- a/packages/ckeditor5-engine/tests/model/writer.js +++ b/packages/ckeditor5-engine/tests/model/writer.js @@ -3,6 +3,8 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +/* global console */ + import Model from '../../src/model/model'; import Writer from '../../src/model/writer'; import Batch from '../../src/model/batch'; @@ -20,9 +22,13 @@ import { getNodesAndText } from '../../tests/model/_utils/utils'; import DocumentSelection from '../../src/model/documentselection'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + describe( 'Writer', () => { let model, doc, batch; + testUtils.createSinonSandbox(); + beforeEach( () => { model = new Model(); batch = new Batch(); @@ -2496,19 +2502,32 @@ describe( 'Writer', () => { }, 'writer-updatemarker-marker-not-exists', model ); } ); - it( 'should only refresh the marker when there is no provided options to update', () => { + it( 'should only refresh (but warn()) the marker when there is no provided options to update', () => { const marker = addMarker( 'name', { range, usingOperation: true } ); const spy = sinon.spy( model.markers, '_refresh' ); + const consoleWarnStub = testUtils.sinon.stub( console, 'warn' ); updateMarker( marker ); sinon.assert.calledOnce( spy ); sinon.assert.calledWithExactly( spy, marker ); + sinon.assert.calledOnce( consoleWarnStub ); + sinon.assert.calledWithExactly( consoleWarnStub.firstCall, + sinon.match( /^writer-updatemarker-reconvert-using-editingcontroller/ ), + { markerName: 'name' }, + sinon.match.string // Link to the documentation + ); updateMarker( 'name' ); sinon.assert.calledTwice( spy ); sinon.assert.calledWithExactly( spy.secondCall, marker ); + sinon.assert.calledTwice( consoleWarnStub ); + sinon.assert.calledWithExactly( consoleWarnStub.secondCall, + sinon.match( /^writer-updatemarker-reconvert-using-editingcontroller/ ), + { markerName: 'name' }, + sinon.match.string // Link to the documentation + ); } ); it( 'should throw when trying to use detached writer', () => { diff --git a/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js b/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js index 319463b9176..e767d3f5fc3 100644 --- a/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js +++ b/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js @@ -15,9 +15,14 @@ import { StylesProcessor } from '../../../src/view/stylesmap'; import DocumentFragment from '../../../src/view/documentfragment'; import HtmlDataProcessor from '../../../src/dataprocessor/htmldataprocessor'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + describe( 'DowncastWriter', () => { let writer, attributes, root, doc; + testUtils.createSinonSandbox(); + beforeEach( () => { attributes = { foo: 'bar', baz: 'quz' }; doc = new Document( new StylesProcessor() ); @@ -143,6 +148,7 @@ describe( 'DowncastWriter', () => { expect( element.name ).to.equal( 'foo' ); expect( element.isAllowedInsideAttributeElement ).to.be.false; assertElementAttributes( element, attributes ); + expect( element.childCount ).to.equal( 0 ); } ); it( 'should allow to pass additional options', () => { @@ -157,6 +163,54 @@ describe( 'DowncastWriter', () => { expect( element.shouldRenderUnsafeAttribute( 'baz' ) ).to.be.true; assertElementAttributes( element, attributes ); } ); + + it( 'should create element without attributes', () => { + const element = writer.createContainerElement( 'foo', null ); + + expect( element.is( 'containerElement' ) ).to.be.true; + expect( element.name ).to.equal( 'foo' ); + expect( element.isAllowedInsideAttributeElement ).to.be.false; + expect( Array.from( element.getAttributes() ).length ).to.equal( 0 ); + expect( element.childCount ).to.equal( 0 ); + } ); + + it( 'should create element with single child', () => { + const child = writer.createEmptyElement( 'bar' ); + const element = writer.createContainerElement( 'foo', null, child ); + + expect( element.is( 'containerElement' ) ).to.be.true; + expect( element.name ).to.equal( 'foo' ); + expect( element.isAllowedInsideAttributeElement ).to.be.false; + expect( Array.from( element.getAttributes() ).length ).to.equal( 0 ); + expect( element.childCount ).to.equal( 1 ); + expect( element.getChild( 0 ) ).to.equal( child ); + } ); + + it( 'should create element with children and attributes', () => { + const first = writer.createEmptyElement( 'aaa' ); + const second = writer.createEmptyElement( 'bbb' ); + const element = writer.createContainerElement( 'foo', attributes, [ first, second ] ); + + expect( element.is( 'containerElement' ) ).to.be.true; + expect( element.name ).to.equal( 'foo' ); + expect( element.isAllowedInsideAttributeElement ).to.be.false; + assertElementAttributes( element, attributes ); + expect( element.childCount ).to.equal( 2 ); + expect( element.getChild( 0 ) ).to.equal( first ); + expect( element.getChild( 1 ) ).to.equal( second ); + } ); + + it( 'should create element with children attributes and allow additional options', () => { + const child = writer.createEmptyElement( 'bar' ); + const element = writer.createContainerElement( 'foo', attributes, child, { isAllowedInsideAttributeElement: true } ); + + expect( element.is( 'containerElement' ) ).to.be.true; + expect( element.name ).to.equal( 'foo' ); + expect( element.isAllowedInsideAttributeElement ).to.be.true; + assertElementAttributes( element, attributes ); + expect( element.childCount ).to.equal( 1 ); + expect( element.getChild( 0 ) ).to.equal( child ); + } ); } ); describe( 'createEditableElement()', () => { @@ -460,6 +514,36 @@ describe( 'DowncastWriter', () => { } ); } ); + describe( 'createSlot()', () => { + it( 'should throw if called before slot factory is initialized', () => { + expect( () => { + writer.createSlot(); + } ).to.throw( CKEditorError, 'view-writer-invalid-create-slot-context' ); + } ); + + it( 'should call slot factory and pass the parameter', () => { + const spy = sinon.spy(); + + writer._registerSlotFactory( spy ); + writer.createSlot( 'foo' ); + + sinon.assert.calledWithExactly( spy, writer, 'foo' ); + } ); + + it( 'should throw if called after slot factory is cleared', () => { + const spy = sinon.spy(); + + writer._registerSlotFactory( spy ); + writer._clearSlotFactory(); + + expect( () => { + writer.createSlot( 'foo' ); + } ).to.throw( CKEditorError, 'view-writer-invalid-create-slot-context' ); + + sinon.assert.notCalled( spy ); + } ); + } ); + describe( 'manages AttributeElement#_clonesGroup', () => { it( 'should return all clones of a broken attribute element with id', () => { const text = writer.createText( 'abccccde' ); diff --git a/packages/ckeditor5-find-and-replace/tests/findcommand.js b/packages/ckeditor5-find-and-replace/tests/findcommand.js index 4e06b50c5d3..ade73f39fee 100644 --- a/packages/ckeditor5-find-and-replace/tests/findcommand.js +++ b/packages/ckeditor5-find-and-replace/tests/findcommand.js @@ -76,7 +76,7 @@ describe( 'FindCommand', () => { const markers = getSimplifiedMarkersFromResults( results ); expect( stringify( model.document.getRoot(), null, markers ) ).to.equal( - 'Foo bar baz. Bam bar bom.' + 'Foo bar baz. Bam bar bom.' ); } ); @@ -348,7 +348,7 @@ describe( 'FindCommand', () => { ); expect( stringify( multiRootModel.document.getRoot( 'second' ), null, [ markerSecond ] ) ).to.equal( - 'Foo bar baz' + 'Foo bar baz' ); } ); @@ -381,9 +381,13 @@ describe( 'FindCommand', () => { * random and unique. */ function getSimplifiedMarkersFromResults( results ) { + let letter = 'X'; + return results.map( item => { // Replace markers id to a predefined value, as originally these are unique random ids. - item.marker.name = 'X'; + item.marker.name = letter; + + letter = String.fromCharCode( letter.charCodeAt( 0 ) + 1 ); return item.marker; } ); diff --git a/packages/ckeditor5-find-and-replace/tests/replacecommand.js b/packages/ckeditor5-find-and-replace/tests/replacecommand.js index 90445b57e62..236a8945bae 100644 --- a/packages/ckeditor5-find-and-replace/tests/replacecommand.js +++ b/packages/ckeditor5-find-and-replace/tests/replacecommand.js @@ -115,7 +115,7 @@ describe( 'ReplaceCommand', () => { } } - expect( getData( editor.model, { convertMarkers: true } ) ).to.equal( + expect( getData( editor.model, { convertMarkers: true, withoutSelection: true } ) ).to.equal( 'bar ' + 'foo' + ' ' + diff --git a/packages/ckeditor5-heading/src/title.js b/packages/ckeditor5-heading/src/title.js index 081386150e8..239d130781a 100644 --- a/packages/ckeditor5-heading/src/title.js +++ b/packages/ckeditor5-heading/src/title.js @@ -87,6 +87,10 @@ export default class Title extends Plugin { // Conversion. editor.conversion.for( 'downcast' ).elementToElement( { model: 'title-content', view: 'h1' } ); + editor.conversion.for( 'downcast' ).add( dispatcher => dispatcher.on( 'insert:title', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + } ) ); + // Custom converter is used for data v -> m conversion to avoid calling post-fixer when setting data. // See https://github.com/ckeditor/ckeditor5/issues/2036. editor.data.upcastDispatcher.on( 'element:h1', dataViewModelH1Insertion, { priority: 'high' } ); @@ -153,27 +157,24 @@ export default class Title extends Plugin { const rootRange = model.createRangeIn( root ); const viewDocumentFragment = viewWriter.createDocumentFragment(); - data.downcastDispatcher.conversionApi.options = options; - - // Convert the entire root to view. - data.mapper.clearBindings(); - data.mapper.bindElements( root, viewDocumentFragment ); - data.downcastDispatcher.convertInsert( rootRange, viewWriter ); - - // Convert all markers that intersects with body. + // Find all markers that intersects with body. const bodyStartPosition = model.createPositionAfter( root.getChild( 0 ) ); const bodyRange = model.createRange( bodyStartPosition, model.createPositionAt( root, 'end' ) ); + const markers = new Map(); + for ( const marker of model.markers ) { const intersection = bodyRange.getIntersection( marker.getRange() ); if ( intersection ) { - data.downcastDispatcher.convertMarkerAdd( marker.name, intersection, viewWriter ); + markers.set( marker.name, intersection ); } } - // Clean `conversionApi`. - delete data.downcastDispatcher.conversionApi.options; + // Convert the entire root to view. + data.mapper.clearBindings(); + data.mapper.bindElements( root, viewDocumentFragment ); + data.downcastDispatcher.convert( rootRange, markers, viewWriter, options ); // Remove title element from view. viewWriter.remove( viewWriter.createRangeOn( viewDocumentFragment.getChild( 0 ) ) ); diff --git a/packages/ckeditor5-horizontal-line/src/horizontallineediting.js b/packages/ckeditor5-horizontal-line/src/horizontallineediting.js index 34c2ec1955f..69099a9e0cc 100644 --- a/packages/ckeditor5-horizontal-line/src/horizontallineediting.js +++ b/packages/ckeditor5-horizontal-line/src/horizontallineediting.js @@ -48,18 +48,18 @@ export default class HorizontalLineEditing extends Plugin { } } ); - conversion.for( 'editingDowncast' ).elementToElement( { + conversion.for( 'editingDowncast' ).elementToStructure( { model: 'horizontalLine', view: ( modelElement, { writer } ) => { const label = t( 'Horizontal line' ); - const viewWrapper = writer.createContainerElement( 'div' ); - const viewHrElement = writer.createEmptyElement( 'hr' ); + + const viewWrapper = writer.createContainerElement( 'div', null, + writer.createEmptyElement( 'hr' ) + ); writer.addClass( 'ck-horizontal-line', viewWrapper ); writer.setCustomProperty( 'hr', true, viewWrapper ); - writer.insert( writer.createPositionAt( viewWrapper, 0 ), viewHrElement ); - return toHorizontalLineWidget( viewWrapper, writer, label ); } } ); diff --git a/packages/ckeditor5-html-embed/src/htmlembedediting.js b/packages/ckeditor5-html-embed/src/htmlembedediting.js index 4e5051bf150..3b5cf316ca1 100644 --- a/packages/ckeditor5-html-embed/src/htmlembedediting.js +++ b/packages/ckeditor5-html-embed/src/htmlembedediting.js @@ -139,21 +139,11 @@ export default class HtmlEmbedEditing extends Plugin { } } ); - editor.conversion.for( 'editingDowncast' ).elementToElement( { - triggerBy: { - attributes: [ 'value' ] - }, - model: 'rawHtml', + editor.conversion.for( 'editingDowncast' ).elementToStructure( { + model: { name: 'rawHtml', attributes: [ 'value' ] }, view: ( modelElement, { writer } ) => { let domContentWrapper, state, props; - const viewContainer = writer.createContainerElement( 'div', { - class: 'raw-html-embed', - 'data-html-embed-label': t( 'HTML snippet' ), - dir: editor.locale.uiLanguageDirection - } ); - // Widget cannot be a raw element because the widget system would not be able - // to add its UI to it. Thus, we need this wrapper. const viewContentWrapper = writer.createRawElement( 'div', { class: 'raw-html-embed__content-wrapper' }, function( domElement ) { @@ -238,7 +228,11 @@ export default class HtmlEmbedEditing extends Plugin { } }; - writer.insert( writer.createPositionAt( viewContainer, 0 ), viewContentWrapper ); + const viewContainer = writer.createContainerElement( 'div', { + class: 'raw-html-embed', + 'data-html-embed-label': t( 'HTML snippet' ), + dir: editor.locale.uiLanguageDirection + }, viewContentWrapper ); writer.setCustomProperty( 'rawHtmlApi', rawHtmlApi, viewContainer ); writer.setCustomProperty( 'rawHtml', true, viewContainer ); diff --git a/packages/ckeditor5-html-support/src/converters.js b/packages/ckeditor5-html-support/src/converters.js index 9746f3bff9d..4de1040f7df 100644 --- a/packages/ckeditor5-html-support/src/converters.js +++ b/packages/ckeditor5-html-support/src/converters.js @@ -40,15 +40,6 @@ export function toObjectWidgetConverter( editor, { view: viewName, isInline } ) return ( modelElement, { writer, consumable } ) => { const widgetLabel = t( 'HTML object' ); - // Widget cannot be a raw element because the widget system would not be able - // to add its UI to it. Thus, we need separate view container. - const viewContainer = writer.createContainerElement( isInline ? 'span' : 'div', { - class: 'html-object-embed', - 'data-html-object-embed-label': widgetLabel - }, { - isAllowedInsideAttributeElement: isInline - } ); - const viewElement = createObjectView( viewName, modelElement, writer ); writer.addClass( 'html-object-embed__content', viewElement ); @@ -57,7 +48,18 @@ export function toObjectWidgetConverter( editor, { view: viewName, isInline } ) setViewAttributes( writer, viewAttributes, viewElement ); } - writer.insert( writer.createPositionAt( viewContainer, 0 ), viewElement ); + // Widget cannot be a raw element because the widget system would not be able + // to add its UI to it. Thus, we need separate view container. + const viewContainer = writer.createContainerElement( isInline ? 'span' : 'div', + { + class: 'html-object-embed', + 'data-html-object-embed-label': widgetLabel + }, + viewElement, + { + isAllowedInsideAttributeElement: isInline + } + ); return toWidget( viewContainer, writer, { widgetLabel } ); }; diff --git a/packages/ckeditor5-html-support/src/datafilter.js b/packages/ckeditor5-html-support/src/datafilter.js index 1c8e09107b3..f78003bd4b9 100644 --- a/packages/ckeditor5-html-support/src/datafilter.js +++ b/packages/ckeditor5-html-support/src/datafilter.js @@ -331,7 +331,7 @@ export default class DataFilter extends Plugin { } ); conversion.for( 'upcast' ).add( viewToModelBlockAttributeConverter( definition, this ) ); - conversion.for( 'editingDowncast' ).elementToElement( { + conversion.for( 'editingDowncast' ).elementToStructure( { model: modelName, view: toObjectWidgetConverter( editor, definition ) } ); diff --git a/packages/ckeditor5-html-support/tests/datafilter.js b/packages/ckeditor5-html-support/tests/datafilter.js index 361be93f54e..8e0366b6e4e 100644 --- a/packages/ckeditor5-html-support/tests/datafilter.js +++ b/packages/ckeditor5-html-support/tests/datafilter.js @@ -372,10 +372,12 @@ describe( 'DataFilter', () => { } ); it( 'should consume htmlAttributes attribute (editing downcast)', () => { - const spy = sinon.spy(); + let consumable; editor.conversion.for( 'editingDowncast' ).add( dispatcher => { - dispatcher.on( 'attribute:htmlAttributes:htmlInput', spy ); + dispatcher.on( 'insert:htmlInput', ( evt, data, conversionApi ) => { + consumable = conversionApi.consumable; + } ); } ); dataFilter.allowElement( 'input' ); @@ -383,7 +385,7 @@ describe( 'DataFilter', () => { editor.setData( '

' ); - expect( spy.called ).to.be.false; + expect( consumable.test( model.document.getRoot().getChild( 0 ).getChild( 0 ), 'attribute:htmlAttributes' ) ).to.be.false; } ); function getObjectModelDataWithAttributes( model, options ) { diff --git a/packages/ckeditor5-html-support/tests/htmlcomment-integration.js b/packages/ckeditor5-html-support/tests/htmlcomment-integration.js index 40553bfdf24..b81b955aafa 100644 --- a/packages/ckeditor5-html-support/tests/htmlcomment-integration.js +++ b/packages/ckeditor5-html-support/tests/htmlcomment-integration.js @@ -29,9 +29,9 @@ import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting'; import LinkImageEditing from '@ckeditor/ckeditor5-link/src/linkimageediting'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; -import ListPropertiesEditing from '@ckeditor/ckeditor5-list/src/listpropertiesediting'; -import TodoListEditing from '@ckeditor/ckeditor5-list/src/todolistediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; +import ListPropertiesEditing from '@ckeditor/ckeditor5-list/src/listproperties/listpropertiesediting'; +import TodoListEditing from '@ckeditor/ckeditor5-list/src/todolist/todolistediting'; import MediaEmbedEditing from '@ckeditor/ckeditor5-media-embed/src/mediaembedediting'; @@ -387,9 +387,9 @@ describe( 'HtmlComment integration', () => { expect( editor.getData() ).to.equal( '' + '
' + - 'Example image' + '' + '' + + 'Example image' + '
' + '' + 'image caption' + diff --git a/packages/ckeditor5-image/src/image/converters.js b/packages/ckeditor5-image/src/image/converters.js index 79d9d9c20b8..d7d166941e7 100644 --- a/packages/ckeditor5-image/src/image/converters.js +++ b/packages/ckeditor5-image/src/image/converters.js @@ -235,13 +235,12 @@ export function downcastSourcesAttribute( imageUtils ) { if ( data.attributeNewValue && data.attributeNewValue.length ) { // Make sure does not break attribute elements, for instance in linked images. - const pictureElement = viewWriter.createContainerElement( 'picture', {}, { isAllowedInsideAttributeElement: true } ); - - for ( const sourceAttributes of data.attributeNewValue ) { - const sourceElement = viewWriter.createEmptyElement( 'source', sourceAttributes ); - - viewWriter.insert( viewWriter.createPositionAt( pictureElement, 'end' ), sourceElement ); - } + const pictureElement = viewWriter.createContainerElement( 'picture', null, + data.attributeNewValue.map( sourceAttributes => { + return viewWriter.createEmptyElement( 'source', sourceAttributes ); + } ), + { isAllowedInsideAttributeElement: true } + ); // Collect all wrapping attribute elements. const attributeElements = []; diff --git a/packages/ckeditor5-image/src/image/imageblockediting.js b/packages/ckeditor5-image/src/image/imageblockediting.js index 9ebf09a6317..c9ddef2774a 100644 --- a/packages/ckeditor5-image/src/image/imageblockediting.js +++ b/packages/ckeditor5-image/src/image/imageblockediting.js @@ -22,7 +22,7 @@ import ImageTypeCommand from './imagetypecommand'; import ImageUtils from '../imageutils'; import { getImgViewElementMatcher, - createImageViewElement, + createBlockImageViewElement, determineImageTypeForInsertionAtSelection } from '../image/utils'; @@ -90,16 +90,16 @@ export default class ImageBlockEditing extends Plugin { const imageUtils = editor.plugins.get( 'ImageUtils' ); conversion.for( 'dataDowncast' ) - .elementToElement( { + .elementToStructure( { model: 'imageBlock', - view: ( modelElement, { writer } ) => createImageViewElement( writer, 'imageBlock' ) + view: ( modelElement, { writer } ) => createBlockImageViewElement( writer ) } ); conversion.for( 'editingDowncast' ) - .elementToElement( { + .elementToStructure( { model: 'imageBlock', view: ( modelElement, { writer } ) => imageUtils.toImageWidget( - createImageViewElement( writer, 'imageBlock' ), writer, t( 'image widget' ) + createBlockImageViewElement( writer ), writer, t( 'image widget' ) ) } ); diff --git a/packages/ckeditor5-image/src/image/imageinlineediting.js b/packages/ckeditor5-image/src/image/imageinlineediting.js index 0e1019a1f11..cc78a21247a 100644 --- a/packages/ckeditor5-image/src/image/imageinlineediting.js +++ b/packages/ckeditor5-image/src/image/imageinlineediting.js @@ -21,7 +21,7 @@ import ImageTypeCommand from './imagetypecommand'; import ImageUtils from '../imageutils'; import { getImgViewElementMatcher, - createImageViewElement, + createInlineImageViewElement, determineImageTypeForInsertionAtSelection } from '../image/utils'; @@ -105,10 +105,10 @@ export default class ImageInlineEditing extends Plugin { } ); conversion.for( 'editingDowncast' ) - .elementToElement( { + .elementToStructure( { model: 'imageInline', view: ( modelElement, { writer } ) => imageUtils.toImageWidget( - createImageViewElement( writer, 'imageInline' ), writer, t( 'image widget' ) + createInlineImageViewElement( writer ), writer, t( 'image widget' ) ) } ); diff --git a/packages/ckeditor5-image/src/image/utils.js b/packages/ckeditor5-image/src/image/utils.js index 4bdc4ce2c61..12584bb1db9 100644 --- a/packages/ckeditor5-image/src/image/utils.js +++ b/packages/ckeditor5-image/src/image/utils.js @@ -10,33 +10,39 @@ import { first } from 'ckeditor5/src/utils'; /** - * Creates a view element representing the image of provided image type. + * Creates a view element representing the inline image. * - * An 'imageBlock' type (block image): + * * - *
+ * Note that `alt` and `src` attributes are converted separately, so they are not included. * - * An 'imageInline' type (inline image): + * @protected + * @param {module:engine/view/downcastwriter~DowncastWriter} writer + * @returns {module:engine/view/containerelement~ContainerElement} + */ +export function createInlineImageViewElement( writer ) { + return writer.createContainerElement( 'span', { class: 'image-inline' }, + writer.createEmptyElement( 'img' ), + { isAllowedInsideAttributeElement: true } + ); +} + +/** + * Creates a view element representing the block image. * - * + *
* * Note that `alt` and `src` attributes are converted separately, so they are not included. * * @protected * @param {module:engine/view/downcastwriter~DowncastWriter} writer - * @param {'imageBlock'|'imageInline'} imageType The type of created image. * @returns {module:engine/view/containerelement~ContainerElement} */ -export function createImageViewElement( writer, imageType ) { - const emptyElement = writer.createEmptyElement( 'img' ); - - const container = imageType === 'imageBlock' ? - writer.createContainerElement( 'figure', { class: 'image' } ) : - writer.createContainerElement( 'span', { class: 'image-inline' }, { isAllowedInsideAttributeElement: true } ); - - writer.insert( writer.createPositionAt( container, 0 ), emptyElement ); - - return container; +export function createBlockImageViewElement( writer ) { + return writer.createContainerElement( 'figure', { class: 'image' }, [ + writer.createEmptyElement( 'img' ), + writer.createSlot() + ] ); } /** diff --git a/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js b/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js index 72677571529..349d91bd305 100644 --- a/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js +++ b/packages/ckeditor5-image/src/imagecaption/imagecaptionediting.js @@ -134,9 +134,6 @@ export default class ImageCaptionEditing extends Plugin { return toWidgetEditable( figcaptionElement, writer ); } } ); - - editor.editing.mapper.on( 'modelToViewPosition', mapModelPositionToView( view ) ); - editor.data.mapper.on( 'modelToViewPosition', mapModelPositionToView( view ) ); } /** @@ -244,28 +241,3 @@ export default class ImageCaptionEditing extends Plugin { this._savedCaptionsMap.set( imageModelElement, caption.toJSON() ); } } - -// Creates a mapper callback that reverses the order of `` and `
` in the image. -// Without it, `
` would precede the `` in the conversion. -// -// ^ ->
^
-// -// @private -// @param {module:engine/view/view~View} editingView -// @returns {Function} -function mapModelPositionToView( editingView ) { - return ( evt, data ) => { - const modelPosition = data.modelPosition; - const parent = modelPosition.parent; - - if ( !parent.is( 'element', 'imageBlock' ) ) { - return; - } - - const viewElement = data.mapper.toViewElement( parent ); - - // The "img" element is inserted by ImageBlockEditing during the downcast conversion via - // an explicit view position so the "0" position does not need any mapping. - data.viewPosition = editingView.createPositionAt( viewElement, modelPosition.offset + 1 ); - }; -} diff --git a/packages/ckeditor5-image/tests/image/converters.js b/packages/ckeditor5-image/tests/image/converters.js index c7532e37ca7..ba57b52f559 100644 --- a/packages/ckeditor5-image/tests/image/converters.js +++ b/packages/ckeditor5-image/tests/image/converters.js @@ -8,7 +8,10 @@ import { upcastImageFigure, downcastImageAttribute } from '../../src/image/converters'; -import { createImageViewElement } from '../../src/image/utils'; +import { + createBlockImageViewElement, + createInlineImageViewElement +} from '../../src/image/utils'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; @@ -47,17 +50,17 @@ describe( 'Image converters', () => { } ); const imageEditingElementCreator = ( modelElement, { writer } ) => - imageUtils.toImageWidget( createImageViewElement( writer, 'imageBlock' ), writer, '' ); + imageUtils.toImageWidget( createBlockImageViewElement( writer ), writer, '' ); const imageInlineEditingElementCreator = ( modelElement, { writer } ) => - imageUtils.toImageWidget( createImageViewElement( writer, 'imageInline' ), writer, '' ); + imageUtils.toImageWidget( createInlineImageViewElement( writer ), writer, '' ); - editor.conversion.for( 'editingDowncast' ).elementToElement( { + editor.conversion.for( 'editingDowncast' ).elementToStructure( { model: 'imageBlock', view: imageEditingElementCreator } ); - editor.conversion.for( 'editingDowncast' ).elementToElement( { + editor.conversion.for( 'editingDowncast' ).elementToStructure( { model: 'imageInline', view: imageInlineEditingElementCreator } ); @@ -258,6 +261,7 @@ describe( 'Image converters', () => { describe( 'downcastImageAttribute', () => { it( 'should convert adding attribute to image', () => { setModelData( model, '' ); + const image = document.getRoot().getChild( 0 ); model.change( writer => { @@ -334,7 +338,7 @@ describe( 'Image converters', () => { } ); expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( - '
foo bar
' + '
foo bar
' ); } ); } ); diff --git a/packages/ckeditor5-image/tests/image/imageinlineediting.js b/packages/ckeditor5-image/tests/image/imageinlineediting.js index 7480d10323a..5afa15366b4 100644 --- a/packages/ckeditor5-image/tests/image/imageinlineediting.js +++ b/packages/ckeditor5-image/tests/image/imageinlineediting.js @@ -11,7 +11,7 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import DataTransfer from '@ckeditor/ckeditor5-clipboard/src/datatransfer'; import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml'; diff --git a/packages/ckeditor5-image/tests/image/utils.js b/packages/ckeditor5-image/tests/image/utils.js index 216aab3b09a..49914381b80 100644 --- a/packages/ckeditor5-image/tests/image/utils.js +++ b/packages/ckeditor5-image/tests/image/utils.js @@ -25,7 +25,8 @@ import ImageUtils from '../../src/imageutils'; import { getImgViewElementMatcher, - createImageViewElement, + createBlockImageViewElement, + createInlineImageViewElement, determineImageTypeForInsertionAtSelection } from '../../src/image/utils'; @@ -280,7 +281,7 @@ describe( 'image utils', () => { } ); } ); - describe( 'createImageViewElement()', () => { + describe( 'createBlockImageViewElement()', () => { let writer; beforeEach( () => { @@ -289,16 +290,30 @@ describe( 'image utils', () => { } ); it( 'should create a figure element for "image" type', () => { - const element = createImageViewElement( writer, 'imageBlock' ); + sinon.stub( writer, 'createSlot' ).callsFake( function createSlot() { + return writer.createEmptyElement( '$slot' ); + } ); + + const element = createBlockImageViewElement( writer ); expect( element.is( 'element', 'figure' ) ).to.be.true; expect( element.hasClass( 'image' ) ).to.be.true; - expect( element.childCount ).to.equal( 1 ); + expect( element.childCount ).to.equal( 2 ); expect( element.getChild( 0 ).is( 'emptyElement', 'img' ) ).to.be.true; + expect( element.getChild( 1 ).is( 'emptyElement', '$slot' ) ).to.be.true; + } ); + } ); + + describe( 'createInlineImageViewElement()', () => { + let writer; + + beforeEach( () => { + const document = new ViewDocument( new StylesProcessor() ); + writer = new ViewDowncastWriter( document ); } ); it( 'should create a span element for "imageInline" type', () => { - const element = createImageViewElement( writer, 'imageInline' ); + const element = createInlineImageViewElement( writer ); expect( element.is( 'element', 'span' ) ).to.be.true; expect( element.hasClass( 'image-inline' ) ).to.be.true; @@ -308,7 +323,7 @@ describe( 'image utils', () => { it( 'should create a span element for "imageInline" type that does not break the parent attribute element', () => { const paragraph = writer.createContainerElement( 'p' ); - const imageElement = createImageViewElement( writer, 'imageInline' ); + const imageElement = createInlineImageViewElement( writer ); const attributeElement = writer.createAttributeElement( 'a', { foo: 'bar' } ); writer.insert( writer.createPositionAt( paragraph, 0 ), imageElement ); diff --git a/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js b/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js index 07cf73d45ca..ca23852e867 100644 --- a/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js +++ b/packages/ckeditor5-image/tests/imagecaption/imagecaptionediting.js @@ -179,6 +179,12 @@ describe( 'ImageCaptionEditing', () => { } ); it( 'should not convert caption from other elements', () => { + editor.conversion.for( 'downcast' ).add( + dispatcher => dispatcher.on( 'insert:caption', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'lowest' } ) + ); + setModelData( model, 'foo bar
' ); expect( editor.getData() ).to.equal( 'foo bar' ); @@ -203,6 +209,12 @@ describe( 'ImageCaptionEditing', () => { } ); it( 'should not convert caption from other elements', () => { + editor.conversion.for( 'downcast' ).add( + dispatcher => dispatcher.on( 'insert:caption', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'lowest' } ) + ); + setModelData( model, 'foo bar' ); expect( getViewData( view, { withoutSelection: true } ) ).to.equal( 'foo bar' ); } ); diff --git a/packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js b/packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js index 208c55c3fba..4d4ac6a0b10 100644 --- a/packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js +++ b/packages/ckeditor5-image/tests/imageupload/imageuploadprogress.js @@ -125,9 +125,9 @@ describe( 'ImageUploadProgress', () => { model.document.registerPostFixer( () => { for ( const change of doc.differ.getChanges() ) { - // The differ.refreshItem() simulates remove and insert of and image parent thus preventing image from proper work. + // The editing.reconvertItem() simulates remove and insert of and image parent thus preventing image from proper work. if ( change.type == 'insert' && change.name == 'imageBlock' ) { - doc.differ.refreshItem( change.position.parent ); + editor.editing.reconvertItem( change.position.parent ); return false; // Refreshing item should not trigger calling post-fixer again. } diff --git a/packages/ckeditor5-indent/src/indent.js b/packages/ckeditor5-indent/src/indent.js index ab577dab937..a5b04d57e45 100644 --- a/packages/ckeditor5-indent/src/indent.js +++ b/packages/ckeditor5-indent/src/indent.js @@ -19,7 +19,7 @@ import IndentUI from './indentui'; * * The compatible features are: * - * * The {@link module:list/list~List} or {@link module:list/listediting~ListEditing} feature for list indentation. + * * The {@link module:list/list~List} or {@link module:list/list/listediting~ListEditing} feature for list indentation. * * The {@link module:indent/indentblock~IndentBlock} feature for block indentation. * * This is a "glue" plugin that loads the following plugins: diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index bdacf5673ee..2f5007e2908 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -1115,7 +1115,7 @@ describe( 'LinkUI', () => { allowAttributesOf: '$text' } ); - editor.conversion.for( 'downcast' ).elementToElement( { + editor.conversion.for( 'downcast' ).elementToStructure( { model: 'inlineWidget', view: ( modelItem, { writer } ) => { const spanView = writer.createContainerElement( 'span', {}, { diff --git a/packages/ckeditor5-list/docs/features/lists.md b/packages/ckeditor5-list/docs/features/lists.md index e5ba9c4be63..8c8ff5f4e4d 100644 --- a/packages/ckeditor5-list/docs/features/lists.md +++ b/packages/ckeditor5-list/docs/features/lists.md @@ -153,23 +153,23 @@ ClassicEditor - The {@link module:list/listproperties~ListProperties} feature overrides UI button implementations from the {@link module:list/listui~ListUI}. + The {@link module:list/listproperties~ListProperties} feature overrides UI button implementations from the {@link module:list/list/listui~ListUI}. ## Common API The {@link module:list/list~List} plugin registers: -* The {@link module:list/listcommand~ListCommand `'numberedList'`} command. -* The {@link module:list/listcommand~ListCommand `'bulletedList'`} command. -* The {@link module:list/indentcommand~IndentCommand `'indentList'`} command. -* The {@link module:list/indentcommand~IndentCommand `'outdentList'`} command. +* The {@link module:list/list/listcommand~ListCommand `'numberedList'`} command. +* The {@link module:list/list/listcommand~ListCommand `'bulletedList'`} command. +* The {@link module:list/list/indentcommand~IndentCommand `'indentList'`} command. +* The {@link module:list/list/indentcommand~IndentCommand `'outdentList'`} command. * The `'numberedList'` UI button. * The `'bulletedList'` UI button. The {@link module:list/listproperties~ListProperties} plugin registers: -* The {@link module:list/liststylecommand~ListStyleCommand `'listStyle'`} command that accepts the `type` of the list style to set. If not set, is uses the default marker (usually decimal). +* The {@link module:list/listproperties/liststylecommand~ListStyleCommand `listStyle`} command that accepts the `type` of the list style to set. If not set, is uses the default marker (usually decimal). ```js editor.execute( 'listStyle', { type: 'lower-roman' } ); ``` @@ -177,20 +177,20 @@ The {@link module:list/listproperties~ListProperties} plugin registers: * For bulleted lists: `'disc'`, `'circle'` and `'square'`. * For numbered lists: `'decimal'`, `'decimal-leading-zero'`, `'lower-roman'`, `'upper-roman'`, `'lower-latin'` and `'upper-latin'`. -* The {@link module:list/liststartcommand~ListStartCommand `'listStart'`} command which is a Boolean and defaults to `false` (meaning a list starts with `1`). If enabled, it accepts a numerical value for the `start` attribute. +* The {@link module:list/listproperties/liststartcommand~ListStartCommand `listStart`} command which is a Boolean and defaults to `false` (meaning a list starts with `1`). If enabled, it accepts a numerical value for the `start` attribute. ```js editor.execute( 'listStart', { startIndex: 3 } ); ``` -* The {@link module:list/listreversedcommand~ListReversedCommand `'listReversed'`} command which is a Boolean and defaults to `false` (meaning the list order is ascending). +* The {@link module:list/listproperties/listreversedcommand~ListReversedCommand `listReversed`} command which is a Boolean and defaults to `false` (meaning the list order is ascending). ```js editor.execute( 'listReversed', { reversed: 'true' } ); ``` -* The `'numberedList'` UI split button that overrides the UI button registered by the `List` plugin. -* The `'bulletedList'` UI split button that overrides the UI button registered by the `List` plugin. +* The `numberedList` UI split button that overrides the UI button registered by the `List` plugin. +* The `bulletedList` UI split button that overrides the UI button registered by the `List` plugin. ## Contribute diff --git a/packages/ckeditor5-list/src/index.js b/packages/ckeditor5-list/src/index.js index 8577bf0c14b..758c07a1814 100644 --- a/packages/ckeditor5-list/src/index.js +++ b/packages/ckeditor5-list/src/index.js @@ -8,11 +8,11 @@ */ export { default as List } from './list'; -export { default as ListEditing } from './listediting'; -export { default as ListUI } from './listui'; +export { default as ListEditing } from './list/listediting'; +export { default as ListUI } from './list/listui'; export { default as ListProperties } from './listproperties'; -export { default as ListPropertiesEditing } from './listpropertiesediting'; -export { default as ListPropertiesUI } from './listpropertiesui'; +export { default as ListPropertiesEditing } from './listproperties/listpropertiesediting'; +export { default as ListPropertiesUI } from './listproperties/listpropertiesui'; export { default as TodoList } from './todolist'; -export { default as TodoListEditing } from './todolistediting'; -export { default as TodoListUI } from './todolistui'; +export { default as TodoListEditing } from './todolist/todolistediting'; +export { default as TodoListUI } from './todolist/todolistui'; diff --git a/packages/ckeditor5-list/src/list.js b/packages/ckeditor5-list/src/list.js index 737f378905e..6165892d0cc 100644 --- a/packages/ckeditor5-list/src/list.js +++ b/packages/ckeditor5-list/src/list.js @@ -7,16 +7,16 @@ * @module list/list */ -import ListEditing from './listediting'; -import ListUI from './listui'; +import ListEditing from './list/listediting'; +import ListUI from './list/listui'; import { Plugin } from 'ckeditor5/src/core'; /** * The list feature. * - * This is a "glue" plugin that loads the {@link module:list/listediting~ListEditing list editing feature} - * and {@link module:list/listui~ListUI list UI feature}. + * This is a "glue" plugin that loads the {@link module:list/list/listediting~ListEditing list editing feature} + * and {@link module:list/list/listui~ListUI list UI feature}. * * @extends module:core/plugin~Plugin */ diff --git a/packages/ckeditor5-list/src/converters.js b/packages/ckeditor5-list/src/list/converters.js similarity index 98% rename from packages/ckeditor5-list/src/converters.js rename to packages/ckeditor5-list/src/list/converters.js index ff8d00a46f7..d7b35cfd848 100644 --- a/packages/ckeditor5-list/src/converters.js +++ b/packages/ckeditor5-list/src/list/converters.js @@ -4,7 +4,7 @@ */ /** - * @module list/converters + * @module list/list/converters */ import { TreeWalker } from 'ckeditor5/src/engine'; @@ -97,11 +97,11 @@ export function modelViewRemove( model ) { * A model-to-view converter for the `type` attribute change on the `listItem` model element. * * This change means that the `
  • ` element parent changes from `
      ` to `
        ` (or vice versa). This is accomplished - * by breaking view elements and changing their name. The next {@link module:list/converters~modelViewMergeAfterChangeType} + * by breaking view elements and changing their name. The next {@link module:list/list/converters~modelViewMergeAfterChangeType} * converter will attempt to merge split nodes. * * Splitting this conversion into 2 steps makes it possible to add an additional conversion in the middle. - * Check {@link module:list/todolistconverters~modelViewChangeType} to see an example of it. + * Check {@link module:list/todolist/todolistconverters~modelViewChangeType} to see an example of it. * * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event. @@ -109,7 +109,7 @@ export function modelViewRemove( model ) { * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface. */ export function modelViewChangeType( evt, data, conversionApi ) { - if ( !conversionApi.consumable.consume( data.item, 'attribute:listType' ) ) { + if ( !conversionApi.consumable.test( data.item, evt.name ) ) { return; } @@ -130,7 +130,7 @@ export function modelViewChangeType( evt, data, conversionApi ) { } /** - * A model-to-view converter that attempts to merge nodes split by {@link module:list/converters~modelViewChangeType}. + * A model-to-view converter that attempts to merge nodes split by {@link module:list/list/converters~modelViewChangeType}. * * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event. @@ -138,6 +138,8 @@ export function modelViewChangeType( evt, data, conversionApi ) { * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface. */ export function modelViewMergeAfterChangeType( evt, data, conversionApi ) { + conversionApi.consumable.consume( data.item, evt.name ); + const viewItem = conversionApi.mapper.toViewElement( data.item ); const viewList = viewItem.parent; const viewWriter = conversionApi.writer; @@ -145,11 +147,6 @@ export function modelViewMergeAfterChangeType( evt, data, conversionApi ) { // Merge the changed view list with other lists, if possible. mergeViewLists( viewWriter, viewList, viewList.nextSibling ); mergeViewLists( viewWriter, viewList.previousSibling, viewList ); - - // Consumable insertion of children inside the item. They are already handled by re-building the item in view. - for ( const child of data.item.getChildren() ) { - conversionApi.consumable.consume( child, 'insert' ); - } } /** diff --git a/packages/ckeditor5-list/src/indentcommand.js b/packages/ckeditor5-list/src/list/indentcommand.js similarity index 99% rename from packages/ckeditor5-list/src/indentcommand.js rename to packages/ckeditor5-list/src/list/indentcommand.js index 49a7578a3d5..df18729c30d 100644 --- a/packages/ckeditor5-list/src/indentcommand.js +++ b/packages/ckeditor5-list/src/list/indentcommand.js @@ -4,7 +4,7 @@ */ /** - * @module list/indentcommand + * @module list/list/indentcommand */ import { Command } from 'ckeditor5/src/core'; diff --git a/packages/ckeditor5-list/src/listcommand.js b/packages/ckeditor5-list/src/list/listcommand.js similarity index 99% rename from packages/ckeditor5-list/src/listcommand.js rename to packages/ckeditor5-list/src/list/listcommand.js index 49a9bf0b12e..58bea6e3cfb 100644 --- a/packages/ckeditor5-list/src/listcommand.js +++ b/packages/ckeditor5-list/src/list/listcommand.js @@ -4,7 +4,7 @@ */ /** - * @module list/listcommand + * @module list/list/listcommand */ import { Command } from 'ckeditor5/src/core'; diff --git a/packages/ckeditor5-list/src/listediting.js b/packages/ckeditor5-list/src/list/listediting.js similarity index 99% rename from packages/ckeditor5-list/src/listediting.js rename to packages/ckeditor5-list/src/list/listediting.js index f072633201b..2d9d791858a 100644 --- a/packages/ckeditor5-list/src/listediting.js +++ b/packages/ckeditor5-list/src/list/listediting.js @@ -4,7 +4,7 @@ */ /** - * @module list/listediting + * @module list/list/listediting */ import ListCommand from './listcommand'; diff --git a/packages/ckeditor5-list/src/listui.js b/packages/ckeditor5-list/src/list/listui.js similarity index 85% rename from packages/ckeditor5-list/src/listui.js rename to packages/ckeditor5-list/src/list/listui.js index 716e782ad22..8091ed659d3 100644 --- a/packages/ckeditor5-list/src/listui.js +++ b/packages/ckeditor5-list/src/list/listui.js @@ -4,13 +4,13 @@ */ /** - * @module list/listui + * @module list/list/listui */ import { createUIComponent } from './utils'; -import numberedListIcon from '../theme/icons/numberedlist.svg'; -import bulletedListIcon from '../theme/icons/bulletedlist.svg'; +import numberedListIcon from '../../theme/icons/numberedlist.svg'; +import bulletedListIcon from '../../theme/icons/bulletedlist.svg'; import { Plugin } from 'ckeditor5/src/core'; diff --git a/packages/ckeditor5-list/src/utils.js b/packages/ckeditor5-list/src/list/utils.js similarity index 99% rename from packages/ckeditor5-list/src/utils.js rename to packages/ckeditor5-list/src/list/utils.js index b3850be5395..436e4847c20 100644 --- a/packages/ckeditor5-list/src/utils.js +++ b/packages/ckeditor5-list/src/list/utils.js @@ -4,7 +4,7 @@ */ /** - * @module list/utils + * @module list/list/utils */ import { TreeWalker, getFillerOffset } from 'ckeditor5/src/engine'; diff --git a/packages/ckeditor5-list/src/listproperties.js b/packages/ckeditor5-list/src/listproperties.js index 347a9e0174a..cd074e8fd0c 100644 --- a/packages/ckeditor5-list/src/listproperties.js +++ b/packages/ckeditor5-list/src/listproperties.js @@ -8,14 +8,14 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import ListPropertiesEditing from './listpropertiesediting'; -import ListPropertiesUI from './listpropertiesui'; +import ListPropertiesEditing from './listproperties/listpropertiesediting'; +import ListPropertiesUI from './listproperties/listpropertiesui'; /** * The list properties feature. * - * This is a "glue" plugin that loads the {@link module:list/listpropertiesediting~ListPropertiesEditing list properties editing feature} - * and the {@link module:list/listpropertiesui~ListPropertiesUI list properties UI feature}. + * This is a "glue" plugin that loads the {@link module:list/listproperties/listpropertiesediting~ListPropertiesEditing list properties + * editing feature} and the {@link module:list/listproperties/listpropertiesui~ListPropertiesUI list properties UI feature}. * * @extends module:core/plugin~Plugin */ @@ -39,10 +39,10 @@ export default class ListProperties extends Plugin { * The configuration of the {@link module:list/listproperties~ListProperties list properties} feature. * * This configuration controls the individual list properties. For instance, it enables or disables specific editor commands - * operating on lists ({@link module:list/liststylecommand~ListStyleCommand `'listStyle'`}, - * {@link module:list/liststartcommand~ListStartCommand `'listStart'`}, - * {@link module:list/listreversedcommand~ListReversedCommand `'listReversed'`}), the look of the UI - * (`'numberedList'` and `'bulletedList'` dropdowns), and the editor data pipeline (allowed HTML attributes). + * operating on lists ({@link module:list/listproperties/liststylecommand~ListStyleCommand `listStyle`}, + * {@link module:list/listproperties/liststartcommand~ListStartCommand `listStart`}, + * {@link module:list/listproperties/listreversedcommand~ListReversedCommand `listReversed`}), the look of the UI + * (`numberedList` and `bulletedList` dropdowns), and the editor data pipeline (allowed HTML attributes). * * ClassicEditor * .create( editorElement, { diff --git a/packages/ckeditor5-list/src/listpropertiesediting.js b/packages/ckeditor5-list/src/listproperties/listpropertiesediting.js similarity index 98% rename from packages/ckeditor5-list/src/listpropertiesediting.js rename to packages/ckeditor5-list/src/listproperties/listpropertiesediting.js index f6edef21d24..5c2982b9502 100644 --- a/packages/ckeditor5-list/src/listpropertiesediting.js +++ b/packages/ckeditor5-list/src/listproperties/listpropertiesediting.js @@ -4,15 +4,15 @@ */ /** - * @module list/listpropertiesediting + * @module list/listproperties/listpropertiesediting */ import { Plugin } from 'ckeditor5/src/core'; -import ListEditing from './listediting'; +import ListEditing from '../list/listediting'; import ListStyleCommand from './liststylecommand'; import ListReversedCommand from './listreversedcommand'; import ListStartCommand from './liststartcommand'; -import { getSiblingListItem, getSiblingNodes } from './utils'; +import { getSiblingListItem, getSiblingNodes } from '../list/utils'; const DEFAULT_LIST_TYPE = 'default'; @@ -133,7 +133,8 @@ export default class ListPropertiesEditing extends Plugin { * See https://github.com/ckeditor/ckeditor5/issues/7879. * * @private - * @param {Array.} attributeStrategies Strategies for the enabled attributes. + * @param {Array.} attributeStrategies Strategies for the + * enabled attributes. */ _mergeListAttributesWhileMergingLists( attributeStrategies ) { const editor = this.editor; diff --git a/packages/ckeditor5-list/src/listpropertiesui.js b/packages/ckeditor5-list/src/listproperties/listpropertiesui.js similarity index 91% rename from packages/ckeditor5-list/src/listpropertiesui.js rename to packages/ckeditor5-list/src/listproperties/listpropertiesui.js index 0decea62bcf..67e3a17e1ca 100644 --- a/packages/ckeditor5-list/src/listpropertiesui.js +++ b/packages/ckeditor5-list/src/listproperties/listpropertiesui.js @@ -4,7 +4,7 @@ */ /** - * @module list/listpropertiesui + * @module list/listproperties/listpropertiesui */ import { Plugin } from 'ckeditor5/src/core'; @@ -12,26 +12,26 @@ import { ButtonView, SplitButtonView, createDropdown } from 'ckeditor5/src/ui'; import ListPropertiesView from './ui/listpropertiesview'; -import bulletedListIcon from '../theme/icons/bulletedlist.svg'; -import numberedListIcon from '../theme/icons/numberedlist.svg'; +import bulletedListIcon from '../../theme/icons/bulletedlist.svg'; +import numberedListIcon from '../../theme/icons/numberedlist.svg'; -import listStyleDiscIcon from '../theme/icons/liststyledisc.svg'; -import listStyleCircleIcon from '../theme/icons/liststylecircle.svg'; -import listStyleSquareIcon from '../theme/icons/liststylesquare.svg'; -import listStyleDecimalIcon from '../theme/icons/liststyledecimal.svg'; -import listStyleDecimalWithLeadingZeroIcon from '../theme/icons/liststyledecimalleadingzero.svg'; -import listStyleLowerRomanIcon from '../theme/icons/liststylelowerroman.svg'; -import listStyleUpperRomanIcon from '../theme/icons/liststyleupperroman.svg'; -import listStyleLowerLatinIcon from '../theme/icons/liststylelowerlatin.svg'; -import listStyleUpperLatinIcon from '../theme/icons/liststyleupperlatin.svg'; +import listStyleDiscIcon from '../../theme/icons/liststyledisc.svg'; +import listStyleCircleIcon from '../../theme/icons/liststylecircle.svg'; +import listStyleSquareIcon from '../../theme/icons/liststylesquare.svg'; +import listStyleDecimalIcon from '../../theme/icons/liststyledecimal.svg'; +import listStyleDecimalWithLeadingZeroIcon from '../../theme/icons/liststyledecimalleadingzero.svg'; +import listStyleLowerRomanIcon from '../../theme/icons/liststylelowerroman.svg'; +import listStyleUpperRomanIcon from '../../theme/icons/liststyleupperroman.svg'; +import listStyleLowerLatinIcon from '../../theme/icons/liststylelowerlatin.svg'; +import listStyleUpperLatinIcon from '../../theme/icons/liststyleupperlatin.svg'; -import '../theme/liststyles.css'; +import '../../theme/liststyles.css'; /** * The list properties UI plugin. It introduces the extended `'bulletedList'` and `'numberedList'` toolbar * buttons that allow users to control such aspects of list as the marker, start index or order. * - * **Note**: Buttons introduced by this plugin override implementations from the {@link module:list/listui~ListUI} + * **Note**: Buttons introduced by this plugin override implementations from the {@link module:list/list/listui~ListUI} * (because they share the same names). * * @extends module:core/plugin~Plugin diff --git a/packages/ckeditor5-list/src/listreversedcommand.js b/packages/ckeditor5-list/src/listproperties/listreversedcommand.js similarity index 93% rename from packages/ckeditor5-list/src/listreversedcommand.js rename to packages/ckeditor5-list/src/listproperties/listreversedcommand.js index 511b50f1dad..080b9398caa 100644 --- a/packages/ckeditor5-list/src/listreversedcommand.js +++ b/packages/ckeditor5-list/src/listproperties/listreversedcommand.js @@ -4,11 +4,11 @@ */ /** - * @module list/listreversedcommand + * @module list/listproperties/listreversedcommand */ import { Command } from 'ckeditor5/src/core'; -import { getSelectedListItems } from './utils'; +import { getSelectedListItems } from '../list/utils'; /** * The reversed list command. It changes the `listReversed` attribute of the selected list items. As a result, the list order will be diff --git a/packages/ckeditor5-list/src/liststartcommand.js b/packages/ckeditor5-list/src/listproperties/liststartcommand.js similarity index 93% rename from packages/ckeditor5-list/src/liststartcommand.js rename to packages/ckeditor5-list/src/listproperties/liststartcommand.js index 5d11f25a44c..ec025c6a823 100644 --- a/packages/ckeditor5-list/src/liststartcommand.js +++ b/packages/ckeditor5-list/src/listproperties/liststartcommand.js @@ -4,11 +4,11 @@ */ /** - * @module list/liststartcommand + * @module list/listproperties/liststartcommand */ import { Command } from 'ckeditor5/src/core'; -import { getSelectedListItems } from './utils'; +import { getSelectedListItems } from '../list/utils'; /** * The list start index command. It changes the `listStart` attribute of the selected list items. diff --git a/packages/ckeditor5-list/src/liststylecommand.js b/packages/ckeditor5-list/src/listproperties/liststylecommand.js similarity index 98% rename from packages/ckeditor5-list/src/liststylecommand.js rename to packages/ckeditor5-list/src/listproperties/liststylecommand.js index 483fd212ffc..dae224ff1bb 100644 --- a/packages/ckeditor5-list/src/liststylecommand.js +++ b/packages/ckeditor5-list/src/listproperties/liststylecommand.js @@ -4,11 +4,11 @@ */ /** - * @module list/liststylecommand + * @module list/listproperties/liststylecommand */ import { Command } from 'ckeditor5/src/core'; -import { getListTypeFromListStyleType, getSelectedListItems } from './utils'; +import { getListTypeFromListStyleType, getSelectedListItems } from '../list/utils'; /** * The list style command. It changes the `listStyle` attribute of the selected list items. diff --git a/packages/ckeditor5-list/src/ui/collapsibleview.js b/packages/ckeditor5-list/src/listproperties/ui/collapsibleview.js similarity index 98% rename from packages/ckeditor5-list/src/ui/collapsibleview.js rename to packages/ckeditor5-list/src/listproperties/ui/collapsibleview.js index c321ba61f3b..936fa09ed58 100644 --- a/packages/ckeditor5-list/src/ui/collapsibleview.js +++ b/packages/ckeditor5-list/src/listproperties/ui/collapsibleview.js @@ -12,7 +12,7 @@ import { View, ButtonView } from 'ckeditor5/src/ui'; // eslint-disable-next-line ckeditor5-rules/ckeditor-imports import dropdownArrowIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg'; -import '../../theme/collapsible.css'; +import '../../../theme/collapsible.css'; /** * A collapsible UI component. Consists of a labeled button and a container which can be collapsed diff --git a/packages/ckeditor5-list/src/listproperties/ui/inputnumberview.js b/packages/ckeditor5-list/src/listproperties/ui/inputnumberview.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-list/src/ui/listpropertiesview.js b/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.js similarity index 99% rename from packages/ckeditor5-list/src/ui/listpropertiesview.js rename to packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.js index e682538cf00..d2339ed5a71 100644 --- a/packages/ckeditor5-list/src/ui/listpropertiesview.js +++ b/packages/ckeditor5-list/src/listproperties/ui/listpropertiesview.js @@ -22,7 +22,7 @@ import { import CollapsibleView from './collapsibleview'; -import '../../theme/listproperties.css'; +import '../../../theme/listproperties.css'; /** * The list properties view to be displayed in the list dropdown. diff --git a/packages/ckeditor5-list/src/todolist.js b/packages/ckeditor5-list/src/todolist.js index 4056d145220..64a61f657cc 100644 --- a/packages/ckeditor5-list/src/todolist.js +++ b/packages/ckeditor5-list/src/todolist.js @@ -7,16 +7,16 @@ * @module list/todolist */ -import TodoListEditing from './todolistediting'; -import TodoListUI from './todolistui'; +import TodoListEditing from './todolist/todolistediting'; +import TodoListUI from './todolist/todolistui'; import { Plugin } from 'ckeditor5/src/core'; import '../theme/todolist.css'; /** * The to-do list feature. * - * This is a "glue" plugin that loads the {@link module:list/todolistediting~TodoListEditing to-do list editing feature} - * and the {@link module:list/todolistui~TodoListUI to-do list UI feature}. + * This is a "glue" plugin that loads the {@link module:list/todolist/todolistediting~TodoListEditing to-do list editing feature} + * and the {@link module:list/todolist/todolistui~TodoListUI to-do list UI feature}. * * @extends module:core/plugin~Plugin */ diff --git a/packages/ckeditor5-list/src/checktodolistcommand.js b/packages/ckeditor5-list/src/todolist/checktodolistcommand.js similarity index 95% rename from packages/ckeditor5-list/src/checktodolistcommand.js rename to packages/ckeditor5-list/src/todolist/checktodolistcommand.js index 2516bcabc86..2f44a83d3a3 100644 --- a/packages/ckeditor5-list/src/checktodolistcommand.js +++ b/packages/ckeditor5-list/src/todolist/checktodolistcommand.js @@ -4,7 +4,7 @@ */ /** - * @module list/checktodolistcommand + * @module list/todolist/checktodolistcommand */ import { Command } from 'ckeditor5/src/core'; @@ -14,7 +14,7 @@ const attributeKey = 'todoListChecked'; /** * The check to-do command. * - * The command is registered by the {@link module:list/todolistediting~TodoListEditing} as + * The command is registered by the {@link module:list/todolist/todolistediting~TodoListEditing} as * the `checkTodoList` editor command and it is also available via aliased `todoListCheck` name. * * @extends module:core/command~Command diff --git a/packages/ckeditor5-list/src/todolistconverters.js b/packages/ckeditor5-list/src/todolist/todolistconverters.js similarity index 96% rename from packages/ckeditor5-list/src/todolistconverters.js rename to packages/ckeditor5-list/src/todolist/todolistconverters.js index cdb8c48d4f9..024d1511bf0 100644 --- a/packages/ckeditor5-list/src/todolistconverters.js +++ b/packages/ckeditor5-list/src/todolist/todolistconverters.js @@ -4,14 +4,14 @@ */ /** - * @module list/todolistconverters + * @module list/todolist/todolistconverters */ /* global document */ import { createElement } from 'ckeditor5/src/utils'; -import { generateLiInUl, injectViewList, positionAfterUiElements, findNestedList } from './utils'; +import { generateLiInUl, injectViewList, positionAfterUiElements, findNestedList } from '../list/utils'; /** * A model-to-view converter for the `listItem` model element insertion. @@ -171,8 +171,8 @@ export function dataViewModelCheckmarkInsertion( evt, data, conversionApi ) { * {@link module:engine/view/uielement~UIElement checkbox UI element} is added at the beginning * of the list item element (or vice versa). * - * This converter is preceded by {@link module:list/converters~modelViewChangeType} and followed by - * {@link module:list/converters~modelViewMergeAfterChangeType} to handle splitting and merging surrounding lists of the same type. + * This converter is preceded by {@link module:list/list/converters~modelViewChangeType} and followed by + * {@link module:list/list/converters~modelViewMergeAfterChangeType} to handle splitting and merging surrounding lists of the same type. * * It is used by {@link module:engine/controller/editingcontroller~EditingController}. * @@ -183,6 +183,10 @@ export function dataViewModelCheckmarkInsertion( evt, data, conversionApi ) { */ export function modelViewChangeType( onCheckedChange, view ) { return ( evt, data, conversionApi ) => { + if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { + return; + } + const viewItem = conversionApi.mapper.toViewElement( data.item ); const viewWriter = conversionApi.writer; diff --git a/packages/ckeditor5-list/src/todolistediting.js b/packages/ckeditor5-list/src/todolist/todolistediting.js similarity index 97% rename from packages/ckeditor5-list/src/todolistediting.js rename to packages/ckeditor5-list/src/todolist/todolistediting.js index d88697b825f..675991421e1 100644 --- a/packages/ckeditor5-list/src/todolistediting.js +++ b/packages/ckeditor5-list/src/todolist/todolistediting.js @@ -4,7 +4,7 @@ */ /** - * @module list/todolistediting + * @module list/todolist/todolistediting */ import { Plugin } from 'ckeditor5/src/core'; @@ -14,8 +14,8 @@ import { getLocalizedArrowKeyCodeDirection } from 'ckeditor5/src/utils'; -import ListCommand from './listcommand'; -import ListEditing from './listediting'; +import ListCommand from '../list/listcommand'; +import ListEditing from '../list/listediting'; import CheckTodoListCommand from './checktodolistcommand'; import { dataModelViewInsertion, @@ -31,7 +31,7 @@ const ITEM_TOGGLE_KEYSTROKE = parseKeystroke( 'Ctrl+Enter' ); /** * The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items. * - * It registers the entire functionality of the {@link module:list/listediting~ListEditing list editing plugin} and extends + * It registers the entire functionality of the {@link module:list/list/listediting~ListEditing list editing plugin} and extends * it with the commands: * * - `'todoList'`, diff --git a/packages/ckeditor5-list/src/todolistui.js b/packages/ckeditor5-list/src/todolist/todolistui.js similarity index 83% rename from packages/ckeditor5-list/src/todolistui.js rename to packages/ckeditor5-list/src/todolist/todolistui.js index 03eeea344e3..cdd6a264a6e 100644 --- a/packages/ckeditor5-list/src/todolistui.js +++ b/packages/ckeditor5-list/src/todolist/todolistui.js @@ -4,11 +4,11 @@ */ /** - * @module list/todolistui + * @module list/todolist/todolistui */ -import { createUIComponent } from './utils'; -import todoListIcon from '../theme/icons/todolist.svg'; +import { createUIComponent } from '../list/utils'; +import todoListIcon from '../../theme/icons/todolist.svg'; import { Plugin } from 'ckeditor5/src/core'; /** diff --git a/packages/ckeditor5-list/tests/list.js b/packages/ckeditor5-list/tests/list.js index a0a706fb77f..b8bd4c59169 100644 --- a/packages/ckeditor5-list/tests/list.js +++ b/packages/ckeditor5-list/tests/list.js @@ -4,8 +4,8 @@ */ import List from '../src/list'; -import ListEditing from '../src/listediting'; -import ListUI from '../src/listui'; +import ListEditing from '../src/list/listediting'; +import ListUI from '../src/list/listui'; describe( 'List', () => { it( 'should be named', () => { diff --git a/packages/ckeditor5-list/tests/indentcommand.js b/packages/ckeditor5-list/tests/list/indentcommand.js similarity index 99% rename from packages/ckeditor5-list/tests/indentcommand.js rename to packages/ckeditor5-list/tests/list/indentcommand.js index afb3b2daf58..ff409c208c2 100644 --- a/packages/ckeditor5-list/tests/indentcommand.js +++ b/packages/ckeditor5-list/tests/list/indentcommand.js @@ -5,7 +5,7 @@ import Editor from '@ckeditor/ckeditor5-core/src/editor/editor'; import Model from '@ckeditor/ckeditor5-engine/src/model/model'; -import IndentCommand from '../src/indentcommand'; +import IndentCommand from '../../src/list/indentcommand'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; describe( 'IndentCommand', () => { diff --git a/packages/ckeditor5-list/tests/listcommand.js b/packages/ckeditor5-list/tests/list/listcommand.js similarity index 99% rename from packages/ckeditor5-list/tests/listcommand.js rename to packages/ckeditor5-list/tests/list/listcommand.js index 80a2e79453d..4a80955678a 100644 --- a/packages/ckeditor5-list/tests/listcommand.js +++ b/packages/ckeditor5-list/tests/list/listcommand.js @@ -5,7 +5,7 @@ import Editor from '@ckeditor/ckeditor5-core/src/editor/editor'; import Model from '@ckeditor/ckeditor5-engine/src/model/model'; -import ListCommand from '../src/listcommand'; +import ListCommand from '../../src/list/listcommand'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; describe( 'ListCommand', () => { diff --git a/packages/ckeditor5-list/tests/listediting.js b/packages/ckeditor5-list/tests/list/listediting.js similarity index 99% rename from packages/ckeditor5-list/tests/listediting.js rename to packages/ckeditor5-list/tests/list/listediting.js index f72b0da64ab..48e091d54c7 100644 --- a/packages/ckeditor5-list/tests/listediting.js +++ b/packages/ckeditor5-list/tests/list/listediting.js @@ -3,9 +3,9 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import ListEditing from '../src/listediting'; -import ListCommand from '../src/listcommand'; -import IndentCommand from '../src/indentcommand'; +import ListEditing from '../../src/list/listediting'; +import ListCommand from '../../src/list/listcommand'; +import IndentCommand from '../../src/list/indentcommand'; import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; diff --git a/packages/ckeditor5-list/tests/listui.js b/packages/ckeditor5-list/tests/list/listui.js similarity index 96% rename from packages/ckeditor5-list/tests/listui.js rename to packages/ckeditor5-list/tests/list/listui.js index ee4649bd584..4ec4d2eb0c4 100644 --- a/packages/ckeditor5-list/tests/listui.js +++ b/packages/ckeditor5-list/tests/list/listui.js @@ -5,8 +5,8 @@ /* globals document */ -import ListEditing from '../src/listediting'; -import ListUI from '../src/listui'; +import ListEditing from '../../src/list/listediting'; +import ListUI from '../../src/list/listui'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; diff --git a/packages/ckeditor5-list/tests/utils.js b/packages/ckeditor5-list/tests/list/utils.js similarity index 98% rename from packages/ckeditor5-list/tests/utils.js rename to packages/ckeditor5-list/tests/list/utils.js index e28be9124c1..6601eafce4d 100644 --- a/packages/ckeditor5-list/tests/utils.js +++ b/packages/ckeditor5-list/tests/list/utils.js @@ -8,10 +8,10 @@ import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwrit import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -import ListEditing from '../src/listediting'; -import ListPropertiesEditing from '../src/listpropertiesediting'; +import ListEditing from '../../src/list/listediting'; +import ListPropertiesEditing from '../../src/listproperties/listpropertiesediting'; -import { createViewListItemElement, getListTypeFromListStyleType, getSiblingListItem, getSiblingNodes } from '../src/utils'; +import { createViewListItemElement, getListTypeFromListStyleType, getSiblingListItem, getSiblingNodes } from '../../src/list/utils'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; describe( 'utils', () => { diff --git a/packages/ckeditor5-list/tests/listproperties.js b/packages/ckeditor5-list/tests/listproperties.js index b8833704a50..68cf932f7ce 100644 --- a/packages/ckeditor5-list/tests/listproperties.js +++ b/packages/ckeditor5-list/tests/listproperties.js @@ -4,8 +4,8 @@ */ import ListProperties from '../src/listproperties'; -import ListPropertiesEditing from '../src/listpropertiesediting'; -import ListPropertiesUI from '../src/listpropertiesui'; +import ListPropertiesEditing from '../src/listproperties/listpropertiesediting'; +import ListPropertiesUI from '../src/listproperties/listpropertiesui'; describe( 'ListProperties', () => { it( 'should be named', () => { diff --git a/packages/ckeditor5-list/tests/listpropertiesediting.js b/packages/ckeditor5-list/tests/listproperties/listpropertiesediting.js similarity index 99% rename from packages/ckeditor5-list/tests/listpropertiesediting.js rename to packages/ckeditor5-list/tests/listproperties/listpropertiesediting.js index 6a8654044b7..a3a6b247fd3 100644 --- a/packages/ckeditor5-list/tests/listpropertiesediting.js +++ b/packages/ckeditor5-list/tests/listproperties/listpropertiesediting.js @@ -14,11 +14,11 @@ import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; -import ListPropertiesEditing from '../src/listpropertiesediting'; -import TodoListEditing from '../src/todolistediting'; -import ListStyleCommand from '../src/liststylecommand'; -import ListReversedCommand from '../src/listreversedcommand'; -import ListStartCommand from '../src/liststartcommand'; +import ListPropertiesEditing from '../../src/listproperties/listpropertiesediting'; +import TodoListEditing from '../../src/todolist/todolistediting'; +import ListStyleCommand from '../../src/listproperties/liststylecommand'; +import ListReversedCommand from '../../src/listproperties/listreversedcommand'; +import ListStartCommand from '../../src/listproperties/liststartcommand'; import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor'; describe( 'ListPropertiesEditing', () => { diff --git a/packages/ckeditor5-list/tests/listpropertiesui.js b/packages/ckeditor5-list/tests/listproperties/listpropertiesui.js similarity index 96% rename from packages/ckeditor5-list/tests/listpropertiesui.js rename to packages/ckeditor5-list/tests/listproperties/listpropertiesui.js index 30375dcb60c..2ec929ce1d1 100644 --- a/packages/ckeditor5-list/tests/listpropertiesui.js +++ b/packages/ckeditor5-list/tests/listproperties/listpropertiesui.js @@ -5,8 +5,8 @@ /* globals document */ -import ListProperties from '../src/listproperties'; -import ListPropertiesUI from '../src/listpropertiesui'; +import ListProperties from '../../src/listproperties'; +import ListPropertiesUI from '../../src/listproperties/listpropertiesui'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; @@ -14,17 +14,17 @@ import { UndoEditing } from '@ckeditor/ckeditor5-undo'; import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview'; import { View, ButtonView, LabeledFieldView, SwitchButtonView } from '@ckeditor/ckeditor5-ui'; -import bulletedListIcon from '../theme/icons/bulletedlist.svg'; -import numberedListIcon from '../theme/icons/numberedlist.svg'; -import listStyleDiscIcon from '../theme/icons/liststyledisc.svg'; -import listStyleCircleIcon from '../theme/icons/liststylecircle.svg'; -import listStyleSquareIcon from '../theme/icons/liststylesquare.svg'; -import listStyleDecimalIcon from '../theme/icons/liststyledecimal.svg'; -import listStyleDecimalWithLeadingZeroIcon from '../theme/icons/liststyledecimalleadingzero.svg'; -import listStyleLowerRomanIcon from '../theme/icons/liststylelowerroman.svg'; -import listStyleUpperRomanIcon from '../theme/icons/liststyleupperroman.svg'; -import listStyleLowerLatinIcon from '../theme/icons/liststylelowerlatin.svg'; -import listStyleUpperLatinIcon from '../theme/icons/liststyleupperlatin.svg'; +import bulletedListIcon from '../../theme/icons/bulletedlist.svg'; +import numberedListIcon from '../../theme/icons/numberedlist.svg'; +import listStyleDiscIcon from '../../theme/icons/liststyledisc.svg'; +import listStyleCircleIcon from '../../theme/icons/liststylecircle.svg'; +import listStyleSquareIcon from '../../theme/icons/liststylesquare.svg'; +import listStyleDecimalIcon from '../../theme/icons/liststyledecimal.svg'; +import listStyleDecimalWithLeadingZeroIcon from '../../theme/icons/liststyledecimalleadingzero.svg'; +import listStyleLowerRomanIcon from '../../theme/icons/liststylelowerroman.svg'; +import listStyleUpperRomanIcon from '../../theme/icons/liststyleupperroman.svg'; +import listStyleLowerLatinIcon from '../../theme/icons/liststylelowerlatin.svg'; +import listStyleUpperLatinIcon from '../../theme/icons/liststyleupperlatin.svg'; import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; diff --git a/packages/ckeditor5-list/tests/listreversedcommand.js b/packages/ckeditor5-list/tests/listproperties/listreversedcommand.js similarity index 99% rename from packages/ckeditor5-list/tests/listreversedcommand.js rename to packages/ckeditor5-list/tests/listproperties/listreversedcommand.js index 2b339031dac..65760182527 100644 --- a/packages/ckeditor5-list/tests/listreversedcommand.js +++ b/packages/ckeditor5-list/tests/listproperties/listreversedcommand.js @@ -6,7 +6,7 @@ import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import ListPropertiesEditing from '../src/listpropertiesediting'; +import ListPropertiesEditing from '../../src/listproperties/listpropertiesediting'; describe( 'ListReversedCommand', () => { let editor, model, listReversedCommand; diff --git a/packages/ckeditor5-list/tests/liststartcommand.js b/packages/ckeditor5-list/tests/listproperties/liststartcommand.js similarity index 99% rename from packages/ckeditor5-list/tests/liststartcommand.js rename to packages/ckeditor5-list/tests/listproperties/liststartcommand.js index 0132cfc693f..976d77befff 100644 --- a/packages/ckeditor5-list/tests/liststartcommand.js +++ b/packages/ckeditor5-list/tests/listproperties/liststartcommand.js @@ -6,7 +6,7 @@ import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import ListPropertiesEditing from '../src/listpropertiesediting'; +import ListPropertiesEditing from '../../src/listproperties/listpropertiesediting'; describe( 'ListStartCommand', () => { let editor, model, listStartCommand; diff --git a/packages/ckeditor5-list/tests/liststylecommand.js b/packages/ckeditor5-list/tests/listproperties/liststylecommand.js similarity index 99% rename from packages/ckeditor5-list/tests/liststylecommand.js rename to packages/ckeditor5-list/tests/listproperties/liststylecommand.js index 6cd4157a230..c5eb112204c 100644 --- a/packages/ckeditor5-list/tests/liststylecommand.js +++ b/packages/ckeditor5-list/tests/listproperties/liststylecommand.js @@ -6,7 +6,7 @@ import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import ListPropertiesEditing from '../src/listpropertiesediting'; +import ListPropertiesEditing from '../../src/listproperties/listpropertiesediting'; describe( 'ListStyleCommand', () => { let editor, model, bulletedListCommand, numberedListCommand, listStyleCommand; diff --git a/packages/ckeditor5-list/tests/ui/collapsibleview.js b/packages/ckeditor5-list/tests/listproperties/ui/collapsibleview.js similarity index 98% rename from packages/ckeditor5-list/tests/ui/collapsibleview.js rename to packages/ckeditor5-list/tests/listproperties/ui/collapsibleview.js index ae0eff98a0c..30c488581a5 100644 --- a/packages/ckeditor5-list/tests/ui/collapsibleview.js +++ b/packages/ckeditor5-list/tests/listproperties/ui/collapsibleview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import CollapsibleView from '../../src/ui/collapsibleview'; +import CollapsibleView from '../../../src/listproperties/ui/collapsibleview'; import { ButtonView, ViewCollection } from '@ckeditor/ckeditor5-ui'; import dropdownArrowIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg'; diff --git a/packages/ckeditor5-list/tests/listproperties/ui/inputnumberview.js b/packages/ckeditor5-list/tests/listproperties/ui/inputnumberview.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-list/tests/ui/listpropertiesview.js b/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js similarity index 99% rename from packages/ckeditor5-list/tests/ui/listpropertiesview.js rename to packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js index 60f2d90ee66..6d7bb6f433e 100644 --- a/packages/ckeditor5-list/tests/ui/listpropertiesview.js +++ b/packages/ckeditor5-list/tests/listproperties/ui/listpropertiesview.js @@ -5,8 +5,8 @@ /* globals document, Event */ -import ListPropertiesView from '../../src/ui/listpropertiesview'; -import CollapsibleView from '../../src/ui/collapsibleview'; +import ListPropertiesView from '../../../src/listproperties/ui/listpropertiesview'; +import CollapsibleView from '../../../src/listproperties/ui/collapsibleview'; import { ButtonView, diff --git a/packages/ckeditor5-list/tests/todolist.js b/packages/ckeditor5-list/tests/todolist.js index cfcdc7e9858..506cfba823a 100644 --- a/packages/ckeditor5-list/tests/todolist.js +++ b/packages/ckeditor5-list/tests/todolist.js @@ -4,8 +4,8 @@ */ import TodoList from '../src/todolist'; -import TodoListEditing from '../src/todolistediting'; -import TodoListUI from '../src/todolistui'; +import TodoListEditing from '../src/todolist/todolistediting'; +import TodoListUI from '../src/todolist/todolistui'; describe( 'TodoList', () => { it( 'should be named', () => { diff --git a/packages/ckeditor5-list/tests/checktodolistcommand.js b/packages/ckeditor5-list/tests/todolist/checktodolistcommand.js similarity index 98% rename from packages/ckeditor5-list/tests/checktodolistcommand.js rename to packages/ckeditor5-list/tests/todolist/checktodolistcommand.js index 6a6715df9c7..7fa00323b7e 100644 --- a/packages/ckeditor5-list/tests/checktodolistcommand.js +++ b/packages/ckeditor5-list/tests/todolist/checktodolistcommand.js @@ -3,8 +3,8 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import TodoListEditing from '../src/todolistediting'; -import CheckTodoListCommand from '../src/checktodolistcommand'; +import TodoListEditing from '../../src/todolist/todolistediting'; +import CheckTodoListCommand from '../../src/todolist/checktodolistcommand'; import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; diff --git a/packages/ckeditor5-list/tests/todolistediting.js b/packages/ckeditor5-list/tests/todolist/todolistediting.js similarity index 99% rename from packages/ckeditor5-list/tests/todolistediting.js rename to packages/ckeditor5-list/tests/todolist/todolistediting.js index e5880ee9a02..3cbaf420784 100644 --- a/packages/ckeditor5-list/tests/todolistediting.js +++ b/packages/ckeditor5-list/tests/todolist/todolistediting.js @@ -3,13 +3,13 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import TodoListEditing from '../src/todolistediting'; -import ListEditing from '../src/listediting'; +import TodoListEditing from '../../src/todolist/todolistediting'; +import ListEditing from '../../src/list/listediting'; import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; -import ListCommand from '../src/listcommand'; -import CheckTodoListCommand from '../src/checktodolistcommand'; +import ListCommand from '../../src/list/listcommand'; +import CheckTodoListCommand from '../../src/todolist/checktodolistcommand'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview'; import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting'; diff --git a/packages/ckeditor5-list/tests/todolistui.js b/packages/ckeditor5-list/tests/todolist/todolistui.js similarity index 93% rename from packages/ckeditor5-list/tests/todolistui.js rename to packages/ckeditor5-list/tests/todolist/todolistui.js index 3e4e32ab26d..d49ce0c0ebc 100644 --- a/packages/ckeditor5-list/tests/todolistui.js +++ b/packages/ckeditor5-list/tests/todolist/todolistui.js @@ -5,8 +5,8 @@ /* globals document */ -import TodoListEditing from '../src/todolistediting'; -import TodoListUI from '../src/todolistui'; +import TodoListEditing from '../../src/todolist/todolistediting'; +import TodoListUI from '../../src/todolist/todolistui'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; diff --git a/packages/ckeditor5-media-embed/src/mediaembedediting.js b/packages/ckeditor5-media-embed/src/mediaembedediting.js index 27f74ab04b4..449606cbe5b 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedediting.js +++ b/packages/ckeditor5-media-embed/src/mediaembedediting.js @@ -184,12 +184,12 @@ export default class MediaEmbedEditing extends Plugin { } ); // Model -> Data - conversion.for( 'dataDowncast' ).elementToElement( { + conversion.for( 'dataDowncast' ).elementToStructure( { model: 'media', - view: ( modelElement, { writer } ) => { + view: ( modelElement, conversionApi ) => { const url = modelElement.getAttribute( 'url' ); - return createMediaFigureElement( writer, registry, url, { + return createMediaFigureElement( conversionApi, registry, url, { elementName, renderMediaPreview: url && renderMediaPreview } ); @@ -204,16 +204,16 @@ export default class MediaEmbedEditing extends Plugin { } ) ); // Model -> View (element) - conversion.for( 'editingDowncast' ).elementToElement( { + conversion.for( 'editingDowncast' ).elementToStructure( { model: 'media', - view: ( modelElement, { writer } ) => { + view: ( modelElement, conversionApi ) => { const url = modelElement.getAttribute( 'url' ); - const figure = createMediaFigureElement( writer, registry, url, { + const figure = createMediaFigureElement( conversionApi, registry, url, { elementName, renderForEditingView: true } ); - return toMediaWidget( figure, writer, t( 'media widget' ) ); + return toMediaWidget( figure, conversionApi.writer, t( 'media widget' ) ); } } ); diff --git a/packages/ckeditor5-media-embed/src/utils.js b/packages/ckeditor5-media-embed/src/utils.js index 491ca395b16..f6159843cff 100644 --- a/packages/ckeditor5-media-embed/src/utils.js +++ b/packages/ckeditor5-media-embed/src/utils.js @@ -64,7 +64,7 @@ export function isMediaWidget( viewElement ) { *
        [ non-semantic media preview for "foo" ]
        * * - * @param {module:engine/view/downcastwriter~DowncastWriter} writer + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi * @param {module:media-embed/mediaregistry~MediaRegistry} registry * @param {String} url * @param {Object} options @@ -73,12 +73,11 @@ export function isMediaWidget( viewElement ) { * @param {Boolean} [options.renderForEditingView] * @returns {module:engine/view/containerelement~ContainerElement} */ -export function createMediaFigureElement( writer, registry, url, options ) { - const figure = writer.createContainerElement( 'figure', { class: 'media' } ); - - writer.insert( writer.createPositionAt( figure, 0 ), registry.getMediaViewElement( writer, url, options ) ); - - return figure; +export function createMediaFigureElement( { writer }, registry, url, options ) { + return writer.createContainerElement( 'figure', { class: 'media' }, [ + registry.getMediaViewElement( writer, url, options ), + writer.createSlot() + ] ); } /** diff --git a/packages/ckeditor5-page-break/src/pagebreakediting.js b/packages/ckeditor5-page-break/src/pagebreakediting.js index 54cef8796b8..aeb74b42bfb 100644 --- a/packages/ckeditor5-page-break/src/pagebreakediting.js +++ b/packages/ckeditor5-page-break/src/pagebreakediting.js @@ -41,28 +41,27 @@ export default class PageBreakEditing extends Plugin { allowWhere: '$block' } ); - conversion.for( 'dataDowncast' ).elementToElement( { + conversion.for( 'dataDowncast' ).elementToStructure( { model: 'pageBreak', view: ( modelElement, { writer } ) => { - const divElement = writer.createContainerElement( 'div', { - class: 'page-break', - // If user has no `.ck-content` styles, it should always break a page during print. - style: 'page-break-after: always' - } ); - - // For a rationale of using span inside a div see: - // https://github.com/ckeditor/ckeditor5-page-break/pull/1#discussion_r328934062. - const spanElement = writer.createContainerElement( 'span', { - style: 'display: none' - } ); - - writer.insert( writer.createPositionAt( divElement, 0 ), spanElement ); + const divElement = writer.createContainerElement( 'div', + { + class: 'page-break', + // If user has no `.ck-content` styles, it should always break a page during print. + style: 'page-break-after: always' + }, + // For a rationale of using span inside a div see: + // https://github.com/ckeditor/ckeditor5-page-break/pull/1#discussion_r328934062. + writer.createContainerElement( 'span', { + style: 'display: none' + } ) + ); return divElement; } } ); - conversion.for( 'editingDowncast' ).elementToElement( { + conversion.for( 'editingDowncast' ).elementToStructure( { model: 'pageBreak', view: ( modelElement, { writer } ) => { const label = t( 'Page break' ); diff --git a/packages/ckeditor5-table/src/converters/downcast.js b/packages/ckeditor5-table/src/converters/downcast.js index a043f23e696..86e17e9661b 100644 --- a/packages/ckeditor5-table/src/converters/downcast.js +++ b/packages/ckeditor5-table/src/converters/downcast.js @@ -13,123 +13,57 @@ import { toWidget, toWidgetEditable } from 'ckeditor5/src/widget'; /** * Model table element to view table element conversion helper. * - * This conversion helper creates the whole table element with child elements. - * - * @param {Object} options - * @param {Boolean} options.asWidget If set to `true`, the downcast conversion will produce a widget. - * @returns {Function} Conversion helper. + * @param {module:table/tableutils~TableUtils} tableUtils The `TableUtils` plugin instance. + * @param {Object} [options] + * @param {Boolean} [options.asWidget] If set to `true`, the downcast conversion will produce a widget. + * @returns {Function} Element creator. */ -export function downcastInsertTable( options = {} ) { - return dispatcher => dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => { - const table = data.item; - - if ( !conversionApi.consumable.consume( table, 'insert' ) ) { - return; +export function downcastTable( tableUtils, options = {} ) { + return ( table, { writer } ) => { + const headingRows = table.getAttribute( 'headingRows' ) || 0; + const tableSections = []; + + // Table head slot. + if ( headingRows > 0 ) { + tableSections.push( + writer.createContainerElement( 'thead', null, + writer.createSlot( element => element.is( 'element', 'tableRow' ) && element.index < headingRows ) + ) + ); } - // Consume attributes if present to not fire attribute change downcast - conversionApi.consumable.consume( table, 'attribute:headingRows:table' ); - conversionApi.consumable.consume( table, 'attribute:headingColumns:table' ); - - const asWidget = options && options.asWidget; - - const figureElement = conversionApi.writer.createContainerElement( 'figure', { class: 'table' } ); - const tableElement = conversionApi.writer.createContainerElement( 'table' ); - conversionApi.writer.insert( conversionApi.writer.createPositionAt( figureElement, 0 ), tableElement ); - - let tableWidget; - - if ( asWidget ) { - tableWidget = toTableWidget( figureElement, conversionApi.writer ); + // Table body slot. + if ( headingRows < tableUtils.getRows( table ) ) { + tableSections.push( + writer.createContainerElement( 'tbody', null, + writer.createSlot( element => element.is( 'element', 'tableRow' ) && element.index >= headingRows ) + ) + ); } - const tableWalker = new TableWalker( table ); - - const tableAttributes = { - headingRows: table.getAttribute( 'headingRows' ) || 0, - headingColumns: table.getAttribute( 'headingColumns' ) || 0 - }; - - // Cache for created table rows. - const viewRows = new Map(); + const figureElement = writer.createContainerElement( 'figure', { class: 'table' }, [ + // Table with proper sections (thead, tbody). + writer.createContainerElement( 'table', null, tableSections ), - for ( const tableSlot of tableWalker ) { - const { row, cell } = tableSlot; - - const tableRow = table.getChild( row ); - const trElement = viewRows.get( row ) || createTr( tableElement, tableRow, row, tableAttributes, conversionApi ); - viewRows.set( row, trElement ); - - // Consume table cell - it will be always consumed as we convert whole table at once. - conversionApi.consumable.consume( cell, 'insert' ); + // Slot for the rest (for example caption). + writer.createSlot( element => !element.is( 'element', 'tableRow' ) ) + ] ); - const insertPosition = conversionApi.writer.createPositionAt( trElement, 'end' ); - - createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, options ); - } - - // Insert empty TR elements if there are any rows without anchored cells. Since the model is always normalized - // this can happen only in the document fragment that only part of the table is down-casted. - for ( const tableRow of table.getChildren() ) { - const rowIndex = tableRow.index; - - // Make sure that this is a table row and not some other element (i.e., caption). - if ( tableRow.is( 'element', 'tableRow' ) && !viewRows.has( rowIndex ) ) { - viewRows.set( rowIndex, createTr( tableElement, tableRow, rowIndex, tableAttributes, conversionApi ) ); - } - } - - const viewPosition = conversionApi.mapper.toViewPosition( data.range.start ); - - conversionApi.mapper.bindElements( table, asWidget ? tableWidget : figureElement ); - conversionApi.writer.insert( viewPosition, asWidget ? tableWidget : figureElement ); - } ); + return options.asWidget ? toTableWidget( figureElement, writer ) : figureElement; + }; } /** - * Model row element to view `
  • ` element conversion helper. - * - * This conversion helper creates the whole `` element with child elements. + * Model table row element to view `` element conversion helper. * - * @returns {Function} Conversion helper. + * @returns {Function} Element creator. */ -export function downcastInsertRow() { - return dispatcher => dispatcher.on( 'insert:tableRow', ( evt, data, conversionApi ) => { - const tableRow = data.item; - - if ( !conversionApi.consumable.consume( tableRow, 'insert' ) ) { - return; - } - - const table = tableRow.parent; - - const figureElement = conversionApi.mapper.toViewElement( table ); - const tableElement = getViewTable( figureElement ); - - const row = table.getChildIndex( tableRow ); - - const tableWalker = new TableWalker( table, { row } ); - - const tableAttributes = { - headingRows: table.getAttribute( 'headingRows' ) || 0, - headingColumns: table.getAttribute( 'headingColumns' ) || 0 - }; - - // Cache for created table rows. - const viewRows = new Map(); - - for ( const tableSlot of tableWalker ) { - const trElement = viewRows.get( row ) || createTr( tableElement, tableRow, row, tableAttributes, conversionApi ); - viewRows.set( row, trElement ); - - // Consume table cell - it will be always consumed as we convert whole row at once. - conversionApi.consumable.consume( tableSlot.cell, 'insert' ); - - const insertPosition = conversionApi.writer.createPositionAt( trElement, 'end' ); - - createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, { asWidget: true } ); - } - } ); +export function downcastRow() { + return ( tableRow, { writer } ) => { + return tableRow.isEmpty ? + writer.createEmptyElement( 'tr' ) : + writer.createContainerElement( 'tr' ); + }; } /** @@ -138,129 +72,65 @@ export function downcastInsertRow() { * This conversion helper will create proper ` from the view. - const removeRange = viewWriter.createRangeOn( viewItem ); - const removed = viewWriter.remove( removeRange ); - - for ( const child of viewWriter.createRangeIn( removed ).getItems() ) { - mapper.unbindViewElement( child ); - } - - // Cleanup: Ensure that thead & tbody sections are removed if left empty after removing rows. See #6437, #6391. - removeTableSectionIfEmpty( 'thead', viewTable, conversionApi ); - removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi ); - }, { priority: 'higher' } ); + }; } /** * Overrides paragraph inside table cell conversion. * * This converter: - * * should be used to override default paragraph conversion in the editing view. - * * It will only convert placed directly inside . + * * should be used to override default paragraph conversion. + * * It will only convert `` placed directly inside ``. * * For a single paragraph without attributes it returns `` to simulate data table. * * For all other cases it returns `

    ` element. * - * @param {module:engine/model/element~Element} modelElement - * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi - * @returns {module:engine/view/containerelement~ContainerElement|undefined} + * @param {Object} [options] + * @param {Boolean} [options.asWidget] If set to `true`, the downcast conversion will produce a widget. + * @returns {Function} Element creator. */ -export function convertParagraphInTableCell( modelElement, conversionApi ) { - const { writer } = conversionApi; +export function convertParagraphInTableCell( options = {} ) { + return ( modelElement, { writer, consumable, mapper } ) => { + if ( !modelElement.parent.is( 'element', 'tableCell' ) ) { + return; + } - if ( !modelElement.parent.is( 'element', 'tableCell' ) ) { - return; - } + if ( !isSingleParagraphWithoutAttributes( modelElement ) ) { + return; + } - if ( isSingleParagraphWithoutAttributes( modelElement ) ) { - return writer.createContainerElement( 'span', { class: 'ck-table-bogus-paragraph' } ); - } else { - return writer.createContainerElement( 'p' ); - } + if ( options.asWidget ) { + return writer.createContainerElement( 'span', { class: 'ck-table-bogus-paragraph' } ); + } else { + // Additional requirement for data pipeline to have backward compatible data tables. + consumable.consume( modelElement, 'insert' ); + mapper.bindElements( modelElement, mapper.toViewElement( modelElement.parent ) ); + } + }; } /** @@ -277,7 +147,7 @@ export function convertParagraphInTableCell( modelElement, conversionApi ) { export function isSingleParagraphWithoutAttributes( modelElement ) { const tableCell = modelElement.parent; - const isSingleParagraph = tableCell.childCount === 1; + const isSingleParagraph = tableCell.childCount == 1; return isSingleParagraph && !hasAnyAttribute( modelElement ); } @@ -296,207 +166,6 @@ function toTableWidget( viewElement, writer ) { return toWidget( viewElement, writer, { hasSelectionHandle: true } ); } -// Renames an existing table cell in the view to a given element name. -// -// **Note** This method will not do anything if a view table cell has not been converted yet. -// -// @param {module:engine/model/element~Element} tableCell -// @param {String} desiredCellElementName -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -function renameViewTableCell( tableCell, desiredCellElementName, conversionApi ) { - const viewWriter = conversionApi.writer; - const viewCell = conversionApi.mapper.toViewElement( tableCell ); - - const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() ); - const renamedCell = toWidgetEditable( editable, viewWriter ); - - viewWriter.insert( viewWriter.createPositionAfter( viewCell ), renamedCell ); - viewWriter.move( viewWriter.createRangeIn( viewCell ), viewWriter.createPositionAt( renamedCell, 0 ) ); - viewWriter.remove( viewWriter.createRangeOn( viewCell ) ); - - conversionApi.mapper.unbindViewElement( viewCell ); - conversionApi.mapper.bindElements( tableCell, renamedCell ); -} - -// Renames a table cell element in the view according to its location in the table. -// -// @param {module:table/tablewalker~TableSlot} tableSlot -// @param {{headingColumns, headingRows}} tableAttributes -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -function renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi ) { - const { cell } = tableSlot; - - // Check whether current columnIndex is overlapped by table cells from previous rows. - const desiredCellElementName = getCellElementName( tableSlot, tableAttributes ); - - const viewCell = conversionApi.mapper.toViewElement( cell ); - - // If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view - // because of child conversion is done after parent. - if ( viewCell && viewCell.name !== desiredCellElementName ) { - renameViewTableCell( cell, desiredCellElementName, conversionApi ); - } -} - -// Creates a table cell element in the view. -// -// @param {module:table/tablewalker~TableSlot} tableSlot -// @param {module:engine/view/position~Position} insertPosition -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -function createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, options ) { - const asWidget = options && options.asWidget; - const cellElementName = getCellElementName( tableSlot, tableAttributes ); - - const cellElement = asWidget ? - toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) : - conversionApi.writer.createContainerElement( cellElementName ); - - const tableCell = tableSlot.cell; - - const firstChild = tableCell.getChild( 0 ); - const isSingleParagraph = tableCell.childCount === 1 && firstChild.name === 'paragraph'; - - conversionApi.writer.insert( insertPosition, cellElement ); - - conversionApi.mapper.bindElements( tableCell, cellElement ); - - // Additional requirement for data pipeline to have backward compatible data tables. - if ( !asWidget && isSingleParagraph && !hasAnyAttribute( firstChild ) ) { - const innerParagraph = tableCell.getChild( 0 ); - - conversionApi.consumable.consume( innerParagraph, 'insert' ); - - conversionApi.mapper.bindElements( innerParagraph, cellElement ); - } -} - -// Creates a `

    ` view element. -// -// @param {module:engine/view/element~Element} tableElement -// @param {module:engine/model/element~Element} tableRow -// @param {Number} rowIndex -// @param {{headingColumns, headingRows}} tableAttributes -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -// @returns {module:engine/view/element~Element} -function createTr( tableElement, tableRow, rowIndex, tableAttributes, conversionApi ) { - // Will always consume since we're converting element from a parent
    ` elements for table cells that are in the heading section (heading row or column) * and `` otherwise. * - * @returns {Function} Conversion helper. + * @param {Object} [options] + * @param {Boolean} [options.asWidget] If set to `true`, the downcast conversion will produce a widget. + * @returns {Function} Element creator. */ -export function downcastInsertCell() { - return dispatcher => dispatcher.on( 'insert:tableCell', ( evt, data, conversionApi ) => { - const tableCell = data.item; - - if ( !conversionApi.consumable.consume( tableCell, 'insert' ) ) { - return; - } - +export function downcastCell( options = {} ) { + return ( tableCell, { writer } ) => { const tableRow = tableCell.parent; const table = tableRow.parent; const rowIndex = table.getChildIndex( tableRow ); const tableWalker = new TableWalker( table, { row: rowIndex } ); + const headingRows = table.getAttribute( 'headingRows' ) || 0; + const headingColumns = table.getAttribute( 'headingColumns' ) || 0; - const tableAttributes = { - headingRows: table.getAttribute( 'headingRows' ) || 0, - headingColumns: table.getAttribute( 'headingColumns' ) || 0 - }; - - // We need to iterate over a table in order to get proper row & column values from a walker + // We need to iterate over a table in order to get proper row & column values from a walker. for ( const tableSlot of tableWalker ) { - if ( tableSlot.cell === tableCell ) { - const trElement = conversionApi.mapper.toViewElement( tableRow ); - const insertPosition = conversionApi.writer.createPositionAt( trElement, tableRow.getChildIndex( tableCell ) ); + if ( tableSlot.cell == tableCell ) { + const isHeading = tableSlot.row < headingRows || tableSlot.column < headingColumns; + const cellElementName = isHeading ? 'th' : 'td'; - createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, { asWidget: true } ); - - // No need to iterate further. - return; + return options.asWidget ? + toWidgetEditable( writer.createEditableElement( cellElementName ), writer ) : + writer.createContainerElement( cellElementName ); } } - } ); -} - -/** - * Conversion helper that acts on heading column table attribute change. - * - * Depending on changed attributes this converter will rename `` elements or vice versa depending on the cell column index. - * - * @returns {Function} Conversion helper. - */ -export function downcastTableHeadingColumnsChange() { - return dispatcher => dispatcher.on( 'attribute:headingColumns:table', ( evt, data, conversionApi ) => { - const table = data.item; - - if ( !conversionApi.consumable.consume( data.item, evt.name ) ) { - return; - } - - const tableAttributes = { - headingRows: table.getAttribute( 'headingRows' ) || 0, - headingColumns: table.getAttribute( 'headingColumns' ) || 0 - }; - - const oldColumns = data.attributeOldValue; - const newColumns = data.attributeNewValue; - - const lastColumnToCheck = ( oldColumns > newColumns ? oldColumns : newColumns ) - 1; - - for ( const tableSlot of new TableWalker( table, { endColumn: lastColumnToCheck } ) ) { - renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi ); - } - } ); -} - -/** - * Conversion helper that acts on a removed row. - * - * @returns {Function} Conversion helper. - */ -export function downcastRemoveRow() { - return dispatcher => dispatcher.on( 'remove:tableRow', ( evt, data, conversionApi ) => { - // Prevent default remove converter. - evt.stop(); - const viewWriter = conversionApi.writer; - const mapper = conversionApi.mapper; - - const viewStart = mapper.toViewPosition( data.position ).getLastMatchingPosition( value => !value.item.is( 'element', 'tr' ) ); - const viewItem = viewStart.nodeAfter; - const tableSection = viewItem.parent; - const viewTable = tableSection.parent; - - // Remove associated
    . - conversionApi.consumable.consume( tableRow, 'insert' ); - - const trElement = tableRow.isEmpty ? - conversionApi.writer.createEmptyElement( 'tr' ) : - conversionApi.writer.createContainerElement( 'tr' ); - - conversionApi.mapper.bindElements( tableRow, trElement ); - - const headingRows = tableAttributes.headingRows; - const tableSection = getOrCreateTableSection( getSectionName( rowIndex, tableAttributes ), tableElement, conversionApi ); - - const offset = headingRows > 0 && rowIndex >= headingRows ? rowIndex - headingRows : rowIndex; - const position = conversionApi.writer.createPositionAt( tableSection, offset ); - - conversionApi.writer.insert( position, trElement ); - - return trElement; -} - -// Returns `th` for heading cells and `td` for other cells for the current table walker value. -// -// @param {module:table/tablewalker~TableSlot} tableSlot -// @param {{headingColumns, headingRows}} tableAttributes -// @returns {String} -function getCellElementName( tableSlot, tableAttributes ) { - const { row, column } = tableSlot; - const { headingColumns, headingRows } = tableAttributes; - - // Column heading are all tableCells in the first `columnHeading` rows. - const isColumnHeading = headingRows && headingRows > row; - - // So a whole row gets ` or `` element with caching. -// -// @param {String} sectionName -// @param {module:engine/view/element~Element} viewTable -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -// @param {Object} cachedTableSection An object that stores cached elements. -// @returns {module:engine/view/containerelement~ContainerElement} -function getOrCreateTableSection( sectionName, viewTable, conversionApi ) { - const viewTableSection = getExistingTableSectionElement( sectionName, viewTable ); - - return viewTableSection ? viewTableSection : createTableSection( sectionName, viewTable, conversionApi ); -} - -// Finds an existing `` or `` element or returns undefined. -// -// @param {String} sectionName -// @param {module:engine/view/element~Element} tableElement -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -function getExistingTableSectionElement( sectionName, tableElement ) { - for ( const tableSection of tableElement.getChildren() ) { - if ( tableSection.name == sectionName ) { - return tableSection; - } - } -} - -// Creates a table section at the end of the table. -// -// @param {String} sectionName -// @param {module:engine/view/element~Element} tableElement -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -// @returns {module:engine/view/containerelement~ContainerElement} -function createTableSection( sectionName, tableElement, conversionApi ) { - const tableChildElement = conversionApi.writer.createContainerElement( sectionName ); - - const insertPosition = conversionApi.writer.createPositionAt( tableElement, sectionName == 'tbody' ? 'end' : 0 ); - - conversionApi.writer.insert( insertPosition, tableChildElement ); - - return tableChildElement; -} - -// Removes an existing `` or `` element if it is empty. -// -// @param {String} sectionName -// @param {module:engine/view/element~Element} tableElement -// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi -function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) { - const tableSection = getExistingTableSectionElement( sectionName, tableElement ); - - if ( tableSection && tableSection.childCount === 0 ) { - conversionApi.writer.remove( conversionApi.writer.createRangeOn( tableSection ) ); - } -} - -// Finds a '
    element. - if ( isColumnHeading ) { - return 'th'; - } - - // Row heading are tableCells which columnIndex is lower then headingColumns. - const isRowHeading = headingColumns && headingColumns > column; - - return isRowHeading ? 'th' : 'td'; -} - -// Returns the table section name for the current table walker value. -// -// @param {Number} row -// @param {{headingColumns, headingRows}} tableAttributes -// @returns {String} -function getSectionName( row, tableAttributes ) { - return row < tableAttributes.headingRows ? 'thead' : 'tbody'; -} - -// Creates or returns an existing `
    ' element inside the `
    ` widget. -// -// @param {module:engine/view/element~Element} viewFigure -function getViewTable( viewFigure ) { - for ( const child of viewFigure.getChildren() ) { - if ( child.name === 'table' ) { - return child; - } - } -} - // Checks if an element has any attributes set. // // @param {module:engine/model/element~Element element diff --git a/packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js b/packages/ckeditor5-table/src/converters/table-cell-refresh-handler.js similarity index 58% rename from packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js rename to packages/ckeditor5-table/src/converters/table-cell-refresh-handler.js index 7dc5fd5aa15..68b42a68751 100644 --- a/packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js +++ b/packages/ckeditor5-table/src/converters/table-cell-refresh-handler.js @@ -10,7 +10,7 @@ import { isSingleParagraphWithoutAttributes } from './downcast'; /** - * Injects a table cell post-fixer into the model which marks the table cell in the differ to have it re-rendered. + * A table cell refresh handler which marks the table cell in the differ to have it re-rendered. * * Model `paragraph` inside a table cell can be rendered as `` or `

    `. It is rendered as `` if this is the only block * element in that table cell and it does not have any attributes. It is rendered as `

    ` otherwise. @@ -19,16 +19,12 @@ import { isSingleParagraphWithoutAttributes } from './downcast'; * re-rendered so it changes from `` to `

    `. The easiest way to do it is to re-render the entire table cell. * * @param {module:engine/model/model~Model} model - * @param {module:engine/conversion/mapper~Mapper} mapper + * @param {module:engine/controller/editingcontroller~EditingController} editing */ -export default function injectTableCellRefreshPostFixer( model, mapper ) { - model.document.registerPostFixer( () => tableCellRefreshPostFixer( model.document.differ, mapper ) ); -} +export default function tableCellRefreshHandler( model, editing ) { + const differ = model.document.differ; -function tableCellRefreshPostFixer( differ, mapper ) { // Stores cells to be refreshed, so the table cell will be refreshed once for multiple changes. - - // 1. Gather all changes inside table cell. const cellsToCheck = new Set(); for ( const change of differ.getChanges() ) { @@ -39,20 +35,13 @@ function tableCellRefreshPostFixer( differ, mapper ) { } } - // @if CK_DEBUG_TABLE // console.log( `Post-fixing table: Checking table cell to refresh (${ cellsToCheck.size }).` ); - // @if CK_DEBUG_TABLE // let paragraphsRefreshed = 0; - for ( const tableCell of cellsToCheck.values() ) { - for ( const paragraph of [ ...tableCell.getChildren() ].filter( child => shouldRefresh( child, mapper ) ) ) { - // @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing paragraph in table cell (${++paragraphsRefreshed}).` ); - differ.refreshItem( paragraph ); + const paragraphsToRefresh = Array.from( tableCell.getChildren() ).filter( child => shouldRefresh( child, editing.mapper ) ); + + for ( const paragraph of paragraphsToRefresh ) { + editing.reconvertItem( paragraph ); } } - - // Always return false to prevent the refresh post-fixer from re-running on the same set of changes and going into an infinite loop. - // This "post-fixer" does not change the model structure so there shouldn't be need to run other post-fixers again. - // See https://github.com/ckeditor/ckeditor5/issues/1936 & https://github.com/ckeditor/ckeditor5/issues/8200. - return false; } // Check if given model element needs refreshing. diff --git a/packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js b/packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js deleted file mode 100644 index e07898c9c9f..00000000000 --- a/packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module table/converters/table-heading-rows-refresh-post-fixer - */ - -/** - * Injects a table post-fixer into the model which marks the table in the differ to have it re-rendered. - * - * Table heading rows are represented in the model by a `headingRows` attribute. However, in the view, it's represented as separate - * sections of the table (`

    ` or ``) and changing `headingRows` attribute requires moving table rows between two sections. - * This causes problems with structural changes in a table (like adding and removing rows) thus atomic converters cannot be used. - * - * When table `headingRows` attribute changes, the entire table is re-rendered. - * - * @param {module:engine/model/model~Model} model - */ -export default function injectTableHeadingRowsRefreshPostFixer( model ) { - model.document.registerPostFixer( () => tableHeadingRowsRefreshPostFixer( model ) ); -} - -function tableHeadingRowsRefreshPostFixer( model ) { - const differ = model.document.differ; - - // Stores tables to be refreshed so the table will be refreshed once for multiple changes. - const tablesToRefresh = new Set(); - - for ( const change of differ.getChanges() ) { - if ( change.type === 'attribute' ) { - const element = change.range.start.nodeAfter; - - if ( element && element.is( 'element', 'table' ) && change.attributeKey === 'headingRows' ) { - tablesToRefresh.add( element ); - } - } else { - /* istanbul ignore else */ - if ( change.type === 'insert' || change.type === 'remove' ) { - if ( change.name === 'tableRow' ) { - const table = change.position.findAncestor( 'table' ); - const headingRows = table.getAttribute( 'headingRows' ) || 0; - - if ( change.position.offset < headingRows ) { - tablesToRefresh.add( table ); - } - } else if ( change.name === 'tableCell' ) { - const table = change.position.findAncestor( 'table' ); - const headingColumns = table.getAttribute( 'headingColumns' ) || 0; - - if ( change.position.offset < headingColumns ) { - tablesToRefresh.add( table ); - } - } - } - } - } - - if ( tablesToRefresh.size ) { - // @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing heading rows (${ tablesToRefresh.size }).` ); - - for ( const table of tablesToRefresh.values() ) { - // Should be handled by a `triggerBy` configuration. See: https://github.com/ckeditor/ckeditor5/issues/8138. - differ.refreshItem( table ); - } - - return true; - } - - return false; -} diff --git a/packages/ckeditor5-table/src/converters/table-headings-refresh-handler.js b/packages/ckeditor5-table/src/converters/table-headings-refresh-handler.js new file mode 100644 index 00000000000..9b2aa448eac --- /dev/null +++ b/packages/ckeditor5-table/src/converters/table-headings-refresh-handler.js @@ -0,0 +1,68 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module table/converters/table-heading-rows-refresh-post-fixer + */ + +import TableWalker from '../tablewalker'; + +/** + * A table headings refresh handler which marks the table cells or rows in the differ to have it re-rendered + * if the headings attribute changed. + * + * Table heading rows and heading columns are represented in the model by a `headingRows` and `headingColumns` attributes. + * + * When table headings attribute changes, all the cells/rows are marked to re-render to change between `' + '' + - '' + + '' + + '' + + '' + + '
    ` and ``. + * + * @param {module:engine/model/model~Model} model + * @param {module:engine/controller/editingcontroller~EditingController} editing + */ +export default function tableHeadingsRefreshHandler( model, editing ) { + const differ = model.document.differ; + + for ( const change of differ.getChanges() ) { + let table; + let isRowChange = false; + + if ( change.type == 'attribute' ) { + const element = change.range.start.nodeAfter; + + if ( !element || !element.is( 'element', 'table' ) ) { + continue; + } + + if ( change.attributeKey != 'headingRows' && change.attributeKey != 'headingColumns' ) { + continue; + } + + table = element; + isRowChange = change.attributeKey == 'headingRows'; + } else if ( change.name == 'tableRow' || change.name == 'tableCell' ) { + table = change.position.findAncestor( 'table' ); + isRowChange = change.name == 'tableRow'; + } + + if ( !table ) { + continue; + } + + const headingRows = table.getAttribute( 'headingRows' ) || 0; + const headingColumns = table.getAttribute( 'headingColumns' ) || 0; + + const tableWalker = new TableWalker( table ); + + for ( const tableSlot of tableWalker ) { + const isHeading = tableSlot.row < headingRows || tableSlot.column < headingColumns; + const expectedElementName = isHeading ? 'th' : 'td'; + + const viewElement = editing.mapper.toViewElement( tableSlot.cell ); + + if ( viewElement && viewElement.is( 'element' ) && viewElement.name != expectedElementName ) { + editing.reconvertItem( isRowChange ? tableSlot.cell.parent : tableSlot.cell ); + } + } + } +} diff --git a/packages/ckeditor5-table/src/tableediting.js b/packages/ckeditor5-table/src/tableediting.js index 382e247cf1e..f5a619c16a5 100644 --- a/packages/ckeditor5-table/src/tableediting.js +++ b/packages/ckeditor5-table/src/tableediting.js @@ -10,14 +10,7 @@ import { Plugin } from 'ckeditor5/src/core'; import upcastTable, { ensureParagraphInTableCell, skipEmptyTableRow, upcastTableFigure } from './converters/upcasttable'; -import { - convertParagraphInTableCell, - downcastInsertCell, - downcastInsertRow, - downcastInsertTable, - downcastRemoveRow, - downcastTableHeadingColumnsChange -} from './converters/downcast'; +import { convertParagraphInTableCell, downcastCell, downcastRow, downcastTable } from './converters/downcast'; import InsertTableCommand from './commands/inserttablecommand'; import InsertRowCommand from './commands/insertrowcommand'; @@ -35,8 +28,9 @@ import TableUtils from '../src/tableutils'; import injectTableLayoutPostFixer from './converters/table-layout-post-fixer'; import injectTableCellParagraphPostFixer from './converters/table-cell-paragraph-post-fixer'; -import injectTableCellRefreshPostFixer from './converters/table-cell-refresh-post-fixer'; -import injectTableHeadingRowsRefreshPostFixer from './converters/table-heading-rows-refresh-post-fixer'; + +import tableHeadingsRefreshHandler from './converters/table-headings-refresh-handler'; +import tableCellRefreshHandler from './converters/table-cell-refresh-handler'; import '../theme/tableediting.css'; @@ -53,6 +47,13 @@ export default class TableEditing extends Plugin { return 'TableEditing'; } + /** + * @inheritDoc + */ + static get requires() { + return [ TableUtils ]; + } + /** * @inheritDoc */ @@ -61,6 +62,7 @@ export default class TableEditing extends Plugin { const model = editor.model; const schema = model.schema; const conversion = editor.conversion; + const tableUtils = editor.plugins.get( TableUtils ); schema.register( 'table', { allowWhere: '$block', @@ -88,15 +90,29 @@ export default class TableEditing extends Plugin { // Table conversion. conversion.for( 'upcast' ).add( upcastTable() ); - conversion.for( 'editingDowncast' ).add( downcastInsertTable( { asWidget: true } ) ); - conversion.for( 'dataDowncast' ).add( downcastInsertTable() ); + conversion.for( 'editingDowncast' ).elementToStructure( { + model: { + name: 'table', + attributes: [ 'headingRows' ] + }, + view: downcastTable( tableUtils, { asWidget: true } ) + } ); + conversion.for( 'dataDowncast' ).elementToStructure( { + model: { + name: 'table', + attributes: [ 'headingRows' ] + }, + view: downcastTable( tableUtils ) + } ); // Table row conversion. conversion.for( 'upcast' ).elementToElement( { model: 'tableRow', view: 'tr' } ); conversion.for( 'upcast' ).add( skipEmptyTableRow() ); - conversion.for( 'editingDowncast' ).add( downcastInsertRow() ); - conversion.for( 'editingDowncast' ).add( downcastRemoveRow() ); + conversion.for( 'downcast' ).elementToElement( { + model: 'tableRow', + view: downcastRow() + } ); // Table cell conversion. conversion.for( 'upcast' ).elementToElement( { model: 'tableCell', view: 'td' } ); @@ -104,12 +120,24 @@ export default class TableEditing extends Plugin { conversion.for( 'upcast' ).add( ensureParagraphInTableCell( 'td' ) ); conversion.for( 'upcast' ).add( ensureParagraphInTableCell( 'th' ) ); - conversion.for( 'editingDowncast' ).add( downcastInsertCell() ); + conversion.for( 'editingDowncast' ).elementToElement( { + model: 'tableCell', + view: downcastCell( { asWidget: true } ) + } ); + conversion.for( 'dataDowncast' ).elementToElement( { + model: 'tableCell', + view: downcastCell() + } ); // Duplicates code - needed to properly refresh paragraph inside a table cell. conversion.for( 'editingDowncast' ).elementToElement( { model: 'paragraph', - view: convertParagraphInTableCell, + view: convertParagraphInTableCell( { asWidget: true } ), + converterPriority: 'high' + } ); + conversion.for( 'dataDowncast' ).elementToElement( { + model: 'paragraph', + view: convertParagraphInTableCell(), converterPriority: 'high' } ); @@ -126,9 +154,6 @@ export default class TableEditing extends Plugin { view: 'rowspan' } ); - // Table heading columns conversion (a change of heading rows requires a reconversion of the whole table). - conversion.for( 'editingDowncast' ).add( downcastTableHeadingColumnsChange() ); - // Manually adjust model position mappings in a special case, when a table cell contains a paragraph, which is bound // to its parent (to the table cell). This custom model-to-view position mapping is necessary in data pipeline only, // because only during this conversion a paragraph can be bound to its parent. @@ -164,17 +189,13 @@ export default class TableEditing extends Plugin { editor.commands.add( 'selectTableRow', new SelectRowCommand( editor ) ); editor.commands.add( 'selectTableColumn', new SelectColumnCommand( editor ) ); - injectTableHeadingRowsRefreshPostFixer( model ); injectTableLayoutPostFixer( model ); - injectTableCellRefreshPostFixer( model, editor.editing.mapper ); injectTableCellParagraphPostFixer( model ); - } - /** - * @inheritDoc - */ - static get requires() { - return [ TableUtils ]; + this.listenTo( model.document, 'change:data', () => { + tableHeadingsRefreshHandler( model, editor.editing ); + tableCellRefreshHandler( model, editor.editing ); + } ); } } diff --git a/packages/ckeditor5-table/tests/converters/downcast.js b/packages/ckeditor5-table/tests/converters/downcast.js index 09351ab6985..b98859780a3 100644 --- a/packages/ckeditor5-table/tests/converters/downcast.js +++ b/packages/ckeditor5-table/tests/converters/downcast.js @@ -7,14 +7,15 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { toWidgetEditable } from '@ckeditor/ckeditor5-widget'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; -import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { modelTable, viewTable } from '../_utils/utils'; import TableEditing from '../../src/tableediting'; describe( 'downcast converters', () => { - let editor, model, root, view; + let editor, model, root, view, viewRoot; testUtils.createSinonSandbox(); @@ -24,13 +25,14 @@ describe( 'downcast converters', () => { model = editor.model; root = model.document.getRoot( 'main' ); view = editor.editing.view; + viewRoot = view.document.getRoot(); } ); afterEach( () => { return editor.destroy(); } ); - describe( 'downcastInsertTable()', () => { + describe( 'downcastTable()', () => { describe( 'editing pipeline', () => { it( 'should create table as a widget', () => { setModelData( model, modelTable( [ [ '' ] ] ) ); @@ -50,6 +52,80 @@ describe( 'downcast converters', () => { '' ); } ); + + it( 'should reconvert table on headingRows attribute change', () => { + setModelData( model, modelTable( [ + [ '00' ], + [ '10' ] + ] ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '00' + + '
    ' + + '10' + + '
    ' + + '
    ' + ); + + const viewFigureBefore = viewRoot.getChild( 0 ); + const viewTableBefore = viewFigureBefore.getChild( 1 ); + const viewTableRow0Before = viewTableBefore.getChild( 0 ).getChild( 0 ); + const viewTableRow1Before = viewTableBefore.getChild( 0 ).getChild( 1 ); + const viewTableCell0Before = viewTableRow0Before.getChild( 0 ); + const viewTableCell1Before = viewTableRow1Before.getChild( 1 ); + + model.change( writer => { + writer.setAttribute( 'headingRows', 1, root.getChild( 0 ) ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '00' + + '
    ' + + '10' + + '
    ' + + '
    ' + ); + + const viewFigureAfter = viewRoot.getChild( 0 ); + const viewTableAfter = viewFigureAfter.getChild( 1 ); + const viewTableRow0After = viewTableAfter.getChild( 0 ).getChild( 0 ); + const viewTableRow1After = viewTableAfter.getChild( 1 ).getChild( 0 ); + const viewTableCell0After = viewTableRow0After.getChild( 0 ); + const viewTableCell1After = viewTableRow1After.getChild( 1 ); + + expect( viewFigureAfter ).to.not.equal( viewFigureBefore ); + expect( viewTableAfter ).to.not.equal( viewTableBefore ); + expect( viewTableRow0After ).to.not.equal( viewTableRow0Before ); + expect( viewTableCell0After ).to.not.equal( viewTableCell0Before ); + expect( viewTableRow1After ).to.equal( viewTableRow1Before ); + expect( viewTableCell1After ).to.equal( viewTableCell1Before ); + } ); } ); describe( 'data pipeline', () => { @@ -196,6 +272,7 @@ describe( 'downcast converters', () => { it( 'should be possible to overwrite', () => { editor.conversion.elementToElement( { model: 'tableRow', view: 'tr', converterPriority: 'high' } ); editor.conversion.elementToElement( { model: 'tableCell', view: 'td', converterPriority: 'high' } ); + editor.conversion.elementToElement( { model: 'paragraph', view: 'p', converterPriority: 'highest' } ); editor.conversion.for( 'downcast' ).add( dispatcher => { dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => { conversionApi.consumable.consume( data.item, 'insert' ); @@ -346,8 +423,7 @@ describe( 'downcast converters', () => { } ); } ); - describe( 'downcastInsertRow()', () => { - // The insert row downcast conversion is not executed in data pipeline. + describe( 'downcastRow()', () => { describe( 'editing pipeline', () => { it( 'should react to changed rows', () => { setModelData( model, modelTable( [ @@ -563,11 +639,249 @@ describe( 'downcast converters', () => { '' + '
    ' + - '' + - '' + + '' + + '
    ' + + '' + ); + } ); + + it( 'should react to removed row from the beginning of a body rows (no heading rows)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 1 ) ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '00' + + '' + + '01' + + '
    ' + + '
    ' + ); + } ); + + it( 'should react to removed row from the end of a body rows (no heading rows)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ] ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 0 ) ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '10' + + '' + + '11' + + '
    ' + + '
    ' + ); + } ); + + it( 'should react to removed row from the beginning of a heading rows (no body rows)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 2 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + // Removing row from a heading section changes requires changing heading rows attribute. + writer.setAttribute( 'headingRows', 1, table ); + writer.remove( table.getChild( 0 ) ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '10' + + '' + + '11' + + '
    ' + + '
    ' + ); + } ); + + it( 'should react to removed row from the end of a heading rows (no body rows)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 2 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + // Removing row from a heading section changes requires changing heading rows attribute. + writer.setAttribute( 'headingRows', 1, table ); + writer.remove( table.getChild( 1 ) ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '00' + + '' + + '01' + + '
    ' + + '
    ' + ); + } ); + + it( 'should react to removed row from the end of a heading rows (first cell in body has colspan)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01', '02', '03' ], + [ { rowspan: 2, colspan: 2, contents: '10' }, '12', '13' ], + [ '22', '23' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + // Removing row from a heading section changes requires changing heading rows attribute. + writer.remove( table.getChild( 0 ) ); + writer.setAttribute( 'headingRows', 0, table ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '10' + + '' + + '12' + + '' + + '13' + + '
    ' + + '22' + + '' + + '23' + + '
    ' + + '
    ' + ); + } ); + + it( 'should remove empty thead if a last row was removed from a heading rows (has heading and body)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + // Removing row from a heading section changes requires changing heading rows attribute. + writer.removeAttribute( 'headingRows', table ); + writer.remove( table.getChild( 0 ) ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '10' + + '' + + '11' + + '
    ' + + '
    ' + ); + } ); + + it( 'should remove empty tbody if a last row was removed a body rows (has heading and body)', () => { + setModelData( model, modelTable( [ + [ '00[]', '01' ], + [ '10', '11' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + model.change( writer => { + writer.remove( table.getChild( 1 ) ); + } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + '' + - '' + + '' + '
    ' + + '00' + + '' + + '01' + + '
    ' + '
    ' ); @@ -575,8 +889,7 @@ describe( 'downcast converters', () => { } ); } ); - describe( 'downcastInsertCell()', () => { - // The insert table cell downcast conversion is not executed in data pipeline. + describe( 'downcastCell()', () => { describe( 'editing pipeline', () => { it( 'should add tableCell on proper index in tr', () => { setModelData( model, modelTable( [ @@ -708,8 +1021,7 @@ describe( 'downcast converters', () => { } ); } ); - describe( 'downcastTableHeadingColumnsChange()', () => { - // The heading columns change downcast conversion is not executed in data pipeline. + describe( 'heading columns conversion', () => { describe( 'editing pipeline', () => { it( 'should work for adding heading columns', () => { setModelData( model, modelTable( [ @@ -784,6 +1096,11 @@ describe( 'downcast converters', () => { it( 'should be possible to overwrite', () => { editor.conversion.attributeToAttribute( { model: 'headingColumns', view: 'headingColumns', converterPriority: 'high' } ); + editor.conversion.elementToElement( { + model: 'tableCell', + view: ( tableCell, { writer } ) => toWidgetEditable( writer.createEditableElement( 'td' ), writer ), + converterPriority: 'high' + } ); setModelData( model, modelTable( [ [ '00[] ' ] ] ) ); const table = root.getChild( 0 ); @@ -809,10 +1126,17 @@ describe( 'downcast converters', () => { } ); it( 'should work with adding table cells', () => { + // +----+----+----+----+ + // | 00 | 01 | 02 | 03 | + // + +----+----+----+ + // | | 11 | 12 | 13 | + // +----+----+----+----+ + // | 20 | 22 | 23 | + // +----+----+----+----+ setModelData( model, modelTable( [ - [ { rowspan: 2, contents: '00' }, '01', '13', '14' ], + [ { contents: '00', rowspan: 2 }, '01', '02', '03' ], [ '11', '12', '13' ], - [ { colspan: 2, contents: '20' }, '22', '23' ] + [ { contents: '20', colspan: 2 }, '22', '23' ] ], { headingColumns: 2 } ) ); const table = root.getChild( 0 ); @@ -826,13 +1150,19 @@ describe( 'downcast converters', () => { writer.insertElement( 'tableCell', table.getChild( 2 ), 1 ); } ); + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( modelTable( [ + [ { contents: '00', rowspan: 2 }, '01', '', '02', '03' ], + [ '11', '', '12', '13' ], + [ { contents: '20', colspan: 2 }, '', '22', '23' ] + ], { headingColumns: 3 } ) ); + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( viewTable( [ [ { isHeading: true, rowspan: 2, contents: '00' }, { isHeading: true, contents: '01' }, { isHeading: true, contents: '' }, - '13', - '14' + '02', + '03' ], [ { isHeading: true, contents: '11' }, @@ -876,10 +1206,7 @@ describe( 'downcast converters', () => { } ); } ); - describe( 'downcastTableHeadingRowsChange', () => { - // The heading rows change downcast conversion is not executed in data pipeline. - // Note that headingRows table attribute triggers whole table downcast. - + describe( 'heading rows conversion', () => { describe( 'editing pipeline', () => { it( 'should work for adding heading rows', () => { setModelData( model, modelTable( [ @@ -1194,249 +1521,6 @@ describe( 'downcast converters', () => { } ); } ); - describe( 'downcastRemoveRow()', () => { - // The remove row downcast conversion is not executed in data pipeline. - describe( 'editing pipeline', () => { - it( 'should react to removed row from the beginning of a body rows (no heading rows)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01' ], - [ '10', '11' ] - ] ) ); - - const table = root.getChild( 0 ); - - model.change( writer => { - writer.remove( table.getChild( 1 ) ); - } ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '00' + - '' + - '01' + - '
    ' + - '
    ' - ); - } ); - - it( 'should react to removed row from the end of a body rows (no heading rows)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01' ], - [ '10', '11' ] - ] ) ); - - const table = root.getChild( 0 ); - - model.change( writer => { - writer.remove( table.getChild( 0 ) ); - } ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '10' + - '' + - '11' + - '
    ' + - '
    ' - ); - } ); - - it( 'should react to removed row from the beginning of a heading rows (no body rows)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01' ], - [ '10', '11' ] - ], { headingRows: 2 } ) ); - - const table = root.getChild( 0 ); - - model.change( writer => { - // Removing row from a heading section changes requires changing heading rows attribute. - writer.setAttribute( 'headingRows', 1, table ); - writer.remove( table.getChild( 0 ) ); - } ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '10' + - '' + - '11' + - '
    ' + - '
    ' - ); - } ); - - it( 'should react to removed row from the end of a heading rows (no body rows)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01' ], - [ '10', '11' ] - ], { headingRows: 2 } ) ); - - const table = root.getChild( 0 ); - - model.change( writer => { - // Removing row from a heading section changes requires changing heading rows attribute. - writer.setAttribute( 'headingRows', 1, table ); - writer.remove( table.getChild( 1 ) ); - } ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '00' + - '' + - '01' + - '
    ' + - '
    ' - ); - } ); - - it( 'should react to removed row from the end of a heading rows (first cell in body has colspan)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01', '02', '03' ], - [ { rowspan: 2, colspan: 2, contents: '10' }, '12', '13' ], - [ '22', '23' ] - ], { headingRows: 1 } ) ); - - const table = root.getChild( 0 ); - - model.change( writer => { - // Removing row from a heading section changes requires changing heading rows attribute. - writer.remove( table.getChild( 0 ) ); - writer.setAttribute( 'headingRows', 0, table ); - } ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '10' + - '' + - '12' + - '' + - '13' + - '
    ' + - '22' + - '' + - '23' + - '
    ' + - '
    ' - ); - } ); - - it( 'should remove empty thead if a last row was removed from a heading rows (has heading and body)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01' ], - [ '10', '11' ] - ], { headingRows: 1 } ) ); - - const table = root.getChild( 0 ); - - model.change( writer => { - // Removing row from a heading section changes requires changing heading rows attribute. - writer.removeAttribute( 'headingRows', table ); - writer.remove( table.getChild( 0 ) ); - } ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '10' + - '' + - '11' + - '
    ' + - '
    ' - ); - } ); - - it( 'should remove empty tbody if a last row was removed a body rows (has heading and body)', () => { - setModelData( model, modelTable( [ - [ '00[]', '01' ], - [ '10', '11' ] - ], { headingRows: 1 } ) ); - - const table = root.getChild( 0 ); - - model.change( writer => { - writer.remove( table.getChild( 1 ) ); - } ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '00' + - '' + - '01' + - '
    ' + - '
    ' - ); - } ); - } ); - } ); - describe( 'marker highlight conversion on table cell', () => { describe( 'single class in highlight descriptor', () => { beforeEach( async () => { diff --git a/packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js b/packages/ckeditor5-table/tests/converters/table-cell-refresh-handler.js similarity index 99% rename from packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js rename to packages/ckeditor5-table/tests/converters/table-cell-refresh-handler.js index 038abc8efe1..b6b4776949f 100644 --- a/packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js +++ b/packages/ckeditor5-table/tests/converters/table-cell-refresh-handler.js @@ -14,7 +14,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import TableEditing from '../../src/tableediting'; import { viewTable } from '../_utils/utils'; -describe( 'Table cell refresh post-fixer', () => { +describe( 'Table cell refresh handler', () => { let editor, model, doc, root, view, element; testUtils.createSinonSandbox(); diff --git a/packages/ckeditor5-table/tests/table-integration.js b/packages/ckeditor5-table/tests/table-integration.js index cb96c110fa2..1a44bb37394 100644 --- a/packages/ckeditor5-table/tests/table-integration.js +++ b/packages/ckeditor5-table/tests/table-integration.js @@ -8,7 +8,7 @@ import Widget from '@ckeditor/ckeditor5-widget/src/widget'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; diff --git a/packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js b/packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js index bf2670e9307..1f6ebfd1056 100644 --- a/packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js +++ b/packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js @@ -26,11 +26,18 @@ describe( 'TableCaptionEditing', () => { isBlock: true, allowWhere: '$block' } ); - schema.register( 'caption', { - allowIn: 'foo', - allowContentOf: '$block', - isLimit: true - } ); + + if ( schema.isRegistered( 'caption' ) ) { + schema.extend( 'caption', { + allowIn: 'foo' + } ); + } else { + schema.register( 'caption', { + allowIn: 'foo', + allowContentOf: '$block', + isLimit: true + } ); + } conversion.elementToElement( { view: 'foo', @@ -87,9 +94,7 @@ describe( 'TableCaptionEditing', () => { it( 'should not convert caption outside of the table', async () => { const editor = await VirtualTestEditor .create( { - plugins: [ - FakePlugin, - TableEditing, TableCaptionEditing, Paragraph, TableCaptionEditing ] + plugins: [ TableEditing, TableCaptionEditing, Paragraph, TableCaptionEditing, FakePlugin ] } ); setModelData( editor.model, diff --git a/packages/ckeditor5-table/tests/tableclipboard-paste.js b/packages/ckeditor5-table/tests/tableclipboard-paste.js index abeb4344706..157df2a4583 100644 --- a/packages/ckeditor5-table/tests/tableclipboard-paste.js +++ b/packages/ckeditor5-table/tests/tableclipboard-paste.js @@ -10,7 +10,7 @@ import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteedi import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import HorizontalLineEditing from '@ckeditor/ckeditor5-horizontal-line/src/horizontallineediting'; import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Input from '@ckeditor/ckeditor5-typing/src/input'; diff --git a/packages/ckeditor5-widget/tests/widget-events.js b/packages/ckeditor5-widget/tests/widget-events.js index 464332e164b..e91c1fcd958 100644 --- a/packages/ckeditor5-widget/tests/widget-events.js +++ b/packages/ckeditor5-widget/tests/widget-events.js @@ -72,14 +72,14 @@ describe( 'Widget - Events', () => { function defineSchema( editor ) { editor.model.schema.register( 'simpleWidgetElement', { - inheritAllFrom: '$block', + allowIn: '$root', isObject: true } ); } function defineConverters( editor ) { editor.conversion.for( 'editingDowncast' ) - .elementToElement( { + .elementToStructure( { model: 'simpleWidgetElement', view: ( modelElement, { writer } ) => { const widgetElement = createWidgetView( modelElement, { writer } ); @@ -89,7 +89,7 @@ describe( 'Widget - Events', () => { } ); editor.conversion.for( 'dataDowncast' ) - .elementToElement( { + .elementToStructure( { model: 'simpleWidgetElement', view: createWidgetView } ); diff --git a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js index f0a6d46da31..3df1bea09e3 100644 --- a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js @@ -1899,13 +1899,14 @@ describe( 'WidgetTypeAround', () => { } ); editor.conversion.for( 'downcast' ) - .elementToElement( { + .elementToStructure( { model: 'inlineWidget', view: ( modelItem, { writer } ) => { const container = writer.createContainerElement( 'inlineWidget' ); const viewText = writer.createText( 'inline-widget' ); writer.insert( writer.createPositionAt( container, 0 ), viewText ); + writer.insert( writer.createPositionAt( container, 0 ), writer.createSlot() ); return toWidget( container, writer, { label: 'inline widget' diff --git a/packages/ckeditor5-word-count/tests/utils.js b/packages/ckeditor5-word-count/tests/utils.js index a517c88d97e..674efeed193 100644 --- a/packages/ckeditor5-word-count/tests/utils.js +++ b/packages/ckeditor5-word-count/tests/utils.js @@ -13,7 +13,7 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; import Enter from '@ckeditor/ckeditor5-enter/src/enter'; import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; diff --git a/packages/ckeditor5-word-count/tests/wordcount.js b/packages/ckeditor5-word-count/tests/wordcount.js index 93db613ed8a..e7451aa4442 100644 --- a/packages/ckeditor5-word-count/tests/wordcount.js +++ b/packages/ckeditor5-word-count/tests/wordcount.js @@ -16,7 +16,7 @@ import Position from '@ckeditor/ckeditor5-engine/src/model/position'; import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; import env from '@ckeditor/ckeditor5-utils/src/env'; -import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; +import ListEditing from '@ckeditor/ckeditor5-list/src/list/listediting'; import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting'; import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting'; import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockediting';