diff --git a/docs/builds/guides/integration/features-html-output-overview.md b/docs/builds/guides/integration/features-html-output-overview.md index 0aa13d2433e..5c8832068f4 100644 --- a/docs/builds/guides/integration/features-html-output-overview.md +++ b/docs/builds/guides/integration/features-html-output-overview.md @@ -972,6 +972,21 @@ The data used to generate the following tables comes from the package metadata.

+ + +

+ HTML comment +

+

+ HtmlComment +

+ + +

+ The plugin can output the HTML comments that exist in the editor data. +

+ +

ckeditor5-image

diff --git a/packages/ckeditor5-engine/src/conversion/downcasthelpers.js b/packages/ckeditor5-engine/src/conversion/downcasthelpers.js index e5b99521167..229622749af 100644 --- a/packages/ckeditor5-engine/src/conversion/downcasthelpers.js +++ b/packages/ckeditor5-engine/src/conversion/downcasthelpers.js @@ -272,8 +272,11 @@ export default class DowncastHelpers extends ConversionHelpers { /** * Model marker to view element conversion helper. * - * **Note**: This method should be used only for editing downcast. For data downcast, use - * {@link #markerToData `#markerToData()`} that produces valid HTML data. + * **Note**: This method should be used mainly for editing downcast and it is recommended + * to use {@link #markerToData `#markerToData()`} helper instead. + * + * This helper may produce invalid HTML code (e.g. a span between table cells). + * It should be used only when you are sure that the produced HTML will be semantically correct. * * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker * is collapsed, only one element is created. For example, model marker set like this: `F[oo b]ar` diff --git a/packages/ckeditor5-engine/src/conversion/upcasthelpers.js b/packages/ckeditor5-engine/src/conversion/upcasthelpers.js index 796298f7f80..4ed5dd21afe 100644 --- a/packages/ckeditor5-engine/src/conversion/upcasthelpers.js +++ b/packages/ckeditor5-engine/src/conversion/upcasthelpers.js @@ -7,7 +7,6 @@ import Matcher from '../view/matcher'; import ConversionHelpers from './conversionhelpers'; import { cloneDeep } from 'lodash-es'; -import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; import { isParagraphable, wrapInParagraph } from '../model/utils/autoparagraphing'; @@ -294,13 +293,17 @@ export default class UpcastHelpers extends ConversionHelpers { /** * View element to model marker conversion helper. * - * **Note**: This method was deprecated. Please use {@link #dataToMarker} instead. - * * This conversion results in creating a model marker. For example, if the marker was stored in a view as an element: * `

Foo

Bar

`, * after the conversion is done, the marker will be available in * {@link module:engine/model/model~Model#markers model document markers}. * + * **Note**: When this helper is used in the data upcast in combination with + * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`} in the data downcast, + * then invalid HTML code (e.g. a span between table cells) may be produced by the latter converter. + * + * In most of the cases, the {@link #dataToMarker} should be used instead. + * * editor.conversion.for( 'upcast' ).elementToMarker( { * view: 'marker-search', * model: 'search' @@ -330,7 +333,6 @@ export default class UpcastHelpers extends ConversionHelpers { * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter * to the conversion process. * - * @deprecated * @method #elementToMarker * @param {Object} config Conversion configuration. * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted. @@ -340,15 +342,6 @@ export default class UpcastHelpers extends ConversionHelpers { * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers} */ elementToMarker( config ) { - /** - * The {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToMarker `UpcastHelpers#elementToMarker()`} - * method was deprecated and will be removed in the near future. - * Please use {@link module:engine/conversion/upcasthelpers~UpcastHelpers#dataToMarker `UpcastHelpers#dataToMarker()`} instead. - * - * @error upcast-helpers-element-to-marker-deprecated - */ - logWarning( 'upcast-helpers-element-to-marker-deprecated' ); - return this.add( upcastElementToMarker( config ) ); } diff --git a/packages/ckeditor5-engine/src/view/domconverter.js b/packages/ckeditor5-engine/src/view/domconverter.js index 8c633901765..19408c93cab 100644 --- a/packages/ckeditor5-engine/src/view/domconverter.js +++ b/packages/ckeditor5-engine/src/view/domconverter.js @@ -11,6 +11,7 @@ import ViewText from './text'; import ViewElement from './element'; +import ViewUIElement from './uielement'; import ViewPosition from './position'; import ViewRange from './range'; import ViewSelection from './selection'; @@ -252,8 +253,12 @@ export default class DomConverter { this.bindDocumentFragments( domElement, viewNode ); } } else if ( viewNode.is( 'uiElement' ) ) { - // UIElement has its own render() method (see #799). - domElement = viewNode.render( domDocument ); + if ( viewNode.name === '$comment' ) { + domElement = domDocument.createComment( viewNode.getCustomProperty( '$rawContent' ) ); + } else { + // UIElement has its own render() method (see #799). + domElement = viewNode.render( domDocument ); + } if ( options.bind ) { this.bindElements( domElement, viewNode ); @@ -421,7 +426,9 @@ export default class DomConverter { * @param {Object} [options] Conversion options. * @param {Boolean} [options.bind=false] Determines whether new elements will be bound. * @param {Boolean} [options.withChildren=true] If `true`, node's and document fragment's children will be converted too. - * @param {Boolean} [options.keepOriginalCase=false] If `false`, node's tag name will be converter to lower case. + * @param {Boolean} [options.keepOriginalCase=false] If `false`, node's tag name will be converted to lower case. + * @param {Boolean} [options.skipComments=false] If `false`, comment nodes will be converted to `$comment` + * {@link module:engine/view/uielement~UIElement view UI elements}. * @returns {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment|null} Converted node or document fragment * or `null` if DOM node is a {@link module:engine/view/filler filler} or the given node is an empty text node. */ @@ -437,6 +444,10 @@ export default class DomConverter { return hostElement; } + if ( this.isComment( domNode ) && options.skipComments ) { + return null; + } + if ( isText( domNode ) ) { if ( isInlineFiller( domNode ) ) { return null; @@ -445,8 +456,6 @@ export default class DomConverter { return textData === '' ? null : new ViewText( this.document, textData ); } - } else if ( this.isComment( domNode ) ) { - return null; } else { if ( this.mapDomToView( domNode ) ) { return this.mapDomToView( domNode ); @@ -463,8 +472,7 @@ export default class DomConverter { } } else { // Create view element. - const viewName = options.keepOriginalCase ? domNode.tagName : domNode.tagName.toLowerCase(); - viewElement = new ViewElement( this.document, viewName ); + viewElement = this._createViewElement( domNode, options ); if ( options.bind ) { this.bindElements( domNode, viewElement ); @@ -473,13 +481,18 @@ export default class DomConverter { // Copy element's attributes. const attrs = domNode.attributes; - for ( let i = attrs.length - 1; i >= 0; i-- ) { - viewElement._setAttribute( attrs[ i ].name, attrs[ i ].value ); + if ( attrs ) { + for ( let i = attrs.length - 1; i >= 0; i-- ) { + viewElement._setAttribute( attrs[ i ].name, attrs[ i ].value ); + } } // Treat this element's content as a raw data if it was registered as such. - if ( options.withChildren !== false && this._rawContentElementMatcher.match( viewElement ) ) { - viewElement._setCustomProperty( '$rawContent', domNode.innerHTML ); + // Comment node is also treated as an element with raw data. + if ( this._isViewElementWithRawContent( viewElement, options ) || this.isComment( domNode ) ) { + const rawContent = this.isComment( domNode ) ? domNode.data : domNode.innerHTML; + + viewElement._setCustomProperty( '$rawContent', rawContent ); // Store a DOM node to prevent left trimming of the following text node. this._encounteredRawContentDomNodes.add( domNode ); @@ -1330,6 +1343,36 @@ export default class DomConverter { _isInlineObjectElement( node ) { return this.isElement( node ) && this.inlineObjectElements.includes( node.tagName.toLowerCase() ); } + + /** + * Creates view element basing on the node type. + * + * @private + * @param {Node} node DOM node to check. + * @param {Object} options Conversion options. See {@link module:engine/view/domconverter~DomConverter#domToView} options parameter. + * @returns {Element} + */ + _createViewElement( node, options ) { + if ( this.isComment( node ) ) { + return new ViewUIElement( this.document, '$comment' ); + } + + const viewName = options.keepOriginalCase ? node.tagName : node.tagName.toLowerCase(); + + return new ViewElement( this.document, viewName ); + } + + /** + * Checks if view element's content should be treated as a raw data. + * + * @private + * @param {Element} viewElement View element to check. + * @param {Object} options Conversion options. See {@link module:engine/view/domconverter~DomConverter#domToView} options parameter. + * @returns {Boolean} + */ + _isViewElementWithRawContent( viewElement, options ) { + return options.withChildren !== false && this._rawContentElementMatcher.match( viewElement ); + } } // Helper function. diff --git a/packages/ckeditor5-engine/tests/conversion/upcasthelpers.js b/packages/ckeditor5-engine/tests/conversion/upcasthelpers.js index 22a49d9f8fd..8178aa56ab2 100644 --- a/packages/ckeditor5-engine/tests/conversion/upcasthelpers.js +++ b/packages/ckeditor5-engine/tests/conversion/upcasthelpers.js @@ -33,8 +33,6 @@ import Writer from '../../src/model/writer'; import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; -/* globals console */ - describe( 'UpcastHelpers', () => { let upcastDispatcher, model, schema, upcastHelpers, viewDocument; @@ -751,16 +749,6 @@ describe( 'UpcastHelpers', () => { } ); describe( 'elementToMarker()', () => { - beforeEach( () => { - // Silence warning about deprecated method. - // This whole suite will be removed when the deprecated method is removed. - sinon.stub( console, 'warn' ); - } ); - - afterEach( () => { - console.warn.restore(); - } ); - it( 'should be chainable', () => { expect( upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } ) ).to.equal( upcastHelpers ); } ); diff --git a/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js b/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js index 774efc28608..e40f8a9823f 100644 --- a/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js +++ b/packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js @@ -6,6 +6,7 @@ /* globals document */ import ViewElement from '../../../src/view/element'; +import ViewUIElement from '../../../src/view/uielement'; import ViewDocument from '../../../src/view/document'; import ViewDocumentSelection from '../../../src/view/documentselection'; import DomConverter from '../../../src/view/domconverter'; @@ -171,10 +172,38 @@ describe( 'DomConverter', () => { expect( converter.domToView( textNode ) ).to.be.null; } ); - it( 'should return null for a comment', () => { - const comment = document.createComment( 'abc' ); + it( 'should create UIElement for comment', () => { + const domComment = document.createComment( 'abc' ); - expect( converter.domToView( comment ) ).to.be.null; + const viewComment = converter.domToView( domComment ); + + expect( viewComment ).to.be.an.instanceof( ViewUIElement ); + expect( viewComment.name ).to.equal( '$comment' ); + + expect( viewComment.getCustomProperty( '$rawContent' ) ).to.equal( 'abc' ); + + expect( converter.mapViewToDom( viewComment ) ).to.not.equal( domComment ); + } ); + + it( 'should create UIElement for comment and bind elements', () => { + const domComment = document.createComment( 'abc' ); + + const viewComment = converter.domToView( domComment, { bind: true } ); + + expect( viewComment ).to.be.an.instanceof( ViewUIElement ); + expect( viewComment.name ).to.equal( '$comment' ); + + expect( viewComment.getCustomProperty( '$rawContent' ) ).to.equal( 'abc' ); + + expect( converter.mapViewToDom( viewComment ) ).to.equal( domComment ); + } ); + + it( 'should return `null` for a comment when the `skipComments` option is set to `true`', () => { + const domComment = document.createComment( 'abc' ); + + const viewComment = converter.domToView( domComment, { skipComments: true } ); + + expect( viewComment ).to.be.null; } ); describe( 'it should clear whitespaces', () => { diff --git a/packages/ckeditor5-engine/tests/view/domconverter/rawcontent.js b/packages/ckeditor5-engine/tests/view/domconverter/rawcontent.js index 942f5413565..932668b1b9b 100644 --- a/packages/ckeditor5-engine/tests/view/domconverter/rawcontent.js +++ b/packages/ckeditor5-engine/tests/view/domconverter/rawcontent.js @@ -52,9 +52,10 @@ describe( 'DOMConverter raw content matcher', () => { expect( viewDiv.getChild( 0 ).name ).to.equal( 'img' ); expect( viewDiv.getChild( 1 ).getCustomProperty( '$rawContent' ) ).to.equal( 'bar\n123' ); expect( viewDiv.getChild( 2 ).getCustomProperty( '$rawContent' ) ).to.be.undefined; - expect( viewDiv.getChild( 2 ).childCount ).to.equal( 2 ); - expect( viewDiv.getChild( 2 ).getChild( 0 ).name ).to.equal( 'img' ); - expect( viewDiv.getChild( 2 ).getChild( 1 ).data ).to.equal( 'bar 123' ); + expect( viewDiv.getChild( 2 ).childCount ).to.equal( 3 ); + expect( viewDiv.getChild( 2 ).getChild( 0 ).getCustomProperty( '$rawContent' ) ).to.equal( 'foo' ); + expect( viewDiv.getChild( 2 ).getChild( 1 ).name ).to.equal( 'img' ); + expect( viewDiv.getChild( 2 ).getChild( 2 ).data ).to.equal( 'bar 123' ); expect( viewDiv.getChild( 3 ).data ).to.equal( 'abc' ); } ); @@ -92,9 +93,10 @@ describe( 'DOMConverter raw content matcher', () => { expect( viewDiv.getChild( 0 ).name ).to.equal( 'img' ); expect( viewDiv.getChild( 1 ).getCustomProperty( '$rawContent' ) ).to.equal( 'bar\n123' ); expect( viewDiv.getChild( 2 ).getCustomProperty( '$rawContent' ) ).to.be.undefined; - expect( viewDiv.getChild( 2 ).childCount ).to.equal( 2 ); - expect( viewDiv.getChild( 2 ).getChild( 0 ).name ).to.equal( 'img' ); - expect( viewDiv.getChild( 2 ).getChild( 1 ).data ).to.equal( 'bar 123' ); + expect( viewDiv.getChild( 2 ).childCount ).to.equal( 3 ); + expect( viewDiv.getChild( 2 ).getChild( 0 ).getCustomProperty( '$rawContent' ) ).to.equal( 'foo' ); + expect( viewDiv.getChild( 2 ).getChild( 1 ).name ).to.equal( 'img' ); + expect( viewDiv.getChild( 2 ).getChild( 2 ).data ).to.equal( 'bar 123' ); expect( viewDiv.getChild( 3 ).data ).to.equal( 'abc' ); } ); @@ -148,9 +150,10 @@ describe( 'DOMConverter raw content matcher', () => { 'nested spanbar\n123' ); expect( viewDiv.getChild( 2 ).getCustomProperty( '$rawContent' ) ).to.be.undefined; - expect( viewDiv.getChild( 2 ).childCount ).to.equal( 2 ); - expect( viewDiv.getChild( 2 ).getChild( 0 ).name ).to.equal( 'img' ); - expect( viewDiv.getChild( 2 ).getChild( 1 ).data ).to.equal( 'bar 123' ); + expect( viewDiv.getChild( 2 ).childCount ).to.equal( 3 ); + expect( viewDiv.getChild( 2 ).getChild( 0 ).getCustomProperty( '$rawContent' ) ).to.equal( 'foo' ); + expect( viewDiv.getChild( 2 ).getChild( 1 ).name ).to.equal( 'img' ); + expect( viewDiv.getChild( 2 ).getChild( 2 ).data ).to.equal( 'bar 123' ); expect( viewDiv.getChild( 3 ).getCustomProperty( '$rawContent' ) ).to.equal( 'some span' ); expect( viewDiv.getChild( 4 ).name ).to.equal( 'span' ); expect( viewDiv.getChild( 4 ).getChild( 0 ).data ).to.equal( 'other span' ); diff --git a/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js b/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js index 0f701eae1f8..e24cc2fe88f 100644 --- a/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js +++ b/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js @@ -3,10 +3,11 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals Range, DocumentFragment, HTMLElement, document, Text */ +/* globals Range, DocumentFragment, HTMLElement, Comment, document, Text */ import ViewText from '../../../src/view/text'; import ViewElement from '../../../src/view/element'; +import ViewUIElement from '../../../src/view/uielement'; import ViewPosition from '../../../src/view/position'; import ViewContainerElement from '../../../src/view/containerelement'; import ViewAttributeElement from '../../../src/view/attributeelement'; @@ -186,6 +187,34 @@ describe( 'DomConverter', () => { expect( domSvg.createSVGRect ).to.be.a( 'function' ); } ); + it( 'should create a DOM comment node from a view `$comment` UIElement', () => { + const viewComment = new ViewUIElement( viewDocument, '$comment' ); + + viewComment._setCustomProperty( '$rawContent', 'foo' ); + + const domComment = converter.viewToDom( viewComment, document ); + + expect( domComment ).to.be.an.instanceof( Comment ); + expect( domComment.nodeName ).to.equal( '#comment' ); + expect( domComment.data ).to.equal( 'foo' ); + + expect( converter.mapDomToView( domComment ) ).to.not.equal( viewComment ); + } ); + + it( 'should create a DOM comment node from a view `$comment` UIElement and bind them', () => { + const viewComment = new ViewUIElement( viewDocument, '$comment' ); + + viewComment._setCustomProperty( '$rawContent', 'foo' ); + + const domComment = converter.viewToDom( viewComment, document, { bind: true } ); + + expect( domComment ).to.be.an.instanceof( Comment ); + expect( domComment.nodeName ).to.equal( '#comment' ); + expect( domComment.data ).to.equal( 'foo' ); + + expect( converter.mapDomToView( domComment ) ).to.equal( viewComment ); + } ); + describe( 'it should convert spaces to  ', () => { it( 'at the beginning of each container element', () => { const viewDiv = new ViewContainerElement( viewDocument, 'div', null, [ diff --git a/packages/ckeditor5-html-support/ckeditor5-metadata.json b/packages/ckeditor5-html-support/ckeditor5-metadata.json index 6d02093c22d..3ba3f3408b7 100644 --- a/packages/ckeditor5-html-support/ckeditor5-metadata.json +++ b/packages/ckeditor5-html-support/ckeditor5-metadata.json @@ -36,6 +36,18 @@ "className": "DataSchema", "description": "Holds representation of the extended HTML document type definitions. Part of General HTML Support feature.", "path": "src/dataschema.js" + }, + { + "name": "HTML comment", + "className": "HtmlComment", + "description": "Preserves the HTML comments in the editor data.", + "docs": "features/general-html-support.html#html-comments", + "path": "src/htmlcomment.js", + "htmlOutput": [ + { + "_comment": "The plugin can output HTML comments that were added from the editor inital data or by the plugin API." + } + ] } ] } diff --git a/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support-source.html b/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support-source.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support-source.js b/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support-source.js new file mode 100644 index 00000000000..2f73aa5323b --- /dev/null +++ b/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support-source.js @@ -0,0 +1,57 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window */ + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import Code from '@ckeditor/ckeditor5-basic-styles/src/code'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; + +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment'; + +ClassicEditor.builtinPlugins.push( + CloudServices, + Code, + EasyImage, + ImageUpload, + SourceEditing +); + +ClassicEditor.defaultConfig = { + cloudServices: CS_CONFIG, + toolbar: { + items: [ + 'sourceEditing', + '|', + 'heading', + '|', + 'bold', + 'italic', + 'code', + 'bulletedList', + 'numberedList', + '|', + 'blockQuote', + 'link', + 'uploadImage', + 'mediaEmbed', + 'insertTable', + '|', + 'undo', + 'redo' + ], + viewportTopOffset: window.getViewportTopOffsetConfig() + } +}; + +window.ClassicEditor = ClassicEditor; +window.GeneralHtmlSupport = GeneralHtmlSupport; +window.HtmlComment = HtmlComment; diff --git a/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support.js b/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support.js index 558b009de2a..91a36e761f5 100644 --- a/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support.js +++ b/packages/ckeditor5-html-support/docs/_snippets/features/general-html-support.js @@ -3,56 +3,11 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals console, window, document */ - -import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js'; - -import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; -import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; -import Code from '@ckeditor/ckeditor5-basic-styles/src/code'; -import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; -import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; -import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; - -import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; -import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; +/* globals console, window, document, ClassicEditor, GeneralHtmlSupport */ ClassicEditor .create( document.querySelector( '#snippet-general-html-support' ), { - plugins: [ - ArticlePluginSet, - Code, - EasyImage, - ImageUpload, - CloudServices, - SourceEditing, - GeneralHtmlSupport - ], - toolbar: { - items: [ - 'sourceEditing', - '|', - 'heading', - '|', - 'bold', - 'italic', - 'code', - 'bulletedList', - 'numberedList', - '|', - 'outdent', - 'indent', - '|', - 'blockQuote', - 'link', - 'mediaEmbed', - 'insertTable', - '|', - 'undo', - 'redo' - ], - viewportTopOffset: window.getViewportTopOffsetConfig() - }, + extraPlugins: [ GeneralHtmlSupport ], image: { toolbar: [ 'imageStyle:inline', @@ -66,8 +21,6 @@ ClassicEditor table: { contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ] }, - cloudServices: CS_CONFIG, - htmlSupport: { allow: [ // Enables
,
, and elements with all kind of attributes. diff --git a/packages/ckeditor5-html-support/docs/_snippets/features/html-comment.html b/packages/ckeditor5-html-support/docs/_snippets/features/html-comment.html new file mode 100644 index 00000000000..431cf46a6f5 --- /dev/null +++ b/packages/ckeditor5-html-support/docs/_snippets/features/html-comment.html @@ -0,0 +1,11 @@ +
+

The three greatest things you learn from traveling

+ +
A lone wanderer looking at Mount Bromo volcano in Indonesia.
+ +

+ +

Appreciation of diversity

+ +

Getting used to an entirely different culture can be challenging. While it’s also nice to learn about cultures online or from books, nothing comes close to experiencing the cultural diversity in person. You learn to appreciate each and every single one of the differences while you become more culturally fluid.

+
diff --git a/packages/ckeditor5-html-support/docs/_snippets/features/html-comment.js b/packages/ckeditor5-html-support/docs/_snippets/features/html-comment.js new file mode 100644 index 00000000000..fc8444e5c6a --- /dev/null +++ b/packages/ckeditor5-html-support/docs/_snippets/features/html-comment.js @@ -0,0 +1,37 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document, ClassicEditor, HtmlComment */ + +ClassicEditor + .create( document.querySelector( '#snippet-html-comment' ), { + extraPlugins: [ HtmlComment ], + image: { + toolbar: [ + 'imageStyle:inline', + 'imageStyle:wrapText', + 'imageStyle:breakText', + '|', + 'toggleImageCaption', + 'imageTextAlternative' + ] + }, + table: { + contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ] + } + } ) + .then( editor => { + window.editor2 = editor; + + window.attachTourBalloon( { + target: window.findToolbarItem( editor.ui.view.toolbar, + item => item.label && item.label === 'Source' ), + text: 'Switch to the source mode to check out the source of the content and play with it.', + editor + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-html-support/docs/features/general-html-support.md b/packages/ckeditor5-html-support/docs/features/general-html-support.md index 053e97c163d..4ae58092031 100644 --- a/packages/ckeditor5-html-support/docs/features/general-html-support.md +++ b/packages/ckeditor5-html-support/docs/features/general-html-support.md @@ -75,6 +75,8 @@ ClassicEditor .catch( ... ); ``` +{@snippet features/general-html-support-source} + {@snippet features/general-html-support} ## Level of support @@ -246,6 +248,55 @@ Most of the existing CKEditor 5 features can already be extended this way, howev We're open for feedback, so if you find any issue, feel free to report it in the [main CKEditor 5 repository](https://github.com/ckeditor/ckeditor5/issues/). +## HTML comments + +By default, all HTML comments are filtered out during the editor initialization. The {@link module:html-support/htmlcomment~HtmlComment} feature allows developers to keep them in the document content and retrieve them back, e.g. while {@link builds/guides/integration/saving-data saving the editor data}. The comments are transparent from the users point of view and they are not displayed in the editable content. + + + The HTML comment feature is **experimental and not yet production-ready**. + + The support for HTML comments is at the basic level so far - see the [known issues](#known-issues-2) section below. + + +### Demo + +The CKEditor 5 instance below is configured to keep the HTML comments in the document content. You can view the source of the document using {@link features/source-editing source editing} feature. Toggle the source editing mode {@icon @ckeditor/ckeditor5-source-editing/theme/icons/source-editing.svg Source editing} to see that the HTML comment is present in the document source. You can uncomment the paragraph below the picture and upon leaving the source editing mode, you will see this paragraph in the editable area. + +{@snippet features/html-comment} + +### Installation + +To add this feature to your rich-text editor, install the [`@ckeditor/ckeditor5-html-support`](https://www.npmjs.com/package/@ckeditor/ckeditor5-html-support) package: + +```plaintext +npm install --save @ckeditor/ckeditor5-html-support +``` + +Then add it to the editor configuration: + +```js +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ HtmlComment, ... ], + } ) + .then( ... ) + .catch( ... ); +``` + + + Read more about {@link builds/guides/integration/installing-plugins installing plugins}. + + +HTML comment feature does not require any configuration. + +### Known issues + +The main issue with the HTML comments feature is that comments can be easily repositioned or lost in various cases [#10118](https://github.com/ckeditor/ckeditor5/issues/10118), [#10119](https://github.com/ckeditor/ckeditor5/issues/10119). Also copying and pasting (or dragging and dropping) elements containing HTML comments within the editor does not work as expected [#10127](https://github.com/ckeditor/ckeditor5/issues/10127). + +We are open for feedback, so if you find any issue, feel free to report it in the [main CKEditor 5 repository](https://github.com/ckeditor/ckeditor5/issues/). + ## Related features There are other HTML editing related CKEditor 5 features you may want to check: diff --git a/packages/ckeditor5-html-support/package.json b/packages/ckeditor5-html-support/package.json index b1a9e0c7a09..970d2c103a5 100644 --- a/packages/ckeditor5-html-support/package.json +++ b/packages/ckeditor5-html-support/package.json @@ -41,8 +41,10 @@ "@ckeditor/ckeditor5-indent": "^29.0.0", "@ckeditor/ckeditor5-link": "^29.0.0", "@ckeditor/ckeditor5-list": "^29.0.0", + "@ckeditor/ckeditor5-media-embed": "^29.0.0", "@ckeditor/ckeditor5-page-break": "^29.0.0", "@ckeditor/ckeditor5-paragraph": "^29.0.0", + "@ckeditor/ckeditor5-paste-from-office": "^29.0.0", "@ckeditor/ckeditor5-source-editing": "^29.0.0", "@ckeditor/ckeditor5-table": "^29.0.0", "@ckeditor/ckeditor5-theme-lark": "^29.0.0", diff --git a/packages/ckeditor5-html-support/src/htmlcomment.js b/packages/ckeditor5-html-support/src/htmlcomment.js new file mode 100644 index 00000000000..2302f744ef5 --- /dev/null +++ b/packages/ckeditor5-html-support/src/htmlcomment.js @@ -0,0 +1,243 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module html-support/htmlcomment + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { uid } from 'ckeditor5/src/utils'; + +/** + * The HTML comment feature. It preserves the HTML comments (``) in the editor data. + * + * For a detailed overview, check the {@glink features/general-html-support#html-comments HTML comment feature documentation}. + * + * @extends module:core/plugin~Plugin + */ +export default class HtmlComment extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'HtmlComment'; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + + // Convert the `$comment` view element to `$comment:` marker and store its content (the comment itself) as a $root + // attribute. The comment content is needed in the `dataDowncast` pipeline to re-create the comment node. + editor.conversion.for( 'upcast' ).elementToMarker( { + view: '$comment', + model: ( viewElement, { writer } ) => { + const root = this.editor.model.document.getRoot(); + const commentContent = viewElement.getCustomProperty( '$rawContent' ); + const markerName = `$comment:${ uid() }`; + + writer.setAttribute( markerName, commentContent, root ); + + return markerName; + } + } ); + + // Convert the `$comment` marker to `$comment` UI element with `$rawContent` custom property containing the comment content. + editor.conversion.for( 'dataDowncast' ).markerToElement( { + model: '$comment', + view: ( modelElement, { writer } ) => { + const root = this.editor.model.document.getRoot(); + const markerName = modelElement.markerName; + const commentContent = root.getAttribute( markerName ); + const comment = writer.createUIElement( '$comment' ); + + writer.setCustomProperty( '$rawContent', commentContent, comment ); + + return comment; + } + } ); + + // Remove comments' markers and their corresponding $root attributes, which are no longer present. + editor.model.document.registerPostFixer( writer => { + const root = editor.model.document.getRoot(); + + const changedMarkers = editor.model.document.differ.getChangedMarkers(); + + const changedCommentMarkers = changedMarkers.filter( marker => { + return marker.name.startsWith( '$comment' ); + } ); + + const removedCommentMarkers = changedCommentMarkers.filter( marker => { + const newRange = marker.data.newRange; + + return newRange && newRange.root.rootName === '$graveyard'; + } ); + + if ( removedCommentMarkers.length === 0 ) { + return false; + } + + for ( const marker of removedCommentMarkers ) { + writer.removeMarker( marker.name ); + writer.removeAttribute( marker.name, root ); + } + + return true; + } ); + + // Delete all comment markers from the document before setting new data. + editor.data.on( 'set', () => { + for ( const commentMarker of editor.model.markers.getMarkersGroup( '$comment' ) ) { + this.removeHtmlComment( commentMarker.name ); + } + }, { priority: 'high' } ); + + // Delete all comment markers that are within a removed range. + // Delete all comment markers at the limit element boundaries if the whole content of the limit element is removed. + editor.model.on( 'deleteContent', ( evt, [ selection ] ) => { + for ( const range of selection.getRanges() ) { + const limitElement = editor.model.schema.getLimitElement( range ); + const firstPosition = editor.model.createPositionAt( limitElement, 0 ); + const lastPosition = editor.model.createPositionAt( limitElement, 'end' ); + + let affectedCommentIDs; + + if ( firstPosition.isTouching( range.start ) && lastPosition.isTouching( range.end ) ) { + affectedCommentIDs = this.getHtmlCommentsInRange( editor.model.createRange( firstPosition, lastPosition ) ); + } else { + affectedCommentIDs = this.getHtmlCommentsInRange( range, { skipBoundaries: true } ); + } + + for ( const commentMarkerID of affectedCommentIDs ) { + this.removeHtmlComment( commentMarkerID ); + } + } + }, { priority: 'high' } ); + } + + /** + * Creates an HTML comment on the specified position and returns its ID. + * + * *Note*: If two comments are created at the same position, the second comment will be inserted before the first one. + * + * @param {module:engine/model/position~Position} position + * @param {String} content + * @returns {String} Comment ID. This ID can be later used to e.g. remove the comment from the content. + */ + createHtmlComment( position, content ) { + const id = uid(); + const editor = this.editor; + const model = editor.model; + const root = model.document.getRoot(); + const markerName = `$comment:${ id }`; + + return model.change( writer => { + const range = writer.createRange( position ); + + writer.addMarker( markerName, { + usingOperation: true, + affectsData: true, + range + } ); + + writer.setAttribute( markerName, content, root ); + + return markerName; + } ); + } + + /** + * Removes an HTML comment with the given comment ID. + * + * It does nothing and returns `false` if the comment with the given ID does not exist. + * Otherwise it removes the comment and returns `true`. + * + * Note that a comment can be removed also by removing the content around the comment. + * + * @param {String} commentID The ID of the comment to be removed. + * @returns {Boolean} `true` when the comment with the given ID was removed, `false` otherwise. + */ + removeHtmlComment( commentID ) { + const editor = this.editor; + const root = editor.model.document.getRoot(); + + const marker = editor.model.markers.get( commentID ); + + if ( !marker ) { + return false; + } + + editor.model.change( writer => { + writer.removeMarker( marker ); + writer.removeAttribute( commentID, root ); + } ); + + return true; + } + + /** + * Gets the HTML comment data for the comment with a given ID. + * + * Returns `null` if the comment does not exist. + * + * @param {String} commentID + * @returns {module:html-support/htmlcomment~HtmlCommentData} + */ + getHtmlCommentData( commentID ) { + const editor = this.editor; + const marker = editor.model.markers.get( commentID ); + const root = editor.model.document.getRoot(); + + if ( !marker ) { + return null; + } + + return { + content: root.getAttribute( commentID ), + position: marker.getStart() + }; + } + + /** + * Gets all HTML comments in the given range. + * + * By default it includes comments at the range boundaries. + * + * @param {module:engine/model/range~Range} range + * @param {Object} [options] + * @param {Boolean} [options.skipBoundaries=false] When set to `true` the range boundaries will be skipped. + * @returns {Array.} HTML comment IDs + */ + getHtmlCommentsInRange( range, { skipBoundaries = false } = {} ) { + const includeBoundaries = !skipBoundaries; + + // Unfortunately, MarkerCollection#getMarkersAtPosition() filters out collapsed markers. + return Array.from( this.editor.model.markers.getMarkersGroup( '$comment' ) ) + .filter( marker => isCommentMarkerInRange( marker, range ) ) + .map( marker => marker.name ); + + function isCommentMarkerInRange( commentMarker, range ) { + const position = commentMarker.getRange().start; + + return ( + ( position.isAfter( range.start ) || ( includeBoundaries && position.isEqual( range.start ) ) ) && + ( position.isBefore( range.end ) || ( includeBoundaries && position.isEqual( range.end ) ) ) + ); + } + } +} + +/** + * An interface for the HTML comments data. + * + * It consists of the {@link module:engine/model/position~Position `position`} and `content`. + * + * @typedef {Object} module:html-support/htmlcomment~HtmlCommentData + * + * @property {module:engine/model/position~Position} position + * @property {String} content + */ diff --git a/packages/ckeditor5-html-support/src/index.js b/packages/ckeditor5-html-support/src/index.js index 0b58680969b..ffd8fac2c41 100644 --- a/packages/ckeditor5-html-support/src/index.js +++ b/packages/ckeditor5-html-support/src/index.js @@ -10,3 +10,4 @@ export { default as GeneralHtmlSupport } from './generalhtmlsupport'; export { default as DataFilter } from './datafilter'; export { default as DataSchema } from './dataschema'; +export { default as HtmlComment } from './htmlcomment'; diff --git a/packages/ckeditor5-html-support/tests/htmlcomment-integration.js b/packages/ckeditor5-html-support/tests/htmlcomment-integration.js new file mode 100644 index 00000000000..fd601655c40 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/htmlcomment-integration.js @@ -0,0 +1,1438 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals Event */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; + +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; + +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; + +import HighlightEditing from '@ckeditor/ckeditor5-highlight/src/highlightediting'; + +import HtmlEmbedEditing from '@ckeditor/ckeditor5-html-embed/src/htmlembedediting'; + +import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockediting'; +import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; +import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting'; + +import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock'; +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 ListStyleEditing from '@ckeditor/ckeditor5-list/src/liststyleediting'; +import TodoListEditing from '@ckeditor/ckeditor5-list/src/todolistediting'; + +import MediaEmbedEditing from '@ckeditor/ckeditor5-media-embed/src/mediaembedediting'; + +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; + +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption'; + +import HtmlComment from '../src/htmlcomment'; + +describe( 'HtmlComment integration', () => { + describe( 'integration with BlockQuote', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, BlockQuoteEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty blockquote', async () => { + editor = await createEditor( '
' ); + + expect( editor.getData() ).to.equal( '' ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '
' + + '' + + '

foobar

' + + '' + + '
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '
' + + '' + + '

foobar

' + + '' + + '
' + + '' + ); + } ); + } ); + + describe( 'integration with CodeBlock', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, CodeBlockEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty code block', async () => { + editor = await createEditor( + '
' +
+					'' +
+						'' +
+					'' +
+				'
' + ); + + expect( editor.getData() ).to.equal( + '
' +
+					'' +
+						'' +
+						' ' +
+					'' +
+				'
' + ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '
' +
+					'' +
+					'' +
+						'' +
+						'Plain text' +
+						'' +
+					'' +
+					'' +
+				'
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '
' +
+					'' +
+						'' +
+						'Plain text' +
+						'' +
+					'' +
+				'
' + + '' + + '' + ); + } ); + } ); + + describe( 'integration with Heading', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, HeadingEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty heading', async () => { + editor = await createEditor( + '

' + + '

' + ); + + expect( editor.getData() ).to.equal( + '

 

' + + '

 

' + ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '

' + + '' + + 'Heading 1' + + '' + + '

' + + '' + + '

' + + '' + + 'Heading 2' + + '' + + '

' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '

' + + '' + + 'Heading 1' + + '' + + '

' + + '' + + '

' + + '' + + 'Heading 2' + + '' + + '

' + + '' + ); + } ); + } ); + + describe( 'integration with Highlight', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, HighlightEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty highlight', async () => { + editor = await createEditor( + '

' + + '' + + '' + + '' + + '

' + + '

' + + '' + + '' + + '' + + '

' + ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + ' ' + + '

' + + '

' + + '' + + ' ' + + '

' + ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '

' + + '' + + '' + + ' ' + + 'Yellow marker' + + ' ' + + '' + + ' ' + + '

' + + '

' + + '' + + '' + + ' ' + + 'Red pen' + + ' ' + + '' + + ' ' + + '

' + ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + '' + + '' + + 'Yellow marker' + + '' + + ' ' + + '' + + '' + + '

' + + '

' + + '' + + '' + + '' + + 'Red pen' + + '' + + ' ' + + '' + + '' + + '

' + ); + } ); + } ); + + describe( 'integration with HtmlEmbed', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, HtmlEmbedEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty embedded HTML', async () => { + editor = await createEditor( + '
' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '
' + ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '
' + + '' + + '

' + + 'Paragraph' + + '

' + + '' + + '
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '
' + + '' + + '

' + + 'Paragraph' + + '

' + + '' + + '
' + + '' + ); + } ); + } ); + + describe( 'integration with Image', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, ImageBlockEditing, ImageInlineEditing, ImageCaptionEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comments are between block image tags', async () => { + editor = await createEditor( + '' + + '
' + + '' + + 'Example image' + + '' + + '
' + + '' + + 'image caption' + + '' + + '
' + + '' + + '
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '
' + + 'Example image' + + '' + + '' + + '' + + '
' + + '' + + 'image caption' + + '' + + '
' + + '
' + + '' + ); + } ); + + it( 'should work if comment is in an empty image caption', async () => { + editor = await createEditor( + '
' + + 'Example image' + + '
' + + '' + + '
' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + 'Example image' + + '
' + + '' + + ' ' + + '
' + + '
' + ); + } ); + + it( 'should work if comments are between inline image tags', async () => { + editor = await createEditor( + '' + + '

' + + '' + + 'Example image' + + '' + + '

' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '

' + + '' + + 'Example image' + + '' + + '

' + + '' + ); + } ); + } ); + + describe( 'integration with Indent', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, IndentEditing, IndentBlock ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty indented paragraph', async () => { + editor = await createEditor( + '

' + + '' + + '

' + ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + ' ' + + '

' + ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '

' + + '' + + 'Indented paragraph' + + '' + + '

' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '

' + + '' + + 'Indented paragraph' + + '' + + '

' + + '' + ); + } ); + } ); + + describe( 'integration with Link', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, ImageBlockEditing, ImageInlineEditing, LinkEditing, LinkImageEditing ], + link: { + addTargetToExternalLinks: true + } + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty link', async () => { + editor = await createEditor( + '

' + + '' + + '' + + '' + + '

' + ); + + expect( editor.getData() ).to.equal( '

 

' ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '

' + + '' + + '' + + '' + + 'Link' + + '' + + '' + + '' + + '

' + ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + '' + + '' + + 'Link' + + '' + + '' + + '' + + '

' + ); + } ); + + it( 'should work with image link', async () => { + editor = await createEditor( + '

' + + '' + + '' + + 'Link with inline image: ' + + '' + + 'Example image' + + '' + + '' + + '

' + ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + '' + + 'Link with inline image:' + + '' + + 'Example image' + + '' + + '' + + '

' + ); + } ); + + it( 'should work with links with decorators', async () => { + editor = await createEditor( + '

' + + '' + + '' + + 'External link with inline image: ' + + '' + + 'Example image' + + '' + + '' + + '

' + ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + '' + + 'External link with inline image:' + + '' + + 'Example image' + + '' + + '' + + '

' + ); + } ); + } ); + + describe( 'integration with List', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, ListEditing, ListStyleEditing, TodoListEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty list item', async () => { + editor = await createEditor( + '
' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
  1.  
' + + '
  •  
' + ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '
    ' + + '' + + '
  1. ' + + '' + + 'Ordered list item' + + '' + + '
  2. ' + + '' + + '
' + + '' + + '
    ' + + '' + + '
  • ' + + '' + + 'Bulleted list item' + + '' + + '
  • ' + + '' + + '
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '
    ' + + '
  1. ' + + '' + + 'Ordered list item' + + '' + + '
  2. ' + + '
' + + '' + + '
    ' + + '
  • ' + + '' + + 'Bulleted list item' + + '' + + '
  • ' + + '
' + + '' + ); + } ); + + it( 'should work with nested lists', async () => { + editor = await createEditor( + '
    ' + + '
  • ' + + '' + + '
      ' + + '' + + '
    • ' + + '' + + 'List item' + + '' + + '
    • ' + + '' + + '
    ' + + '' + + '
  • ' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
    ' + + '
  • ' + + '' + + '
      ' + + '
    • ' + + '' + + 'List item' + + '' + + '
    • ' + + '
    ' + + '
  • ' + + '
' + ); + } ); + + it( 'should work with a to-do list', async () => { + editor = await createEditor( + '
    ' + + '
  • ' + + '' + + '' + + 'To-do list item 1' + + '' + + '
  • ' + + '
  • ' + + '' + + '' + + 'To-do list item 2' + + '' + + '
  • ' + + '
' + ); + + // Currently, if input element in a to-do list is preceded by a comment, a to-do list is not created. + // See https://github.com/ckeditor/ckeditor5/issues/10129. + // + // expect( editor.getData() ).to.equal( + // '
    ' + + // '
  • ' + + // '' + + // '
  • ' + + // '
  • ' + + // '' + + // '
  • ' + + // '
' + // ); + + expect( editor.getData() ).to.equal( + '
    ' + + '
  • ' + + '' + + 'To-do list item 1' + + '' + + '
  • ' + + '
  • ' + + '' + + 'To-do list item 2' + + '' + + '
  • ' + + '
' + ); + } ); + + it( 'should work with a list style', async () => { + editor = await createEditor( + '
    ' + + '
  • ' + + '' + + 'List item' + + '' + + '
  • ' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
    ' + + '
  • ' + + '' + + 'List item' + + '' + + '
  • ' + + '
' + ); + } ); + } ); + + describe( 'integration with MediaEmbed', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, MediaEmbedEditing ], + mediaEmbed: { + previewsInData: true, + providers: [ + { + name: 'example', + url: /^example\.com\/(\w+)/, + html: match => `example provider, id=${ match[ 1 ] }` + } + ] + } + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty media wrapper tag', async () => { + editor = await createEditor( '
' ); + + expect( editor.getData() ).to.equal( '' ); + } ); + + it( 'should work if comment is in an empty non-semantic media', async () => { + editor = await createEditor( + '
' + + '
' + + '' + + '
' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '
' + + 'example provider, id=1234' + + '
' + + '
' + ); + } ); + + it( 'should work if comments are between semantic media tags', async () => { + editor = await createEditor( + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '
' + + '' + + '
' + + 'example provider, id=1234' + + '
' + + '
' + + '' + ); + } ); + + it( 'should work if comments are between non-semantic media tags', async () => { + editor = await createEditor( + '' + + '
' + + '' + + '
' + + '' + + '
' + + '' + + '
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '' + + '
' + + '' + + '
' + + 'example provider, id=1234' + + '
' + + '
' + + '' + + '' + ); + } ); + } ); + + describe( 'integration with Paragraph', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if comment is in an empty paragraph', async () => { + editor = await createEditor( '

' ); + + expect( editor.getData() ).to.equal( '

 

' ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '

' + + '' + + 'paragraph' + + '' + + '

' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '

' + + '' + + 'paragraph' + + '' + + '

' + + '' + ); + } ); + + it( 'should remove comments when the content including them is removed', async () => { + editor = await createEditor( + '

' + + '' + + 'Foo' + + '' + + '

' + + '

' + + '' + + 'Foo' + + '' + + '

' + ); + + const model = editor.model; + const root = model.document.getRoot(); + + model.change( writer => { + const firstParagraph = root.getChild( 0 ); + const secondParagraph = root.getChild( 1 ); + + writer.setSelection( writer.createRange( + writer.createPositionAt( firstParagraph, 'end' ), + writer.createPositionAt( secondParagraph, 'end' ) + ) ); + } ); + + editor.execute( 'delete' ); + + // The following output could be considered as the correct and expected one, + // but currently the comment 4 is not removed, because it is not located at the limit element's boundary: + // expect( editor.getData() ).to.equal( + // '

' + + // '' + + // 'Foo' + + // '' + + // '

' + // ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + 'Foo' + + '' + + '' + + '

' + ); + } ); + } ); + + describe( 'integration with SourceEditing', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, SourceEditing ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should display comments in the source editing mode', async () => { + editor = await createEditor( + '

' + + '' + + 'Foo' + + '' + + '

' + ); + + const toggleSourceEditingModeButton = editor.ui.componentFactory.create( 'sourceEditing' ); + + toggleSourceEditingModeButton.fire( 'execute' ); + + const domRoot = editor.editing.view.getDomRoot(); + const textarea = domRoot.nextSibling.children[ 0 ]; + + expect( textarea.value ).to.equal( + '

\n' + + ' Foo\n' + + '

' + ); + } ); + + it( 'should add comments at non-boundary positions using the source editing mode', async () => { + editor = await createEditor( '

Foo

' ); + + const toggleSourceEditingModeButton = editor.ui.componentFactory.create( 'sourceEditing' ); + + toggleSourceEditingModeButton.fire( 'execute' ); + + const domRoot = editor.editing.view.getDomRoot(); + const textarea = domRoot.nextSibling.children[ 0 ]; + + textarea.value = '

Foo

'; + textarea.dispatchEvent( new Event( 'input' ) ); + + toggleSourceEditingModeButton.fire( 'execute' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + + it( 'should add comments at boundary positions using the source editing mode', async () => { + editor = await createEditor( '

Foo

' ); + + const toggleSourceEditingModeButton = editor.ui.componentFactory.create( 'sourceEditing' ); + + toggleSourceEditingModeButton.fire( 'execute' ); + + const domRoot = editor.editing.view.getDomRoot(); + const textarea = domRoot.nextSibling.children[ 0 ]; + + textarea.value = '

Foo

'; + textarea.dispatchEvent( new Event( 'input' ) ); + + toggleSourceEditingModeButton.fire( 'execute' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + + it( 'should properly handle existing and newly added comments after exiting from the source editing mode', async () => { + editor = await createEditor( + '' + + '

' + + 'Foo' + + '

' + + '' + ); + + const toggleSourceEditingModeButton = editor.ui.componentFactory.create( 'sourceEditing' ); + + toggleSourceEditingModeButton.fire( 'execute' ); + + const domRoot = editor.editing.view.getDomRoot(); + const textarea = domRoot.nextSibling.children[ 0 ]; + + textarea.value = '

Foo

'; + textarea.dispatchEvent( new Event( 'input' ) ); + + toggleSourceEditingModeButton.fire( 'execute' ); + + expect( editor.getData() ).to.equal( + '' + + '

' + + '' + + 'Foo' + + '' + + '

' + + '' + ); + } ); + } ); + + describe( 'integration with Table', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph, TableEditing, TableCaption ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + // See https://github.com/ckeditor/ckeditor5/issues/10116. + it( 'should work if comment is in an empty table cell', async () => { + editor = await createEditor( + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + ' ' + + '
' + + '
' + ); + } ); + + // See https://github.com/ckeditor/ckeditor5/issues/10116. + it( 'should work if comment is in table cell after empty paragraph', async () => { + editor = await createEditor( + '' + + '' + + '' + + '' + + '
' + + '

' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + ' ' + + '
' + + '
' + ); + } ); + + // See https://github.com/ckeditor/ckeditor5/issues/10116. + it( 'should work if comment is in table cell after non-empty paragraph', async () => { + editor = await createEditor( + '' + + '' + + '' + + '' + + '
' + + '

foobar

' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + 'foobar' + + '' + + '
' + + '
' + ); + } ); + + it( 'should work if comments are in a non-empty table cell', async () => { + editor = await createEditor( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + 'table cell' + + '' + + '' + + '' + + 'table cell' + + '' + + '
' + + '' + + 'table cell' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + 'table cell' + + '' + + '' + + '' + + 'table cell' + + '' + + '
' + + '' + + 'table cell' + + '' + + '
' + + '
' + ); + } ); + + it( 'should work if comments are between tags', async () => { + editor = await createEditor( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
table cell
table cell
table cell
' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
table cell
table cell
table cell
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + ); + } ); + + it( 'should work if comments are in table caption', async () => { + editor = await createEditor( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
table cell
' + + '' + + 'table caption' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
table cell
' + + '' + + '
' + + '' + + 'table caption' + + '' + + '
' + + '' + + '
' + ); + } ); + + it( 'should work if comment is in an empty table caption', async () => { + editor = await createEditor( + '' + + '' + + '' + + '' + + '' + + '
table cell
' + + '' + + '
' + ); + + expect( editor.getData() ).to.equal( + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
table cell
' + + '
' + + '' + + ' ' + + '
' + + '
' + ); + } ); + } ); + + describe( 'integration with Undo', () => { + let editor; + + function createEditor( initialData = '' ) { + return ClassicTestEditor + .create( initialData, { + plugins: [ HtmlComment, Essentials, Paragraph ] + } ); + } + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work if content with comments is removed and then restored', async () => { + editor = await createEditor( + '

' + + '' + + 'paragraph' + + '' + + '

' + ); + + const model = editor.model; + const root = model.document.getRoot(); + + model.change( writer => { + writer.remove( writer.createRangeIn( root ) ); + } ); + + editor.execute( 'undo' ); + + expect( editor.getData() ).to.equal( + '

' + + '' + + 'paragraph' + + '' + + '

' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-html-support/tests/htmlcomment.js b/packages/ckeditor5-html-support/tests/htmlcomment.js new file mode 100644 index 00000000000..7deb67caee6 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/htmlcomment.js @@ -0,0 +1,551 @@ + +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import HtmlComment from '../src/htmlcomment'; +import Range from '@ckeditor/ckeditor5-engine/src/model/range'; + +describe( 'HtmlComment', () => { + let model, root, editor, htmlCommentPlugin; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ HtmlComment, Paragraph ] + } ); + + model = editor.model; + root = model.document.getRoot(); + + model.schema.register( 'div' ); + model.schema.extend( '$block', { allowIn: 'div' } ); + model.schema.extend( 'div', { allowIn: '$root' } ); + model.schema.extend( 'div', { allowIn: 'div' } ); + + editor.conversion.elementToElement( { model: 'div', view: 'div' } ); + + htmlCommentPlugin = editor.plugins.get( HtmlComment ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should be loadable using its plugin name', () => { + expect( editor.plugins.get( 'HtmlComment' ) ).to.be.instanceOf( HtmlComment ); + } ); + + describe( 'upcast conversion', () => { + it( 'should convert each comment node to a collapsed marker', () => { + editor.setData( '

Foo

' ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 2 ); + + expect( commentMarkers[ 0 ].getStart().path ).to.deep.equal( [ 0, 0 ] ); + expect( commentMarkers[ 0 ].getEnd().path ).to.deep.equal( [ 0, 0 ] ); + + expect( commentMarkers[ 1 ].getStart().path ).to.deep.equal( [ 0, 3 ] ); + expect( commentMarkers[ 1 ].getEnd().path ).to.deep.equal( [ 0, 3 ] ); + } ); + + it( 'should convert each comment node located at root\'s boundary to a collapsed marker', () => { + editor.setData( '

Foo

' ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 2 ); + + expect( commentMarkers[ 0 ].getStart().path ).to.deep.equal( [ 0 ] ); + expect( commentMarkers[ 0 ].getEnd().path ).to.deep.equal( [ 0 ] ); + + expect( commentMarkers[ 1 ].getStart().path ).to.deep.equal( [ 1 ] ); + expect( commentMarkers[ 1 ].getEnd().path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should convert each comment node from a nested tree to a collapsed marker', () => { + editor.setData( '

Foo

' ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 2 ); + + expect( commentMarkers[ 0 ].getStart().path ).to.deep.equal( [ 0, 0, 0, 0, 0 ] ); + expect( commentMarkers[ 0 ].getEnd().path ).to.deep.equal( [ 0, 0, 0, 0, 0 ] ); + + expect( commentMarkers[ 1 ].getStart().path ).to.deep.equal( [ 0, 0, 0, 0, 3 ] ); + expect( commentMarkers[ 1 ].getEnd().path ).to.deep.equal( [ 0, 0, 0, 0, 3 ] ); + } ); + + it( 'should set a root attribute containing comment\'s content for each marker', () => { + editor.setData( '

Foo

' ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 2 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( root.getAttribute( commentMarkers[ 0 ].name ) ).to.equal( ' comment 1 ' ); + expect( root.getAttribute( commentMarkers[ 1 ].name ) ).to.equal( ' comment 2 ' ); + } ); + + it( 'should set a root attribute containing comment\'s content for each marker located in a nested tree', () => { + editor.setData( '

Foo

' ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 2 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( root.getAttribute( commentMarkers[ 0 ].name ) ).to.equal( ' comment 1 ' ); + expect( root.getAttribute( commentMarkers[ 1 ].name ) ).to.equal( ' comment 2 ' ); + } ); + + it( 'should not create a dedicated model element for a comment node', () => { + editor.setData( '

Foo

' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( 'Foo' ); + } ); + } ); + + describe( 'data downcast conversion', () => { + it( 'should convert each $comment marker to a comment node', () => { + editor.setData( '

Foo

' ); + + const paragraph = root.getChild( 0 ); + + addMarker( '$comment:1', paragraph, 1 ); + root._setAttribute( '$comment:1', ' comment 1 ' ); + + addMarker( '$comment:2', paragraph, 2 ); + root._setAttribute( '$comment:2', ' comment 2 ' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + + it( 'should convert each $comment marker located at root\'s boundary to a comment node', () => { + editor.setData( '

Foo

' ); + + addMarker( '$comment:1', root, 0 ); + root._setAttribute( '$comment:1', ' comment 1 ' ); + + addMarker( '$comment:2', root, 1 ); + root._setAttribute( '$comment:2', ' comment 2 ' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + + it( 'should convert each $comment marker to a comment node inside a nested tree', () => { + editor.setData( '

Foo

' ); + + const paragraph = root.getNodeByPath( [ 0, 0, 0, 0 ] ); + + addMarker( '$comment:1', paragraph, 1 ); + root._setAttribute( '$comment:1', ' comment 1 ' ); + + addMarker( '$comment:2', paragraph, 2 ); + root._setAttribute( '$comment:2', ' comment 2 ' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + } ); + + describe( 'removing comments when the corresponding content is removed', () => { + it( 'should remove all non-boundary comments when the whole content is removed', () => { + editor.setData( '

Foo

' ); + + model.change( writer => { + writer.remove( writer.createRangeIn( root ) ); + } ); + + expect( editor.getData( { trim: false } ) ).to.equal( '

 

' ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 0 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 0 ); + } ); + + it( 'should remove comments when the content including them is removed', () => { + editor.setData( '

Foo

Foo

' ); + + model.change( writer => { + const firstParagraph = root.getChild( 0 ); + + writer.remove( writer.createRangeOn( firstParagraph ) ); + } ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 2 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 2 ); + + expect( root.getAttribute( commentMarkers[ 0 ].name ) ).to.equal( ' comment 3 ' ); + expect( root.getAttribute( commentMarkers[ 1 ].name ) ).to.equal( ' comment 4 ' ); + } ); + + it( 'should remove all comments when the whole content is removed with editor.setData( \'\' )', () => { + editor.setData( + '' + + '

Foo

' + + '' + + '

Bar

' + + '' + ); + editor.setData( '' ); + + expect( editor.getData( { trim: false } ) ).to.equal( '

 

' ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 0 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 0 ); + } ); + + it( 'should replace all comments with new comments when the whole content is replaced with editor.setData()', () => { + editor.setData( + '' + + '

Foo

' + + '' + + '

Bar

' + + '' + ); + + editor.setData( + '' + + '

Foo

' + + '' + + '

Bar

' + + '' + ); + + expect( editor.getData( { trim: false } ) ).to.equal( + '' + + '

Foo

' + + '' + + '

Bar

' + + '' + ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 5 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 5 ); + } ); + + it( 'should remove all comments when the whole content is removed with model.deleteContent()', () => { + editor.setData( + '' + + '

Foo

' + + '' + + '

Bar

' + + '' + ); + + model.deleteContent( model.createSelection( root, 'in' ) ); + + expect( editor.getData( { trim: false } ) ).to.equal( '

 

' ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 0 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 0 ); + } ); + + it( 'should not remove boundary comments when only the start of the content is removed with model.deleteContent()', () => { + editor.setData( + '' + + '

Foo

' + + '' + + '

Bar

' + + '' + ); + + model.deleteContent( model.createSelection( root.getChild( 0 ), 'on' ) ); + + expect( editor.getData( { trim: false } ) ).to.equal( + // The order is not perfect. + '

 

' + + '' + + '' + + '

Bar

' + + '' + ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 4 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 4 ); + } ); + + it( 'should not remove boundary comments when only the end of the content is removed with model.deleteContent()', () => { + editor.setData( + '' + + '

Foo

' + + '' + + '

Bar

' + + '' + ); + + model.deleteContent( model.createSelection( root.getChild( 1 ), 'on' ) ); + + expect( editor.getData( { trim: false } ) ).to.equal( + // The order is not perfect. + '' + + '

Foo

' + + '

 

' + + '' + + '' + ); + + const rootAttributes = [ ...root.getAttributeKeys() ].filter( attr => attr.startsWith( '$comment' ) ); + + expect( rootAttributes ).to.have.length( 4 ); + + const commentMarkers = [ ...editor.model.markers ].filter( marker => marker.name.startsWith( '$comment' ) ); + + expect( commentMarkers ).to.have.length( 4 ); + } ); + } ); + + describe( 'createHtmlComment()', () => { + it( 'should create an HTML comment between elements', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 1 ), 'first' ); + + expect( editor.getData() ).to.equal( '

Foo

Bar

Baz

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 2 ), 'second' ); + + expect( editor.getData() ).to.equal( '

Foo

Bar

Baz

' ); + } ); + + it( 'should return a comment ID of the comment', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + const firstCommentID = htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 1 ), 'foo' ); + + const secondCommentID = htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 1 ), 'bar' ); + + expect( firstCommentID ).to.be.a( 'string' ); + expect( secondCommentID ).to.be.a( 'string' ); + + expect( firstCommentID ).to.not.equal( secondCommentID ); + } ); + + it( 'should allow creating an HTML comment inside the text', () => { + editor.setData( '

Foo

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 0, 1 ] ), 'foo' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + + it( 'should allow creating a few HTML comments in the same place', () => { + editor.setData( '

Foo

' ); + + const position = model.createPositionFromPath( root, [ 0, 1 ] ); + + htmlCommentPlugin.createHtmlComment( position, 'foo' ); + htmlCommentPlugin.createHtmlComment( position, 'bar' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + + it( 'should allow creating an HTML comment before the first element', () => { + editor.setData( '

Foo

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 0 ), 'foo' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + + it( 'should allow creating an HTML comment after the last element', () => { + editor.setData( '

Foo

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 1 ), 'foo' ); + + expect( editor.getData() ).to.equal( '

Foo

' ); + } ); + } ); + + describe( 'removeHtmlComment()', () => { + it( 'should remove a comment and return true if the comment with the given comment ID exists', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + const firstCommentID = htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 1 ), 'foo' ); + const secondCommentID = htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 1 ), 'bar' ); + + const result1 = htmlCommentPlugin.removeHtmlComment( firstCommentID ); + + expect( editor.getData() ).to.equal( '

Foo

Bar

Baz

' ); + + const result2 = htmlCommentPlugin.removeHtmlComment( secondCommentID ); + + expect( editor.getData() ).to.equal( '

Foo

Bar

Baz

' ); + + expect( result1 ).to.equal( true ); + expect( result2 ).to.equal( true ); + } ); + + // Note that the comment could have been removed via the content changes. + it( 'should do nothing and return `false` if a comment with the given comment ID does not exist', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionAt( root, 1 ), 'bar' ); + + const result = htmlCommentPlugin.removeHtmlComment( 'invalid-comment-id' ); + + expect( editor.getData() ).to.equal( '

Foo

Bar

Baz

' ); + + expect( result ).to.equal( false ); + } ); + } ); + + describe( 'getHtmlCommentsInRange()', () => { + it( 'should return all comment marker IDs present in the specified range', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + htmlCommentPlugin.createHtmlComment( + model.createPositionFromPath( root, [ 1, 0 ] ), + 'foo' + ); + + htmlCommentPlugin.createHtmlComment( + model.createPositionFromPath( root, [ 2 ] ), + 'bar' + ); + + const id3 = htmlCommentPlugin.createHtmlComment( + model.createPositionFromPath( root, [ 2, 1 ] ), + 'foo' + ); + + const id4 = htmlCommentPlugin.createHtmlComment( + model.createPositionFromPath( root, [ 2, 3 ] ), + 'foo' + ); + + const range = model.createRangeIn( root.getChild( 2 ) ); + + expect( htmlCommentPlugin.getHtmlCommentsInRange( range ) ).to.deep.equal( [ id3, id4 ] ); + } ); + + it( 'should return all comment marker IDs present in the specified range including comments at range boundaries', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 1, 0 ] ), 'foo' ); + htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 2 ] ), 'bar' ); + + const posStart = model.createPositionFromPath( root, [ 2, 1 ] ); + const posEnd = model.createPositionFromPath( root, [ 2, 3 ] ); + + const range = new Range( posStart, posEnd ); + + // Comments at the range boundaries. + const id3 = htmlCommentPlugin.createHtmlComment( posStart, 'baz' ); + const id4 = htmlCommentPlugin.createHtmlComment( posEnd, 'biz' ); + + expect( htmlCommentPlugin.getHtmlCommentsInRange( range ) ).to.deep.equal( [ id3, id4 ] ); + } ); + + it( 'should not return comments at range boundaries when the skipBoundaries option is set to true', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 1, 0 ] ), 'foo' ); + htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 2 ] ), 'bar' ); + + const posStart = model.createPositionFromPath( root, [ 2, 1 ] ); + const posEnd = model.createPositionFromPath( root, [ 2, 3 ] ); + + const range = new Range( posStart, posEnd ); + + // Comments at the range boundaries. + htmlCommentPlugin.createHtmlComment( posStart, 'baz' ); + htmlCommentPlugin.createHtmlComment( posEnd, 'biz' ); + + expect( htmlCommentPlugin.getHtmlCommentsInRange( range, { skipBoundaries: true } ) ).to.deep.equal( [] ); + } ); + + it( 'should return all comment marker IDs present in the specified collapsed range', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 2, 0 ] ), 'foo' ); + htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 2, 2 ] ), 'bar' ); + + const position = model.createPositionFromPath( root, [ 2, 1 ] ); + + const range = new Range( position, position ); + + // Two comments at the position of the collapsed range. + const id1 = htmlCommentPlugin.createHtmlComment( position, 'baz' ); + const id2 = htmlCommentPlugin.createHtmlComment( position, 'biz' ); + + expect( htmlCommentPlugin.getHtmlCommentsInRange( range ) ).to.deep.equal( [ id1, id2 ] ); + } ); + } ); + + describe( 'getHtmlCommentData()', () => { + it( 'should return a position and the content for the given comment', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + const id1 = htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 0 ] ), 'foo' ); + const id2 = htmlCommentPlugin.createHtmlComment( model.createPositionFromPath( root, [ 2, 2 ] ), 'bar' ); + + const commentData1 = htmlCommentPlugin.getHtmlCommentData( id1 ); + const commentData2 = htmlCommentPlugin.getHtmlCommentData( id2 ); + + expect( commentData1 ).to.be.an( 'object' ); + expect( commentData1.position.isEqual( model.createPositionFromPath( root, [ 0 ] ) ) ).to.be.true; + expect( commentData1.content ).to.equal( 'foo' ); + + expect( commentData2 ).to.be.an( 'object' ); + expect( commentData2.position.isEqual( model.createPositionFromPath( root, [ 2, 2 ] ) ) ).to.be.true; + expect( commentData2.content ).to.equal( 'bar' ); + } ); + + it( 'should return null if the given comment does not exist', () => { + editor.setData( '

Foo

Bar

Baz

' ); + + expect( htmlCommentPlugin.getHtmlCommentData( 'invalid-id' ) ).to.be.null; + } ); + } ); + + function addMarker( name, element, offset ) { + model.change( writer => { + writer.addMarker( name, { + usingOperation: true, + affectsData: true, + range: writer.createRange( + writer.createPositionAt( element, offset ) + ) + } ); + } ); + } +} ); diff --git a/packages/ckeditor5-html-support/tests/manual/htmlcomment.html b/packages/ckeditor5-html-support/tests/manual/htmlcomment.html new file mode 100644 index 00000000000..8a5d4348655 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/htmlcomment.html @@ -0,0 +1,23 @@ +
+ +

Paragraph

+ + + + + + + +
Table cell #1Table cell #2Table cell #3
+
    +
  1. Item #1
  2. +
  3. Item #2
  4. +
  5. Item #3 +
      +
    • Nested item #3.1
    • +
    • Nested item #3.2
    • +
    • Nested item #3.3
    • +
    +
  6. +
+
\ No newline at end of file diff --git a/packages/ckeditor5-html-support/tests/manual/htmlcomment.js b/packages/ckeditor5-html-support/tests/manual/htmlcomment.js new file mode 100644 index 00000000000..23e4e9775a9 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/htmlcomment.js @@ -0,0 +1,63 @@ +/* global document, window, console */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; +import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar'; +import TodoList from '@ckeditor/ckeditor5-list/src/todolist'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import HtmlComment from '../../src/htmlcomment'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + ArticlePluginSet, + CloudServices, + EasyImage, + ImageUpload, + PasteFromOffice, + SourceEditing, + Table, + TableToolbar, + TodoList, + HtmlComment + ], + cloudServices: CS_CONFIG, + toolbar: [ + 'heading', '|', 'bold', 'italic', 'link', '|', + 'bulletedList', 'numberedList', 'todoList', '|', + 'blockQuote', 'uploadImage', 'insertTable', '|', + 'sourceEditing', '|', + 'undo', 'redo' + ], + image: { + toolbar: [ + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side', + '|', + 'toggleImageCaption', + 'imageTextAlternative' + ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-html-support/tests/manual/htmlcomment.md b/packages/ckeditor5-html-support/tests/manual/htmlcomment.md new file mode 100644 index 00000000000..80d0e7d7306 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/htmlcomment.md @@ -0,0 +1,11 @@ +## HTML comment + +1. Toggle the source editing mode and verify that all comments are present. There should be 10 comments: from `C1` to `C10`. +1. In source editing mode add and remove some comments. Please note that, currently, the comment support is on a basic level. See the **Known limitations** section below containing missing functionalities. +1. Copy & paste some content from Word and Google Docs. Content should be parsed without errors and displayed correctly. + +### Known limitations + +1. [Issue #10118](https://github.com/ckeditor/ckeditor5/issues/10118): comments located between some HTML tags are repositioned or lost. +1. [Issue #10119](https://github.com/ckeditor/ckeditor5/issues/10119): comments can be easily repositioned during editing the document. +1. [Issue #10127](https://github.com/ckeditor/ckeditor5/issues/10127): comments are not handled in copy & paste or drag & drop. \ No newline at end of file diff --git a/packages/ckeditor5-paste-from-office/src/filters/parse.js b/packages/ckeditor5-paste-from-office/src/filters/parse.js index b427afb33b9..c69ed55b9b1 100644 --- a/packages/ckeditor5-paste-from-office/src/filters/parse.js +++ b/packages/ckeditor5-paste-from-office/src/filters/parse.js @@ -56,7 +56,7 @@ export function parseHtml( htmlString, stylesProcessor ) { }; } -// Transforms native `Document` object into {@link module:engine/view/documentfragment~DocumentFragment}. +// Transforms native `Document` object into {@link module:engine/view/documentfragment~DocumentFragment}. Comments are skipped. // // @param {Document} htmlDocument Native `Document` object to be transformed. // @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor @@ -71,7 +71,7 @@ function documentToView( htmlDocument, stylesProcessor ) { fragment.appendChild( nodes[ 0 ] ); } - return domConverter.domToView( fragment ); + return domConverter.domToView( fragment, { skipComments: true } ); } // Extracts both `CSSStyleSheet` and string representation from all `style` elements available in a provided `htmlDocument`. diff --git a/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.js b/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.js index 2ae827e3107..770e87c9931 100644 --- a/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.js +++ b/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.js @@ -45,8 +45,11 @@ export default class GoogleDocsNormalizer { */ execute( data ) { const writer = new UpcastWriter( this.document ); + const { body: documentFragment } = data._parsedData; - removeBoldWrapper( data.content, writer ); - unwrapParagraphInListItem( data.content, writer ); + removeBoldWrapper( documentFragment, writer ); + unwrapParagraphInListItem( documentFragment, writer ); + + data.content = documentFragment; } } diff --git a/packages/ckeditor5-paste-from-office/src/normalizers/mswordnormalizer.js b/packages/ckeditor5-paste-from-office/src/normalizers/mswordnormalizer.js index 5d14fd02c00..a2196ba7a28 100644 --- a/packages/ckeditor5-paste-from-office/src/normalizers/mswordnormalizer.js +++ b/packages/ckeditor5-paste-from-office/src/normalizers/mswordnormalizer.js @@ -7,7 +7,6 @@ * @module paste-from-office/normalizers/mswordnormalizer */ -import { parseHtml } from '../filters/parse'; import { transformListItemLikeElementsIntoLists } from '../filters/list'; import { replaceImagesSourceWithBase64 } from '../filters/image'; @@ -44,11 +43,11 @@ export default class MSWordNormalizer { * @inheritDoc */ execute( data ) { - const { body, stylesString } = parseHtml( data.dataTransfer.getData( 'text/html' ), this.document.stylesProcessor ); + const { body: documentFragment, stylesString } = data._parsedData; - transformListItemLikeElementsIntoLists( body, stylesString ); - replaceImagesSourceWithBase64( body, data.dataTransfer.getData( 'text/rtf' ) ); + transformListItemLikeElementsIntoLists( documentFragment, stylesString ); + replaceImagesSourceWithBase64( documentFragment, data.dataTransfer.getData( 'text/rtf' ) ); - data.content = body; + data.content = documentFragment; } } diff --git a/packages/ckeditor5-paste-from-office/src/pastefromoffice.js b/packages/ckeditor5-paste-from-office/src/pastefromoffice.js index 546e9913e3a..1051b9f28b2 100644 --- a/packages/ckeditor5-paste-from-office/src/pastefromoffice.js +++ b/packages/ckeditor5-paste-from-office/src/pastefromoffice.js @@ -13,6 +13,8 @@ import { ClipboardPipeline } from 'ckeditor5/src/clipboard'; import GoogleDocsNormalizer from './normalizers/googledocsnormalizer'; import MSWordNormalizer from './normalizers/mswordnormalizer'; +import { parseHtml } from './filters/parse'; + /** * The Paste from Office plugin. * @@ -57,7 +59,7 @@ export default class PasteFromOffice extends Plugin { editor.plugins.get( 'ClipboardPipeline' ).on( 'inputTransformation', ( evt, data ) => { - if ( data.isTransformedWithPasteFromOffice ) { + if ( data._isTransformedWithPasteFromOffice ) { return; } @@ -65,9 +67,11 @@ export default class PasteFromOffice extends Plugin { const activeNormalizer = normalizers.find( normalizer => normalizer.isActive( htmlString ) ); if ( activeNormalizer ) { + data._parsedData = parseHtml( htmlString, viewDocument.stylesProcessor ); + activeNormalizer.execute( data ); - data.isTransformedWithPasteFromOffice = true; + data._isTransformedWithPasteFromOffice = true; } }, { priority: 'high' } diff --git a/packages/ckeditor5-paste-from-office/tests/_utils/utils.js b/packages/ckeditor5-paste-from-office/tests/_utils/utils.js index 39ffb1727c2..22e1a45d400 100644 --- a/packages/ckeditor5-paste-from-office/tests/_utils/utils.js +++ b/packages/ckeditor5-paste-from-office/tests/_utils/utils.js @@ -265,6 +265,8 @@ function generateIntegrationTests( title, fixtures, editorConfig, skip, only ) { // these images are extracted (so HTML diff is readable) and compared // one by one separately (so it is visible if base64 representation is malformed). // +// Comments are removed from the expected HTML struture, to be consistent with the actual pasted data in the plugin. +// // This function is designed for comparing normalized data so expected input is preprocessed before comparing: // // * Tabs on the lines beginnings are removed. @@ -291,7 +293,7 @@ function expectNormalized( actualView, expectedHtml ) { // We are ok with both spaces and non-breaking spaces in the actual content. // Replace ` ` with regular spaces to align with expected content. const actualNormalized = stringifyView( actualView ).replace( /\u00A0/g, ' ' ); - const expectedNormalized = normalizeHtml( inlineData( expectedHtml ) ); + const expectedNormalized = normalizeHtml( inlineData( expectedHtml ), { skipComments: true } ); compareContentWithBase64Images( actualNormalized, expectedNormalized ); } diff --git a/packages/ckeditor5-paste-from-office/tests/filters/parse.js b/packages/ckeditor5-paste-from-office/tests/filters/parse.js index ef644569a60..3ae31f137c4 100644 --- a/packages/ckeditor5-paste-from-office/tests/filters/parse.js +++ b/packages/ckeditor5-paste-from-office/tests/filters/parse.js @@ -161,6 +161,17 @@ describe( 'PasteFromOffice - filters', () => { expect( stylesString ).to.equal( '' ); } ); + + it( 'should remove all comments', () => { + const html = '

Foo Bar

'; + const { body } = parseHtml( html ); + + expect( body ).to.instanceof( DocumentFragment ); + + expect( body.childCount ).to.equal( 1 ); + + expect( body.getChild( 0 ).name ).to.equal( 'p' ); + } ); } ); } ); } ); diff --git a/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js b/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js index edfef7ffd3a..331f955446c 100644 --- a/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js +++ b/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js @@ -12,6 +12,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap'; import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document'; +import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfragment'; describe( 'PasteFromOffice', () => { const htmlDataProcessor = new HtmlDataProcessor( new ViewDocument( new StylesProcessor() ) ); @@ -62,7 +63,13 @@ describe( 'PasteFromOffice', () => { clipboard.fire( 'inputTransformation', data ); - expect( data.isTransformedWithPasteFromOffice ).to.be.true; + expect( data._isTransformedWithPasteFromOffice ).to.be.true; + expect( data._parsedData ).to.have.property( 'body' ); + expect( data._parsedData ).to.have.property( 'bodyString' ); + expect( data._parsedData ).to.have.property( 'styles' ); + expect( data._parsedData ).to.have.property( 'stylesString' ); + expect( data._parsedData.body ).to.be.instanceOf( ViewDocumentFragment ); + sinon.assert.called( getDataSpy ); } } ); @@ -82,7 +89,9 @@ describe( 'PasteFromOffice', () => { clipboard.fire( 'inputTransformation', data ); - expect( data.isTransformedWithPasteFromOffice ).to.be.undefined; + expect( data._isTransformedWithPasteFromOffice ).to.be.undefined; + expect( data._parsedData ).to.be.undefined; + sinon.assert.called( getDataSpy ); } } ); @@ -104,7 +113,9 @@ describe( 'PasteFromOffice', () => { clipboard.fire( 'inputTransformation', data ); - expect( data.isTransformedWithPasteFromOffice ).to.be.true; + expect( data._isTransformedWithPasteFromOffice ).to.be.true; + expect( data._parsedData ).to.be.undefined; + sinon.assert.notCalled( getDataSpy ); } } ); @@ -120,7 +131,7 @@ describe( 'PasteFromOffice', () => { }; if ( isTransformedWithPasteFromOffice ) { - data.isTransformedWithPasteFromOffice = true; + data._isTransformedWithPasteFromOffice = true; } return data; diff --git a/packages/ckeditor5-table/src/tableediting.js b/packages/ckeditor5-table/src/tableediting.js index ff556cfb185..838d1643f16 100644 --- a/packages/ckeditor5-table/src/tableediting.js +++ b/packages/ckeditor5-table/src/tableediting.js @@ -129,6 +129,11 @@ export default class TableEditing extends Plugin { // 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. + editor.data.mapper.on( 'modelToViewPosition', mapTableCellModelPositionToView() ); + // Define the config. editor.config.define( 'table.defaultHeadings.rows', 0 ); editor.config.define( 'table.defaultHeadings.columns', 0 ); @@ -173,6 +178,40 @@ export default class TableEditing extends Plugin { } } +// Creates a mapper callback to adjust model position mappings in a table cell containing a paragraph, which is bound to its parent +// (to the table cell). Only positions after this paragraph have to be adjusted, because after binding this paragraph to the table cell, +// elements located after this paragraph would point either to a non-existent offset inside `tableCell` (if paragraph is empty), or after +// the first character of the paragraph's text. See https://github.com/ckeditor/ckeditor5/issues/10116. +// +// ^ -> ^  +// +// foobar^ -> foobar^ +// +// @returns {Function} +function mapTableCellModelPositionToView() { + return ( evt, data ) => { + const modelParent = data.modelPosition.parent; + const modelNodeBefore = data.modelPosition.nodeBefore; + + if ( !modelParent.is( 'element', 'tableCell' ) ) { + return; + } + + if ( !modelNodeBefore || !modelNodeBefore.is( 'element', 'paragraph' ) ) { + return; + } + + const viewNodeBefore = data.mapper.toViewElement( modelNodeBefore ); + const viewParent = data.mapper.toViewElement( modelParent ); + + if ( viewNodeBefore === viewParent ) { + // Since the paragraph has already been bound to its parent, update the current position in the model with paragraph's + // max offset, so it points to the place which should normally (in all other cases) be the end position of this paragraph. + data.viewPosition = data.mapper.findPositionIn( viewParent, modelNodeBefore.maxOffset ); + } + }; +} + // Returns fixed colspan and rowspan attrbutes values. // // @private diff --git a/packages/ckeditor5-table/tests/table-integration.js b/packages/ckeditor5-table/tests/table-integration.js index e9692922453..3e21b059753 100644 --- a/packages/ckeditor5-table/tests/table-integration.js +++ b/packages/ckeditor5-table/tests/table-integration.js @@ -224,4 +224,62 @@ describe( 'Table feature – integration with markers', () => { expect( getModelData( editor.model, { withoutSelection: true } ) ) .to.equal( '
' ); } ); + + // https://github.com/ckeditor/ckeditor5/issues/10116 + describe( 'markers converted to UI elements and vice versa', () => { + function CustomPlugin( editor ) { + editor.conversion.for( 'upcast' ).elementToMarker( { view: 'foo', model: 'bar' } ); + editor.conversion.for( 'dataDowncast' ).markerToElement( { view: 'foo', model: 'bar' } ); + } + + beforeEach( async () => { + editor = await ClassicTestEditor.create( '', { plugins: [ CustomPlugin, Paragraph, TableEditing ] } ); + } ); + + it( 'should not throw if marker is inside an empty table cell', async () => { + editor.setData( '
' ); + + expect( () => editor.getData() ).to.not.throw(); + } ); + + it( 'should adjust the model position mapping - table cell containing marker only', async () => { + editor.setData( '
' ); + + expect( editor.getData() ).to.equal( + '
 
' + ); + } ); + + it( 'should adjust the model position mapping - table cell containing marker preceded by an empty paragraph', async () => { + editor.setData( '

' ); + + expect( editor.getData() ).to.equal( + '
 
' + ); + } ); + + it( 'should adjust the model position mapping - table cell containing marker followed by an empty paragraph', async () => { + editor.setData( '

' ); + + expect( editor.getData() ).to.equal( + '
 
' + ); + } ); + + it( 'should adjust the model position mapping - table cell containing marker preceded by a non-empty paragraph', async () => { + editor.setData( '

foobar

' ); + + expect( editor.getData() ).to.equal( + '
foobar
' + ); + } ); + + it( 'should adjust the model position mapping - table cell containing marker followed by a non-empty paragraph', async () => { + editor.setData( '

foobar

' ); + + expect( editor.getData() ).to.equal( + '
foobar
' + ); + } ); + } ); } ); diff --git a/packages/ckeditor5-utils/tests/_utils/normalizehtml.js b/packages/ckeditor5-utils/tests/_utils/normalizehtml.js index a309d34dc73..e078c416e71 100644 --- a/packages/ckeditor5-utils/tests/_utils/normalizehtml.js +++ b/packages/ckeditor5-utils/tests/_utils/normalizehtml.js @@ -12,11 +12,13 @@ import Document from '@ckeditor/ckeditor5-engine/src/view/document'; * Parses given string of HTML and returns normalized HTML. * * @param {String} html HTML string to normalize. + * @param {Object} [options] DOM to View conversion options. See {@link module:engine/view/domconverter~DomConverter#domToView} options. * @returns {String} Normalized HTML string. */ -export default function normalizeHtml( html ) { +export default function normalizeHtml( html, options = {} ) { const processor = new HtmlDataProcessor( new Document( new StylesProcessor() ) ); - const parsed = processor.toView( html ); + const domFragment = processor._toDom( html ); + const viewFragment = processor._domConverter.domToView( domFragment, options ); - return stringify( parsed ); + return stringify( viewFragment ); } diff --git a/tests/manual/all-features-dll.js b/tests/manual/all-features-dll.js index e0912d3b4a5..727c83c7a81 100644 --- a/tests/manual/all-features-dll.js +++ b/tests/manual/all-features-dll.js @@ -35,6 +35,7 @@ import '@ckeditor/ckeditor5-heading/build/heading'; import '@ckeditor/ckeditor5-highlight/build/highlight'; import '@ckeditor/ckeditor5-horizontal-line/build/horizontal-line'; import '@ckeditor/ckeditor5-html-embed/build/html-embed'; +import '@ckeditor/ckeditor5-html-support/build/html-support'; import '@ckeditor/ckeditor5-language/build/language'; import '@ckeditor/ckeditor5-media-embed/build/media-embed'; import '@ckeditor/ckeditor5-mention/build/mention'; @@ -71,6 +72,7 @@ const { Heading } = window.CKEditor5.heading; const { Highlight } = window.CKEditor5.highlight; const { HorizontalLine } = window.CKEditor5.horizontalLine; const { HtmlEmbed } = window.CKEditor5.htmlEmbed; +const { HtmlComment } = window.CKEditor5.htmlSupport; const { MediaEmbed } = window.CKEditor5.mediaEmbed; const { Mention } = window.CKEditor5.mention; const { PageBreak } = window.CKEditor5.pageBreak; @@ -125,6 +127,7 @@ const config = { Highlight, HorizontalLine, HtmlEmbed, + HtmlComment, Indent, IndentBlock, List, ListStyle, TodoList, MediaEmbed, diff --git a/tests/manual/all-features.html b/tests/manual/all-features.html index d52bf47c89b..e776220d974 100644 --- a/tests/manual/all-features.html +++ b/tests/manual/all-features.html @@ -239,6 +239,29 @@

Text part language

والاحتكاك بين أفراد امجتمع في جميع ميادين الحياة. وبدون اللغة يتعذر نشاط الناس المعرفي.

+ +

HTML comments

+ +

Paragraph

+ + + + + + + +
Table cell #1Table cell #2Table cell #3
+
    +
  1. Item #1
  2. +
  3. Item #2
  4. +
  5. Item #3 +
      +
    • Nested item #3.1
    • +
    • Nested item #3.2
    • +
    • Nested item #3.3
    • +
    +
  6. +