diff --git a/docs/_snippets/examples/multi-root-editor.js b/docs/_snippets/examples/multi-root-editor.js index 3cf214d5ee2..aac83110c0d 100644 --- a/docs/_snippets/examples/multi-root-editor.js +++ b/docs/_snippets/examples/multi-root-editor.js @@ -12,7 +12,6 @@ import getDataFromElement from '@ckeditor/ckeditor5-utils/src/dom/getdatafromele import setDataInElement from '@ckeditor/ckeditor5-utils/src/dom/setdatainelement'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; -import enableToolbarKeyboardFocus from '@ckeditor/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus'; import { enablePlaceholder } from '@ckeditor/ckeditor5-engine/src/view/placeholder'; import EditorUIView from '@ckeditor/ckeditor5-ui/src/editorui/editoruiview'; import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview'; @@ -205,11 +204,6 @@ class MultirootEditorUI extends EditorUI { // editable areas (roots) but the decoupled editor has only one. this.setEditableElement( editable.name, editableElement ); - // Let the global focus tracker know that the editable UI element is focusable and - // belongs to the editor. From now on, the focus tracker will sustain the editor focus - // as long as the editable is focused (e.g. the user is typing). - this.focusTracker.add( editableElement ); - // Let the editable UI element respond to the changes in the global editor focus // tracker. It has been added to the same tracker a few lines above but, in reality, there are // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long @@ -279,12 +273,8 @@ class MultirootEditorUI extends EditorUI { toolbar.fillFromConfig( editor.config.get( 'toolbar' ), this.componentFactory ); - enableToolbarKeyboardFocus( { - origin: editor.editing.view, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar - } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + this.addToolbar( view.toolbar ); } /** diff --git a/docs/examples/framework/multi-root-editor.md b/docs/examples/framework/multi-root-editor.md index 846119ec9c5..780bdc44208 100644 --- a/docs/examples/framework/multi-root-editor.md +++ b/docs/examples/framework/multi-root-editor.md @@ -28,7 +28,6 @@ import getDataFromElement from '@ckeditor/ckeditor5-utils/src/dom/getdatafromele import setDataInElement from '@ckeditor/ckeditor5-utils/src/dom/setdatainelement'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; -import enableToolbarKeyboardFocus from '@ckeditor/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus'; import { enablePlaceholder } from '@ckeditor/ckeditor5-engine/src/view/placeholder'; import EditorUIView from '@ckeditor/ckeditor5-ui/src/editorui/editoruiview'; import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview'; @@ -220,11 +219,6 @@ class MultirootEditorUI extends EditorUI { // editable areas (roots) but the decoupled editor has only one. this.setEditableElement( editable.name, editableElement ); - // Let the global focus tracker know that the editable UI element is focusable and - // belongs to the editor. From now on, the focus tracker will sustain the editor focus - // as long as the editable is focused (e.g. the user is typing). - this.focusTracker.add( editableElement ); - // Let the editable UI element respond to the changes in the global editor focus // tracker. It has been added to the same tracker a few lines above but, in reality, there are // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long @@ -294,12 +288,8 @@ class MultirootEditorUI extends EditorUI { toolbar.fillFromConfig( editor.config.get( 'toolbar' ), this.componentFactory ); - enableToolbarKeyboardFocus( { - origin: editor.editing.view, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar - } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + this.addToolbar( view.toolbar ); } /** diff --git a/docs/framework/guides/custom-editor-creator.md b/docs/framework/guides/custom-editor-creator.md index 4133a554659..a346c9f1b8d 100644 --- a/docs/framework/guides/custom-editor-creator.md +++ b/docs/framework/guides/custom-editor-creator.md @@ -124,7 +124,6 @@ The `*EditorUI` class is the main UI class which initializes UI components (the ```js import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; -import enableToolbarKeyboardFocus from '@ckeditor/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus'; import { enablePlaceholder } from '@ckeditor/ckeditor5-engine/src/view/placeholder'; /** @@ -194,11 +193,6 @@ class MultirootEditorUI extends EditorUI { // Register each editable UI view in the editor. this.setEditableElement( editable.name, editableElement ); - // Let the global focus tracker know that the editable UI element is focusable and - // belongs to the editor. From now on, the focus tracker will sustain the editor focus - // as long as the editable is focused (e.g. the user is typing). - this.focusTracker.add( editableElement ); - // Let the editable UI element respond to the changes in the global editor focus // tracker. It has been added to the same tracker a few lines above but, in reality, there are // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long @@ -268,12 +262,8 @@ class MultirootEditorUI extends EditorUI { toolbar.fillFromConfig( editor.config.get( 'toolbar' ), this.componentFactory ); - enableToolbarKeyboardFocus( { - origin: editor.editing.view, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar - } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + this.addToolbar( view.toolbar ); } /** diff --git a/docs/updating/migration-to-35.md b/docs/updating/migration-to-35.md index cee474a9432..f4ae5ec26bb 100644 --- a/docs/updating/migration-to-35.md +++ b/docs/updating/migration-to-35.md @@ -5,7 +5,76 @@ order: 89 modified_at: 2022-07-18 --- -# Migration to CKEditor 5 v35.0.0 +# Migration to CKEditor 5 v35.x + +## Migration to CKEditor 5 v35.1.0 + +### Changes to API providing the accessible navigation between editing roots and toolbars on Alt+F10 and Esc keystrokes + + + This information applies only to integrators who develop their own {@link framework/guides/custom-editor-creator editor creators} from scratch by using {@link module:core/editor/editor~Editor} and {@link module:core/editor/editorui~EditorUI} classes as building blocks. + + +* The `enableToolbarKeyboardFocus()` helper that allowed the navigation has been removed. To bring the functionality back, use the {@link module:core/editor/editorui~EditorUI#addToolbar} method instead. +* Also, please note that editable elements are now automatically added to the {@link module:core/editor/editorui~EditorUI#focusTracker main focus tracker} and should not be added individually. + +**Before**: +```js +import { EditorUI } from 'ckeditor5/src/core'; + +export default class MyEditorUI extends EditorUI { + // ... + + init() { + const view = this.view; + const editableElement = view.editable.element; + const toolbarViewInstance = this.view.toolbar; + + // ... + + this.setEditableElement( 'editableName', editableElement ); + + this.focusTracker.add( editableElement ); + + enableToolbarKeyboardFocus( { + // ... + + toolbar: toolbarViewInstance + } ); + + // ... + } +} +``` + +**After**: +```js +import { EditorUI } from 'ckeditor5/src/core'; + +export default class MyEditorUI extends EditorUI { + // ... + + init() { + const view = this.view; + const editableElement = view.editable.element; + const toolbarViewInstance = this.view.toolbar; + + // ... + + // Note: You should not add the editable element to the focus tracker here. + // This is handled internally by EditorUI#setEditableElement() method. + this.setEditableElement( 'editableName', editableElement ); + + // Note: Add the toolbar to enable Alt+F10 navigation. + // The rest (e.g. Esc key handling) is handled by EditorUI#setEditableElement() method. + this.addToolbar( toolbarViewInstance ); + + // ... + } +} +``` + +## Migration to CKEditor 5 v35.0.0 When updating your CKEditor 5 installation, make sure **all the packages are the same version** to avoid errors. @@ -17,9 +86,9 @@ For the entire list of changes introduced in version 35.0.0, see the [changelog Listed below are the most important changes that require your attention when upgrading to CKEditor 5 v35.0.0. -## Important changes +### Important changes -### The source element is not updated automatically after the editor destroy +#### The source element is not updated automatically after the editor destroy The last version of CKEditor 5 changes the default behavior of the source element after the editor is destroyed (when `editor.destroy()` is called). So far, the source element was updated with the output coming from `editor.getData()`. Now, the source element becomes empty after the editor is destroyed and it is not updated anymore. @@ -36,7 +105,7 @@ ClassicEditor.create( sourceElement, { Enabling the `updateSourceElementOnDestroy` option in your configuration, depending on the plugins you use, might have some security implications. While the editing view is secured, there might be some unsafe content in the data output, so enable this option only if you know what you are doing. Be especially careful when using Markdown, General HTML Support and HTML embed features. -### Dropdown focus is moved back to the dropdown button after choosing an option +#### Dropdown focus is moved back to the dropdown button after choosing an option Due to the ongoing accessibility improvements the default behavior of the {@link module:ui/dropdown/dropdownview~DropdownView dropdown UI component} has been changed. From now on, by default, after choosing an option from a dropdown (either by mouse or keyboard), the focus will be automatically moved to the dropdown button. @@ -64,15 +133,15 @@ dropdownView.on( 'execute', () => { } ); ``` -### There is now a TypeScript code on GitHub (and how it affects your build) +#### There is now a TypeScript code on GitHub (and how it affects your build) Starting from v35.0.0, the first of CKEditor 5 packages (namely: `@ckeditor/ckeditor5-utils`) is developed in TypeScript. This is the first step of [our migration to TypeScript](https://github.com/ckeditor/ckeditor5/issues/11704). -#### Whom does it affect? +##### Whom does it affect? It affects you **only if** you use the [source code directly from git repository (GitHub)](https://github.com/ckeditor/ckeditor5). If you use it via any other channel (npm, CDN, ZIP, etc.) this change is completely transparent for you as we publish only JavaScript code there. -#### How does it affect you? +##### How does it affect you? For instance, if you happen to have a custom CKEditor 5 build that, for some reason, installs its dependencies from the git repository, you will need to update your webpack config to support the TypeScript code. diff --git a/packages/ckeditor5-core/src/editor/editorui.js b/packages/ckeditor5-core/src/editor/editorui.js index 55b83244885..bc4b462e0ee 100644 --- a/packages/ckeditor5-core/src/editor/editorui.js +++ b/packages/ckeditor5-core/src/editor/editorui.js @@ -14,6 +14,7 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; +import isVisible from '@ckeditor/ckeditor5-utils/src/dom/isvisible'; /** * A class providing the minimal interface that is required to successfully bootstrap any editor UI. @@ -86,6 +87,18 @@ export default class EditorUI { */ this.set( 'viewportOffset', this._readViewportOffsetFromConfig() ); + /** + * Indicates the UI is ready. Set `true` after {@link #event:ready} event is fired. + * + * @readonly + * @default false + * @member {Boolean} #isReady + */ + this.isReady = false; + this.once( 'ready', () => { + this.isReady = true; + } ); + /** * Stores all editable elements used by the editor instance. * @@ -94,8 +107,18 @@ export default class EditorUI { */ this._editableElementsMap = new Map(); + /** + * All available & focusable toolbars. + * + * @private + * @type {Array.} + */ + this._focusableToolbarDefinitions = []; + // Informs UI components that should be refreshed after layout change. this.listenTo( editor.editing.view.document, 'layoutChanged', () => this.update() ); + + this._initFocusTracking(); } /** @@ -141,11 +164,14 @@ export default class EditorUI { } this._editableElementsMap = new Map(); + this._focusableToolbarDefinitions = []; } /** - * Store the native DOM editable element used by the editor under - * a unique name. + * Stores the native DOM editable element used by the editor under a unique name. + * + * Also, registers the element in the editor to maintain the accessibility of the UI. When the user is editing text in a focusable + * editable area, they can use the Alt + F10 keystroke to navigate over editor toolbars. See {@link #addToolbar}. * * @param {String} rootName The unique name of the editable element. * @param {HTMLElement} domElement The native DOM editable element. @@ -160,6 +186,28 @@ export default class EditorUI { if ( !domElement.ckeditorInstance ) { domElement.ckeditorInstance = this.editor; } + + // Register the element so it becomes available for Alt+F10 and Esc navigation. + this.focusTracker.add( domElement ); + + const setUpKeystrokeHandler = () => { + // The editing view of the editor is already listening to keystrokes from DOM roots (see: KeyObserver). + // Do not duplicate listeners. + if ( this.editor.editing.view.getDomRoot( rootName ) ) { + return; + } + + this.editor.keystrokes.listenTo( domElement ); + }; + + // For editable elements set by features after EditorUI is ready (e.g. source editing). + if ( this.isReady ) { + setUpKeystrokeHandler(); + } + // For editable elements set while the editor is being created (e.g. DOM roots). + else { + this.once( 'ready', setUpKeystrokeHandler ); + } } /** @@ -181,6 +229,35 @@ export default class EditorUI { return this._editableElementsMap.keys(); } + /** + * Adds a toolbar to the editor UI. Used primarily to maintain the accessibility of the UI. + * + * Focusable toolbars can be accessed (focused) by users by pressing the Alt + F10 keystroke. + * Successive keystroke presses navigate over available toolbars. + * + * @param {module:ui/toolbar/toolbarview~ToolbarView} toolbarView A instance of the toolbar to be registered. + * @param {Object} [options] + * @param {Boolean} [options.isContextual] Set `true` if the toolbar is attached to the content of the editor. Such toolbar takes + * a precedence over other toolbars when a user pressed Alt + F10. + * @param {Function} [options.beforeFocus] Specify a callback executed before the toolbar instance DOM element gains focus + * upon the Alt + F10 keystroke. + * @param {Function} [options.afterBlur] Specify a callback executed after the toolbar instance DOM element loses focus upon + * Esc keystroke but before the focus goes back to the {@link #setEditableElement editable element}. + */ + addToolbar( toolbarView, options = {} ) { + if ( toolbarView.isRendered ) { + this.focusTracker.add( toolbarView.element ); + this.editor.keystrokes.listenTo( toolbarView.element ); + } else { + toolbarView.once( 'render', () => { + this.focusTracker.add( toolbarView.element ); + this.editor.keystrokes.listenTo( toolbarView.element ); + } ); + } + + this._focusableToolbarDefinitions.push( { toolbarView, options } ); + } + /** * Stores all editable elements used by the editor instance. * @@ -254,6 +331,175 @@ export default class EditorUI { return { top: 0 }; } + /** + * Starts listening for Alt + F10 and Esc keystrokes in the context of focusable + * {@link #setEditableElement editable elements} and {@link #addToolbar toolbars} + * to allow users navigate across the UI. + * + * @private + */ + _initFocusTracking() { + const editor = this.editor; + const editingView = editor.editing.view; + + let lastFocusedForeignElement; + let candidateDefinitions; + + // Focus the next focusable toolbar on Alt + F10. + editor.keystrokes.set( 'Alt+F10', ( data, cancel ) => { + const focusedElement = this.focusTracker.focusedElement; + + // Focus moved out of a DOM element that + // * is not a toolbar, + // * does not belong to the editing view (e.g. source editing). + if ( + Array.from( this._editableElementsMap.values() ).includes( focusedElement ) && + !Array.from( editingView.domRoots.values() ).includes( focusedElement ) + ) { + lastFocusedForeignElement = focusedElement; + } + + const currentFocusedToolbarDefinition = this._getCurrentFocusedToolbarDefinition(); + + // When focusing a toolbar for the first time, set the array of definitions for successive presses of Alt+F10. + // This ensures, the navigation works always the same and no pair of toolbars takes over + // (e.g. image and table toolbars when a selected image is inside a cell). + if ( !currentFocusedToolbarDefinition ) { + candidateDefinitions = this._getFocusableCandidateToolbarDefinitions( currentFocusedToolbarDefinition ); + } + + // In a single Alt+F10 press, check all candidates but if none were focused, don't go any further. + // This prevents an infinite loop. + for ( let i = 0; i < candidateDefinitions.length; i++ ) { + const candidateDefinition = candidateDefinitions.shift(); + + // Put the first definition to the back of the array. This allows circular navigation over all toolbars + // on successive presses of Alt+F10. + candidateDefinitions.push( candidateDefinition ); + + // Don't focus the same toolbar again. If you did, this would move focus from the nth focused toolbar item back to the + // first item as per ToolbarView#focus() if the user navigated inside the toolbar. + if ( + candidateDefinition !== currentFocusedToolbarDefinition && + this._focusFocusableCandidateToolbar( candidateDefinition ) + ) { + // Clean up after a current visible toolbar when switching to the next one. + if ( currentFocusedToolbarDefinition && currentFocusedToolbarDefinition.options.afterBlur ) { + currentFocusedToolbarDefinition.options.afterBlur(); + } + + break; + } + } + + cancel(); + } ); + + // Blur the focused toolbar on Esc and bring the focus back to its origin. + editor.keystrokes.set( 'Esc', ( data, cancel ) => { + const focusedToolbarDef = this._getCurrentFocusedToolbarDefinition(); + + if ( !focusedToolbarDef ) { + return; + } + + // Bring focus back to where it came from before focusing the toolbar: + // 1. If it came from outside the engine view (e.g. source editing), move it there. + if ( lastFocusedForeignElement ) { + lastFocusedForeignElement.focus(); + lastFocusedForeignElement = null; + } + // 2. There are two possibilities left: + // 2.1. It could be that the focus went from an editable element in the view (root or nested). + // 2.2. It could be the focus went straight to the toolbar before even focusing the editing area. + // In either case, just focus the view editing. The focus will land where it belongs. + else { + editor.editing.view.focus(); + } + + // Clean up after the toolbar if there is anything to do there. + if ( focusedToolbarDef.options.afterBlur ) { + focusedToolbarDef.options.afterBlur(); + } + + cancel(); + } ); + } + + /** + * Returns definitions of toolbars that could potentially be focused, sorted by their importance for the user. + * + * Focusable toolbars candidates are either: + * * already visible, + * * have `beforeFocus()` set in their {@link module:core/editor/editorui~FocusableToolbarDefinition definition} that suggests that + * they might show up when called. Keep in mind that determining whether a toolbar will show up (and become focusable) is impossible + * at this stage because it depends on its implementation, that in turn depends on the editing context (selection). + * + * **Note**: Contextual toolbars take precedence over regular toolbars. + * + * @private + * @returns {Array.} + */ + _getFocusableCandidateToolbarDefinitions() { + const definitions = []; + + for ( const toolbarDef of this._focusableToolbarDefinitions ) { + const { toolbarView, options } = toolbarDef; + + if ( isVisible( toolbarView.element ) || options.beforeFocus ) { + definitions.push( toolbarDef ); + } + } + + // Contextual and already visible toolbars have higher priority. If both are true, the toolbar will always focus first. + // For instance, a selected widget toolbar vs inline editor toolbar: both are visible but the widget toolbar is contextual. + definitions.sort( ( defA, defB ) => getToolbarDefinitionWeight( defA ) - getToolbarDefinitionWeight( defB ) ); + + return definitions; + } + + /** + * Returns a definition of the toolbar that is currently visible and focused (one of its children has focus). + * + * `null` is returned when no toolbar is currently focused. + * + * @private + * @returns {module:core/editor/editorui~FocusableToolbarDefinition|null} + */ + _getCurrentFocusedToolbarDefinition() { + for ( const definition of this._focusableToolbarDefinitions ) { + if ( definition.toolbarView.element && definition.toolbarView.element.contains( this.focusTracker.focusedElement ) ) { + return definition; + } + } + + return null; + } + + /** + * Focuses a focusable toolbar candidate using its definition. + * + * @private + * @param {module:core/editor/editorui~FocusableToolbarDefinition} candidateToolbarDefinition A definition of the toolbar to focus. + * @returns {Boolean} `true` when the toolbar candidate was focused. `false` otherwise. + */ + _focusFocusableCandidateToolbar( candidateToolbarDefinition ) { + const { toolbarView, options: { beforeFocus } } = candidateToolbarDefinition; + + if ( beforeFocus ) { + beforeFocus(); + } + + // If it didn't show up after beforeFocus(), it's not focusable at all. + if ( !isVisible( toolbarView.element ) ) { + return false; + } + + toolbarView.focus(); + + return true; + } + /** * Fired when the editor UI is ready. * @@ -273,3 +519,52 @@ export default class EditorUI { } mix( EditorUI, ObservableMixin ); + +/** + * A definition of a focusable toolbar. Used by {@link module:core/editor/editorui~EditorUI#addToolbar}. + * + * @private + * @interface module:core/editor/editorui~FocusableToolbarDefinition + */ + +/** + * An instance of a focusable toolbar view. + * + * @member {module:ui/toolbar/toolbarview~ToolbarView} #toolbarView + */ + +/** + * Options of a focusable toolbar view: + * + * * `isContextual`: Marks the higher priority toolbar. For example when there are 2 visible toolbars, + * it allows to distinguish which toolbar should be focused first after the `alt+f10` keystroke + * * `beforeFocus`: A callback executed before the `ToolbarView` gains focus upon the `Alt+F10` keystroke. + * * `afterBlur`: A callback executed after `ToolbarView` loses focus upon `Esc` keystroke but before the focus goes back to the `origin`. + * + * @member {Object} #options + */ + +// Returns a number (weight) for a toolbar definition. Visible toolbars have a higher priority and so do +// contextual toolbars (displayed in the context of a content, for instance, an image toolbar). +// +// A standard invisible toolbar is the heaviest. A visible contextual toolbar is the lightest. +// +// @private +// @param {module:core/editor/editorui~FocusableToolbarDefinition} toolbarDef A toolbar definition to be weighted. +// @returns {Number} +function getToolbarDefinitionWeight( toolbarDef ) { + const { toolbarView, options } = toolbarDef; + let weight = 10; + + // Prioritize already visible toolbars. They should get focused first. + if ( isVisible( toolbarView.element ) ) { + weight--; + } + + // Prioritize contextual toolbars. They are displayed at the selection. + if ( options.isContextual ) { + weight--; + } + + return weight; +} diff --git a/packages/ckeditor5-core/tests/editor/editorui.js b/packages/ckeditor5-core/tests/editor/editorui.js index 982429f8156..85b45506b21 100644 --- a/packages/ckeditor5-core/tests/editor/editorui.js +++ b/packages/ckeditor5-core/tests/editor/editorui.js @@ -8,6 +8,8 @@ import Editor from '../../src/editor/editor'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import ComponentFactory from '@ckeditor/ckeditor5-ui/src/componentfactory'; +import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import testUtils from '../_utils/utils'; @@ -44,6 +46,15 @@ describe( 'EditorUI', () => { expect( ui.element ).to.null; } ); + it( 'should set isReady to false', () => { + expect( ui.isReady ).to.be.false; + } ); + + it( 'should set isReady to true after #ready is fired', () => { + ui.fire( 'ready' ); + expect( ui.isReady ).to.be.true; + } ); + it( 'should fire update event after viewDocument#layoutChanged', () => { const spy = sinon.spy(); @@ -113,9 +124,14 @@ describe( 'EditorUI', () => { } ); describe( 'setEditableElement()', () => { + let element; + + beforeEach( () => { + element = document.createElement( 'div' ); + } ); + it( 'should register the editable element under a name', () => { const ui = new EditorUI( editor ); - const element = document.createElement( 'div' ); ui.setEditableElement( 'main', element ); @@ -124,7 +140,6 @@ describe( 'EditorUI', () => { it( 'puts a reference to the editor instance in domElement#ckeditorInstance', () => { const ui = new EditorUI( editor ); - const element = document.createElement( 'div' ); ui.setEditableElement( 'main', element ); @@ -133,7 +148,6 @@ describe( 'EditorUI', () => { it( 'does not override a reference to the editor instance in domElement#ckeditorInstance', () => { const ui = new EditorUI( editor ); - const element = document.createElement( 'div' ); element.ckeditorInstance = 'foo'; @@ -141,6 +155,52 @@ describe( 'EditorUI', () => { expect( element.ckeditorInstance ).to.equal( 'foo' ); } ); + + describe( 'Focus tracking and accessibility', () => { + it( 'should add the passed DOM element to EditorUI#focusTracker ', () => { + const spy = testUtils.sinon.spy( ui.focusTracker, 'add' ); + + ui.setEditableElement( 'main', element ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, element ); + } ); + + it( 'should add a DOM element to Editor#keystokeHandler', () => { + const spy = sinon.spy( editor.keystrokes, 'listenTo' ); + + ui.setEditableElement( 'main', element ); + ui.fire( 'ready' ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, element ); + } ); + + it( 'should not add a DOM element to Editor#keystokeHandler if an editing DOM root (to avoid duplication)', () => { + const keystorkesSpy = sinon.spy( editor.keystrokes, 'listenTo' ); + + ui.setEditableElement( 'main', element ); + editor.model.document.createRoot(); + editor.editing.view.attachDomRoot( element ); + ui.fire( 'ready' ); + + sinon.assert.notCalled( keystorkesSpy ); + } ); + + it( 'should enable accessibility features after the editor UI was ready', () => { + const focusTrackerSpy = testUtils.sinon.spy( ui.focusTracker, 'add' ); + const keystrokesSpy = sinon.spy( editor.keystrokes, 'listenTo' ); + + ui.fire( 'ready' ); + ui.setEditableElement( 'main', element ); + + sinon.assert.calledOnce( focusTrackerSpy ); + sinon.assert.calledWithExactly( focusTrackerSpy, element ); + + sinon.assert.calledOnce( keystrokesSpy ); + sinon.assert.calledWithExactly( keystrokesSpy, element ); + } ); + } ); } ); describe( 'getEditableElement()', () => { @@ -230,4 +290,499 @@ describe( 'EditorUI', () => { sinon.assert.calledWithMatch( consoleStub, 'editor-ui-deprecated-viewport-offset-config' ); } ); } ); + + describe( 'Focus handling and navigation between editable areas and editor toolbars', () => { + describe( 'addToolbar()', () => { + let locale, toolbar; + + beforeEach( () => { + ui = new EditorUI( editor ); + locale = { t: val => val }; + toolbar = new ToolbarView( locale ); + } ); + + describe( 'for a ToolbarView that has already been rendered', () => { + it( 'adds ToolbarView#element to the EditorUI#focusTracker', () => { + const spy = testUtils.sinon.spy( ui.focusTracker, 'add' ); + toolbar.render(); + + ui.addToolbar( toolbar ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'adds ToolbarView#element to Editor#keystokeHandler', () => { + const spy = sinon.spy( editor.keystrokes, 'listenTo' ); + toolbar.render(); + + ui.addToolbar( toolbar ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'for a toolbar that has not been yet rendered', () => { + it( 'delayes changes to EditorUI#focusTracker and Editor#keystokeHandler until the toolbar gets rendered', async () => { + const spy = sinon.spy( editor.keystrokes, 'listenTo' ); + const spy2 = testUtils.sinon.spy( ui.focusTracker, 'add' ); + + ui.addToolbar( toolbar ); + + await new Promise( resolve => { + toolbar.once( 'render', () => { + sinon.assert.calledOnce( spy ); + sinon.assert.calledOnce( spy2 ); + + resolve(); + } ); + + toolbar.render(); + } ); + } ); + } ); + + it( 'adds toolbar to the `_focusableToolbarDefinitions` array', () => { + ui.addToolbar( toolbar ); + + expect( ui._focusableToolbarDefinitions.length ).to.equal( 1 ); + } ); + + it( 'adds toolbar to the `_focusableToolbarDefinitions` array with passed options', () => { + ui.addToolbar( toolbar, { isContextual: true } ); + + expect( ui._focusableToolbarDefinitions.length ).to.equal( 1 ); + expect( ui._focusableToolbarDefinitions[ 0 ].options ).to.not.be.undefined; + } ); + } ); + + describe( 'Focusing toolbars on Alt+F10 key press', () => { + let locale, visibleToolbar, invisibleToolbar, visibleContextualToolbar, toolbarWithSetupAndCleanup; + let editingArea; + let visibleSpy, visibleContextualSpy, invisibleSpy, toolbarWithSetupAndCleanupSpy; + + beforeEach( () => { + locale = { t: val => val }; + + visibleToolbar = new ToolbarView( locale ); + visibleToolbar.ariaLabel = 'visible'; + visibleToolbar.render(); + document.body.appendChild( visibleToolbar.element ); + + visibleContextualToolbar = new ToolbarView( locale ); + visibleContextualToolbar.ariaLabel = 'visible contextual'; + visibleContextualToolbar.render(); + document.body.appendChild( visibleContextualToolbar.element ); + + invisibleToolbar = new ToolbarView( locale ); + invisibleToolbar.ariaLabel = 'invisible contextual'; + invisibleToolbar.render(); + + toolbarWithSetupAndCleanup = new ToolbarView( locale ); + toolbarWithSetupAndCleanup.ariaLabel = 'with before focus'; + toolbarWithSetupAndCleanup.render(); + + ui.addToolbar( visibleToolbar ); + ui.addToolbar( visibleContextualToolbar, { isContextual: true } ); + ui.addToolbar( invisibleToolbar ); + + // E.g. a contextual balloon toolbar. + ui.addToolbar( toolbarWithSetupAndCleanup, { + beforeFocus: () => { + document.body.appendChild( toolbarWithSetupAndCleanup.element ); + }, + afterBlur: () => { + toolbarWithSetupAndCleanup.element.remove(); + } + } ); + + editingArea = document.createElement( 'div' ); + document.body.appendChild( editingArea ); + + ui.setEditableElement( 'main', editingArea ); + ui.fire( 'ready' ); + + // Let's start with the editing root already focused. + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = editingArea; + + visibleSpy = sinon.spy( visibleToolbar, 'focus' ).named( 'visible' ); + visibleContextualSpy = sinon.spy( visibleContextualToolbar, 'focus' ).named( 'visibleContextual' ); + invisibleSpy = sinon.spy( invisibleToolbar, 'focus' ).named( 'invisible' ); + toolbarWithSetupAndCleanupSpy = sinon.spy( toolbarWithSetupAndCleanup, 'focus' ).named( 'withSetupAndCleanup' ); + } ); + + afterEach( () => { + visibleToolbar.element.remove(); + visibleContextualToolbar.element.remove(); + toolbarWithSetupAndCleanup.element.remove(); + + editingArea.remove(); + + visibleToolbar.destroy(); + visibleContextualToolbar.destroy(); + invisibleToolbar.destroy(); + toolbarWithSetupAndCleanup.destroy(); + } ); + + it( 'should do nothing if no focusable toolbar was found', () => { + visibleContextualToolbar.element.remove(); + visibleToolbar.element.remove(); + toolbarWithSetupAndCleanup.element.style.display = 'none'; + + pressAltF10(); + + sinon.assert.notCalled( visibleContextualSpy ); + sinon.assert.notCalled( visibleContextualSpy ); + sinon.assert.notCalled( toolbarWithSetupAndCleanupSpy ); + sinon.assert.notCalled( invisibleSpy ); + } ); + + it( 'should do nothing if no toolbars were registered', () => { + const editor = new Editor(); + const ui = new EditorUI( editor ); + const editingArea = document.createElement( 'div' ); + document.body.appendChild( editingArea ); + + ui.setEditableElement( 'main', editingArea ); + ui.fire( 'ready' ); + + expect( () => { + pressAltF10( editor ); + } ).to.not.throw(); + + editingArea.remove(); + editor.destroy(); + ui.destroy(); + } ); + + it( 'should do nothing if the toolbar is already focused and there is nowhere else for the focus to go ' + + '(a toolbar without beforeFocus() / afterBlur())', + () => { + visibleToolbar.element.remove(); + toolbarWithSetupAndCleanup.element.style.display = 'none'; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleContextualToolbar.element; + + sinon.assert.calledOnce( visibleContextualSpy ); + + pressAltF10(); + sinon.assert.calledOnce( visibleContextualSpy ); + sinon.assert.notCalled( visibleSpy ); + sinon.assert.notCalled( toolbarWithSetupAndCleanupSpy ); + sinon.assert.notCalled( invisibleSpy ); + + expect( visibleContextualToolbar.element.parentNode ).to.equal( document.body ); + } ); + + it( 'should do nothing if the toolbar is already focused and there is nowhere else for the focus to go ' + + '(a toolbar with beforeFocus() / afterBlur())', + () => { + visibleToolbar.element.remove(); + visibleContextualToolbar.element.style.display = 'none'; + + pressAltF10(); + ui.focusTracker.focusedElement = toolbarWithSetupAndCleanup.element; + + sinon.assert.calledOnce( toolbarWithSetupAndCleanupSpy ); + + pressAltF10(); + sinon.assert.calledOnce( toolbarWithSetupAndCleanupSpy ); + sinon.assert.notCalled( visibleSpy ); + sinon.assert.notCalled( visibleContextualSpy ); + sinon.assert.notCalled( invisibleSpy ); + + expect( toolbarWithSetupAndCleanup.element.parentNode ).to.equal( document.body ); + } ); + + it( 'should focus the first focusable toolbar (and pick the contextual one first)', () => { + pressAltF10(); + + sinon.assert.calledOnce( visibleContextualSpy ); + sinon.assert.notCalled( visibleSpy ); + sinon.assert.notCalled( invisibleSpy ); + } ); + + it( 'should focus the next focusable toolbar', () => { + pressAltF10(); + ui.focusTracker.focusedElement = visibleContextualToolbar.element; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbar.element; + + sinon.assert.callOrder( visibleContextualSpy, visibleSpy ); + sinon.assert.notCalled( invisibleSpy ); + } ); + + it( 'should navigate across focusable toolbars and go back to the first one respecting priorities', () => { + pressAltF10(); + ui.focusTracker.focusedElement = visibleContextualToolbar.element; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbar.element; + + pressAltF10(); + ui.focusTracker.focusedElement = toolbarWithSetupAndCleanup.element; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleContextualToolbar.element; + + sinon.assert.callOrder( visibleContextualSpy, visibleSpy, toolbarWithSetupAndCleanupSpy, visibleContextualSpy ); + sinon.assert.notCalled( invisibleSpy ); + } ); + + it( 'should avoid race betwen toolbars with beforeFocus()/afterBlur()', () => { + const secondToolbarWithSetupAndCleanup = new ToolbarView( locale ); + secondToolbarWithSetupAndCleanup.ariaLabel = 'second with before focus'; + secondToolbarWithSetupAndCleanup.render(); + + visibleToolbar.element.style.display = 'none'; + + const secondToolbarWithSetupAndCleanupSpy = sinon.spy( secondToolbarWithSetupAndCleanup, 'focus' ) + .named( 'secondWithSetupAndCleanup' ); + + ui.addToolbar( secondToolbarWithSetupAndCleanup, { + beforeFocus: () => { + document.body.appendChild( secondToolbarWithSetupAndCleanup.element ); + }, + afterBlur: () => { + secondToolbarWithSetupAndCleanup.element.remove(); + } + } ); + + // ---------------------------------------- + + pressAltF10(); + ui.focusTracker.focusedElement = visibleContextualToolbar.element; + + pressAltF10(); + ui.focusTracker.focusedElement = toolbarWithSetupAndCleanup.element; + + pressAltF10(); + ui.focusTracker.focusedElement = secondToolbarWithSetupAndCleanup.element; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleContextualToolbar.element; + + pressAltF10(); + ui.focusTracker.focusedElement = toolbarWithSetupAndCleanup.element; + + sinon.assert.callOrder( + visibleContextualSpy, + toolbarWithSetupAndCleanupSpy, + secondToolbarWithSetupAndCleanupSpy, + visibleContextualSpy, + toolbarWithSetupAndCleanupSpy + ); + + sinon.assert.notCalled( invisibleSpy ); + + // ---------------------------------------- + + secondToolbarWithSetupAndCleanup.element.remove(); + } ); + } ); + + describe( 'Restoring focus on Esc key press', () => { + let locale, visibleToolbarA, visibleToolbarB, editingAreaA, editingAreaB, nonEngineEditingArea, invisibleEditingArea; + let editingFocusSpy, editingAreaASpy, editingAreaBSpy, nonEngineEditingAreaSpy, invisibleEditingAreaSpy; + + beforeEach( () => { + locale = { t: val => val }; + + visibleToolbarA = new ToolbarView( locale ); + visibleToolbarA.ariaLabel = 'visible A'; + visibleToolbarA.render(); + document.body.appendChild( visibleToolbarA.element ); + + visibleToolbarB = new ToolbarView( locale ); + visibleToolbarB.ariaLabel = 'visible B'; + visibleToolbarB.render(); + document.body.appendChild( visibleToolbarB.element ); + + ui.addToolbar( visibleToolbarA ); + ui.addToolbar( visibleToolbarB ); + + editingAreaA = document.createElement( 'div' ); + editingAreaB = document.createElement( 'div' ); + nonEngineEditingArea = document.createElement( 'div' ); + invisibleEditingArea = document.createElement( 'div' ); + + editingAreaA.setAttribute( 'id', 'A' ); + editingAreaB.setAttribute( 'id', 'B' ); + nonEngineEditingArea.setAttribute( 'id', 'non-engine' ); + invisibleEditingArea.setAttribute( 'id', 'invisible' ); + + document.body.appendChild( editingAreaA ); + document.body.appendChild( editingAreaB ); + document.body.appendChild( nonEngineEditingArea ); + document.body.appendChild( invisibleEditingArea ); + + // Simulate e.g. a hidden source area. + invisibleEditingArea.style.display = 'none'; + + editor.model.document.createRoot( '$root', 'invisible' ); + editor.model.document.createRoot( '$root', 'areaA' ); + editor.model.document.createRoot( '$root', 'areaB' ); + editor.editing.view.attachDomRoot( invisibleEditingArea, 'invisible' ); + editor.editing.view.attachDomRoot( editingAreaA, 'areaA' ); + editor.editing.view.attachDomRoot( editingAreaB, 'areaB' ); + + ui.setEditableElement( 'invisible', invisibleEditingArea ); + ui.setEditableElement( 'areaA', editingAreaA ); + ui.setEditableElement( 'areaB', editingAreaB ); + ui.setEditableElement( 'nonEngine', nonEngineEditingArea ); + ui.fire( 'ready' ); + + // Let's start with the editing root "A" already focused. + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = editingAreaA; + + editingFocusSpy = sinon.spy( editor.editing.view, 'focus' ); + editingAreaASpy = sinon.spy( editingAreaA, 'focus' ); + editingAreaBSpy = sinon.spy( editingAreaB, 'focus' ); + nonEngineEditingAreaSpy = sinon.spy( nonEngineEditingArea, 'focus' ); + invisibleEditingAreaSpy = sinon.spy( invisibleEditingArea, 'focus' ); + } ); + + afterEach( () => { + visibleToolbarA.element.remove(); + visibleToolbarB.element.remove(); + + editingAreaA.remove(); + editingAreaB.remove(); + nonEngineEditingArea.remove(); + invisibleEditingArea.remove(); + + visibleToolbarA.destroy(); + visibleToolbarB.destroy(); + } ); + + it( 'should do nothing if no toolbar is focused', () => { + expect( () => { + pressEsc(); + } ).to.not.throw(); + } ); + + it( 'should return focus back to the editing view if it came from there', () => { + // Catches the `There is no selection in any editable to focus.` warning. + sinon.stub( console, 'warn' ); + + ui.focusTracker.focusedElement = editor.editing.view.getDomRoot(); + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbarA.element; + + pressEsc(); + + sinon.assert.calledOnce( editingFocusSpy ); + sinon.assert.notCalled( editingAreaASpy ); + sinon.assert.notCalled( editingAreaBSpy ); + sinon.assert.notCalled( nonEngineEditingAreaSpy ); + sinon.assert.notCalled( invisibleEditingAreaSpy ); + } ); + + it( 'should return focus back to the last focused editing area that does not belong to the editing view', () => { + ui.focusTracker.focusedElement = nonEngineEditingArea; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbarA.element; + + pressEsc(); + + sinon.assert.calledOnce( nonEngineEditingAreaSpy ); + sinon.assert.notCalled( editingAreaASpy ); + sinon.assert.notCalled( editingAreaBSpy ); + sinon.assert.notCalled( invisibleEditingAreaSpy ); + } ); + + it( 'should return focus back to the last focused editing area after navigating across multiple toolbars', () => { + // Catches the `There is no selection in any editable to focus.` warning. + sinon.stub( console, 'warn' ); + + ui.focusTracker.focusedElement = editingAreaB; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbarA.element; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbarB.element; + + pressEsc(); + + sinon.assert.calledOnce( editingFocusSpy ); + sinon.assert.notCalled( editingAreaBSpy ); + sinon.assert.notCalled( editingAreaASpy ); + sinon.assert.notCalled( nonEngineEditingAreaSpy ); + sinon.assert.notCalled( invisibleEditingAreaSpy ); + } ); + + it( 'should focus the first editing area if the focus went straight to the toolbar without focusing any editing areas', () => { + // Catches the `There is no selection in any editable to focus.` warning. + sinon.stub( console, 'warn' ); + + ui.focusTracker.focusedElement = visibleToolbarA.element; + + pressEsc(); + + sinon.assert.calledOnce( editingFocusSpy ); + sinon.assert.notCalled( editingAreaASpy ); + sinon.assert.notCalled( editingAreaBSpy ); + sinon.assert.notCalled( nonEngineEditingAreaSpy ); + sinon.assert.notCalled( invisibleEditingAreaSpy ); + } ); + + it( 'should clean up after a focused toolbar that had afterBlur() defined in options', () => { + // Catches the `There is no selection in any editable to focus.` warning. + sinon.stub( console, 'warn' ); + + const toolbarWithCallbacks = new ToolbarView( locale ); + toolbarWithCallbacks.ariaLabel = 'with callbacks'; + toolbarWithCallbacks.render(); + + // E.g. a contextual balloon toolbar. + ui.addToolbar( toolbarWithCallbacks, { + beforeFocus: () => { + document.body.appendChild( toolbarWithCallbacks.element ); + }, + afterBlur: () => { + toolbarWithCallbacks.element.remove(); + } + } ); + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbarA.element; + + pressAltF10(); + ui.focusTracker.focusedElement = visibleToolbarB.element; + + pressAltF10(); + ui.focusTracker.focusedElement = toolbarWithCallbacks.element; + + pressEsc(); + sinon.assert.calledOnce( editingFocusSpy ); + sinon.assert.notCalled( editingAreaASpy ); + sinon.assert.notCalled( editingAreaBSpy ); + sinon.assert.notCalled( nonEngineEditingAreaSpy ); + sinon.assert.notCalled( invisibleEditingAreaSpy ); + } ); + } ); + + function pressAltF10( specificEditor ) { + ( specificEditor || editor ).keystrokes.press( { + keyCode: keyCodes.f10, + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + + function pressEsc() { + editor.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + } ); } ); diff --git a/packages/ckeditor5-editor-balloon/package.json b/packages/ckeditor5-editor-balloon/package.json index d06ae63252f..ba9ccfc8ad6 100644 --- a/packages/ckeditor5-editor-balloon/package.json +++ b/packages/ckeditor5-editor-balloon/package.json @@ -21,6 +21,7 @@ "@ckeditor/ckeditor5-engine": "^35.0.1", "@ckeditor/ckeditor5-enter": "^35.0.1", "@ckeditor/ckeditor5-heading": "^35.0.1", + "@ckeditor/ckeditor5-image": "^35.0.1", "@ckeditor/ckeditor5-paragraph": "^35.0.1", "@ckeditor/ckeditor5-theme-lark": "^35.0.1", "@ckeditor/ckeditor5-typing": "^35.0.1", diff --git a/packages/ckeditor5-editor-balloon/src/ballooneditorui.js b/packages/ckeditor5-editor-balloon/src/ballooneditorui.js index 580d70cf79a..153ce71e358 100644 --- a/packages/ckeditor5-editor-balloon/src/ballooneditorui.js +++ b/packages/ckeditor5-editor-balloon/src/ballooneditorui.js @@ -8,7 +8,6 @@ */ import { EditorUI } from 'ckeditor5/src/core'; -import { enableToolbarKeyboardFocus } from 'ckeditor5/src/ui'; import { enablePlaceholder } from 'ckeditor5/src/engine'; /** @@ -48,7 +47,6 @@ export default class BalloonEditorUI extends EditorUI { init() { const editor = this.editor; const view = this.view; - const balloonToolbar = editor.plugins.get( 'BalloonToolbar' ); const editingView = editor.editing.view; const editable = view.editable; const editingRoot = editingView.document.getRoot(); @@ -67,11 +65,6 @@ export default class BalloonEditorUI extends EditorUI { // editable areas (roots) but the balloon editor has only one. this.setEditableElement( editable.name, editableElement ); - // Let the global focus tracker know that the editable UI element is focusable and - // belongs to the editor. From now on, the focus tracker will sustain the editor focus - // as long as the editable is focused (e.g. the user is typing). - this.focusTracker.add( editableElement ); - // Let the editable UI element respond to the changes in the global editor focus // tracker. It has been added to the same tracker a few lines above but, in reality, there are // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long @@ -85,19 +78,6 @@ export default class BalloonEditorUI extends EditorUI { // of the editor's engine. This is where the engine meets the UI. editingView.attachDomRoot( editableElement ); - enableToolbarKeyboardFocus( { - origin: editingView, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar: balloonToolbar.toolbarView, - beforeFocus() { - balloonToolbar.show(); - }, - afterBlur() { - balloonToolbar.hide(); - } - } ); - this._initPlaceholder(); this.fire( 'ready' ); } diff --git a/packages/ckeditor5-editor-balloon/tests/ballooneditorui.js b/packages/ckeditor5-editor-balloon/tests/ballooneditorui.js index 0fc99ec4b92..88287abc4e2 100644 --- a/packages/ckeditor5-editor-balloon/tests/ballooneditorui.js +++ b/packages/ckeditor5-editor-balloon/tests/ballooneditorui.js @@ -5,15 +5,19 @@ /* globals document, Event */ +import BalloonEditor from '../src/ballooneditor'; import BalloonEditorUI from '../src/ballooneditorui'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; import BalloonEditorUIView from '../src/ballooneditoruiview'; -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import BalloonToolbar from '@ckeditor/ckeditor5-ui/src/toolbar/balloon/balloontoolbar'; +import { Image, ImageCaption, ImageToolbar } from '@ckeditor/ckeditor5-image'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; + import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { isElement } from 'lodash-es'; - +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { assertBinding } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; @@ -50,41 +54,6 @@ describe( 'BalloonEditorUI', () => { expect( view.isRendered ).to.be.true; } ); - it( 'initializes keyboard navigation between view#toolbar and view#editable', () => { - const toolbar = editor.plugins.get( 'BalloonToolbar' ); - const toolbarFocusSpy = testUtils.sinon.stub( toolbar.toolbarView, 'focus' ).returns( {} ); - const toolbarShowSpy = testUtils.sinon.stub( toolbar, 'show' ).returns( {} ); - const toolbarHideSpy = testUtils.sinon.stub( toolbar, 'hide' ).returns( {} ); - const editingFocusSpy = testUtils.sinon.stub( editor.editing.view, 'focus' ).returns( {} ); - - ui.focusTracker.isFocused = true; - - // #show and #hide are mocked so mocking the focus as well. - toolbar.toolbarView.focusTracker.isFocused = false; - - editor.keystrokes.press( { - keyCode: keyCodes.f10, - altKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - } ); - - sinon.assert.callOrder( toolbarShowSpy, toolbarFocusSpy ); - sinon.assert.notCalled( toolbarHideSpy ); - sinon.assert.notCalled( editingFocusSpy ); - - // #show and #hide are mocked so mocking the focus as well. - toolbar.toolbarView.focusTracker.isFocused = true; - - toolbar.toolbarView.keystrokes.press( { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - } ); - - sinon.assert.callOrder( editingFocusSpy, toolbarHideSpy ); - } ); - describe( 'editable', () => { let editable; @@ -240,6 +209,144 @@ describe( 'BalloonEditorUI', () => { } ); } ); +describe( 'Focus handling and navigation between editing root and editor toolbar', () => { + let editorElement, editor, ui, toolbarView, domRoot; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.body.appendChild( document.createElement( 'div' ) ); + + editor = await BalloonEditor.create( editorElement, { + plugins: [ BalloonToolbar, Heading, Paragraph, Image, ImageToolbar, ImageCaption ], + toolbar: [ 'heading', 'imageTextAlternative' ], + image: { + toolbar: [ 'toggleImageCaption' ] + } + } ); + + domRoot = editor.editing.view.domRoots.get( 'main' ); + + ui = editor.ui; + toolbarView = editor.plugins.get( 'BalloonToolbar' ).toolbarView; + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + describe( 'Focusing toolbars on Alt+F10 key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should focus the main toolbar when the focus is in the editing root', () => { + const spy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + + pressAltF10(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should do nothing if the toolbar is already focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + // Try Alt+F10 again. + pressAltF10(); + + sinon.assert.calledOnce( toolbarFocusSpy ); + sinon.assert.notCalled( domRootFocusSpy ); + } ); + + it( 'should prioritize widget toolbar over the global toolbar', () => { + const widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + const imageToolbar = widgetToolbarRepository._toolbarDefinitions.get( 'image' ).view; + + const toolbarSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + const imageToolbarSpy = testUtils.sinon.spy( imageToolbar, 'focus' ); + + setModelData( editor.model, + 'foo' + + '[bar]' + + 'baz' + ); + + // Focus the image balloon toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = imageToolbar.element; + + sinon.assert.calledOnce( imageToolbarSpy ); + sinon.assert.notCalled( toolbarSpy ); + } ); + } ); + + describe( 'Restoring focus on Esc key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should move the focus back from the main toolbar to the editing root', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + pressEsc(); + + sinon.assert.callOrder( toolbarFocusSpy, domRootFocusSpy ); + } ); + + it( 'should do nothing if it was pressed when no toolbar was focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + pressEsc(); + + sinon.assert.notCalled( domRootFocusSpy ); + sinon.assert.notCalled( toolbarFocusSpy ); + } ); + } ); + + function pressAltF10() { + editor.keystrokes.press( { + keyCode: keyCodes.f10, + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + + function pressEsc() { + editor.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } +} ); + class VirtualBalloonTestEditor extends VirtualTestEditor { constructor( sourceElementOrData, config ) { super( config ); diff --git a/packages/ckeditor5-editor-classic/package.json b/packages/ckeditor5-editor-classic/package.json index b98632f661f..e55aa49f296 100644 --- a/packages/ckeditor5-editor-classic/package.json +++ b/packages/ckeditor5-editor-classic/package.json @@ -21,6 +21,7 @@ "@ckeditor/ckeditor5-engine": "^35.0.1", "@ckeditor/ckeditor5-enter": "^35.0.1", "@ckeditor/ckeditor5-heading": "^35.0.1", + "@ckeditor/ckeditor5-image": "^35.0.1", "@ckeditor/ckeditor5-paragraph": "^35.0.1", "@ckeditor/ckeditor5-theme-lark": "^35.0.1", "@ckeditor/ckeditor5-typing": "^35.0.1", diff --git a/packages/ckeditor5-editor-classic/src/classiceditorui.js b/packages/ckeditor5-editor-classic/src/classiceditorui.js index c25b2ab1036..58430baae5c 100644 --- a/packages/ckeditor5-editor-classic/src/classiceditorui.js +++ b/packages/ckeditor5-editor-classic/src/classiceditorui.js @@ -8,7 +8,7 @@ */ import { EditorUI } from 'ckeditor5/src/core'; -import { enableToolbarKeyboardFocus, normalizeToolbarConfig } from 'ckeditor5/src/ui'; +import { normalizeToolbarConfig } from 'ckeditor5/src/ui'; import { enablePlaceholder } from 'ckeditor5/src/engine'; import { ElementReplacer } from 'ckeditor5/src/utils'; @@ -85,11 +85,6 @@ export default class ClassicEditorUI extends EditorUI { // editable areas (roots) but the classic editor has only one. this.setEditableElement( editable.name, editableElement ); - // Let the global focus tracker know that the editable UI element is focusable and - // belongs to the editor. From now on, the focus tracker will sustain the editor focus - // as long as the editable is focused (e.g. the user is typing). - this.focusTracker.add( editableElement ); - // Let the editable UI element respond to the changes in the global editor focus // tracker. It has been added to the same tracker a few lines above but, in reality, there are // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long @@ -135,9 +130,7 @@ export default class ClassicEditorUI extends EditorUI { * @private */ _initToolbar() { - const editor = this.editor; const view = this.view; - const editingView = editor.editing.view; // Set–up the sticky panel with toolbar. view.stickyPanel.bind( 'isActive' ).to( this.focusTracker, 'isFocused' ); @@ -146,12 +139,8 @@ export default class ClassicEditorUI extends EditorUI { view.toolbar.fillFromConfig( this._toolbarConfig, this.componentFactory ); - enableToolbarKeyboardFocus( { - origin: editingView, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar: view.toolbar - } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + this.addToolbar( view.toolbar ); } /** diff --git a/packages/ckeditor5-editor-classic/tests/classiceditorui.js b/packages/ckeditor5-editor-classic/tests/classiceditorui.js index c8d8ba07916..01fc64b6c47 100644 --- a/packages/ckeditor5-editor-classic/tests/classiceditorui.js +++ b/packages/ckeditor5-editor-classic/tests/classiceditorui.js @@ -8,10 +8,13 @@ import View from '@ckeditor/ckeditor5-ui/src/view'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ClassicEditor from '../src/classiceditor'; import ClassicEditorUI from '../src/classiceditorui'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import ClassicEditorUIView from '../src/classiceditoruiview'; +import { Image, ImageCaption, ImageToolbar } from '@ckeditor/ckeditor5-image'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; @@ -253,29 +256,6 @@ describe( 'ClassicEditorUI', () => { } ); } ); } ); - - it( 'initializes keyboard navigation between view#toolbar and view#editable', () => { - return VirtualClassicTestEditor.create( '' ) - .then( editor => { - const ui = editor.ui; - const view = ui.view; - const spy = testUtils.sinon.spy( view.toolbar, 'focus' ); - - ui.focusTracker.isFocused = true; - ui.view.toolbar.focusTracker.isFocused = false; - - editor.keystrokes.press( { - keyCode: keyCodes.f10, - altKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - } ); - - sinon.assert.calledOnce( spy ); - - return editor.destroy(); - } ); - } ); } ); describe( 'destroy()', () => { @@ -346,6 +326,144 @@ describe( 'ClassicEditorUI', () => { } ); } ); +describe( 'Focus handling and navigation between editing root and editor toolbar', () => { + let editorElement, editor, ui, toolbarView, domRoot; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.body.appendChild( document.createElement( 'div' ) ); + + editor = await ClassicEditor.create( editorElement, { + plugins: [ Paragraph, Image, ImageToolbar, ImageCaption ], + toolbar: [ 'imageTextAlternative' ], + image: { + toolbar: [ 'toggleImageCaption' ] + } + } ); + + domRoot = editor.editing.view.domRoots.get( 'main' ); + + ui = editor.ui; + toolbarView = ui.view.toolbar; + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + describe( 'Focusing toolbars on Alt+F10 key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should focus the main toolbar when the focus is in the editing root', () => { + const spy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + + pressAltF10(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should do nothing if the toolbar is already focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + // Try Alt+F10 again. + pressAltF10(); + + sinon.assert.calledOnce( toolbarFocusSpy ); + sinon.assert.notCalled( domRootFocusSpy ); + } ); + + it( 'should prioritize widget toolbar over the global toolbar', () => { + const widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + const imageToolbar = widgetToolbarRepository._toolbarDefinitions.get( 'image' ).view; + + const toolbarSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + const imageToolbarSpy = testUtils.sinon.spy( imageToolbar, 'focus' ); + + setModelData( editor.model, + 'foo' + + '[bar]' + + 'baz' + ); + + // Focus the image balloon toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = imageToolbar.element; + + sinon.assert.calledOnce( imageToolbarSpy ); + sinon.assert.notCalled( toolbarSpy ); + } ); + } ); + + describe( 'Restoring focus on Esc key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should move the focus back from the main toolbar to the editing root', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + pressEsc(); + + sinon.assert.callOrder( toolbarFocusSpy, domRootFocusSpy ); + } ); + + it( 'should do nothing if it was pressed when no toolbar was focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + pressEsc(); + + sinon.assert.notCalled( domRootFocusSpy ); + sinon.assert.notCalled( toolbarFocusSpy ); + } ); + } ); + + function pressAltF10() { + editor.keystrokes.press( { + keyCode: keyCodes.f10, + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + + function pressEsc() { + editor.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } +} ); + function viewCreator( name ) { return locale => { const view = new View( locale ); diff --git a/packages/ckeditor5-editor-decoupled/package.json b/packages/ckeditor5-editor-decoupled/package.json index 3aa4d0cf14f..c73e1319118 100644 --- a/packages/ckeditor5-editor-decoupled/package.json +++ b/packages/ckeditor5-editor-decoupled/package.json @@ -21,6 +21,7 @@ "@ckeditor/ckeditor5-engine": "^35.0.1", "@ckeditor/ckeditor5-enter": "^35.0.1", "@ckeditor/ckeditor5-heading": "^35.0.1", + "@ckeditor/ckeditor5-image": "^35.0.1", "@ckeditor/ckeditor5-paragraph": "^35.0.1", "@ckeditor/ckeditor5-theme-lark": "^35.0.1", "@ckeditor/ckeditor5-typing": "^35.0.1", diff --git a/packages/ckeditor5-editor-decoupled/src/decouplededitorui.js b/packages/ckeditor5-editor-decoupled/src/decouplededitorui.js index 83f739b55ff..aafec9e14d4 100644 --- a/packages/ckeditor5-editor-decoupled/src/decouplededitorui.js +++ b/packages/ckeditor5-editor-decoupled/src/decouplededitorui.js @@ -8,7 +8,6 @@ */ import { EditorUI } from 'ckeditor5/src/core'; -import { enableToolbarKeyboardFocus } from 'ckeditor5/src/ui'; import { enablePlaceholder } from 'ckeditor5/src/engine'; /** @@ -59,11 +58,6 @@ export default class DecoupledEditorUI extends EditorUI { // editable areas (roots) but the decoupled editor has only one. this.setEditableElement( editable.name, editableElement ); - // Let the global focus tracker know that the editable UI element is focusable and - // belongs to the editor. From now on, the focus tracker will sustain the editor focus - // as long as the editable is focused (e.g. the user is typing). - this.focusTracker.add( editableElement ); - // Let the editable UI element respond to the changes in the global editor focus // tracker. It has been added to the same tracker a few lines above but, in reality, there are // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long @@ -107,12 +101,8 @@ export default class DecoupledEditorUI extends EditorUI { toolbar.fillFromConfig( editor.config.get( 'toolbar' ), this.componentFactory ); - enableToolbarKeyboardFocus( { - origin: editor.editing.view, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar - } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + this.addToolbar( view.toolbar ); } /** diff --git a/packages/ckeditor5-editor-decoupled/tests/decouplededitorui.js b/packages/ckeditor5-editor-decoupled/tests/decouplededitorui.js index c29e5aa7df9..4c94753f1ce 100644 --- a/packages/ckeditor5-editor-decoupled/tests/decouplededitorui.js +++ b/packages/ckeditor5-editor-decoupled/tests/decouplededitorui.js @@ -7,16 +7,19 @@ import View from '@ckeditor/ckeditor5-ui/src/view'; -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import DecoupledEditor from '../src/decouplededitor'; import DecoupledEditorUI from '../src/decouplededitorui'; +import DecoupledEditorUIView from '../src/decouplededitoruiview'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import DecoupledEditorUIView from '../src/decouplededitoruiview'; +import { Image, ImageCaption, ImageToolbar } from '@ckeditor/ckeditor5-image'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { assertBinding } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; import { isElement } from 'lodash-es'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; describe( 'DecoupledEditorUI', () => { let editor, view, ui, viewElement; @@ -194,29 +197,6 @@ describe( 'DecoupledEditorUI', () => { } ); } ); } ); - - it( 'initializes keyboard navigation between view#toolbar and view#editable', () => { - return VirtualDecoupledTestEditor.create( '' ) - .then( editor => { - const ui = editor.ui; - const view = ui.view; - const spy = testUtils.sinon.spy( view.toolbar, 'focus' ); - - ui.focusTracker.isFocused = true; - ui.view.toolbar.focusTracker.isFocused = false; - - editor.keystrokes.press( { - keyCode: keyCodes.f10, - altKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - } ); - - sinon.assert.calledOnce( spy ); - - return editor.destroy(); - } ); - } ); } ); describe( 'destroy()', () => { @@ -281,6 +261,147 @@ describe( 'DecoupledEditorUI', () => { } ); } ); +describe( 'Focus handling and navigation between editing root and editor toolbar', () => { + let editorElement, editor, ui, toolbarView, domRoot; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.body.appendChild( document.createElement( 'div' ) ); + + editor = await DecoupledEditor.create( editorElement, { + plugins: [ Paragraph, Image, ImageToolbar, ImageCaption ], + toolbar: [ 'imageTextAlternative' ], + image: { + toolbar: [ 'toggleImageCaption' ] + } + } ); + + domRoot = editor.editing.view.domRoots.get( 'main' ); + + ui = editor.ui; + toolbarView = ui.view.toolbar; + + document.body.appendChild( toolbarView.element ); + } ); + + afterEach( () => { + editorElement.remove(); + toolbarView.element.remove(); + + return editor.destroy(); + } ); + + describe( 'Focusing toolbars on Alt+F10 key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should focus the main toolbar when the focus is in the editing root', () => { + const spy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + + pressAltF10(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should do nothing if the toolbar is already focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + // Try Alt+F10 again. + pressAltF10(); + + sinon.assert.calledOnce( toolbarFocusSpy ); + sinon.assert.notCalled( domRootFocusSpy ); + } ); + + it( 'should prioritize widget toolbar over the global toolbar', () => { + const widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + const imageToolbar = widgetToolbarRepository._toolbarDefinitions.get( 'image' ).view; + + const toolbarSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + const imageToolbarSpy = testUtils.sinon.spy( imageToolbar, 'focus' ); + + setModelData( editor.model, + 'foo' + + '[bar]' + + 'baz' + ); + + // Focus the image balloon toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = imageToolbar.element; + + sinon.assert.calledOnce( imageToolbarSpy ); + sinon.assert.notCalled( toolbarSpy ); + } ); + } ); + + describe( 'Restoring focus on Esc key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should move the focus back from the main toolbar to the editing root', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + pressEsc(); + + sinon.assert.callOrder( toolbarFocusSpy, domRootFocusSpy ); + } ); + + it( 'should do nothing if it was pressed when no toolbar was focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + pressEsc(); + + sinon.assert.notCalled( domRootFocusSpy ); + sinon.assert.notCalled( toolbarFocusSpy ); + } ); + } ); + + function pressAltF10() { + editor.keystrokes.press( { + keyCode: keyCodes.f10, + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + + function pressEsc() { + editor.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } +} ); + function viewCreator( name ) { return locale => { const view = new View( locale ); diff --git a/packages/ckeditor5-editor-inline/package.json b/packages/ckeditor5-editor-inline/package.json index f8636d691d8..2877a736d74 100644 --- a/packages/ckeditor5-editor-inline/package.json +++ b/packages/ckeditor5-editor-inline/package.json @@ -21,6 +21,7 @@ "@ckeditor/ckeditor5-engine": "^35.0.1", "@ckeditor/ckeditor5-enter": "^35.0.1", "@ckeditor/ckeditor5-heading": "^35.0.1", + "@ckeditor/ckeditor5-image": "^35.0.1", "@ckeditor/ckeditor5-paragraph": "^35.0.1", "@ckeditor/ckeditor5-theme-lark": "^35.0.1", "@ckeditor/ckeditor5-typing": "^35.0.1", diff --git a/packages/ckeditor5-editor-inline/src/inlineeditorui.js b/packages/ckeditor5-editor-inline/src/inlineeditorui.js index 55867920342..fe41ba51422 100644 --- a/packages/ckeditor5-editor-inline/src/inlineeditorui.js +++ b/packages/ckeditor5-editor-inline/src/inlineeditorui.js @@ -8,7 +8,7 @@ */ import { EditorUI } from 'ckeditor5/src/core'; -import { enableToolbarKeyboardFocus, normalizeToolbarConfig } from 'ckeditor5/src/ui'; +import { normalizeToolbarConfig } from 'ckeditor5/src/ui'; import { enablePlaceholder } from 'ckeditor5/src/engine'; /** @@ -74,11 +74,6 @@ export default class InlineEditorUI extends EditorUI { // editable areas (roots) but the inline editor has only one. this.setEditableElement( editable.name, editableElement ); - // Let the global focus tracker know that the editable UI element is focusable and - // belongs to the editor. From now on, the focus tracker will sustain the editor focus - // as long as the editable is focused (e.g. the user is typing). - this.focusTracker.add( editableElement ); - // Let the editable UI element respond to the changes in the global editor focus // tracker. It has been added to the same tracker a few lines above but, in reality, there are // many focusable areas in the editor, like balloons, toolbars or dropdowns and as long @@ -119,7 +114,6 @@ export default class InlineEditorUI extends EditorUI { const editor = this.editor; const view = this.view; const editableElement = view.editable.element; - const editingView = editor.editing.view; const toolbar = view.toolbar; // Set–up the view#panel. @@ -141,12 +135,8 @@ export default class InlineEditorUI extends EditorUI { toolbar.fillFromConfig( this._toolbarConfig, this.componentFactory ); - enableToolbarKeyboardFocus( { - origin: editingView, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar - } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + this.addToolbar( toolbar ); } /** diff --git a/packages/ckeditor5-editor-inline/tests/inlineeditorui.js b/packages/ckeditor5-editor-inline/tests/inlineeditorui.js index 2ba4ff3bd8f..23a4b92baf5 100644 --- a/packages/ckeditor5-editor-inline/tests/inlineeditorui.js +++ b/packages/ckeditor5-editor-inline/tests/inlineeditorui.js @@ -10,13 +10,16 @@ import View from '@ckeditor/ckeditor5-ui/src/view'; import InlineEditorUI from '../src/inlineeditorui'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; import InlineEditorUIView from '../src/inlineeditoruiview'; +import InlineEditor from '../src/inlineeditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import { Image, ImageCaption, ImageToolbar } from '@ckeditor/ckeditor5-image'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { assertBinding } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; import { isElement } from 'lodash-es'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; describe( 'InlineEditorUI', () => { let editor, view, ui, viewElement; @@ -279,29 +282,6 @@ describe( 'InlineEditorUI', () => { } ); } ); } ); - - it( 'initializes keyboard navigation between view#toolbar and view#editable', () => { - return VirtualInlineTestEditor.create( '' ) - .then( editor => { - const ui = editor.ui; - const view = ui.view; - const spy = testUtils.sinon.spy( view.toolbar, 'focus' ); - - ui.focusTracker.isFocused = true; - ui.view.toolbar.focusTracker.isFocused = false; - - editor.keystrokes.press( { - keyCode: keyCodes.f10, - altKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - } ); - - sinon.assert.calledOnce( spy ); - - return editor.destroy(); - } ); - } ); } ); describe( 'destroy()', () => { @@ -366,6 +346,144 @@ describe( 'InlineEditorUI', () => { } ); } ); +describe( 'Focus handling and navigation between editing root and editor toolbar', () => { + let editorElement, editor, ui, toolbarView, domRoot; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.body.appendChild( document.createElement( 'div' ) ); + + editor = await InlineEditor.create( editorElement, { + plugins: [ Paragraph, Image, ImageToolbar, ImageCaption ], + toolbar: [ 'imageTextAlternative' ], + image: { + toolbar: [ 'toggleImageCaption' ] + } + } ); + + domRoot = editor.editing.view.domRoots.get( 'main' ); + + ui = editor.ui; + toolbarView = ui.view.toolbar; + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + describe( 'Focusing toolbars on Alt+F10 key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should focus the main toolbar when the focus is in the editing root', () => { + const spy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + + pressAltF10(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should do nothing if the toolbar is already focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + // Try Alt+F10 again. + pressAltF10(); + + sinon.assert.calledOnce( toolbarFocusSpy ); + sinon.assert.notCalled( domRootFocusSpy ); + } ); + + it( 'should prioritize widget toolbar over the global toolbar', () => { + const widgetToolbarRepository = editor.plugins.get( 'WidgetToolbarRepository' ); + const imageToolbar = widgetToolbarRepository._toolbarDefinitions.get( 'image' ).view; + + const toolbarSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + const imageToolbarSpy = testUtils.sinon.spy( imageToolbar, 'focus' ); + + setModelData( editor.model, + 'foo' + + '[bar]' + + 'baz' + ); + + // Focus the image balloon toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = imageToolbar.element; + + sinon.assert.calledOnce( imageToolbarSpy ); + sinon.assert.notCalled( toolbarSpy ); + } ); + } ); + + describe( 'Restoring focus on Esc key press', () => { + beforeEach( () => { + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + it( 'should move the focus back from the main toolbar to the editing root', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + pressEsc(); + + sinon.assert.callOrder( toolbarFocusSpy, domRootFocusSpy ); + } ); + + it( 'should do nothing if it was pressed when no toolbar was focused', () => { + const domRootFocusSpy = testUtils.sinon.spy( domRoot, 'focus' ); + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + + setModelData( editor.model, 'foo[]' ); + + pressEsc(); + + sinon.assert.notCalled( domRootFocusSpy ); + sinon.assert.notCalled( toolbarFocusSpy ); + } ); + } ); + + function pressAltF10() { + editor.keystrokes.press( { + keyCode: keyCodes.f10, + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + + function pressEsc() { + editor.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } +} ); + function viewCreator( name ) { return locale => { const view = new View( locale ); diff --git a/packages/ckeditor5-find-and-replace/tests/manual/multiroot.js b/packages/ckeditor5-find-and-replace/tests/manual/multiroot.js index d97b4a95ce0..c06ba8e27f7 100644 --- a/packages/ckeditor5-find-and-replace/tests/manual/multiroot.js +++ b/packages/ckeditor5-find-and-replace/tests/manual/multiroot.js @@ -11,7 +11,6 @@ import getDataFromElement from '@ckeditor/ckeditor5-utils/src/dom/getdatafromele import setDataInElement from '@ckeditor/ckeditor5-utils/src/dom/setdatainelement'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; -import enableToolbarKeyboardFocus from '@ckeditor/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus'; import EditorUIView from '@ckeditor/ckeditor5-ui/src/editorui/editoruiview'; import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview'; import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview'; @@ -117,7 +116,6 @@ class MultirootEditorUI extends EditorUI { const editableElement = editable.element; this.setEditableElement( editable.name, editableElement ); - this.focusTracker.add( editableElement ); editable.bind( 'isFocused' ).to( this.focusTracker, 'isFocused', this.focusTracker, 'focusedElement', ( isFocused, focusedElement ) => { @@ -159,12 +157,8 @@ class MultirootEditorUI extends EditorUI { toolbar.fillFromConfig( editor.config.get( 'toolbar' ), this.componentFactory ); - enableToolbarKeyboardFocus( { - origin: editor.editing.view, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar - } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + this.addToolbar( view.toolbar ); } } diff --git a/packages/ckeditor5-image/tests/integration.js b/packages/ckeditor5-image/tests/integration.js index bd86bed1d1c..112f60d41e2 100644 --- a/packages/ckeditor5-image/tests/integration.js +++ b/packages/ckeditor5-image/tests/integration.js @@ -70,7 +70,7 @@ describe( 'ImageToolbar integration', () => { balloonToolbar.show(); // BalloonToolbar should not be visible. - expect( balloon.visibleView ).to.be.null; + expect( balloon.visibleView.ariaLabel ).to.equal( 'Image toolbar' ); } ); it( 'should listen to BalloonToolbar#show event with the high priority', () => { diff --git a/packages/ckeditor5-source-editing/src/sourceediting.js b/packages/ckeditor5-source-editing/src/sourceediting.js index 8ee502dd07b..c9ff82b29ab 100644 --- a/packages/ckeditor5-source-editing/src/sourceediting.js +++ b/packages/ckeditor5-source-editing/src/sourceediting.js @@ -252,6 +252,9 @@ export default class SourceEditing extends Plugin { writer.addClass( 'ck-hidden', viewRoot ); } ); + // Register the element so it becomes available for Alt+F10 and Esc navigation. + editor.ui.setEditableElement( 'sourceEditing:' + rootName, domSourceEditingElementTextarea ); + this._replacedRoots.set( rootName, domSourceEditingElementWrapper ); this._elementReplacer.replace( domRootElement, domSourceEditingElementWrapper ); @@ -318,10 +321,16 @@ export default class SourceEditing extends Plugin { * @private */ _focusSourceEditing() { + const editor = this.editor; const [ domSourceEditingElementWrapper ] = this._replacedRoots.values(); - const textarea = domSourceEditingElementWrapper.querySelector( 'textarea' ); + // The FocusObserver was disabled by View.render() while the DOM root was getting hidden and the replacer + // revealed the textarea. So it couldn't notice that the DOM root got blurred in the process. + // Let's sync this state manually here because otherwise Renderer will attempt to render selection + // in an invisible DOM root. + editor.editing.view.document.isFocused = false; + textarea.focus(); } diff --git a/packages/ckeditor5-source-editing/tests/sourceediting.js b/packages/ckeditor5-source-editing/tests/sourceediting.js index 01bcc21c4cb..1836dac0580 100644 --- a/packages/ckeditor5-source-editing/tests/sourceediting.js +++ b/packages/ckeditor5-source-editing/tests/sourceediting.js @@ -5,20 +5,24 @@ /* globals document, Event, console */ -import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import SourceEditing from '../src/sourceediting'; + import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview'; import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions'; -import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import { _getEmitterListenedTo, _getEmitterId } from '@ckeditor/ckeditor5-utils/src/emittermixin'; -import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import Markdown from '@ckeditor/ckeditor5-markdown-gfm/src/markdown'; import Heading from '@ckeditor/ckeditor5-heading/src/heading'; -import SourceEditing from '../src/sourceediting'; +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { _getEmitterListenedTo, _getEmitterId } from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; describe( 'SourceEditing', () => { let editor, editorElement, plugin, button; @@ -292,6 +296,12 @@ describe( 'SourceEditing', () => { ); } ); + it( 'should register a textarea in EditorUI when first shown', () => { + button.fire( 'execute' ); + + expect( [ ...editor.ui.getEditableElementsNames() ] ).to.include.members( [ 'sourceEditing:main' ] ); + } ); + it( 'should add an event listener in textarea on input which updates data property in the wrapper', () => { button.fire( 'execute' ); @@ -597,3 +607,101 @@ describe( 'SourceEditing - integration with Markdown', () => { expect( textarea.value ).to.equal( '\\Foo\\' ); } ); } ); + +describe( 'Focus handling and navigation between source editing and editor toolbar', () => { + let editorElement, editor, ui, toolbarView, domRoot, sourceEditingButton; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.body.appendChild( document.createElement( 'div' ) ); + + editor = await ClassicEditor.create( editorElement, { + plugins: [ Paragraph, Heading, SourceEditing ], + toolbar: [ 'heading' ] + } ); + + domRoot = editor.editing.view.domRoots.get( 'main' ); + + ui = editor.ui; + toolbarView = ui.view.toolbar; + sourceEditingButton = ui.componentFactory.create( 'sourceEditing' ); + + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot; + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should focus the source editing textarea when entering the source mode', () => { + sourceEditingButton.fire( 'execute' ); + + expect( editor.ui.focusTracker.isFocused ).to.be.true; + expect( document.activeElement ).to.equal( domRoot.nextSibling.children[ 0 ] ); + expect( editor.editing.view.document.isFocused ).to.be.false; + } ); + + it( 'should focus the editing root when leaving the source mode', () => { + const viewFocusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); + + sourceEditingButton.fire( 'execute' ); + + ui.focusTracker.focusedElement = domRoot.nextSibling.children[ 0 ]; + + sourceEditingButton.fire( 'execute' ); + + expect( editor.ui.focusTracker.isFocused ).to.be.true; + sinon.assert.calledOnce( viewFocusSpy ); + } ); + + it( 'Alt+F10 should focus the main toolbar when the focus is in the editing root', () => { + const spy = testUtils.sinon.spy( toolbarView, 'focus' ); + + sourceEditingButton.fire( 'execute' ); + + ui.focusTracker.isFocused = true; + ui.focusTracker.focusedElement = domRoot.nextSibling.children[ 0 ]; + + pressAltF10(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'Esc should move the focus back from the main toolbar to the source editing', () => { + sourceEditingButton.fire( 'execute' ); + + ui.focusTracker.focusedElement = domRoot.nextSibling.children[ 0 ]; + + const toolbarFocusSpy = testUtils.sinon.spy( toolbarView, 'focus' ); + const sourceEditingTextareaFocusSpy = testUtils.sinon.spy( domRoot.nextSibling.children[ 0 ], 'focus' ); + + // Focus the toolbar. + pressAltF10(); + ui.focusTracker.focusedElement = toolbarView.element; + + pressEsc(); + + sinon.assert.callOrder( toolbarFocusSpy, sourceEditingTextareaFocusSpy ); + } ); + + function pressAltF10() { + editor.keystrokes.press( { + keyCode: keyCodes.f10, + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } + + function pressEsc() { + editor.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + } +} ); diff --git a/packages/ckeditor5-ui/docs/_snippets/examples/bootstrap-ui-inner.js b/packages/ckeditor5-ui/docs/_snippets/examples/bootstrap-ui-inner.js index ad759588dd5..45ccb50b008 100644 --- a/packages/ckeditor5-ui/docs/_snippets/examples/bootstrap-ui-inner.js +++ b/packages/ckeditor5-ui/docs/_snippets/examples/bootstrap-ui-inner.js @@ -156,9 +156,6 @@ class BootstrapEditorUI extends EditorUI { // Register editable element so it is available via getEditableElement() method. this.setEditableElement( view.editable.name, editableElement ); - // Let the editable UI element respond to the changes in the global editor focus tracker - // and let the focus tracker know about the editable element. - this.focusTracker.add( editableElement ); view.editable.bind( 'isFocused' ).to( this.focusTracker ); // Bind the editable UI element to the editing view, making it an end– and entry–point diff --git a/packages/ckeditor5-ui/lang/contexts.json b/packages/ckeditor5-ui/lang/contexts.json index 8d445ab594e..782d26eca9f 100644 --- a/packages/ckeditor5-ui/lang/contexts.json +++ b/packages/ckeditor5-ui/lang/contexts.json @@ -20,5 +20,7 @@ "Turquoise": "Label of a button that applies a turquoise color in color pickers.", "Light blue": "Label of a button that applies a light blue color in color pickers.", "Blue": "Label of a button that applies a blue color in color pickers.", - "Purple": "Label of a button that applies a purple color in color pickers." + "Purple": "Label of a button that applies a purple color in color pickers.", + "Editor block content toolbar": "Accessible label of a toolbar that shows up next to the blocks of content (e.g. headings, paragraphs).", + "Editor contextual toolbar": "Accessible label of a balloon toolbar that shows up right next to the user selection (the caret)." } diff --git a/packages/ckeditor5-ui/src/index.js b/packages/ckeditor5-ui/src/index.js index 560e17993c6..338f1b74075 100644 --- a/packages/ckeditor5-ui/src/index.js +++ b/packages/ckeditor5-ui/src/index.js @@ -58,7 +58,6 @@ export { default as Template } from './template'; export { default as ToolbarView } from './toolbar/toolbarview'; export { default as ToolbarSeparatorView } from './toolbar/toolbarseparatorview'; -export { default as enableToolbarKeyboardFocus } from './toolbar/enabletoolbarkeyboardfocus'; export { default as normalizeToolbarConfig } from './toolbar/normalizetoolbarconfig'; export { default as BalloonToolbar } from './toolbar/balloon/balloontoolbar'; export { default as BlockToolbar } from './toolbar/block/blocktoolbar'; diff --git a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js index 0883d5f0c54..af10fd63532 100644 --- a/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js +++ b/packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js @@ -79,6 +79,13 @@ export default class BalloonToolbar extends Plugin { this.focusTracker.add( this.toolbarView.element ); } ); + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + editor.ui.addToolbar( this.toolbarView, { + beforeFocus: () => this.show( true ), + afterBlur: () => this.hide(), + isContextual: true + } ); + /** * An instance of the resize observer that allows to respond to changes in editable's geometry * so the toolbar can stay within its boundaries (and group toolbar items that do not fit). @@ -196,12 +203,14 @@ export default class BalloonToolbar extends Plugin { * @returns {module:ui/toolbar/toolbarview~ToolbarView} */ _createToolbarView() { + const t = this.editor.locale.t; const shouldGroupWhenFull = !this._balloonConfig.shouldNotGroupWhenFull; const toolbarView = new ToolbarView( this.editor.locale, { shouldGroupWhenFull, isFloating: true } ); + toolbarView.ariaLabel = t( 'Editor contextual toolbar' ); toolbarView.render(); return toolbarView; @@ -211,8 +220,11 @@ export default class BalloonToolbar extends Plugin { * Shows the toolbar and attaches it to the selection. * * Fires {@link #event:show} event which can be stopped to prevent the toolbar from showing up. + * + * @param {Boolean} [showForCollapsedSelection=false] When set `true`, the toolbar will show despite collapsed selection in the + * editing view. */ - show() { + show( showForCollapsedSelection = false ) { const editor = this.editor; const selection = editor.model.document.selection; const schema = editor.model.schema; @@ -223,7 +235,7 @@ export default class BalloonToolbar extends Plugin { } // Do not show the toolbar when the selection is collapsed. - if ( selection.isCollapsed ) { + if ( selection.isCollapsed && !showForCollapsedSelection ) { return; } diff --git a/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js b/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js index ecc077b3d9e..19996279de1 100644 --- a/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js +++ b/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js @@ -166,6 +166,12 @@ export default class BlockToolbar extends Plugin { this._hidePanel(); } } ); + + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + editor.ui.addToolbar( this.toolbarView, { + beforeFocus: () => this._showPanel(), + afterBlur: () => this._hidePanel() + } ); } /** @@ -221,12 +227,15 @@ export default class BlockToolbar extends Plugin { * @returns {module:ui/toolbar/toolbarview~ToolbarView} */ _createToolbarView() { + const t = this.editor.locale.t; const shouldGroupWhenFull = !this._blockToolbarConfig.shouldNotGroupWhenFull; const toolbarView = new ToolbarView( this.editor.locale, { shouldGroupWhenFull, isFloating: true } ); + toolbarView.ariaLabel = t( 'Editor block content toolbar' ); + // When toolbar lost focus then panel should hide. toolbarView.focusTracker.on( 'change:isFocused', ( evt, name, is ) => { if ( !is ) { @@ -363,6 +372,14 @@ export default class BlockToolbar extends Plugin { * @private */ _showPanel() { + // Usually, the only way to show the toolbar is by pressing the block button. It makes it impossible for + // the toolbar to show up when the button is invisible (feature does not make sense for the selection then). + // The toolbar navigation using Alt+F10 does not access the button but shows the panel directly using this method. + // So we need to check whether this is possible first. + if ( !this.buttonView.isVisible ) { + return; + } + const wasVisible = this.panelView.isVisible; // So here's the thing: If there was no initial panelView#show() or these two were in different order, the toolbar diff --git a/packages/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus.js b/packages/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus.js deleted file mode 100644 index 6013d1fd081..00000000000 --- a/packages/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module ui/toolbar/enabletoolbarkeyboardfocus - */ - -/** - * Enables focus/blur toolbar navigation using `Alt+F10` and `Esc` keystrokes. - * - * @param {Object} options Options of the utility. - * @param {*} options.origin A view to which the focus will return when `Esc` is pressed and - * `options.toolbar` is focused. - * @param {module:utils/keystrokehandler~KeystrokeHandler} options.originKeystrokeHandler A keystroke - * handler to register `Alt+F10` keystroke. - * @param {module:utils/focustracker~FocusTracker} options.originFocusTracker A focus tracker - * for `options.origin`. - * @param {module:ui/toolbar/toolbarview~ToolbarView} options.toolbar A toolbar which is to gain - * focus when `Alt+F10` is pressed. - * @param {Function} [options.beforeFocus] A callback executed before the `options.toolbar` gains focus - * upon the `Alt+F10` keystroke. - * @param {Function} [options.afterBlur] A callback executed after `options.toolbar` loses focus upon - * `Esc` keystroke but before the focus goes back to `options.origin`. - */ -export default function enableToolbarKeyboardFocus( { - origin, - originKeystrokeHandler, - originFocusTracker, - toolbar, - beforeFocus, - afterBlur -} ) { - // Because toolbar items can get focus, the overall state of the toolbar must - // also be tracked. - originFocusTracker.add( toolbar.element ); - - // Focus the toolbar on the keystroke, if not already focused. - originKeystrokeHandler.set( 'Alt+F10', ( data, cancel ) => { - if ( originFocusTracker.isFocused && !toolbar.focusTracker.isFocused ) { - if ( beforeFocus ) { - beforeFocus(); - } - - toolbar.focus(); - - cancel(); - } - } ); - - // Blur the toolbar and bring the focus back to origin. - toolbar.keystrokes.set( 'Esc', ( data, cancel ) => { - if ( toolbar.focusTracker.isFocused ) { - origin.focus(); - - if ( afterBlur ) { - afterBlur(); - } - - cancel(); - } - } ); -} diff --git a/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js b/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js index abfa957efe5..e614877e66e 100644 --- a/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js +++ b/packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js @@ -19,6 +19,7 @@ import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver'; import env from '@ckeditor/ckeditor5-utils/src/env'; +import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { stringify as viewStringify } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; @@ -34,7 +35,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; describe( 'BalloonToolbar', () => { let editor, model, selection, editingView, balloonToolbar, balloon, editorElement; - let resizeCallback; + let resizeCallback, addToolbarSpy; testUtils.createSinonSandbox(); @@ -56,6 +57,8 @@ describe( 'BalloonToolbar', () => { }; } ); + addToolbarSpy = sinon.spy( EditorUI.prototype, 'addToolbar' ); + return ClassicTestEditor .create( editorElement, { plugins: [ Paragraph, Bold, Italic, BalloonToolbar, HorizontalLine, TableEditing ], @@ -184,6 +187,29 @@ describe( 'BalloonToolbar', () => { expect( balloonToolbar.toolbarView.options.isFloating ).to.be.true; } ); + it( 'should have the accessible label', () => { + expect( balloonToolbar.toolbarView.ariaLabel ).to.equal( 'Editor contextual toolbar' ); + } ); + + it( 'should register its toolbar as focusable toolbar in EditorUI with proper configuration responsible for presentation', () => { + const showPanelSpy = sinon.spy( balloonToolbar, 'show' ); + const hidePanelSpy = sinon.spy( balloonToolbar, 'hide' ); + + sinon.assert.calledWithExactly( addToolbarSpy.lastCall, balloonToolbar.toolbarView, sinon.match( { + beforeFocus: sinon.match.func, + afterBlur: sinon.match.func, + isContextual: true + } ) ); + + addToolbarSpy.lastCall.args[ 1 ].beforeFocus(); + + sinon.assert.calledOnceWithExactly( showPanelSpy, true ); + + addToolbarSpy.lastCall.args[ 1 ].afterBlur(); + + sinon.assert.calledOnce( hidePanelSpy ); + } ); + describe( 'pluginName', () => { it( 'should return plugin by its name', () => { expect( editor.plugins.get( 'BalloonToolbar' ) ).to.equal( balloonToolbar ); @@ -393,6 +419,13 @@ describe( 'BalloonToolbar', () => { sinon.assert.notCalled( balloonAddSpy ); } ); + it( 'should display the toolbar for a focused selection when called with an argument', () => { + setData( model, 'b[]ar' ); + + balloonToolbar.show( true ); + sinon.assert.calledOnce( balloonAddSpy ); + } ); + // https://github.com/ckeditor/ckeditor5/issues/6443 it( 'should not add the #toolbarView to the #_balloon when the selection contains more than one fully contained object', () => { setData( model, '[]foo[]' ); diff --git a/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js b/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js index 4996e435bb4..77d7896100a 100644 --- a/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js +++ b/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js @@ -21,6 +21,7 @@ import Image from '@ckeditor/ckeditor5-image/src/image'; import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver'; +import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -30,7 +31,7 @@ import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; describe( 'BlockToolbar', () => { let editor, element, blockToolbar; - let resizeCallback; + let resizeCallback, addToolbarSpy; testUtils.createSinonSandbox(); @@ -52,6 +53,8 @@ describe( 'BlockToolbar', () => { }; } ); + addToolbarSpy = sinon.spy( EditorUI.prototype, 'addToolbar' ); + return ClassicTestEditor.create( element, { plugins: [ BlockToolbar, Heading, HeadingButtonsUI, Paragraph, ParagraphButtonUI, BlockQuote, Image, ImageCaption ], blockToolbar: [ 'paragraph', 'heading1', 'heading2', 'blockQuote' ] @@ -124,6 +127,37 @@ describe( 'BlockToolbar', () => { expect( blockToolbar.toolbarView.options.isFloating ).to.be.true; } ); + it( 'should have an accessible ARIA label set on the toolbar', () => { + expect( blockToolbar.toolbarView.ariaLabel ).to.equal( 'Editor block content toolbar' ); + } ); + + it( 'should register its toolbar as focusable toolbar in EditorUI with proper configuration responsible for presentation', () => { + sinon.assert.calledWithExactly( addToolbarSpy.lastCall, blockToolbar.toolbarView, sinon.match( { + beforeFocus: sinon.match.func, + afterBlur: sinon.match.func + } ) ); + + addToolbarSpy.lastCall.args[ 1 ].beforeFocus(); + + expect( blockToolbar.panelView.isVisible ).to.be.true; + + addToolbarSpy.lastCall.args[ 1 ].afterBlur(); + + expect( blockToolbar.panelView.isVisible ).to.be.false; + } ); + + it( 'should not show the panel on Alt+F10 when the button is invisible', () => { + // E.g. due to the toolbar not making sense for a selection. + blockToolbar.buttonView.isVisible = false; + addToolbarSpy.lastCall.args[ 1 ].beforeFocus(); + + expect( blockToolbar.panelView.isVisible ).to.be.false; + + blockToolbar.buttonView.isVisible = true; + addToolbarSpy.lastCall.args[ 1 ].beforeFocus(); + expect( blockToolbar.panelView.isVisible ).to.be.true; + } ); + describe( 'child views', () => { describe( 'panelView', () => { it( 'should create a view instance', () => { diff --git a/packages/ckeditor5-ui/tests/toolbar/enabletoolbarkeyboardfocus.js b/packages/ckeditor5-ui/tests/toolbar/enabletoolbarkeyboardfocus.js deleted file mode 100644 index 58a50bfd0cc..00000000000 --- a/packages/ckeditor5-ui/tests/toolbar/enabletoolbarkeyboardfocus.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/* global document */ - -import View from '../../src/view'; -import ToolbarView from '../../src/toolbar/toolbarview'; -import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; -import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import enableToolbarKeyboardFocus from '../../src/toolbar/enabletoolbarkeyboardfocus'; - -describe( 'enableToolbarKeyboardFocus()', () => { - let origin, originFocusTracker, originKeystrokeHandler, toolbar; - - beforeEach( () => { - origin = viewCreator(); - originFocusTracker = new FocusTracker(); - originKeystrokeHandler = new KeystrokeHandler(); - toolbar = new ToolbarView( { t: langString => langString } ); - - toolbar.render(); - - enableToolbarKeyboardFocus( { - origin, - originFocusTracker, - originKeystrokeHandler, - toolbar - } ); - } ); - - it( 'focuses the toolbar on Alt+F10', () => { - const spy = sinon.spy( toolbar, 'focus' ); - const toolbarFocusTracker = toolbar.focusTracker; - const keyEvtData = { - keyCode: keyCodes.f10, - altKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - toolbarFocusTracker.isFocused = false; - originFocusTracker.isFocused = false; - - originKeystrokeHandler.press( keyEvtData ); - sinon.assert.notCalled( spy ); - - toolbarFocusTracker.isFocused = true; - originFocusTracker.isFocused = true; - - originKeystrokeHandler.press( keyEvtData ); - sinon.assert.notCalled( spy ); - - toolbarFocusTracker.isFocused = false; - originFocusTracker.isFocused = true; - - originKeystrokeHandler.press( keyEvtData ); - sinon.assert.calledOnce( spy ); - - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - } ); - - it( 're–foucuses origin on Esc', () => { - const spy = origin.focus = sinon.spy(); - const toolbarFocusTracker = toolbar.focusTracker; - const keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - toolbarFocusTracker.isFocused = false; - - toolbar.keystrokes.press( keyEvtData ); - sinon.assert.notCalled( spy ); - - toolbarFocusTracker.isFocused = true; - - toolbar.keystrokes.press( keyEvtData ); - - sinon.assert.calledOnce( spy ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - } ); - - it( 'supports beforeFocus and afterBlur callbacks', () => { - const beforeFocus = sinon.spy(); - const afterBlur = sinon.spy(); - - origin = viewCreator(); - originFocusTracker = new FocusTracker(); - originKeystrokeHandler = new KeystrokeHandler(); - toolbar = new ToolbarView( { t: langString => langString } ); - - const toolbarFocusSpy = sinon.spy( toolbar, 'focus' ); - const originFocusSpy = origin.focus = sinon.spy(); - const toolbarFocusTracker = toolbar.focusTracker; - - toolbar.render(); - - enableToolbarKeyboardFocus( { - origin, - originFocusTracker, - originKeystrokeHandler, - toolbar, - beforeFocus, - afterBlur - } ); - - let keyEvtData = { - keyCode: keyCodes.f10, - altKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - toolbarFocusTracker.isFocused = false; - originFocusTracker.isFocused = true; - - originKeystrokeHandler.press( keyEvtData ); - sinon.assert.callOrder( beforeFocus, toolbarFocusSpy ); - - keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - toolbarFocusTracker.isFocused = true; - - toolbar.keystrokes.press( keyEvtData ); - sinon.assert.callOrder( originFocusSpy, afterBlur ); - } ); -} ); - -function viewCreator( name ) { - return locale => { - const view = new View( locale ); - - view.name = name; - view.element = document.createElement( 'a' ); - - return view; - }; -} diff --git a/packages/ckeditor5-widget/src/widgettoolbarrepository.js b/packages/ckeditor5-widget/src/widgettoolbarrepository.js index 86f9c858324..1291574614e 100644 --- a/packages/ckeditor5-widget/src/widgettoolbarrepository.js +++ b/packages/ckeditor5-widget/src/widgettoolbarrepository.js @@ -160,11 +160,28 @@ export default class WidgetToolbarRepository extends Plugin { toolbarView.fillFromConfig( items, editor.ui.componentFactory ); - this._toolbarDefinitions.set( toolbarId, { + const toolbarDefinition = { view: toolbarView, getRelatedElement, balloonClassName + }; + + // Register the toolbar so it becomes available for Alt+F10 and Esc navigation. + editor.ui.addToolbar( toolbarView, { + isContextual: true, + beforeFocus: () => { + const relatedElement = getRelatedElement( editor.editing.view.document.selection ); + + if ( relatedElement ) { + this._showToolbar( toolbarDefinition, relatedElement ); + } + }, + afterBlur: () => { + this._hideToolbar( toolbarDefinition ); + } } ); + + this._toolbarDefinitions.set( toolbarId, toolbarDefinition ); } /** diff --git a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js index 5bd73231326..adaa7adb630 100644 --- a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js +++ b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js @@ -17,13 +17,14 @@ import WidgetToolbarRepository from '../src/widgettoolbarrepository'; import { isWidget, toWidget } from '../src/utils'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import View from '@ckeditor/ckeditor5-ui/src/view'; +import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; describe( 'WidgetToolbarRepository', () => { - let editor, model, balloon, widgetToolbarRepository, editorElement; + let editor, model, balloon, widgetToolbarRepository, editorElement, addToolbarSpy; testUtils.createSinonSandbox(); @@ -31,6 +32,8 @@ describe( 'WidgetToolbarRepository', () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); + addToolbarSpy = sinon.spy( EditorUI.prototype, 'addToolbar' ); + return ClassicTestEditor .create( editorElement, { plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget, FakeChildWidget, BlockQuote ], @@ -78,6 +81,61 @@ describe( 'WidgetToolbarRepository', () => { expect( widgetToolbarRepository._toolbarDefinitions.get( 'fake' ) ).to.be.an( 'object' ); } ); + describe( 'Focus handling and navigation across toolbars using keyboard', () => { + it( 'should register the toolbar as focusable toolbar in EditorUI with proper configuration', () => { + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: () => null + } ); + + sinon.assert.calledWithExactly( + addToolbarSpy.lastCall, + widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view, + sinon.match( { + isContextual: true, + beforeFocus: sinon.match.func + } ) + ); + } ); + + it( 'should show the toolbar when Alt+F10 is pressed if there is an element to attach to', () => { + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: () => editor.editing.view.document.getRoot() + } ); + + addToolbarSpy.lastCall.args[ 1 ].beforeFocus(); + + expect( balloon.visibleView ).to.equal( widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view ); + } ); + + it( 'should not show the toolbar when Alt+F10 is pressed if not possible because there is no element to attach to', () => { + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: () => null + } ); + + addToolbarSpy.lastCall.args[ 1 ].beforeFocus(); + + expect( balloon.visibleView ).to.be.null; + } ); + + it( 'should provide the logic to hide the toolbar', () => { + widgetToolbarRepository.register( 'fake', { + items: editor.config.get( 'fake.toolbar' ), + getRelatedElement: () => editor.editing.view.document.getRoot() + } ); + + addToolbarSpy.lastCall.args[ 1 ].beforeFocus(); + + expect( balloon.visibleView ).to.equal( widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view ); + + addToolbarSpy.lastCall.args[ 1 ].afterBlur(); + + expect( balloon.visibleView ).to.be.null; + } ); + } ); + it( 'should throw when adding two times widget with the same id', () => { widgetToolbarRepository.register( 'fake', { items: editor.config.get( 'fake.toolbar' ), diff --git a/tests/manual/all-types.html b/tests/manual/all-types.html new file mode 100644 index 00000000000..430722fdc70 --- /dev/null +++ b/tests/manual/all-types.html @@ -0,0 +1,65 @@ + + + + + + +

Classic

+
+

Inline

+
+

Balloon

+
+

Balloon block

+
+

Document

+
+
+
+
diff --git a/tests/manual/all-types.js b/tests/manual/all-types.js new file mode 100644 index 00000000000..a47c5bbcf9d --- /dev/null +++ b/tests/manual/all-types.js @@ -0,0 +1,94 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document, CKEditorInspector */ + +import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor'; +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import DecoupledEditor from '@ckeditor/ckeditor5-editor-decoupled/src/decouplededitor'; +import InlineEditor from '@ckeditor/ckeditor5-editor-inline/src/inlineeditor'; + +import BlockToolbar from '@ckeditor/ckeditor5-ui/src/toolbar/block/blocktoolbar'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; + +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; + +window.editors = {}; + +function createEditor( EditorConstructor, containerId, extraPlugins = [], afterCreate ) { + const config = { + initialData: document.getElementById( 'fixtures' ).innerHTML, + plugins: [ ArticlePluginSet ], + extraPlugins, + toolbar: [ + '|', + 'heading', + '|', + 'bold', + 'italic', + 'link', + 'bulletedList', + 'numberedList', + '|', + 'outdent', + 'indent', + '|', + 'blockQuote', + 'insertTable', + 'mediaEmbed', + 'undo', + 'redo' + ], + blockToolbar: [ + 'heading', + 'bulletedList', + 'numberedList', + 'blockQuote', + 'insertTable', + 'mediaEmbed', + 'undo', + 'redo' + ], + image: { + toolbar: [ 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', '|', 'imageTextAlternative' ] + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + } + }; + + if ( extraPlugins.includes( SourceEditing ) ) { + config.toolbar.unshift( 'sourceEditing', '|' ); + } + + EditorConstructor + .create( document.querySelector( containerId ), config ) + .then( editor => { + window.editors[ containerId ] = editor; + + if ( afterCreate ) { + afterCreate( editor ); + } + + CKEditorInspector.attach( { [ containerId ]: editor } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); +} + +createEditor( ClassicEditor, '#editor-classic', [ SourceEditing ] ); +createEditor( InlineEditor, '#editor-inline' ); +createEditor( BalloonEditor, '#editor-balloon' ); +createEditor( BalloonEditor, '#editor-balloon-block', [ BlockToolbar ] ); +createEditor( DecoupledEditor, '#editor-document', [], editor => { + const toolbarContainer = document.querySelector( '#editor-document-toolbar' ); + + toolbarContainer.appendChild( editor.ui.view.toolbar.element ); +} ); diff --git a/tests/manual/all-types.md b/tests/manual/all-types.md new file mode 100644 index 00000000000..75276647512 --- /dev/null +++ b/tests/manual/all-types.md @@ -0,0 +1,11 @@ +# All types of editors + +This manual test aggregates various editor types brought by the CKEditor 5 core team: + +* Classic +* Inline +* Balloon +* Balloon block +* Document + +All editors come with the same standard set of features (not all of them, though).