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
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( '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.
+' + + '' + + '' + + '' + ); + + 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( + 'foobar
' + + '' + + '
' +
+ '' +
+ '' +
+ '
' +
+ '
'
+ );
+
+ 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(
+ '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
' + ); + + 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( + '' + + ' ' + + '' + ); + + expect( editor.getData() ).to.equal( + '' + + ' ' + + '' + ); + } ); + } ); + + 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( + '' + + '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '
' + + '' + + '
' + ); + + 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: ' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ 'Link with inline image:' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ 'External link with inline image: ' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ 'External link with inline image:' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' ); + } ); + + 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( + '' + + '' + + ' | ' + + '
' + + '' + + ' ' + + ' | ' + + '
' + + '' + + '' + + ' | ' + + '
' + + '' + + ' ' + + ' | ' + + '
' +
+ ' foobar ' + + '' + + ' | ' +
+ '
' + + 'foobar' + + '' + + ' | ' + + '
' + + '' + + 'table cell' + + '' + + ' | ' + + '' + + '' + + 'table cell' + + '' + + ' | ' + + '|
' + + '' + + 'table cell' + + '' + + ' | ' + + '
' + + '' + + 'table cell' + + '' + + ' | ' + + '' + + '' + + 'table cell' + + '' + + ' | ' + + '|
' + + '' + + 'table cell' + + '' + + ' | ' + + '
table cell | ' + + '' + + '
---|
table cell | ' + + '' + + '
table cell | ' + + '' + + '
table cell | ' + + '' + + '
---|
table cell | ' + + '' + + '
table cell | ' + + '' + + '
table cell | ' + + '
table cell | ' + + '
table cell | ' + + '
table cell | ' + + '
' + + '' + + '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
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
Foo
' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '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
Foo
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 #1 | +Table cell #2 | +Table cell #3 | +
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 |
foobar |
Paragraph
+ +Table cell #1 | +Table cell #2 | +Table cell #3 | +