diff --git a/docs/_snippets/framework/extending-content-arbitrary-attribute-values.js b/docs/_snippets/framework/extending-content-arbitrary-attribute-values.js
new file mode 100644
index 000000000..d02e36b21
--- /dev/null
+++ b/docs/_snippets/framework/extending-content-arbitrary-attribute-values.js
@@ -0,0 +1,68 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+function HandleFontSizeValue( editor ) {
+ // Add special catch-all converter for 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 needed to further convert the value to meet business requirements.
+ // In the sample the font-size is configured to handle only the sizes:
+ // 10, 12, 14, 'default', 18, 20, 22
+ // 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 special converter for font-size feature to convert all (even not configured) model attribute values.
+ editor.conversion.for( 'downcast' ).attributeToElement( {
+ model: {
+ key: 'fontSize'
+ },
+ view: ( modelValue, viewWriter ) => {
+ return viewWriter.createAttributeElement( 'span', {
+ style: `font-size:${ modelValue }px`
+ } );
+ },
+ converterPriority: 'high'
+ } );
+}
+
+ClassicEditor
+ .create( document.querySelector( '#snippet-arbitrary-attribute-values' ), {
+ cloudServices: CS_CONFIG,
+ extraPlugins: [ HandleFontSizeValue ],
+ toolbar: {
+ items: [ 'heading', '|', 'bold', 'italic', '|', 'fontSize' ],
+ viewportTopOffset: window.getViewportTopOffsetConfig()
+ },
+ fontSize: {
+ options: [ 10, 12, 14, 'default', 18, 20, 22 ]
+ }
+ } )
+ .then( editor => {
+ window.editor = editor;
+ } )
+ .catch( err => {
+ console.error( err.stack );
+ } );
+
diff --git a/docs/_snippets/framework/extending-content-custom-figure-attributes.html b/docs/_snippets/framework/extending-content-custom-figure-attributes.html
new file mode 100644
index 000000000..335f70539
--- /dev/null
+++ b/docs/_snippets/framework/extending-content-custom-figure-attributes.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
Image:
+
+
+
Table:
+
+
+
foo
+
+
+
diff --git a/docs/_snippets/framework/extending-content-custom-figure-attributes.js b/docs/_snippets/framework/extending-content-custom-figure-attributes.js
new file mode 100644
index 000000000..2e4643a3b
--- /dev/null
+++ b/docs/_snippets/framework/extending-content-custom-figure-attributes.js
@@ -0,0 +1,170 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+/**
+ * Plugin that converts custom attributes for elements that are wrapped in
in the view.
+ */
+function CustomFigureAttributes( editor ) {
+ // Define on which elements the css classes should be preserved:
+ setupCustomClassConversion( 'img', 'image', editor );
+ setupCustomClassConversion( 'table', 'table', editor );
+
+ editor.conversion.for( 'upcast' ).add( upcastCustomClasses( 'figure' ), { priority: 'low' } );
+
+ // Define custom attributes that should be preserved.
+ setupCustomAttributeConversion( 'img', 'image', 'id', editor );
+ setupCustomAttributeConversion( 'table', 'table', 'id', editor );
+}
+
+/**
+ * Setups conversion that preservers classes on img/table elements
+ */
+function setupCustomClassConversion( viewElementName, modelElementName, editor ) {
+ // The 'customClass' attribute will store custom classes from data in the model so schema definitions to allow this attribute.
+ editor.model.schema.extend( modelElementName, { allowAttributes: [ 'customClass' ] } );
+
+ // Define upcast converters for and
elements with "low" priority so they are run after default converters.
+ editor.conversion.for( 'upcast' ).add( upcastCustomClasses( viewElementName ), { priority: 'low' } );
+
+ // Define downcast converters for model element with "low" priority so they are run after default converters.
+ editor.conversion.for( 'downcast' ).add( downcastCustomClasses( modelElementName ), { priority: 'low' } );
+}
+
+/**
+ * Setups conversion for custom attribute on view elements contained inside figure.
+ *
+ * This method:
+ *
+ * - adds proper schema rules
+ * - adds an upcast converter
+ * - adds a downcast converter
+ */
+function setupCustomAttributeConversion( viewElementName, modelElementName, viewAttribute, editor ) {
+ // Extend schema to store 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 upcast converter that will pass all classes from view element to 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 pick up classes from base element and from figure element also so it should be extensible.
+ const currentAttributeValue = modelElement.getAttribute( 'customClass' ) || [];
+
+ currentAttributeValue.push( ...viewItem.getClassNames() );
+
+ conversionApi.writer.setAttribute( 'customClass', currentAttributeValue, modelElement );
+ } );
+}
+
+/**
+ * Creates downcast converter that add classes defined in `customClass` attribute to given view element.
+ *
+ * This converter expects that view element is nested in figure element.
+ */
+function downcastCustomClasses( modelElementName ) {
+ return dispatcher => dispatcher.on( `insert:${ modelElementName }`, ( evt, data, conversionApi ) => {
+ const modelElement = data.item;
+
+ const viewFigure = conversionApi.mapper.toViewElement( modelElement );
+
+ if ( !viewFigure ) {
+ return;
+ }
+
+ // The below code assumes that classes are set on
element...
+ conversionApi.writer.addClass( modelElement.getAttribute( 'customClass' ), viewFigure );
+
+ // ... but if you preferIf the classes should be passed to the find the view element inside figure:
+ //
+ // const viewElement = findViewChild( viewFigure, viewElementName, conversionApi );
+ //
+ // conversionApi.writer.addClass( modelElement.getAttribute( 'customClass' ), viewElement );
+ } );
+}
+
+/**
+ * Helper method that search for given view element in all children of 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( viewElementName ) );
+}
+
+/**
+ * Returns 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 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 );
+ } );
+}
+
+ClassicEditor
+ .create( document.querySelector( '#snippet-custom-figure-attributes' ), {
+ cloudServices: CS_CONFIG,
+ extraPlugins: [ CustomFigureAttributes ],
+ toolbar: {
+ viewportTopOffset: window.getViewportTopOffsetConfig()
+ }
+ } )
+ .then( editor => {
+ window.editor = editor;
+ } )
+ .catch( err => {
+ console.error( err.stack );
+ } );
diff --git a/docs/api/engine.md b/docs/api/engine.md
index 1cef12f85..d6fa199d4 100644
--- a/docs/api/engine.md
+++ b/docs/api/engine.md
@@ -30,5 +30,5 @@ The source code of this package is available on GitHub in https://github.com/cke
* [`@ckeditor/ckeditor5-engine` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-engine)
* [`ckeditor/ckeditor5-engine` on GitHub](https://github.com/ckeditor/ckeditor5-engine)
-* [Issue tracker](https://github.com/ckeditor/ckeditor5-engine/issues)
+* [Issue tracker](https://github.com/ckeditor/ckeditor5/issues)
* [Changelog](https://github.com/ckeditor/ckeditor5-engine/blob/master/CHANGELOG.md)
diff --git a/docs/framework/guides/deep-dive/conversion-extending-output.md b/docs/framework/guides/deep-dive/conversion-extending-output.md
new file mode 100644
index 000000000..ff46cae84
--- /dev/null
+++ b/docs/framework/guides/deep-dive/conversion-extending-output.md
@@ -0,0 +1,359 @@
+---
+category: framework-deep-dive-conversion
+menu-title: Extending editor output
+order: 20
+---
+
+{@snippet framework/build-extending-content-source}
+
+# Extending editor output
+
+In this guide, we will focus on customization to the one–way {@link framework/guides/architecture/editing-engine#editing-pipeline "downcast"} pipeline of the editor, which transforms data from the model to the editing view and the output data only. 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 editor, check out the {@link framework/guides/deep-dive/conversion-preserving-custom-content next guide} of this guide.
+
+## Before starting
+
+### Code architecture
+
+It is recommended that the code that customizes editor data and editing pipelines is delivered as {@link framework/guides/architecture/core-editor-architecture#plugins plugins} and all examples in this chapter 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 require to import any module from CKEditor 5 Framework, hence, you can write them without rebuilding the editor. In other words, such converters can easily be added to existing CKEditor 5 builds.
+
+### Granular converters
+
+You can create separate converters for data and editing (downcast) pipelines. The former (`dataDowncast`) will customize the data in the editor output (e.g. when {@link module:core/editor/utils/dataapimixin~DataApi#getData `editor.getData()`}) and the later (`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 examples to keep them simple but keep in mind you have 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 data and editing downcast pipelines.
+editor.conversion.for( 'downcast' ).add( dispatcher => {
+ // ...
+} );
+```
+
+### CKEditor 5 inspector
+
+{@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} is an invaluable help with working with the model and view structures. It allows browsing their structure and checking selection positions like in typical browser dev tools. Make sure to enable CKEditor 5 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. That 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',
+ attributes: {
+ class: 'my-green-link'
+ }
+ }
+ }
+ }
+ } )
+ ```
+
+
+{@snippet framework/extending-content-add-link-class}
+
+Adding a custom CSS class to all links is made 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 data and 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 links (`...`) which do not have "ckeditor.com" in their `href="..."` get the `target="_blank"` attribute. That 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}
+
+**Note:** Edit the URL of the links including "ckeditor.com" and other domains to see them marked as "internal" or "external".
+
+Adding the `target` attribute to all "external" links is made 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 data and 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 (`...`) which do not have "https://" in their `href="..."` attribute get the `.unsafe-link` CSS class. That 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 ),
+ attributes: {
+ class: 'unsafe-link'
+ }
+ }
+ }
+ }
+ } )
+ ```
+
+
+{@snippet framework/extending-content-add-unsafe-link-class}
+
+**Note:** Edit the URL of the links using "http://" or "https://" to see them marked as "safe" or "unsafe".
+
+Adding the `.unsafe-link` CSS class to all "unsafe" links is made 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 data and 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" 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. That includes all heading elements in the editor output (`editor.getData()`) and in the edited content (existing and future ones).
+
+{@snippet framework/extending-content-add-heading-class}
+
+Adding a custom CSS class to all `
...
` elements is made 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 a webpage.
+
+
+```js
+// This plugin brings customization to the downcast pipeline of the editor.
+function AddClassToAllHeading1( editor ) {
+ // Both data and 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;
+}
+```
diff --git a/docs/framework/guides/deep-dive/conversion-introduction.md b/docs/framework/guides/deep-dive/conversion-introduction.md
new file mode 100644
index 000000000..e925cbab1
--- /dev/null
+++ b/docs/framework/guides/deep-dive/conversion-introduction.md
@@ -0,0 +1,128 @@
+---
+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
+
+This guide extends the {@link framework/guides/architecture/editing-engine introduction to CKEditor 5 editing engine architecture}. Therefore, we highly recommend reading the former guide first.
+
+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 the content in the editor view and data output: inline and block.
+
+The inline content means elements like ``, `` or ``. Unlike `
`) loaded in 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}
+
+Allowing all attributes on `div` elements is achieved by custom "upcast" and "downcast" converters that copies each attribute one by one.
+
+Allowing every possible attribute on div in the model is done by adding a {@link module:engine/model/schema~Schema#addAttributeCheck addAttributeCheck()} callback.
+
+
+ Allowing every attribute on `
` elements might introduce security issues - ise XSS attacks. The production code should use only application related attributes and/or properly encode data.
+
+
+Adding "upcast" and "downcast" converters for the `
` element is enough for cases where its attributes does not change. If attributes in the model are modified those `elementToElement()` converters will not be called as `div` 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 "downcast" dispatcher.
+
+```js
+function ConvertDivAttributes( editor ) {
+ // Allow divs in the model.
+ editor.model.schema.register( 'div', {
+ allowWhere: '$block',
+ allowContentOf: '$root'
+ } );
+
+ // Allow divs in the model to have all attributes.
+ editor.model.schema.addAttributeCheck( context => {
+ if ( context.endsWith( 'div' ) ) {
+ return true;
+ }
+ } );
+
+ // View-to-model converter converting a view div with all its attributes to the model.
+ editor.conversion.for( 'upcast' ).elementToElement( {
+ view: 'div',
+ model: ( viewElement, modelWriter ) => {
+ return modelWriter.createElement( 'div', viewElement.getAttributes() );
+ }
+ } );
+
+ // Model-to-view convert for the div element (attrbiutes are converted separately).
+ editor.conversion.for( 'downcast' ).elementToElement( {
+ model: 'div',
+ view: 'div'
+ } );
+
+ // Model-to-view converter for div attributes.
+ // Note that we use a lower-level, event-based API here.
+ editor.conversion.for( 'downcast' ).add( dispatcher => {
+ dispatcher.on( 'attribute', ( evt, data, conversionApi ) => {
+ // Convert div 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 );
+ } );
+```
+
+## Parse attribute values
+
+Some features, like {@link features/font Font}, allows only specific values for inline attributes. In this example we'll add a converter that will parse any `font-size` value into one of defined values.
+
+{@snippet framework/extending-content-arbitrary-attribute-values}
+
+Parsing any font value to model requires writing adding custom "upcast" converter that will override 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 we're also adding a model-to-view converter that simply outputs any model value to font-size using `px` units.
+
+```js
+function HandleFontSizeValue( editor ) {
+ // Add special catch-all converter for 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 needed to further convert the value to meet business requirements.
+ // In the sample the font-size is configured to handle only the 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 special converter for font-size feature to convert all (even not configured)
+ // model attribute values.
+ editor.conversion.for( 'downcast' ).attributeToElement( {
+ model: {
+ key: 'fontSize'
+ },
+ view: ( modelValue, 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/image Image} and {@link features/table Table} features wraps view elements (`` for Image nad `
` for Table) in `
`. During the downcast conversion the model element is mapped to `
` not the inner element. In such cases the default `conversion.attributeToAttribute()` conversion helpers could lost information on which element the attribute should be set. To overcome this limitation it is sufficient to write a custom converter that add custom attributes to elements already converted by base features. The key point is to add those converters with lower priority 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 own attributes to preserve just add another `setupCustomAttributeConversion()` call with desired names.
+
+```js
+/**
+ * Plugin that converts custom attributes for elements that are wrapped in
in the view.
+ */
+function CustomFigureAttributes( editor ) {
+ // Define on which elements the css classes should be preserved:
+ setupCustomClassConversion( 'img', 'image', editor );
+ setupCustomClassConversion( 'table', 'table', editor );
+
+ editor.conversion.for( 'upcast' ).add( upcastCustomClasses( 'figure' ), { priority: 'low' } );
+
+ // Define custom attributes that should be preserved.
+ setupCustomAttributeConversion( 'img', 'image', 'id', editor );
+ setupCustomAttributeConversion( 'table', 'table', 'id', editor );
+}
+
+/**
+ * Setups conversion that preservers classes on img/table elements
+ */
+function setupCustomClassConversion( viewElementName, modelElementName, editor ) {
+ // The 'customClass' attribute will store custom classes from data in the model so schema definitions to allow this attribute.
+ editor.model.schema.extend( modelElementName, { allowAttributes: [ 'customClass' ] } );
+
+ // Define upcast converters for and
elements with "low" priority so they are run after default converters.
+ editor.conversion.for( 'upcast' ).add( upcastCustomClasses( viewElementName ), { priority: 'low' } );
+
+ // Define downcast converters for model element with "low" priority so they are run after default converters.
+ editor.conversion.for( 'downcast' ).add( downcastCustomClasses( modelElementName ), { priority: 'low' } );
+}
+
+/**
+ * Setups conversion for custom attribute on view elements contained inside figure.
+ *
+ * This method:
+ *
+ * - adds proper schema rules
+ * - adds an upcast converter
+ * - adds a downcast converter
+ */
+function setupCustomAttributeConversion( viewElementName, modelElementName, viewAttribute, editor ) {
+ // Extend schema to store 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 upcast converter that will pass all classes from view element to 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 pick up classes from base element and from figure element also so it should be extensible.
+ const currentAttributeValue = modelElement.getAttribute( 'customClass' ) || [];
+
+ currentAttributeValue.push( ...viewItem.getClassNames() );
+
+ conversionApi.writer.setAttribute( 'customClass', currentAttributeValue, modelElement );
+ } );
+}
+
+/**
+ * Creates downcast converter that add classes defined in `customClass` attribute to given view element.
+ *
+ * This converter expects that view element is nested in figure element.
+ */
+function downcastCustomClasses( modelElementName ) {
+ return dispatcher => dispatcher.on( `insert:${ modelElementName }`, ( evt, data, conversionApi ) => {
+ const modelElement = data.item;
+
+ const viewFigure = conversionApi.mapper.toViewElement( modelElement );
+
+ if ( !viewFigure ) {
+ return;
+ }
+
+ // The below code assumes that classes are set on
element...
+ conversionApi.writer.addClass( modelElement.getAttribute( 'customClass' ), viewFigure );
+
+ // ... but if you preferIf the classes should be passed to the find the view element inside figure:
+ //
+ // const viewElement = findViewChild( viewFigure, viewElementName, conversionApi );
+ //
+ // conversionApi.writer.addClass( modelElement.getAttribute( 'customClass' ), viewElement );
+ } );
+}
+
+/**
+ * Helper method that search for given view element in all children of 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( viewElementName ) );
+}
+
+/**
+ * Returns 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 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 );
+ } );
+```
diff --git a/src/conversion/upcasthelpers.js b/src/conversion/upcasthelpers.js
index a01022325..719bd7053 100644
--- a/src/conversion/upcasthelpers.js
+++ b/src/conversion/upcasthelpers.js
@@ -405,7 +405,7 @@ function upcastElementToElement( config ) {
const converter = prepareToElementConverter( config );
- const elementName = getViewElementNameFromConfig( config );
+ const elementName = getViewElementNameFromConfig( config.view );
const eventName = elementName ? 'element:' + elementName : 'element';
return dispatcher => {
@@ -431,7 +431,7 @@ function upcastElementToAttribute( config ) {
const converter = prepareToAttributeConverter( config, false );
- const elementName = getViewElementNameFromConfig( config );
+ const elementName = getViewElementNameFromConfig( config.view );
const eventName = elementName ? 'element:' + elementName : 'element';
return dispatcher => {
@@ -493,15 +493,15 @@ function upcastElementToMarker( config ) {
// Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name
// and if so, returns it.
//
-// @param {Object} config Conversion config.
+// @param {Object} config Conversion view config.
// @returns {String|null} View element name or `null` if name is not directly set.
-function getViewElementNameFromConfig( config ) {
- if ( typeof config.view == 'string' ) {
- return config.view;
+function getViewElementNameFromConfig( viewConfig ) {
+ if ( typeof viewConfig == 'string' ) {
+ return viewConfig;
}
- if ( typeof config.view == 'object' && typeof config.view.name == 'string' ) {
- return config.view.name;
+ if ( typeof viewConfig == 'object' && typeof viewConfig.name == 'string' ) {
+ return viewConfig.name;
}
return null;
@@ -684,7 +684,7 @@ function prepareToAttributeConverter( config, shallow ) {
return;
}
- if ( onlyViewNameIsDefined( config ) ) {
+ if ( onlyViewNameIsDefined( config.view, data.viewItem ) ) {
match.match.name = true;
} else {
// Do not test or consume `name` consumable.
@@ -714,14 +714,17 @@ function prepareToAttributeConverter( config, shallow ) {
// Helper function that checks if element name should be consumed in attribute converters.
//
-// @param {Object} config Conversion config.
+// @param {Object} config Conversion view config.
// @returns {Boolean}
-function onlyViewNameIsDefined( config ) {
- if ( typeof config.view == 'object' && !getViewElementNameFromConfig( config ) ) {
+function onlyViewNameIsDefined( viewConfig, viewItem ) {
+ // https://github.com/ckeditor/ckeditor5-engine/issues/1786
+ const configToTest = typeof viewConfig == 'function' ? viewConfig( viewItem ) : viewConfig;
+
+ if ( typeof configToTest == 'object' && !getViewElementNameFromConfig( configToTest ) ) {
return false;
}
- return !config.view.classes && !config.view.attributes && !config.view.styles;
+ return !configToTest.classes && !configToTest.attributes && !configToTest.styles;
}
// Helper function for to-model-attribute converter. Sets model attribute on given range. Checks {@link module:engine/model/schema~Schema}
diff --git a/src/model/documentselection.js b/src/model/documentselection.js
index c40412b16..01daeaa06 100644
--- a/src/model/documentselection.js
+++ b/src/model/documentselection.js
@@ -233,41 +233,26 @@ export default class DocumentSelection {
}
/**
- * Gets elements of type "block" touched by the selection.
+ * Gets elements of type {@link module:engine/model/schema~Schema#isBlock "block"} touched by the selection.
*
* This method's result can be used for example to apply block styling to all blocks covered by this selection.
*
- * **Note:** `getSelectedBlocks()` always returns the deepest block.
+ * **Note:** `getSelectedBlocks()` returns blocks that are nested in other non-block elements
+ * but will not return blocks nested in other blocks.
*
- * In this case the function will return exactly all 3 paragraphs:
+ * In this case the function will return exactly all 3 paragraphs (note: `
` is not a block itself):
*
* [a
- *
+ *
* b
- *
+ *
* c]d
*
* In this case the paragraph will also be returned, despite the collapsed selection:
*
* []a
*
- * **Special case**: If a selection ends at the beginning of a block, that block is not returned as from user perspective
- * this block wasn't selected. See [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984) for more details.
- *
- * [a
- * b
- * ]c // this block will not be returned
- *
- * @returns {Iterable.}
- */
- getSelectedBlocks() {
- return this._selection.getSelectedBlocks();
- }
-
- /**
- * Returns blocks that aren't nested in other selected blocks.
- *
- * In this case the method will return blocks A, B and E because C & D are children of block B:
+ * In such a scenario, however, only blocks A, B & E will be returned as blocks C & D are nested in block B:
*
* [
*
@@ -276,12 +261,24 @@ export default class DocumentSelection {
*
* ]
*
- * **Note:** To get all selected blocks use {@link #getSelectedBlocks `getSelectedBlocks()`}.
+ * If the selection is inside a block all the inner blocks (A & B) are returned:
+ *
+ *
+ * [a
+ * b]
+ *
+ *
+ * **Special case**: If a selection ends at the beginning of a block, that block is not returned as from user perspective
+ * this block wasn't selected. See [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984) for more details.
+ *
+ * [a
+ * b
+ * ]c // this block will not be returned
*
* @returns {Iterable.}
*/
- getTopMostBlocks() {
- return this._selection.getTopMostBlocks();
+ getSelectedBlocks() {
+ return this._selection.getSelectedBlocks();
}
/**
diff --git a/src/model/selection.js b/src/model/selection.js
index 2b14b49a5..11ce80365 100644
--- a/src/model/selection.js
+++ b/src/model/selection.js
@@ -641,24 +641,41 @@ export default class Selection {
}
/**
- * Gets elements of type "block" touched by the selection.
+ * Gets elements of type {@link module:engine/model/schema~Schema#isBlock "block"} touched by the selection.
*
* This method's result can be used for example to apply block styling to all blocks covered by this selection.
*
- * **Note:** `getSelectedBlocks()` always returns the deepest block.
+ * **Note:** `getSelectedBlocks()` returns blocks that are nested in other non-block elements
+ * but will not return blocks nested in other blocks.
*
- * In this case the function will return exactly all 3 paragraphs:
+ * In this case the function will return exactly all 3 paragraphs (note: `
` is not a block itself):
*
* [a
- *
+ *
* b
- *
+ *
* c]d
*
* In this case the paragraph will also be returned, despite the collapsed selection:
*
* []a
*
+ * In such a scenario, however, only blocks A, B & E will be returned as blocks C & D are nested in block B:
+ *
+ * [
+ *
+ *
+ *
+ *
+ * ]
+ *
+ * If the selection is inside a block all the inner blocks (A & B) are returned:
+ *
+ *
+ * [a
+ * b]
+ *
+ *
* **Special case**: If a selection ends at the beginning of a block, that block is not returned as from user perspective
* this block wasn't selected. See [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984) for more details.
*
@@ -672,56 +689,30 @@ export default class Selection {
const visited = new WeakSet();
for ( const range of this.getRanges() ) {
+ // Get start block of range in case of a collapsed range.
const startBlock = getParentBlock( range.start, visited );
- if ( startBlock ) {
+ if ( startBlock && isTopBlockInRange( startBlock, range ) ) {
yield startBlock;
}
for ( const value of range.getWalker() ) {
- if ( value.type == 'elementEnd' && isUnvisitedBlockContainer( value.item, visited ) ) {
- yield value.item;
+ const block = value.item;
+
+ if ( value.type == 'elementEnd' && isUnvisitedTopBlock( block, visited, range ) ) {
+ yield block;
}
}
const endBlock = getParentBlock( range.end, visited );
// #984. Don't return the end block if the range ends right at its beginning.
- if ( endBlock && !range.end.isTouching( Position._createAt( endBlock, 0 ) ) ) {
+ if ( endBlock && !range.end.isTouching( Position._createAt( endBlock, 0 ) ) && isTopBlockInRange( endBlock, range ) ) {
yield endBlock;
}
}
}
- /**
- * Returns blocks that aren't nested in other selected blocks.
- *
- * In this case the method will return blocks A, B and E because C & D are children of block B:
- *
- * [
- *
- *
- *
- *
- * ]
- *
- * **Note:** To get all selected blocks use {@link #getSelectedBlocks `getSelectedBlocks()`}.
- *
- * @returns {Iterable.}
- */
- * getTopMostBlocks() {
- const selected = Array.from( this.getSelectedBlocks() );
-
- for ( const block of selected ) {
- const parentBlock = findAncestorBlock( block );
-
- // Filter out blocks that are nested in other selected blocks (like paragraphs in tables).
- if ( !parentBlock || !selected.includes( parentBlock ) ) {
- yield block;
- }
- }
- }
-
/**
* Checks whether the selection contains the entire content of the given element. This means that selection must start
* at a position {@link module:engine/model/position~Position#isTouching touching} the element's start and ends at position
@@ -831,7 +822,7 @@ mix( Selection, EmitterMixin );
// Checks whether the given element extends $block in the schema and has a parent (is not a root).
// Marks it as already visited.
-function isUnvisitedBlockContainer( element, visited ) {
+function isUnvisitedBlock( element, visited ) {
if ( visited.has( element ) ) {
return false;
}
@@ -841,6 +832,11 @@ function isUnvisitedBlockContainer( element, visited ) {
return element.document.model.schema.isBlock( element ) && element.parent;
}
+// Checks if the given element is a $block was not previously visited and is a top block in a range.
+function isUnvisitedTopBlock( element, visited, range ) {
+ return isUnvisitedBlock( element, visited ) && isTopBlockInRange( element, range );
+}
+
// Finds the lowest element in position's ancestors which is a block.
// It will search until first ancestor that is a limit element.
// Marks all ancestors as already visited to not include any of them later on.
@@ -859,7 +855,7 @@ function getParentBlock( position, visited ) {
hasParentLimit = schema.isLimit( element );
- return !hasParentLimit && isUnvisitedBlockContainer( element, visited );
+ return !hasParentLimit && isUnvisitedBlock( element, visited );
} );
// Mark all ancestors of this position's parent, because find() might've stopped early and
@@ -869,6 +865,23 @@ function getParentBlock( position, visited ) {
return block;
}
+// Checks if the blocks is not nested in other block inside a range.
+//
+// @param {module:engine/model/elmenent~Element} block Block to check.
+// @param {module:engine/model/range~Range} range Range to check.
+function isTopBlockInRange( block, range ) {
+ const parentBlock = findAncestorBlock( block );
+
+ if ( !parentBlock ) {
+ return true;
+ }
+
+ // Add loose flag to check as parentRange can be equal to range.
+ const isParentInRange = range.containsRange( Range._createOn( parentBlock ), true );
+
+ return !isParentInRange;
+}
+
// Returns first ancestor block of a node.
//
// @param {module:engine/model/node~Node} node
diff --git a/src/view/downcastwriter.js b/src/view/downcastwriter.js
index a930e2dda..7561e12df 100644
--- a/src/view/downcastwriter.js
+++ b/src/view/downcastwriter.js
@@ -788,26 +788,26 @@ export default class DowncastWriter {
}
/**
- * Wraps elements within range with provided {@link module:engine/view/attributeelement~AttributeElement AttributeElement}.
- * If a collapsed range is provided, it will be wrapped only if it is equal to view selection.
- *
- * If a collapsed range was passed and is same as selection, the selection
- * will be moved to the inside of the wrapped attribute element.
- *
- * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-invalid-range-container`
- * when {@link module:engine/view/range~Range#start}
- * and {@link module:engine/view/range~Range#end} positions are not placed inside same parent container.
- *
- * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not
- * an instance of {@link module:engine/view/attributeelement~AttributeElement AttributeElement}.
- *
- * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-nonselection-collapsed-range` when passed range
- * is collapsed and different than view selection.
- *
- * @param {module:engine/view/range~Range} range Range to wrap.
- * @param {module:engine/view/attributeelement~AttributeElement} attribute Attribute element to use as wrapper.
- * @returns {module:engine/view/range~Range} range Range after wrapping, spanning over wrapping attribute element.
- */
+ * Wraps elements within range with provided {@link module:engine/view/attributeelement~AttributeElement AttributeElement}.
+ * If a collapsed range is provided, it will be wrapped only if it is equal to view selection.
+ *
+ * If a collapsed range was passed and is same as selection, the selection
+ * will be moved to the inside of the wrapped attribute element.
+ *
+ * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-invalid-range-container`
+ * when {@link module:engine/view/range~Range#start}
+ * and {@link module:engine/view/range~Range#end} positions are not placed inside same parent container.
+ *
+ * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not
+ * an instance of {@link module:engine/view/attributeelement~AttributeElement AttributeElement}.
+ *
+ * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-nonselection-collapsed-range` when passed range
+ * is collapsed and different than view selection.
+ *
+ * @param {module:engine/view/range~Range} range Range to wrap.
+ * @param {module:engine/view/attributeelement~AttributeElement} attribute Attribute element to use as wrapper.
+ * @returns {module:engine/view/range~Range} range Range after wrapping, spanning over wrapping attribute element.
+ */
wrap( range, attribute ) {
if ( !( attribute instanceof AttributeElement ) ) {
throw new CKEditorError( 'view-writer-wrap-invalid-attribute', this.document );
diff --git a/src/view/renderer.js b/src/view/renderer.js
index c8bc5cc2e..8606b8b0b 100644
--- a/src/view/renderer.js
+++ b/src/view/renderer.js
@@ -101,6 +101,13 @@ export default class Renderer {
*/
this.isFocused = false;
+ /**
+ * Indicates if the composition is in progress inside the view document view.
+ *
+ * @member {Boolean}
+ */
+ this.isComposing = false;
+
/**
* The text node in which the inline filler was rendered.
*
@@ -771,6 +778,11 @@ export default class Renderer {
* @returns {Boolean}
*/
_domSelectionNeedsUpdate( domSelection ) {
+ // Remain DOM selection untouched while composing. See #1782.
+ if ( this.isComposing ) {
+ return false;
+ }
+
if ( !this.domConverter.isDomSelectionCorrect( domSelection ) ) {
// Current DOM selection is in incorrect position. We need to update it.
return true;
diff --git a/src/view/view.js b/src/view/view.js
index 6101f5c41..f7f999b34 100644
--- a/src/view/view.js
+++ b/src/view/view.js
@@ -105,6 +105,7 @@ export default class View {
*/
this._renderer = new Renderer( this.domConverter, this.document.selection );
this._renderer.bind( 'isFocused' ).to( this.document );
+ this._renderer.bind( 'isComposing' ).to( this.document );
/**
* A DOM root attributes cache. It saves the initial values of DOM root attributes before the DOM element
diff --git a/tests/conversion/conversion.js b/tests/conversion/conversion.js
index e17ce29b2..659e0ae94 100644
--- a/tests/conversion/conversion.js
+++ b/tests/conversion/conversion.js
@@ -301,6 +301,14 @@ describe( 'Conversion', () => {
} );
it( 'config.view is an object with upcastAlso defined', () => {
+ schema.extend( '$text', {
+ allowAttributes: [ 'bold', 'xBold' ]
+ } );
+ conversion.attributeToElement( {
+ model: 'xBold',
+ view: 'x-bold'
+ } );
+
conversion.attributeToElement( {
model: 'bold',
view: 'strong',
@@ -310,22 +318,18 @@ describe( 'Conversion', () => {
name: 'span',
classes: 'bold'
},
- {
- name: 'span',
- styles: {
- 'font-weight': 'bold'
- }
- },
viewElement => {
const fontWeight = viewElement.getStyle( 'font-weight' );
- if ( viewElement.is( 'span' ) && fontWeight && /\d+/.test( fontWeight ) && Number( fontWeight ) > 500 ) {
+ if ( fontWeight == 'bold' || Number( fontWeight ) > 500 ) {
return {
- name: true,
styles: [ 'font-weight' ]
};
}
- }
+ },
+ // Duplicates the `x-bold` from above to test if only one attribute would be converted.
+ // It should not convert to both bold & x-bold.
+ viewElement => viewElement.is( 'x-bold' ) ? { name: 'x-bold' } : null
]
} );
@@ -363,6 +367,18 @@ describe( 'Conversion', () => {
'<$text bold="true">Foo$text>',
'
' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'c', 'd' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) )
+ .to.deep.equal( [ 'p#c', 'p#d' ] );
} );
it( 'returns an empty array if none of the selected elements is a block', () => {
setData( model, '
a
[ab]
b
' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.be.empty;
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.be.empty;
} );
it( 'returns an empty array if the selected element is not a block', () => {
setData( model, '
a
[]a
b
' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.be.empty;
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.be.empty;
} );
// Super edge case – should not happen (blocks should never be nested),
// but since the code handles it already it's worth testing.
- it( 'returns only the lowest block if blocks are nested', () => {
+ it( 'returns only the lowest block if blocks are nested (case #1)', () => {
setData( model, 'a[]b' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'b' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'nestedBlock#b' ] );
} );
- // Like above but trickier.
- it( 'returns only the lowest block if blocks are nested', () => {
+ // Like above but - with multiple ranges.
+ it( 'returns only the lowest block if blocks are nested (case #2)', () => {
setData(
model,
'a[b' +
'cd]'
);
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'b', 'd' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) )
+ .to.deep.equal( [ 'nestedBlock#b', 'nestedBlock#d' ] );
+ } );
+
+ // Like above but - with multiple collapsed ranges.
+ it( 'returns only the lowest block if blocks are nested (case #3)', () => {
+ setData(
+ model,
+ 'a[]b' +
+ 'cd[]'
+ );
+
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) )
+ .to.deep.equal( [ 'nestedBlock#b', 'nestedBlock#d' ] );
} );
it( 'returns nothing if directly in a root', () => {
@@ -1083,26 +1105,60 @@ describe( 'Selection', () => {
setData( model, 'a[b]c', { rootName: 'inlineOnlyRoot' } );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.be.empty;
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.be.empty;
+ } );
+
+ it( 'does not go cross limit elements', () => {
+ model.schema.register( 'blk', { allowIn: [ '$root', 'tableCell' ], isObject: true, isBlock: true } );
+
+ setData( model, '
]' );
+
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'table' ] );
+ } );
+
+ it( 'returns only selected blocks even if nested in other blocks', () => {
+ setData( model, '
foo
[b]ar
baz
' );
+
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#bar' ] );
+ } );
+
+ it( 'returns only selected blocks even if nested in other blocks (selection on the block)', () => {
+ model.schema.register( 'blk', { allowIn: [ '$root', 'tableCell' ], isObject: true, isBlock: true } );
+
+ setData( model, '
foo
[
bar]
' );
+
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'blk', 'p#bar' ] );
} );
describe( '#984', () => {
it( 'does not return the last block if none of its content is selected', () => {
setData( model, '
[a
b
]c
' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'a', 'b' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b' ] );
} );
it( 'returns only the first block for a non collapsed selection if only end of selection is touching a block', () => {
setData( model, '
a
b[
]c
d
' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'b' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'h#b' ] );
} );
it( 'does not return the last block if none of its content is selected (nested case)', () => {
setData( model, '
[a
]b' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'a' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] );
} );
// Like a super edge case, we can live with this behavior as I don't even know what we could expect here
@@ -1110,20 +1166,20 @@ describe( 'Selection', () => {
it( 'does not return the last block if none of its content is selected (nested case, wrapper with a content)', () => {
setData( model, '
[a
b]c' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'a' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] );
} );
it( 'returns the last block if at least one of its child nodes is selected', () => {
setData( model, '
[a
b
]c
' );
- expect( toText( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'a', 'b', 'c' ] );
+ expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] );
} );
// I needed these last 2 cases to justify the use of isTouching() instead of simple `offset == 0` check.
it( 'returns the last block if at least one of its child nodes is selected (end in an inline element)', () => {
setData( model, '