From 16bb83b60b393f8c984b78f2b5f064f154603741 Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Fri, 19 Jul 2024 15:18:29 +0200 Subject: [PATCH] Add support for balloon toolbar in multi root editor --- packages/ckeditor5-ui/package.json | 2 +- .../src/toolbar/balloon/balloontoolbar.ts | 47 ++++- .../balloontoolbar-multi-root.html | 29 +++ .../balloontoolbar-multi-root.js | 66 +++++++ .../balloontoolbar-multi-root.md | 4 + .../tests/toolbar/balloon/balloontoolbar.js | 182 +++++++++++++++++- 6 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.html create mode 100644 packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.js create mode 100644 packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.md diff --git a/packages/ckeditor5-ui/package.json b/packages/ckeditor5-ui/package.json index 37d5efa9710..6c38c9de7e1 100644 --- a/packages/ckeditor5-ui/package.json +++ b/packages/ckeditor5-ui/package.json @@ -14,6 +14,7 @@ "dependencies": { "@ckeditor/ckeditor5-core": "42.0.2", "@ckeditor/ckeditor5-utils": "42.0.2", + "@ckeditor/ckeditor5-engine": "42.0.2", "color-convert": "2.0.1", "color-parse": "1.4.2", "lodash-es": "4.17.21", @@ -29,7 +30,6 @@ "@ckeditor/ckeditor5-editor-decoupled": "42.0.2", "@ckeditor/ckeditor5-editor-inline": "42.0.2", "@ckeditor/ckeditor5-editor-multi-root": "42.0.2", - "@ckeditor/ckeditor5-engine": "42.0.2", "@ckeditor/ckeditor5-enter": "42.0.2", "@ckeditor/ckeditor5-essentials": "42.0.2", "@ckeditor/ckeditor5-find-and-replace": "42.0.2", diff --git a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts index 2c5f69fecd4..42e59c0d3b2 100644 --- a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts +++ b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts @@ -12,7 +12,10 @@ import ToolbarView, { type ToolbarViewGroupedItemsUpdateEvent } from '../toolbar import BalloonPanelView from '../../panel/balloon/balloonpanelview.js'; import normalizeToolbarConfig from '../normalizetoolbarconfig.js'; -import type { EditorUIReadyEvent, EditorUIUpdateEvent } from '../../editorui/editorui.js'; +import type { + EditorUIReadyEvent, + EditorUIUpdateEvent +} from '../../editorui/editorui.js'; import { Plugin, @@ -30,10 +33,11 @@ import { type ObservableChangeEvent } from '@ckeditor/ckeditor5-utils'; -import type { - DocumentSelection, - DocumentSelectionChangeRangeEvent, - Schema +import { + Observer, + type DocumentSelection, + type DocumentSelectionChangeRangeEvent, + type Schema } from '@ckeditor/ckeditor5-engine'; import { debounce, type DebouncedFunc } from 'lodash-es'; @@ -113,11 +117,9 @@ export default class BalloonToolbar extends Plugin { this.toolbarView = this._createToolbarView(); this.focusTracker = new FocusTracker(); - // Wait for the EditorUI#init. EditableElement is not available before. - editor.ui.once( 'ready', () => { - this.focusTracker.add( editor.ui.getEditableElement()! ); - this.focusTracker.add( this.toolbarView.element! ); - } ); + // Track focusable elements in the toolbar and the editable elements. + this._trackFocusableEditableElements(); + this.focusTracker.add( this.toolbarView.element! ); // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. editor.ui.addToolbar( this.toolbarView, { @@ -275,6 +277,31 @@ export default class BalloonToolbar extends Plugin { } } + /** + * Add or remove editable elements to the focus tracker. It watches added and removed roots + * and adds or removes their editable elements to the focus tracker. + */ + private _trackFocusableEditableElements() { + const { editor, focusTracker } = this; + const { editing } = editor; + + editing.view.addObserver( class TrackEditableElements extends Observer { + /** + * @inheritDoc + */ + public observe( domElement: HTMLElement ) { + focusTracker.add( domElement ); + } + + /** + * @inheritDoc + */ + public stopObserving( domElement: HTMLElement ) { + focusTracker.remove( domElement ); + } + } ); + } + /** * Returns positioning options for the {@link #_balloon}. They control the way balloon is attached * to the selection. diff --git a/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.html b/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.html new file mode 100644 index 00000000000..f741f936a89 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.html @@ -0,0 +1,29 @@ +
+ + +
+ +
+ +
+ +
Content root
+
+ + diff --git a/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.js b/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.js new file mode 100644 index 00000000000..8216e65dbfa --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.js @@ -0,0 +1,66 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window, console:false, document */ + +import { MultiRootEditor } from '@ckeditor/ckeditor5-editor-multi-root'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset.js'; +import BalloonToolbar from '../../../src/toolbar/balloon/balloontoolbar.js'; + +// Plugin that watches for addRoot and detachRoot events and creates or removes editable elements. +class MultiRootWatchEditables { + constructor( editor ) { + this.editor = editor; + } + + init() { + const { editor } = this; + + editor.on( 'addRoot', ( evt, root ) => { + const domElement = this.editor.createEditable( root ); + + document.getElementById( 'editables' ).appendChild( domElement ); + } ); + + editor.on( 'detachRoot', ( evt, root ) => { + this.editor.detachEditable( root ).remove(); + } ); + } +} + +// Build the editor +MultiRootEditor.create( + { + header: document.getElementById( 'header' ), + content: document.getElementById( 'content' ) + }, + { + image: { toolbar: [ 'toggleImageCaption', 'imageTextAlternative' ] }, + plugins: [ ArticlePluginSet, BalloonToolbar, MultiRootWatchEditables ], + toolbar: [ 'bold', 'italic', 'link', 'undo', 'redo' ], + balloonToolbar: [ 'bold', 'italic', 'link' ] + } +) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +// Handle adding and removing roots. +document.getElementById( 'add-root' ).addEventListener( 'click', () => { + const id = Date.now(); + + window.editor.addRoot( `root-${ id }`, { + data: `

Added root - ${ new Date().toISOString() }

` + } ); +} ); + +document.getElementById( 'remove-root' ).addEventListener( 'click', () => { + const rootNames = window.editor.model.document.getRootNames(); + + window.editor.detachRoot( rootNames[ rootNames.length - 1 ] ); +} ); diff --git a/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.md b/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.md new file mode 100644 index 00000000000..e9b5e493d3e --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/balloontoolbar/balloontoolbar-multi-root.md @@ -0,0 +1,4 @@ +## Contextual toolbar multi root demo + +1. Context toolbar should be usable on all roots. +2. Adding / removal of root should be handled. diff --git a/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js b/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js index 41668fe552e..d624521bc89 100644 --- a/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js +++ b/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js @@ -21,6 +21,7 @@ import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting.js'; import global from '@ckeditor/ckeditor5-utils/src/dom/global.js'; import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver.js'; import env from '@ckeditor/ckeditor5-utils/src/env.js'; +import { MultiRootEditor } from '@ckeditor/ckeditor5-editor-multi-root'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; import { stringify as viewStringify } from '@ckeditor/ckeditor5-engine/src/dev-utils/view.js'; @@ -88,10 +89,12 @@ describe( 'BalloonToolbar', () => { } ); } ); - afterEach( () => { + afterEach( async () => { editorElement.remove(); - return editor.destroy(); + if ( editor ) { + await editor.destroy(); + } } ); after( () => { @@ -824,6 +827,181 @@ describe( 'BalloonToolbar', () => { } ); } ); + describe( 'MultiRoot editor integration', () => { + let rootsElements, addEditableOnRootAdd; + + beforeEach( async () => { + addEditableOnRootAdd = true; + rootsElements = [ ...Array( 3 ) ].reduce( ( acc, _, index ) => { + const rootElement = global.document.createElement( 'div' ); + + global.document.body.appendChild( rootElement ); + + return { + ...acc, + [ `root-${ index }` ]: rootElement + }; + }, {} ); + + if ( editor ) { + await editor.destroy(); + } + + editor = await createMultiRootEditor(); + balloonToolbar = editor.plugins.get( BalloonToolbar ); + } ); + + afterEach( async () => { + Object + .values( rootsElements ) + .forEach( rootElement => rootElement.remove() ); + + await editor.destroy(); + editor = null; + } ); + + it( 'should create plugin instance', () => { + expect( balloonToolbar ).to.instanceOf( Plugin ); + expect( balloonToolbar ).to.instanceOf( BalloonToolbar ); + expect( balloonToolbar.toolbarView ).to.instanceof( ToolbarView ); + expect( balloonToolbar.toolbarView.element.classList.contains( 'ck-toolbar_floating' ) ).to.be.true; + } ); + + it( '#focusTracker should include all roots created alongside with editor', () => { + const clock = sinon.useFakeTimers(); + const editables = [ ...editor.ui.getEditableElementsNames() ]; + + expect( editables ).to.be.length( 3 ); + expect( balloonToolbar.focusTracker.isFocused ).to.false; + + for ( const editableName of editables ) { + const editableElement = editor.ui.getEditableElement( editableName ); + + editableElement.dispatchEvent( new Event( 'focus' ) ); + clock.tick( 50 ); + expect( balloonToolbar.focusTracker.isFocused ).to.true; + + editableElement.dispatchEvent( new Event( 'blur' ) ); + clock.tick( 50 ); + expect( balloonToolbar.focusTracker.isFocused ).to.false; + } + + clock.restore(); + } ); + + it( '#focusTracker should track focus on dynamically added roots', async () => { + const clock = sinon.useFakeTimers(); + + expect( balloonToolbar.focusTracker.isFocused ).to.false; + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 ); + + editor.addRoot( 'dynamicRoot' ); + + // Check if newly added editable is tracked in focus tracker. + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 5 ); + + // Check if element is added to focus tracker. + const editableElement = editor.ui.getEditableElement( 'dynamicRoot' ); + expect( balloonToolbar.focusTracker._elements ).contain( editableElement ); + + // Watch focus and blur events. + editableElement.dispatchEvent( new Event( 'focus' ) ); + clock.tick( 50 ); + + expect( balloonToolbar.focusTracker.isFocused ).to.true; + + editableElement.dispatchEvent( new Event( 'blur' ) ); + clock.tick( 50 ); + expect( balloonToolbar.focusTracker.isFocused ).to.false; + + editableElement.remove(); + clock.restore(); + } ); + + it( 'dynamically removed roots should be removed from #focusTracker', () => { + const clock = sinon.useFakeTimers(); + + expect( balloonToolbar.focusTracker.isFocused ).to.false; + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 ); + + editor.addRoot( 'dynamicRoot' ); + const editableElement = editor.ui.getEditableElement( 'dynamicRoot' ); + + // Check if newly added editable is tracked in focus tracker. + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 5 ); + + editor.detachRoot( 'dynamicRoot' ); + + // Check if element is removed from focus tracker. + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 ); + + // Focus is no longer tracked. + editableElement.dispatchEvent( new Event( 'focus' ) ); + clock.tick( 50 ); + + expect( balloonToolbar.focusTracker.isFocused ).to.false; + + clock.restore(); + } ); + + it( 'should track lazy attached and detached editables', () => { + const clock = sinon.useFakeTimers(); + + addEditableOnRootAdd = false; + + expect( balloonToolbar.focusTracker.isFocused ).to.false; + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 ); + + editor.addRoot( 'dynamicRoot' ); + const root = editor.model.document.getRoot( 'dynamicRoot' ); + + // Editable is not yet attached + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 ); + + // Focus is no longer tracked. + const editableElement = editor.createEditable( root ); + + global.document.body.appendChild( editableElement ); + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 5 ); + + // Lets test focus + editableElement.dispatchEvent( new Event( 'focus' ) ); + clock.tick( 50 ); + + expect( balloonToolbar.focusTracker.isFocused ).to.true; + + // Detach editable element + editor.detachEditable( root ); + expect( balloonToolbar.focusTracker._elements.size ).to.be.equal( 4 ); + + editableElement.remove(); + clock.restore(); + } ); + + async function createMultiRootEditor() { + const multiRootEditor = await MultiRootEditor.create( rootsElements, { + plugins: [ Paragraph, Bold, Italic, BalloonToolbar ], + balloonToolbar: [ 'bold', 'italic' ] + } ); + + multiRootEditor.on( 'addRoot', ( evt, root ) => { + if ( addEditableOnRootAdd ) { + const domElement = multiRootEditor.createEditable( root ); + global.document.body.appendChild( domElement ); + } + } ); + + multiRootEditor.on( 'detachRoot', ( evt, root ) => { + if ( addEditableOnRootAdd ) { + const domElement = multiRootEditor.detachEditable( root ); + domElement.remove(); + } + } ); + + return multiRootEditor; + } + } ); + function stubSelectionRects( rects ) { const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;