diff --git a/packages/ckeditor5-heading/package.json b/packages/ckeditor5-heading/package.json index 68bcb38228c..589390c70bc 100644 --- a/packages/ckeditor5-heading/package.json +++ b/packages/ckeditor5-heading/package.json @@ -22,6 +22,7 @@ "@ckeditor/ckeditor5-clipboard": "^38.0.1", "@ckeditor/ckeditor5-dev-utils": "^37.0.0", "@ckeditor/ckeditor5-editor-classic": "^38.0.1", + "@ckeditor/ckeditor5-editor-multi-root": "^38.0.1", "@ckeditor/ckeditor5-engine": "^38.0.1", "@ckeditor/ckeditor5-enter": "^38.0.1", "@ckeditor/ckeditor5-image": "^38.0.1", diff --git a/packages/ckeditor5-heading/src/title.ts b/packages/ckeditor5-heading/src/title.ts index eea88fbe1c1..d5743738dac 100644 --- a/packages/ckeditor5-heading/src/title.ts +++ b/packages/ckeditor5-heading/src/title.ts @@ -43,7 +43,7 @@ export default class Title extends Plugin { * A reference to an empty paragraph in the body * created when there is no element in the body for the placeholder purposes. */ - private _bodyPlaceholder?: null | Element; + private _bodyPlaceholder = new Map(); /** * @inheritDoc @@ -66,8 +66,6 @@ export default class Title extends Plugin { const editor = this.editor; const model = editor.model; - this._bodyPlaceholder = null; - // To use the schema for disabling some features when the selection is inside the title element // it is needed to create the following structure: // @@ -139,7 +137,8 @@ export default class Title extends Plugin { * @returns The title of the document. */ public getTitle( options: Record = {} ): string { - const titleElement = this._getTitleElement(); + const rootName = options.rootName ? options.rootName as string : undefined; + const titleElement = this._getTitleElement( rootName ); const titleContentElement = titleElement!.getChild( 0 ) as Element; return this.editor.data.stringify( titleContentElement, options ); @@ -160,7 +159,8 @@ export default class Title extends Plugin { const editor = this.editor; const data = editor.data; const model = editor.model; - const root = editor.model.document.getRoot()!; + const rootName = options.rootName ? options.rootName as string : undefined; + const root = editor.model.document.getRoot( rootName )!; const view = editor.editing.view; const viewWriter = new DowncastWriter( view.document ); @@ -196,8 +196,8 @@ export default class Title extends Plugin { /** * Returns the `title` element when it is in the document. Returns `undefined` otherwise. */ - private _getTitleElement(): Element | undefined { - const root = this.editor.model.document.getRoot()!; + private _getTitleElement( rootName?: string ): Element | undefined { + const root = this.editor.model.document.getRoot( rootName )!; for ( const child of root.getChildren() as IterableIterator ) { if ( isTitle( child ) ) { @@ -211,24 +211,31 @@ export default class Title extends Plugin { * All additional children should be moved after the `title` element and renamed to a paragraph. */ private _fixTitleContent( writer: Writer ) { - const title = this._getTitleElement(); + let changed = false; - // There's no title in the content - it will be created by _fixTitleElement post-fixer. - if ( !title || title.maxOffset === 1 ) { - return false; - } + for ( const rootName of this.editor.model.document.getRootNames() ) { + const title = this._getTitleElement( rootName ); + + // If there is no title in the content it will be created by `_fixTitleElement` post-fixer. + // If the title has just one element, then it is correct. No fixing. + if ( !title || title.maxOffset === 1 ) { + continue; + } + + const titleChildren = Array.from( title.getChildren() ) as Array; - const titleChildren = Array.from( title.getChildren() as IterableIterator ); + // Skip first child because it is an allowed element. + titleChildren.shift(); - // Skip first child because it is an allowed element. - titleChildren.shift(); + for ( const titleChild of titleChildren ) { + writer.move( writer.createRangeOn( titleChild ), title, 'after' ); + writer.rename( titleChild, 'paragraph' ); + } - for ( const titleChild of titleChildren ) { - writer.move( writer.createRangeOn( titleChild ), title, 'after' ); - writer.rename( titleChild, 'paragraph' ); + changed = true; } - return true; + return changed; } /** @@ -236,43 +243,54 @@ export default class Title extends Plugin { * takes care of the correct position of it and removes additional title elements. */ private _fixTitleElement( writer: Writer ) { + let changed = false; const model = this.editor.model; - const modelRoot = model.document.getRoot()!; - const titleElements = Array.from( modelRoot.getChildren() as IterableIterator ).filter( isTitle ); - const firstTitleElement = titleElements[ 0 ]; - const firstRootChild = modelRoot.getChild( 0 ) as Element; + for ( const rootName of this.editor.model.document.getRootNames() ) { + const modelRoot = model.document.getRoot( rootName )!; - // When title element is at the beginning of the document then try to fix additional - // title elements (if there are any) and stop post-fixer as soon as possible. - if ( firstRootChild.is( 'element', 'title' ) ) { - return fixAdditionalTitleElements( titleElements, writer, model ); - } + const titleElements = Array.from( modelRoot.getChildren() as IterableIterator ).filter( isTitle ); + const firstTitleElement = titleElements[ 0 ]; + const firstRootChild = modelRoot.getChild( 0 ) as Element; - // When there is no title in the document and first element in the document cannot be changed - // to the title then create an empty title element at the beginning of the document. - if ( !firstTitleElement && !titleLikeElements.has( firstRootChild.name ) ) { - const title = writer.createElement( 'title' ); + // When title element is at the beginning of the document then try to fix additional title elements (if there are any). + if ( firstRootChild.is( 'element', 'title' ) ) { + if ( titleElements.length > 1 ) { + fixAdditionalTitleElements( titleElements, writer, model ); - writer.insert( title, modelRoot ); - writer.insertElement( 'title-content', title ); + changed = true; + } - return true; - } + continue; + } - // At this stage, we are sure the title is somewhere in the content. It has to be fixed. + // When there is no title in the document and first element in the document cannot be changed + // to the title then create an empty title element at the beginning of the document. + if ( !firstTitleElement && !titleLikeElements.has( firstRootChild.name ) ) { + const title = writer.createElement( 'title' ); - // Change the first element in the document to the title if it can be changed (is title-like). - if ( titleLikeElements.has( firstRootChild.name ) ) { - changeElementToTitle( firstRootChild, writer, model ); - // Otherwise, move the first occurrence of the title element to the beginning of the document. - } else { - writer.move( writer.createRangeOn( firstTitleElement ), modelRoot, 0 ); - } + writer.insert( title, modelRoot ); + writer.insertElement( 'title-content', title ); + + changed = true; - fixAdditionalTitleElements( titleElements, writer, model ); + continue; + } + + if ( titleLikeElements.has( firstRootChild.name ) ) { + // Change the first element in the document to the title if it can be changed (is title-like). + changeElementToTitle( firstRootChild, writer, model ); + } else { + // Otherwise, move the first occurrence of the title element to the beginning of the document. + writer.move( writer.createRangeOn( firstTitleElement ), modelRoot, 0 ); + } + + fixAdditionalTitleElements( titleElements, writer, model ); + + changed = true; + } - return true; + return changed; } /** @@ -280,16 +298,22 @@ export default class Title extends Plugin { * when it is needed for the placeholder purposes. */ private _fixBodyElement( writer: Writer ) { - const modelRoot = this.editor.model.document.getRoot()!; + let changed = false; - if ( modelRoot.childCount < 2 ) { - this._bodyPlaceholder = writer.createElement( 'paragraph' ); - writer.insert( this._bodyPlaceholder, modelRoot, 1 ); + for ( const rootName of this.editor.model.document.getRootNames() ) { + const modelRoot = this.editor.model.document.getRoot( rootName )!; - return true; + if ( modelRoot.childCount < 2 ) { + const placeholder = writer.createElement( 'paragraph' ); + + writer.insert( placeholder, modelRoot, 1 ); + this._bodyPlaceholder.set( rootName, placeholder ); + + changed = true; + } } - return false; + return changed; } /** @@ -297,17 +321,21 @@ export default class Title extends Plugin { * if it was created for the placeholder purposes and is not needed anymore. */ private _fixExtraParagraph( writer: Writer ) { - const root = this.editor.model.document.getRoot()!; - const placeholder = this._bodyPlaceholder!; + let changed = false; + + for ( const rootName of this.editor.model.document.getRootNames() ) { + const root = this.editor.model.document.getRoot( rootName )!; + const placeholder = this._bodyPlaceholder.get( rootName )!; - if ( shouldRemoveLastParagraph( placeholder, root ) ) { - this._bodyPlaceholder = null; - writer.remove( placeholder ); + if ( shouldRemoveLastParagraph( placeholder, root ) ) { + this._bodyPlaceholder.delete( rootName ); + writer.remove( placeholder ); - return true; + changed = true; + } } - return false; + return changed; } /** @@ -317,7 +345,6 @@ export default class Title extends Plugin { const editor: Editor & Partial = this.editor; const t = editor.t; const view = editor.editing.view; - const viewRoot = view.document.getRoot(); const sourceElement = editor.sourceElement; const titlePlaceholder = editor.config.get( 'title.placeholder' ) || t( 'Type your title' ); @@ -336,35 +363,44 @@ export default class Title extends Plugin { } ); // Attach placeholder to first element after a title element and remove it if it's not needed anymore. - // First element after title can change so we need to observe all changes keep placeholder in sync. - let oldBody: ViewElement; + // First element after title can change, so we need to observe all changes keep placeholder in sync. + const bodyViewElements = new Map(); - // This post-fixer runs after the model post-fixer so we can assume that - // the second child in view root will always exist. + // This post-fixer runs after the model post-fixer, so we can assume that the second child in view root will always exist. view.document.registerPostFixer( writer => { - const body = viewRoot!.getChild( 1 ) as ViewElement; let hasChanged = false; - // If body element has changed we need to disable placeholder on the previous element - // and enable on the new one. - if ( body !== oldBody ) { - if ( oldBody ) { - hidePlaceholder( writer, oldBody ); - writer.removeAttribute( 'data-placeholder', oldBody ); + for ( const viewRoot of view.document.roots ) { + // `viewRoot` can be empty despite the model post-fixers if the model root was detached. + if ( viewRoot.isEmpty ) { + continue; } - writer.setAttribute( 'data-placeholder', bodyPlaceholder, body ); - oldBody = body; - hasChanged = true; - } + // If `viewRoot` is not empty, then we can expect at least two elements in it. + const body = viewRoot!.getChild( 1 ) as ViewElement; + const oldBody = bodyViewElements.get( viewRoot.rootName ); - // Then we need to display placeholder if it is needed. - // See: https://github.com/ckeditor/ckeditor5/issues/8689. - if ( needsPlaceholder( body, true ) && viewRoot!.childCount === 2 && body!.name === 'p' ) { - hasChanged = showPlaceholder( writer, body ) ? true : hasChanged; - // Or hide if it is not needed. - } else { - hasChanged = hidePlaceholder( writer, body ) ? true : hasChanged; + // If body element has changed we need to disable placeholder on the previous element and enable on the new one. + if ( body !== oldBody ) { + if ( oldBody ) { + hidePlaceholder( writer, oldBody ); + writer.removeAttribute( 'data-placeholder', oldBody ); + } + + writer.setAttribute( 'data-placeholder', bodyPlaceholder, body ); + bodyViewElements.set( viewRoot.rootName, body ); + + hasChanged = true; + } + + // Then we need to display placeholder if it is needed. + // See: https://github.com/ckeditor/ckeditor5/issues/8689. + if ( needsPlaceholder( body, true ) && viewRoot!.childCount === 2 && body!.name === 'p' ) { + hasChanged = showPlaceholder( writer, body ) ? true : hasChanged; + } else { + // Or hide if it is not needed. + hasChanged = hidePlaceholder( writer, body ) ? true : hasChanged; + } } return hasChanged; @@ -385,8 +421,11 @@ export default class Title extends Plugin { const selectedElements = Array.from( selection.getSelectedBlocks() ); if ( selectedElements.length === 1 && selectedElements[ 0 ].is( 'element', 'title-content' ) ) { - const firstBodyElement = model.document.getRoot()!.getChild( 1 ); + const root = selection.getFirstPosition()!.root; + const firstBodyElement = root.getChild( 1 ); + writer.setSelection( firstBodyElement!, 0 ); + cancel(); } } ); @@ -401,15 +440,16 @@ export default class Title extends Plugin { return; } - const root = editor.model.document.getRoot()!; const selectedElement = first( selection.getSelectedBlocks() ); const selectionPosition = selection.getFirstPosition()!; + const root = editor.model.document.getRoot( selectionPosition.root.rootName! )!; const title = root.getChild( 0 ) as Element; const body = root.getChild( 1 ); if ( selectedElement === body && selectionPosition.isAtStart ) { writer.setSelection( title.getChild( 0 )!, 0 ); + cancel(); } } ); @@ -503,6 +543,7 @@ function fixAdditionalTitleElements( titleElements: Array, writer: Writ for ( const title of titleElements ) { if ( title.index !== 0 ) { fixTitleElement( title, writer, model ); + hasChanged = true; } } @@ -573,4 +614,3 @@ export interface TitleConfig { */ placeholder?: string; } - diff --git a/packages/ckeditor5-heading/tests/title-integration.js b/packages/ckeditor5-heading/tests/title-integration.js index e8cc35b6c80..d038a0b247a 100644 --- a/packages/ckeditor5-heading/tests/title-integration.js +++ b/packages/ckeditor5-heading/tests/title-integration.js @@ -14,7 +14,9 @@ import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; -describe( 'Title integration', () => { +import MultiRootEditor from '@ckeditor/ckeditor5-editor-multi-root/src/multirooteditor'; + +describe( 'Title integration with feature', () => { let editor, model, doc, element; beforeEach( () => { @@ -38,7 +40,7 @@ describe( 'Title integration', () => { return editor.destroy(); } ); - describe( 'with basic styles', () => { + describe( 'basic styles', () => { // See: https://github.com/ckeditor/ckeditor5/issues/6427 it( 'should work when basic styles are applied to the content', () => { editor.setData( '

Title

Foo

' ); @@ -59,3 +61,42 @@ describe( 'Title integration', () => { } ); } ); } ); + +describe( 'Title integration with multi root editor', () => { + let multiRoot, titlePlugin; + + beforeEach( async () => { + multiRoot = await MultiRootEditor + .create( { + foo: '

FooTitle

Foo

Body

', + bar: '

BarTitle

Bar

Body

' + }, { + plugins: [ Paragraph, Heading, Enter, Title ] + } ); + + titlePlugin = multiRoot.plugins.get( Title ); + } ); + + afterEach( async () => { + multiRoot.destroy(); + } ); + + it( 'should return title value from given root', () => { + expect( titlePlugin.getTitle( { rootName: 'foo' } ) ).to.equal( 'FooTitle' ); + expect( titlePlugin.getTitle( { rootName: 'bar' } ) ).to.equal( 'BarTitle' ); + } ); + + it( 'should return body value from given root', () => { + expect( titlePlugin.getBody( { rootName: 'foo' } ) ).to.equal( '

Foo

Body

' ); + expect( titlePlugin.getBody( { rootName: 'bar' } ) ).to.equal( '

Bar

Body

' ); + } ); + + it( 'should not fix detached roots', () => { + multiRoot.detachRoot( 'bar' ); + + const barModelRoot = multiRoot.model.document.getRoot( 'bar' ); + + // Does not include title and body. + expect( barModelRoot.isEmpty ).to.be.true; + } ); +} ); diff --git a/packages/ckeditor5-html-support/package.json b/packages/ckeditor5-html-support/package.json index 9a8ae82fe82..2ee3f4b0864 100644 --- a/packages/ckeditor5-html-support/package.json +++ b/packages/ckeditor5-html-support/package.json @@ -30,6 +30,7 @@ "@ckeditor/ckeditor5-dev-utils": "^37.0.0", "@ckeditor/ckeditor5-easy-image": "^38.0.1", "@ckeditor/ckeditor5-editor-classic": "^38.0.1", + "@ckeditor/ckeditor5-editor-multi-root": "^38.0.1", "@ckeditor/ckeditor5-engine": "^38.0.1", "@ckeditor/ckeditor5-enter": "^38.0.1", "@ckeditor/ckeditor5-essentials": "^38.0.1", diff --git a/packages/ckeditor5-html-support/src/htmlcomment.ts b/packages/ckeditor5-html-support/src/htmlcomment.ts index 4e8346323fa..0606f2ef64f 100644 --- a/packages/ckeditor5-html-support/src/htmlcomment.ts +++ b/packages/ckeditor5-html-support/src/htmlcomment.ts @@ -7,7 +7,7 @@ * @module html-support/htmlcomment */ -import type { Marker, Position, Range } from 'ckeditor5/src/engine'; +import type { Marker, Position, Range, Element, ViewRootEditableElement } from 'ckeditor5/src/engine'; import { Plugin } from 'ckeditor5/src/core'; import { uid } from 'ckeditor5/src/utils'; @@ -29,6 +29,7 @@ export default class HtmlComment extends Plugin { */ public init(): void { const editor = this.editor; + const loadedCommentsContent = new Map(); editor.data.processor.skipComments = false; @@ -43,12 +44,12 @@ export default class HtmlComment extends Plugin { // 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() }`; + model: viewElement => { + const markerUid = uid(); + const markerName = `$comment:${ markerUid }`; + const commentContent = viewElement.getCustomProperty( '$rawContent' ) as string; - writer.setAttribute( markerName, commentContent, root ); + loadedCommentsContent.set( markerName, commentContent ); return markerName; } @@ -58,9 +59,18 @@ export default class HtmlComment extends Plugin { editor.conversion.for( 'dataDowncast' ).markerToElement( { model: '$comment', view: ( modelElement, { writer } ) => { - const root = this.editor.model.document.getRoot()!; + let root = undefined; + + for ( const rootName of this.editor.model.document.getRootNames() ) { + root = this.editor.model.document.getRoot( rootName )!; + + if ( root.hasAttribute( modelElement.markerName ) ) { + break; + } + } + const markerName = modelElement.markerName; - const commentContent = root.getAttribute( markerName ); + const commentContent = root!.getAttribute( markerName ); const comment = writer.createUIElement( '$comment' ); writer.setCustomProperty( '$rawContent', commentContent, comment ); @@ -69,32 +79,54 @@ export default class HtmlComment extends Plugin { } } ); - // Remove comments' markers and their corresponding $root attributes, which are no longer present. + // Remove comments' markers and their corresponding $root attributes, which are moved to the graveyard. editor.model.document.registerPostFixer( writer => { - const root = editor.model.document.getRoot()!; + let changed = false; + const markers = editor.model.document.differ.getChangedMarkers().filter( marker => marker.name.startsWith( '$comment:' ) ); - const changedMarkers = editor.model.document.differ.getChangedMarkers(); + for ( const marker of markers ) { + const { oldRange, newRange } = marker.data; - const changedCommentMarkers = changedMarkers.filter( marker => { - return marker.name.startsWith( '$comment' ); - } ); + if ( oldRange && newRange && oldRange.root == newRange.root ) { + // The marker was moved in the same root. Don't do anything. + continue; + } - const removedCommentMarkers = changedCommentMarkers.filter( marker => { - const newRange = marker.data.newRange; + if ( oldRange ) { + // The comment marker was moved from one root to another (most probably to the graveyard). + // Remove the related attribute from the previous root. + const oldRoot = oldRange.root as Element; - return newRange && newRange.root.rootName === '$graveyard'; - } ); + if ( oldRoot.hasAttribute( marker.name ) ) { + writer.removeAttribute( marker.name, oldRoot ); - if ( removedCommentMarkers.length === 0 ) { - return false; - } + changed = true; + } + } - for ( const marker of removedCommentMarkers ) { - writer.removeMarker( marker.name ); - writer.removeAttribute( marker.name, root ); + if ( newRange ) { + const newRoot = newRange.root as Element; + + if ( newRoot.rootName == '$graveyard' ) { + // Comment marker was moved to the graveyard -- remove it entirely. + writer.removeMarker( marker.name ); + + changed = true; + } else if ( !newRoot.hasAttribute( marker.name ) ) { + // Comment marker was just added or was moved to another root - updated roots attributes. + // + // Added fallback to `''` for the comment content in case if someone incorrectly added just the marker "by hand" + // and forgot to add the root attribute or add them in different change blocks. + // + // It caused an infinite loop in one of the unit tests. + writer.setAttribute( marker.name, loadedCommentsContent.get( marker.name ) || '', newRoot ); + + changed = true; + } + } } - return true; + return changed; } ); // Delete all comment markers from the document before setting new data. @@ -138,7 +170,7 @@ export default class HtmlComment extends Plugin { const id = uid(); const editor = this.editor; const model = editor.model; - const root = model.document.getRoot()!; + const root = model.document.getRoot( position.root.rootName )!; const markerName = `$comment:${ id }`; return model.change( writer => { @@ -169,8 +201,6 @@ export default class HtmlComment extends Plugin { */ public removeHtmlComment( commentID: string ): boolean { const editor = this.editor; - const root = editor.model.document.getRoot()!; - const marker = editor.model.markers.get( commentID ); if ( !marker ) { @@ -179,7 +209,6 @@ export default class HtmlComment extends Plugin { editor.model.change( writer => { writer.removeMarker( marker ); - writer.removeAttribute( commentID, root ); } ); return true; @@ -194,14 +223,22 @@ export default class HtmlComment extends Plugin { public getHtmlCommentData( commentID: string ): HtmlCommentData | null { const editor = this.editor; const marker = editor.model.markers.get( commentID ); - const root = editor.model.document.getRoot()!; if ( !marker ) { return null; } + let content = ''; + for ( const rootName of this.editor.model.document.getRootNames() ) { + const root = editor.model.document.getRoot( rootName )!; + if ( root.hasAttribute( commentID ) ) { + content = root.getAttribute( commentID ) as string; + break; + } + } + return { - content: root.getAttribute( commentID ) as string, + content, position: marker.getStart() }; } diff --git a/packages/ckeditor5-html-support/tests/htmlcomment-integration.js b/packages/ckeditor5-html-support/tests/htmlcomment-integration.js index 46c9d81b07b..ec0fd4408f3 100644 --- a/packages/ckeditor5-html-support/tests/htmlcomment-integration.js +++ b/packages/ckeditor5-html-support/tests/htmlcomment-integration.js @@ -41,6 +41,7 @@ import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption'; import HtmlComment from '../src/htmlcomment'; +import { MultiRootEditor } from '@ckeditor/ckeditor5-editor-multi-root'; describe( 'HtmlComment integration', () => { describe( 'integration with BlockQuote', () => { @@ -1435,4 +1436,49 @@ describe( 'HtmlComment integration', () => { ); } ); } ); + + describe( 'integration with Multi-root editor', () => { + let editor; + + beforeEach( () => { + return MultiRootEditor + .create( { + main: '

main

', + second: '

second

' + }, { + plugins: [ + HtmlComment, Paragraph + ] + } ) + .then( _editor => { + editor = _editor; + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should work for multiple roots', async () => { + expect( editor.getData( { rootName: 'main' } ) ).to.equal( + '' + + '

' + + '' + + 'main' + + '' + + '

' + + '' + ); + + expect( editor.getData( { rootName: 'second' } ) ).to.equal( + '' + + '

' + + '' + + 'second' + + '' + + '

' + + '' + ); + } ); + } ); } ); diff --git a/packages/ckeditor5-table/package.json b/packages/ckeditor5-table/package.json index 0623c75dbe2..fb071a66f53 100644 --- a/packages/ckeditor5-table/package.json +++ b/packages/ckeditor5-table/package.json @@ -23,6 +23,7 @@ "@ckeditor/ckeditor5-core": "^38.0.1", "@ckeditor/ckeditor5-dev-utils": "^37.0.0", "@ckeditor/ckeditor5-editor-classic": "^38.0.1", + "@ckeditor/ckeditor5-editor-multi-root": "^38.0.1", "@ckeditor/ckeditor5-engine": "^38.0.1", "@ckeditor/ckeditor5-highlight": "^38.0.1", "@ckeditor/ckeditor5-horizontal-line": "^38.0.1", @@ -40,7 +41,6 @@ "@ckeditor/ckeditor5-utils": "^38.0.1", "@ckeditor/ckeditor5-widget": "^38.0.1", "@ckeditor/ckeditor5-source-editing": "^38.0.1", - "@ckeditor/ckeditor5-editor-multi-root": "^38.0.1", "json-diff": "^0.5.4", "typescript": "^4.8.4", "webpack": "^5.58.1", diff --git a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts index 58b70d67115..6e8c1d127ea 100644 --- a/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts +++ b/packages/ckeditor5-table/src/tablecolumnresize/tablecolumnresizeediting.ts @@ -146,6 +146,7 @@ export default class TableColumnResizeEditing extends Plugin { this.on>( 'change:_isResizingAllowed', ( evt, name, value ) => { // Toggling the `ck-column-resize_disabled` class shows and hides the resizers through CSS. const classAction = value ? 'removeClass' : 'addClass'; + editor.editing.view.change( writer => { for ( const root of editor.editing.view.document.roots ) { writer[ classAction ]( 'ck-column-resize_disabled', editor.editing.view.document.getRoot( root.rootName )! ); diff --git a/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts b/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts index 5450bd2dcc0..468be5d88b7 100644 --- a/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts +++ b/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts @@ -423,7 +423,8 @@ export default class BlockToolbar extends Plugin { private _attachButtonToElement( targetElement: HTMLElement ) { const contentStyles = window.getComputedStyle( targetElement ); - const editableRect = new Rect( this.editor.ui.getEditableElement()! ); + const selectedModelRootName = this.editor.model.document.selection.getFirstRange()!.root.rootName!; + const editableRect = new Rect( this.editor.ui.getEditableElement( selectedModelRootName )! ); const contentPaddingTop = parseInt( contentStyles.paddingTop, 10 ); // When line height is not an integer then thread it as "normal". // MDN says that 'normal' == ~1.2 on desktop browsers. diff --git a/packages/ckeditor5-word-count/package.json b/packages/ckeditor5-word-count/package.json index 3a5991e52bd..415c5d97e9c 100644 --- a/packages/ckeditor5-word-count/package.json +++ b/packages/ckeditor5-word-count/package.json @@ -21,6 +21,7 @@ "@ckeditor/ckeditor5-core": "^38.0.1", "@ckeditor/ckeditor5-dev-utils": "^37.0.0", "@ckeditor/ckeditor5-editor-classic": "^38.0.1", + "@ckeditor/ckeditor5-editor-multi-root": "^38.0.1", "@ckeditor/ckeditor5-engine": "^38.0.1", "@ckeditor/ckeditor5-enter": "^38.0.1", "@ckeditor/ckeditor5-image": "^38.0.1", diff --git a/packages/ckeditor5-word-count/src/wordcount.ts b/packages/ckeditor5-word-count/src/wordcount.ts index 25dc6abe1ca..298c482275a 100644 --- a/packages/ckeditor5-word-count/src/wordcount.ts +++ b/packages/ckeditor5-word-count/src/wordcount.ts @@ -112,12 +112,12 @@ export default class WordCount extends Plugin { Object.defineProperties( this, { characters: { get() { - return ( this.characters = this._getCharacters() ); + return ( this.characters = this._getCharacters( this._getText() ) ); } }, words: { get() { - return ( this.words = this._getWords() ); + return ( this.words = this._getWords( this._getText() ) ); } } } ); @@ -251,20 +251,32 @@ export default class WordCount extends Plugin { return this._outputView.element!; } + private _getText(): string { + let txt = ''; + + for ( const rootName of this.editor.model.document.getRootNames() ) { + if ( txt !== '' ) { + // Add a delimiter, so words from each root are treated independently. + txt += '\n'; + } + + txt += modelElementToPlainText( this.editor.model.document.getRoot( rootName )! ); + } + + return txt; + } + /** * Determines the number of characters in the current editor's model. */ - private _getCharacters(): number { - const txt = modelElementToPlainText( this.editor.model.document.getRoot()! ); - + private _getCharacters( txt: string ): number { return txt.replace( /\n/g, '' ).length; } /** * Determines the number of words in the current editor's model. */ - private _getWords(): number { - const txt = modelElementToPlainText( this.editor.model.document.getRoot()! ); + private _getWords( txt: string ): number { const detectedWords = txt.match( this._wordsMatchRegExp ) || []; return detectedWords.length; @@ -277,8 +289,9 @@ export default class WordCount extends Plugin { * @fires update */ private _refreshStats(): void { - const words = this.words = this._getWords(); - const characters = this.characters = this._getCharacters(); + const txt = this._getText(); + const words = this.words = this._getWords( txt ); + const characters = this.characters = this._getCharacters( txt ); this.fire( 'update', { words, diff --git a/packages/ckeditor5-word-count/tests/wordcount.js b/packages/ckeditor5-word-count/tests/wordcount.js index 603d3fa0a66..2adef0247ec 100644 --- a/packages/ckeditor5-word-count/tests/wordcount.js +++ b/packages/ckeditor5-word-count/tests/wordcount.js @@ -9,6 +9,7 @@ import WordCount from '../src/wordcount'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { MultiRootEditor } from '@ckeditor/ckeditor5-editor-multi-root'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service'; @@ -578,5 +579,41 @@ describe( 'WordCount', () => { } ); } ); } ); -} ); + describe( 'multi-root editor integration', () => { + beforeEach( () => { + return MultiRootEditor + .create( { + foo: document.createElement( 'div' ), + bar: document.createElement( 'div' ) + }, { + plugins: [ + WordCount, Paragraph + ] + } ) + .then( _editor => { + editor = _editor; + model = editor.model; + wordCountPlugin = editor.plugins.get( 'WordCount' ); + } ); + } ); + + afterEach( () => { + editor.destroy(); + } ); + + it( 'should sum characters of each root', () => { + setModelData( model, 'foo bar', { rootName: 'foo' } ); + setModelData( model, 'lorem ipsum', { rootName: 'bar' } ); + + expect( wordCountPlugin.characters ).to.be.equal( 18 ); + } ); + + it( 'should sum words of each root', () => { + setModelData( model, 'foo bar', { rootName: 'foo' } ); + setModelData( model, 'lorem ipsum', { rootName: 'bar' } ); + + expect( wordCountPlugin.words ).to.be.equal( 4 ); + } ); + } ); +} );