Skip to content

Commit

Permalink
Merge pull request #16767 from ckeditor/ck/14803
Browse files Browse the repository at this point in the history
Feature (ui): Add support for the balloon toolbar in the multi-root editor. Closes #14803
  • Loading branch information
niegowski committed Aug 5, 2024
2 parents 6316589 + 16bb83b commit 70e3a04
Show file tree
Hide file tree
Showing 6 changed files with 317 additions and 13 deletions.
2 changes: 1 addition & 1 deletion packages/ckeditor5-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
47 changes: 37 additions & 10 deletions packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<EditorUIReadyEvent>( '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, {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div id="toolbar">
<button id="add-root">Add root</button>
<button id="remove-root">Remove root</button>
</div>

<br />

<div id="editables">
<div id="header">Header root</div>
<div id="content">Content root</div>
</div>

<style>
#toolbar {
display: flex;
flex-direction: row;
gap: 8px;
}

#editables {
display: flex;
flex-direction: column;
gap: 16px;

& .ck-editor__editable {
border: 1px solid black;
}
}
</style>
Original file line number Diff line number Diff line change
@@ -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: `<p>Added root - ${ new Date().toISOString() }</p>`
} );
} );

document.getElementById( 'remove-root' ).addEventListener( 'click', () => {
const rootNames = window.editor.model.document.getRootNames();

window.editor.detachRoot( rootNames[ rootNames.length - 1 ] );
} );
Original file line number Diff line number Diff line change
@@ -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.
182 changes: 180 additions & 2 deletions packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -88,10 +89,12 @@ describe( 'BalloonToolbar', () => {
} );
} );

afterEach( () => {
afterEach( async () => {
editorElement.remove();

return editor.destroy();
if ( editor ) {
await editor.destroy();
}
} );

after( () => {
Expand Down Expand Up @@ -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;

Expand Down

0 comments on commit 70e3a04

Please sign in to comment.