From 53402db7709f4048af623411830d68ebd7251d56 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Fri, 11 Dec 2020 19:44:36 +0100 Subject: [PATCH 01/43] PoC of events bubbling over model document tree. --- .../src/blockquoteediting.js | 29 ++- .../src/codeblockediting.js | 5 +- .../src/controller/editingcontroller.js | 56 +++++ .../model/observer/arrowkeysmodelobserver.js | 39 ++++ .../src/model/observer/modelobserver.js | 211 ++++++++++++++++++ packages/ckeditor5-engine/src/view/filler.js | 2 +- .../ckeditor5-engine/src/view/uielement.js | 2 +- packages/ckeditor5-enter/src/enter.js | 10 +- .../ckeditor5-enter/src/entermodelobserver.js | 24 ++ packages/ckeditor5-enter/src/shiftenter.js | 7 +- packages/ckeditor5-list/src/listediting.js | 19 +- packages/ckeditor5-typing/src/delete.js | 8 +- .../src/deletemodelobserver.js | 24 ++ packages/ckeditor5-widget/src/widget.js | 7 +- .../src/widgettypearound/widgettypearound.js | 32 ++- 15 files changed, 437 insertions(+), 38 deletions(-) create mode 100644 packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js create mode 100644 packages/ckeditor5-engine/src/model/observer/modelobserver.js create mode 100644 packages/ckeditor5-enter/src/entermodelobserver.js create mode 100644 packages/ckeditor5-typing/src/deletemodelobserver.js diff --git a/packages/ckeditor5-block-quote/src/blockquoteediting.js b/packages/ckeditor5-block-quote/src/blockquoteediting.js index bbe4047a280..0d2cf646e16 100644 --- a/packages/ckeditor5-block-quote/src/blockquoteediting.js +++ b/packages/ckeditor5-block-quote/src/blockquoteediting.js @@ -8,9 +8,12 @@ */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; import BlockQuoteCommand from './blockquotecommand'; +import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; /** * The block quote editing. @@ -27,6 +30,13 @@ export default class BlockQuoteEditing extends Plugin { return 'BlockQuoteEditing'; } + /** + * @inheritDoc + */ + static get requires() { + return [ Enter, Delete ]; + } + /** * @inheritDoc */ @@ -104,15 +114,14 @@ export default class BlockQuoteEditing extends Plugin { return false; } ); - const viewDocument = this.editor.editing.view.document; const selection = editor.model.document.selection; const blockQuoteCommand = editor.commands.get( 'blockQuote' ); + const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( 'blockQuote' ); + // Overwrite default Enter key behavior. // If Enter key is pressed with selection collapsed in empty block inside a quote, break the quote. - // - // Priority normal - 10 to override default handler but not list's feature listener. - this.listenTo( viewDocument, 'enter', ( evt, data ) => { + this.listenTo( enterObserver, 'enter', ( evt, data ) => { if ( !selection.isCollapsed || !blockQuoteCommand.value ) { return; } @@ -126,13 +135,13 @@ export default class BlockQuoteEditing extends Plugin { data.preventDefault(); evt.stop(); } - }, { priority: priorities.normal - 10 } ); + } ); + + const deleteObserver = editor.editing.getObserver( DeleteModelObserver ).for( 'blockQuote' ); // Overwrite default Backspace key behavior. // If Backspace key is pressed with selection collapsed in first empty block inside a quote, break the quote. - // - // Priority high + 5 to override widget's feature listener but not list's feature listener. - this.listenTo( viewDocument, 'delete', ( evt, data ) => { + this.listenTo( deleteObserver, 'delete', ( evt, data ) => { if ( data.direction != 'backward' || !selection.isCollapsed || !blockQuoteCommand.value ) { return; } @@ -146,6 +155,6 @@ export default class BlockQuoteEditing extends Plugin { data.preventDefault(); evt.stop(); } - }, { priority: priorities.high + 5 } ); + } ); } } diff --git a/packages/ckeditor5-code-block/src/codeblockediting.js b/packages/ckeditor5-code-block/src/codeblockediting.js index 113999fe284..060319470a9 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.js +++ b/packages/ckeditor5-code-block/src/codeblockediting.js @@ -9,6 +9,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; +import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; import CodeBlockCommand from './codeblockcommand'; import IndentCodeBlockCommand from './indentcodeblockcommand'; import OutdentCodeBlockCommand from './outdentcodeblockcommand'; @@ -203,11 +204,13 @@ export default class CodeBlockEditing extends Plugin { outdent.registerChildCommand( commands.get( 'outdentCodeBlock' ) ); } + const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( 'codeBlock' ); + // Customize the response to the Enter and Shift+Enter // key press when the selection is in the code block. Upon enter key press we can either // leave the block if it's "two enters" in a row or create a new code block line, preserving // previous line's indentation. - this.listenTo( editor.editing.view.document, 'enter', ( evt, data ) => { + this.listenTo( enterObserver, 'enter', ( evt, data ) => { const positionParent = editor.model.document.selection.getLastPosition().parent; if ( !positionParent.is( 'element', 'codeBlock' ) ) { diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.js b/packages/ckeditor5-engine/src/controller/editingcontroller.js index f0d3f8caacd..680fcd86913 100644 --- a/packages/ckeditor5-engine/src/controller/editingcontroller.js +++ b/packages/ckeditor5-engine/src/controller/editingcontroller.js @@ -11,6 +11,7 @@ import RootEditableElement from '../view/rooteditableelement'; import View from '../view/view'; import Mapper from '../conversion/mapper'; import DowncastDispatcher from '../conversion/downcastdispatcher'; +import ArrowKeysModelObserver from '../model/observer/arrowkeysmodelobserver'; import { clearAttributes, convertCollapsedSelection, convertRangeSelection, insertText, remove } from '../conversion/downcasthelpers'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; @@ -69,6 +70,14 @@ export default class EditingController { schema: model.schema } ); + /** + * Map of registered {@link module:engine/model/observer/modelobserver~ModelObserver observers}. + * + * @private + * @type {Map.} + */ + this._observers = new Map(); + const doc = this.model.document; const selection = doc.selection; const markers = this.model.markers; @@ -125,6 +134,9 @@ export default class EditingController { return viewRoot; } ); + // TODO + this.addObserver( ArrowKeysModelObserver ); + // @if CK_DEBUG_ENGINE // initDocumentDumping( this.model.document ); // @if CK_DEBUG_ENGINE // initDocumentDumping( this.view.document ); @@ -136,11 +148,55 @@ export default class EditingController { // @if CK_DEBUG_ENGINE // }, { priority: 'lowest' } ); } + /** + * Creates observer of the given type if not yet created, + * {@link module:engine/model/observer/modelobserver~ModelObserver#enable enables} it and + * {@link module:engine/model/observer/modelobserver~ModelObserver#observe attaches} to provided view document. + * + * Note: Observers are recognized by their constructor (classes). A single observer will be instantiated and used only + * when registered for the first time. This means that features and other components can register a single observer + * multiple times without caring whether it has been already added or not. + * + * @param {Function} ModelObserver The constructor of an model observer to add. + * Should create an instance inheriting from {@link module:engine/model/observer/modelobserver~ModelObserver}. + * @returns {module:engine/model/observer/modelobserver~ModelObserver} Added observer instance. + */ + addObserver( ModelObserver ) { + let observer = this._observers.get( ModelObserver ); + + if ( observer ) { + return observer; + } + + observer = new ModelObserver( this.model ); + + this._observers.set( ModelObserver, observer ); + + observer.observe( this.view.document ); + observer.enable(); + + return observer; + } + + /** + * Returns observer of the given type or `undefined` if such observer has not been added yet. + * + * @param {Function} Observer The constructor of an observer to get. + * @returns {module:engine/model/observer/modelobserver~ModelObserver|undefined} Observer instance or undefined. + */ + getObserver( Observer ) { + return this._observers.get( Observer ); + } + /** * Removes all event listeners attached to the `EditingController`. Destroys all objects created * by `EditingController` that need to be destroyed. */ destroy() { + for ( const observer of this._observers.values() ) { + observer.destroy(); + } + this.view.destroy(); this.stopListening(); } diff --git a/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js b/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js new file mode 100644 index 00000000000..83d1ae4f1d5 --- /dev/null +++ b/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js @@ -0,0 +1,39 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/model/observer/arrowkeysmodelobserver + */ + +import ModelObserver from './modelobserver'; +import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +/** + * Observes... TODO + * + * @extends module:engine/model/observer/modelobserver~ModelObserver + */ +export default class ArrowKeysModelObserver extends ModelObserver { + /** + * @inheritDoc + */ + constructor( model ) { + super( model, 'keydown', 'arrowkeydown' ); + } + + /** + * @inheritDoc + */ + translateViewEvent( data ) { + if ( !isArrowKeyCode( data.keyCode ) ) { + return false; + } + + // TODO provide arrow direction + // TODO maybe event type could be namespaced like arrowkeydown:left ? + + return data; + } +} diff --git a/packages/ckeditor5-engine/src/model/observer/modelobserver.js b/packages/ckeditor5-engine/src/model/observer/modelobserver.js new file mode 100644 index 00000000000..effb6a4038c --- /dev/null +++ b/packages/ckeditor5-engine/src/model/observer/modelobserver.js @@ -0,0 +1,211 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/model/observer/modelobserver + */ + +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import mix from '@ckeditor/ckeditor5-utils/src/mix'; + +/** + * Abstract base observer class. Observers are classes which listen to view events, do the preliminary + * processing and fire events on ... TODO + * + * @abstract + */ +export default class ModelObserver { + /** + * Creates an instance of the observer. + * + * @param {module:engine/model/model~Model} model The model. + * @param {String} viewEventType Type of the view event the observer should listen to. + * @param {String} modelEventType Type of the model event the observer should fire. + */ + constructor( model, viewEventType, modelEventType ) { + /** + * An instance of the model. + * + * @readonly + * @member {module:engine/model/model~Model} + */ + this.model = model; + + /** + * State of the observer. If it is disabled no events will be fired. + * + * @readonly + * @member {Boolean} + */ + this.isEnabled = false; + + /** + * Type of the view event the observer should listen to. + * + * @readonly + * @member {String} + */ + this.viewEventType = viewEventType; + + /** + * Type of the model event the observer should fire. + * + * @readonly + * @member {String} + */ + this.modelEventType = modelEventType || viewEventType; + + /** + * TODO + * + * @private + * @member {Map.} + */ + this._elementMap = new Map(); + } + + /** + * Enables the observer. This method is called when the observer is registered to the + * {@link module:engine/model/model~Model}. + * + * @see module:engine/model/observer/modelobserver~Observer#disable + */ + enable() { + this.isEnabled = true; + } + + /** + * Disables the observer. + * + * @see module:engine/model/observer/modelobserver~Observer#enable + */ + disable() { + this.isEnabled = false; + } + + /** + * Disables and destroys the observer, among others removes event listeners created by the observer. + */ + destroy() { + for ( const listener of this._elementMap.values() ) { + listener.stopListening(); + } + + this.disable(); + this.stopListening(); + } + + /** + * Starts observing the given view document object. + * + * @param {module:engine/view/document~Document} viewDocument + */ + observe( viewDocument ) { + const schema = this.model.schema; + const selection = this.model.document.selection; + + this.listenTo( viewDocument, this.viewEventType, ( event, ...args ) => { + if ( !this.isEnabled ) { + return; + } + + let eventArgs = this.translateViewEvent( ...args ); + + if ( eventArgs === false ) { + return; + } + + if ( !Array.isArray( eventArgs ) ) { + eventArgs = [ eventArgs ]; + } + + const eventInfo = new EventInfo( this, this.modelEventType ); + + const position = selection.focus.path.length < selection.anchor.path.length ? selection.anchor : selection.focus; + let node = selection.getSelectedElement() || position.textNode || position.parent; + + while ( node && !eventInfo.stop.called ) { + if ( node.is( 'element' ) ) { + if ( selection.isCollapsed && schema.checkChild( position, '$text' ) ) { + this._fireListenerFor( '$text', eventInfo, ...eventArgs ); + } + + if ( !eventInfo.stop.called ) { + this._fireListenerFor( node.name, eventInfo, ...eventArgs ); + } + + if ( schema.isObject( node ) && !eventInfo.stop.called ) { + this._fireListenerFor( '$object', eventInfo, ...eventArgs ); + } + } else if ( node.is( '$text' ) ) { + this._fireListenerFor( '$text', eventInfo, ...eventArgs ); + } else if ( node.is( 'rootElement' ) ) { + this._fireListenerFor( '$root', eventInfo, ...eventArgs ); + } + + node = node.parent; + } + + if ( !eventInfo.stop.called ) { + this.fire( eventInfo, ...eventArgs ); + } + + if ( eventInfo.stop.called ) { + event.stop(); + } + } ); + } + + /** + * TODO + * + * @param {String|Function} elementNameOrCallback + * @returns {module:utils/emittermixin~Emitter} + */ + for( elementNameOrCallback ) { + let listener = this._elementMap.get( elementNameOrCallback ); + + if ( listener ) { + return listener; + } + + listener = Object.create( EmitterMixin ); + + this._elementMap.set( elementNameOrCallback, listener ); + + return listener; + } + + /** + * TODO + * Callback which should be called when the view event occurred. Note that the callback will not be called if + * observer {@link #isEnabled is not enabled}. + * + * @param {...*} [args] + * @returns {Array.<*>|false} + */ + translateViewEvent( ...args ) { + return args; + } + + /** + * TODO + * + * @private + * @param {String} name + * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. + * @param {...*} [args] Additional arguments to be passed to the callbacks. + */ + _fireListenerFor( name, eventInfo, ...args ) { + const listener = this._elementMap.get( name ); + + if ( listener ) { + listener.fire( eventInfo, ...args ); + } + } +} + +mix( ModelObserver, EmitterMixin ); diff --git a/packages/ckeditor5-engine/src/view/filler.js b/packages/ckeditor5-engine/src/view/filler.js index 25b8fe6853a..99abf66467b 100644 --- a/packages/ckeditor5-engine/src/view/filler.js +++ b/packages/ckeditor5-engine/src/view/filler.js @@ -130,7 +130,7 @@ export function getDataWithoutFiller( domText ) { * @param {module:engine/view/view~View} view View controller instance we should inject quirks handling on. */ export function injectQuirksHandling( view ) { - view.document.on( 'keydown', jumpOverInlineFiller ); + view.document.on( 'keydown', jumpOverInlineFiller, { priority: 'low' } ); } // Move cursor from the end of the inline filler to the beginning of it when, so the filler does not break navigation. diff --git a/packages/ckeditor5-engine/src/view/uielement.js b/packages/ckeditor5-engine/src/view/uielement.js index 9cef935f5eb..5dfa33b36e4 100644 --- a/packages/ckeditor5-engine/src/view/uielement.js +++ b/packages/ckeditor5-engine/src/view/uielement.js @@ -169,7 +169,7 @@ export default class UIElement extends Element { * @param {module:engine/view/view~View} view View controller to which the quirks handling will be injected. */ export function injectUiElementHandling( view ) { - view.document.on( 'keydown', ( evt, data ) => jumpOverUiElement( evt, data, view.domConverter ) ); + view.document.on( 'keydown', ( evt, data ) => jumpOverUiElement( evt, data, view.domConverter ), { priority: 'low' } ); } // Returns `null` because block filler is not needed for UIElements. diff --git a/packages/ckeditor5-enter/src/enter.js b/packages/ckeditor5-enter/src/enter.js index dd419dae18a..8751ee777d8 100644 --- a/packages/ckeditor5-enter/src/enter.js +++ b/packages/ckeditor5-enter/src/enter.js @@ -10,6 +10,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import EnterCommand from './entercommand'; import EnterObserver from './enterobserver'; +import EnterModelObserver from './entermodelobserver'; /** * This plugin handles the Enter key (hard line break) in the editor. @@ -30,14 +31,17 @@ export default class Enter extends Plugin { init() { const editor = this.editor; - const view = editor.editing.view; - const viewDocument = view.document; + const editing = editor.editing; + const view = editing.view; view.addObserver( EnterObserver ); editor.commands.add( 'enter', new EnterCommand( editor ) ); - this.listenTo( viewDocument, 'enter', ( evt, data ) => { + // Add generic enter model observer (not bound to any element). + const enterObserver = editing.addObserver( EnterModelObserver ); + + this.listenTo( enterObserver, 'enter', ( evt, data ) => { data.preventDefault(); // The soft enter key is handled by the ShiftEnter plugin. diff --git a/packages/ckeditor5-enter/src/entermodelobserver.js b/packages/ckeditor5-enter/src/entermodelobserver.js new file mode 100644 index 00000000000..6c621c67755 --- /dev/null +++ b/packages/ckeditor5-enter/src/entermodelobserver.js @@ -0,0 +1,24 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module enter/entermodelobserver + */ + +import ModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/modelobserver'; + +/** + * Observes... TODO + * + * @extends module:engine/model/observer/modelobserver~Observer + */ +export default class EnterModelObserver extends ModelObserver { + /** + * @inheritDoc + */ + constructor( model ) { + super( model, 'enter' ); + } +} diff --git a/packages/ckeditor5-enter/src/shiftenter.js b/packages/ckeditor5-enter/src/shiftenter.js index 01c43b197ad..498921407a3 100644 --- a/packages/ckeditor5-enter/src/shiftenter.js +++ b/packages/ckeditor5-enter/src/shiftenter.js @@ -9,6 +9,7 @@ import ShiftEnterCommand from './shiftentercommand'; import EnterObserver from './enterobserver'; +import EnterModelObserver from './entermodelobserver'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; /** @@ -33,7 +34,6 @@ export default class ShiftEnter extends Plugin { const schema = editor.model.schema; const conversion = editor.conversion; const view = editor.editing.view; - const viewDocument = view.document; // Configure the schema. schema.register( 'softBreak', { @@ -58,7 +58,10 @@ export default class ShiftEnter extends Plugin { editor.commands.add( 'shiftEnter', new ShiftEnterCommand( editor ) ); - this.listenTo( viewDocument, 'enter', ( evt, data ) => { + // Add generic enter model observer (not bound to any element). + const enterObserver = editor.editing.addObserver( EnterModelObserver ); + + this.listenTo( enterObserver, 'enter', ( evt, data ) => { data.preventDefault(); // The hard enter key is handled by the Enter plugin. diff --git a/packages/ckeditor5-list/src/listediting.js b/packages/ckeditor5-list/src/listediting.js index 9df189d6b03..11a03f90531 100644 --- a/packages/ckeditor5-list/src/listediting.js +++ b/packages/ckeditor5-list/src/listediting.js @@ -12,7 +12,10 @@ import IndentCommand from './indentcommand'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; +import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import { cleanList, @@ -50,7 +53,7 @@ export default class ListEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ Paragraph ]; + return [ Paragraph, Enter, Delete ]; } /** @@ -117,11 +120,11 @@ export default class ListEditing extends Plugin { editor.commands.add( 'indentList', new IndentCommand( editor, 'forward' ) ); editor.commands.add( 'outdentList', new IndentCommand( editor, 'backward' ) ); - const viewDocument = editing.view.document; + const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( 'listItem' ); // Overwrite default Enter key behavior. // If Enter key is pressed with selection collapsed in empty list item, outdent it instead of breaking it. - this.listenTo( viewDocument, 'enter', ( evt, data ) => { + this.listenTo( enterObserver, 'enter', ( evt, data ) => { const doc = this.editor.model.document; const positionParent = doc.selection.getLastPosition().parent; @@ -133,11 +136,11 @@ export default class ListEditing extends Plugin { } } ); + const deleteObserver = editor.editing.getObserver( DeleteModelObserver ).for( 'listItem' ); + // Overwrite default Backspace key behavior. // If Backspace key is pressed with selection collapsed on first position in first list item, outdent it. #83 - // - // Priority high + 10 to override widget and blockquote feature listener. - this.listenTo( viewDocument, 'delete', ( evt, data ) => { + this.listenTo( deleteObserver, 'delete', ( evt, data ) => { // Check conditions from those that require less computations like those immediately available. if ( data.direction !== 'backward' ) { return; @@ -171,7 +174,7 @@ export default class ListEditing extends Plugin { data.preventDefault(); evt.stop(); - }, { priority: priorities.high + 10 } ); + } ); const getCommandExecuter = commandName => { return ( data, cancel ) => { diff --git a/packages/ckeditor5-typing/src/delete.js b/packages/ckeditor5-typing/src/delete.js index ea2cf629663..535ca470304 100644 --- a/packages/ckeditor5-typing/src/delete.js +++ b/packages/ckeditor5-typing/src/delete.js @@ -10,6 +10,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import DeleteCommand from './deletecommand'; import DeleteObserver from './deleteobserver'; +import DeleteModelObserver from './deletemodelobserver'; import env from '@ckeditor/ckeditor5-utils/src/env'; /** @@ -35,7 +36,10 @@ export default class Delete extends Plugin { editor.commands.add( 'forwardDelete', new DeleteCommand( editor, 'forward' ) ); editor.commands.add( 'delete', new DeleteCommand( editor, 'backward' ) ); - this.listenTo( viewDocument, 'delete', ( evt, data ) => { + // Add generic delete model observer (not bound to any element). + const deleteObserver = editor.editing.addObserver( DeleteModelObserver ); + + this.listenTo( deleteObserver, 'delete', ( evt, data ) => { const deleteCommandParams = { unit: data.unit, sequence: data.sequence }; // If a specific (view) selection to remove was set, convert it to a model selection and set as a parameter for `DeleteCommand`. @@ -70,7 +74,7 @@ export default class Delete extends Plugin { if ( env.isAndroid ) { let domSelectionAfterDeletion = null; - this.listenTo( viewDocument, 'delete', ( evt, data ) => { + this.listenTo( deleteObserver, 'delete', ( evt, data ) => { const domSelection = data.domTarget.ownerDocument.defaultView.getSelection(); domSelectionAfterDeletion = { diff --git a/packages/ckeditor5-typing/src/deletemodelobserver.js b/packages/ckeditor5-typing/src/deletemodelobserver.js new file mode 100644 index 00000000000..878448ef40a --- /dev/null +++ b/packages/ckeditor5-typing/src/deletemodelobserver.js @@ -0,0 +1,24 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module delete/deletemodelobserver + */ + +import ModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/modelobserver'; + +/** + * Observes... TODO + * + * @extends module:engine/model/observer/modelobserver~Observer + */ +export default class DeleteModelObserver extends ModelObserver { + /** + * @inheritDoc + */ + constructor( model ) { + super( model, 'delete' ); + } +} diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index 067545735cd..469ba83e08e 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -10,6 +10,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import WidgetTypeAround from './widgettypearound/widgettypearound'; +import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils'; import { isArrowKeyCode, @@ -122,13 +123,15 @@ export default class Widget extends Plugin { this.listenTo( viewDocument, 'keydown', verticalNavigationHandler( this.editor.editing ) ); + const deleteObserver = this.editor.editing.getObserver( DeleteModelObserver ).for( '$text' ); + // Handle custom delete behaviour. - this.listenTo( viewDocument, 'delete', ( evt, data ) => { + this.listenTo( deleteObserver, 'delete', ( evt, data ) => { if ( this._handleDelete( data.direction == 'forward' ) ) { data.preventDefault(); evt.stop(); } - }, { priority: 'high' } ); + } ); } /** diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index a58a46408a1..2547e0605b1 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -11,6 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Template from '@ckeditor/ckeditor5-ui/src/template'; +import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; +import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import { isArrowKeyCode, isForwardArrowKeyCode, @@ -531,11 +533,19 @@ export default class WidgetTypeAround extends Plugin { */ _enableInsertingParagraphsOnEnterKeypress() { const editor = this.editor; - const editingView = editor.editing.view; + const selection = editor.model.document.selection; + + const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( '$object' ); + + this._listenToIfEnabled( enterObserver, 'enter', ( evt, domEventData ) => { + const selectedModelElement = selection.getSelectedElement(); + + if ( !selectedModelElement ) { + return; + } + + const selectedViewElement = editor.editing.mapper.toViewElement( selectedModelElement ); - this._listenToIfEnabled( editingView.document, 'enter', ( evt, domEventData ) => { - const selectedViewElement = editingView.document.selection.getSelectedElement(); - const selectedModelElement = editor.editing.mapper.toModelElement( selectedViewElement ); const schema = editor.model.schema; let wasHandled; @@ -609,12 +619,19 @@ export default class WidgetTypeAround extends Plugin { */ _enableDeleteIntegration() { const editor = this.editor; - const editingView = editor.editing.view; const model = editor.model; const schema = model.schema; + const deleteObserver = editor.editing.getObserver( DeleteModelObserver ).for( '$object' ); + // Note: The priority must precede the default Widget class delete handler. - this._listenToIfEnabled( editingView.document, 'delete', ( evt, domEventData ) => { + this._listenToIfEnabled( deleteObserver, 'delete', ( evt, domEventData ) => { + const selectedModelWidget = model.document.selection.getSelectedElement(); + + if ( !selectedModelWidget ) { + return; + } + const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( model.document.selection ); // This listener handles only these cases when the fake caret is active. @@ -623,7 +640,6 @@ export default class WidgetTypeAround extends Plugin { } const direction = domEventData.direction; - const selectedModelWidget = model.document.selection.getSelectedElement(); const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before'; const isForwardDelete = direction == 'forward'; @@ -677,7 +693,7 @@ export default class WidgetTypeAround extends Plugin { // If nothing was deleted, then the default handler will have nothing to do anyway. domEventData.preventDefault(); evt.stop(); - }, { priority: priorities.get( 'high' ) + 1 } ); + } ); } /** From ce2926ed93451f60334f574ded2627ec11724994 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Fri, 11 Dec 2020 21:57:42 +0100 Subject: [PATCH 02/43] Added support for bubbling arrow keys events. --- .../src/blockquoteediting.js | 8 ++--- .../src/codeblockediting.js | 4 +-- .../model/observer/arrowkeysmodelobserver.js | 4 +-- .../src/model/observer/modelobserver.js | 32 +++++++++++++------ packages/ckeditor5-enter/src/enter.js | 2 +- packages/ckeditor5-list/src/listediting.js | 8 ++--- .../ckeditor5-list/src/todolistediting.js | 5 ++- packages/ckeditor5-table/src/tablekeyboard.js | 12 +++---- .../src/twostepcaretmovement.js | 18 +++-------- packages/ckeditor5-widget/src/widget.js | 23 ++++++++----- .../src/widgettypearound/widgettypearound.js | 29 +++++++++-------- 11 files changed, 78 insertions(+), 67 deletions(-) diff --git a/packages/ckeditor5-block-quote/src/blockquoteediting.js b/packages/ckeditor5-block-quote/src/blockquoteediting.js index 0d2cf646e16..301c9f26c9a 100644 --- a/packages/ckeditor5-block-quote/src/blockquoteediting.js +++ b/packages/ckeditor5-block-quote/src/blockquoteediting.js @@ -117,11 +117,11 @@ export default class BlockQuoteEditing extends Plugin { const selection = editor.model.document.selection; const blockQuoteCommand = editor.commands.get( 'blockQuote' ); - const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( 'blockQuote' ); + const enterObserver = editor.editing.getObserver( EnterModelObserver ); // Overwrite default Enter key behavior. // If Enter key is pressed with selection collapsed in empty block inside a quote, break the quote. - this.listenTo( enterObserver, 'enter', ( evt, data ) => { + this.listenTo( enterObserver.for( 'blockQuote' ), 'enter', ( evt, data ) => { if ( !selection.isCollapsed || !blockQuoteCommand.value ) { return; } @@ -137,11 +137,11 @@ export default class BlockQuoteEditing extends Plugin { } } ); - const deleteObserver = editor.editing.getObserver( DeleteModelObserver ).for( 'blockQuote' ); + const deleteObserver = editor.editing.getObserver( DeleteModelObserver ); // Overwrite default Backspace key behavior. // If Backspace key is pressed with selection collapsed in first empty block inside a quote, break the quote. - this.listenTo( deleteObserver, 'delete', ( evt, data ) => { + this.listenTo( deleteObserver.for( 'blockQuote' ), 'delete', ( evt, data ) => { if ( data.direction != 'backward' || !selection.isCollapsed || !blockQuoteCommand.value ) { return; } diff --git a/packages/ckeditor5-code-block/src/codeblockediting.js b/packages/ckeditor5-code-block/src/codeblockediting.js index 060319470a9..95ad9d0e987 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.js +++ b/packages/ckeditor5-code-block/src/codeblockediting.js @@ -204,13 +204,13 @@ export default class CodeBlockEditing extends Plugin { outdent.registerChildCommand( commands.get( 'outdentCodeBlock' ) ); } - const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( 'codeBlock' ); + const enterObserver = editor.editing.getObserver( EnterModelObserver ); // Customize the response to the Enter and Shift+Enter // key press when the selection is in the code block. Upon enter key press we can either // leave the block if it's "two enters" in a row or create a new code block line, preserving // previous line's indentation. - this.listenTo( enterObserver, 'enter', ( evt, data ) => { + this.listenTo( enterObserver.for( 'codeBlock' ), 'enter', ( evt, data ) => { const positionParent = editor.model.document.selection.getLastPosition().parent; if ( !positionParent.is( 'element', 'codeBlock' ) ) { diff --git a/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js b/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js index 83d1ae4f1d5..8a94b112971 100644 --- a/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js +++ b/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js @@ -20,7 +20,7 @@ export default class ArrowKeysModelObserver extends ModelObserver { * @inheritDoc */ constructor( model ) { - super( model, 'keydown', 'arrowkeydown' ); + super( model, 'keydown', 'arrowkey' ); } /** @@ -32,7 +32,7 @@ export default class ArrowKeysModelObserver extends ModelObserver { } // TODO provide arrow direction - // TODO maybe event type could be namespaced like arrowkeydown:left ? + // TODO maybe event type could be namespaced like arrowkey:left ? return data; } diff --git a/packages/ckeditor5-engine/src/model/observer/modelobserver.js b/packages/ckeditor5-engine/src/model/observer/modelobserver.js index effb6a4038c..9373483da9c 100644 --- a/packages/ckeditor5-engine/src/model/observer/modelobserver.js +++ b/packages/ckeditor5-engine/src/model/observer/modelobserver.js @@ -62,7 +62,7 @@ export default class ModelObserver { * TODO * * @private - * @member {Map.} + * @member {Map.} */ this._elementMap = new Map(); } @@ -126,18 +126,25 @@ export default class ModelObserver { const position = selection.focus.path.length < selection.anchor.path.length ? selection.anchor : selection.focus; let node = selection.getSelectedElement() || position.textNode || position.parent; + let bubbling = false; - while ( node && !eventInfo.stop.called ) { + while ( node ) { if ( node.is( 'element' ) ) { - if ( selection.isCollapsed && schema.checkChild( position, '$text' ) ) { + if ( !bubbling && selection.isCollapsed && schema.checkChild( position, '$text' ) ) { this._fireListenerFor( '$text', eventInfo, ...eventArgs ); } - if ( !eventInfo.stop.called ) { - this._fireListenerFor( node.name, eventInfo, ...eventArgs ); + if ( eventInfo.stop.called ) { + break; } - if ( schema.isObject( node ) && !eventInfo.stop.called ) { + this._fireListenerFor( node.name, eventInfo, ...eventArgs ); + + if ( eventInfo.stop.called ) { + break; + } + + if ( schema.isObject( node ) ) { this._fireListenerFor( '$object', eventInfo, ...eventArgs ); } } else if ( node.is( '$text' ) ) { @@ -146,7 +153,12 @@ export default class ModelObserver { this._fireListenerFor( '$root', eventInfo, ...eventArgs ); } + if ( eventInfo.stop.called ) { + break; + } + node = node.parent; + bubbling = true; } if ( !eventInfo.stop.called ) { @@ -162,11 +174,11 @@ export default class ModelObserver { /** * TODO * - * @param {String|Function} elementNameOrCallback + * @param {String} name * @returns {module:utils/emittermixin~Emitter} */ - for( elementNameOrCallback ) { - let listener = this._elementMap.get( elementNameOrCallback ); + for( name ) { + let listener = this._elementMap.get( name ); if ( listener ) { return listener; @@ -174,7 +186,7 @@ export default class ModelObserver { listener = Object.create( EmitterMixin ); - this._elementMap.set( elementNameOrCallback, listener ); + this._elementMap.set( name, listener ); return listener; } diff --git a/packages/ckeditor5-enter/src/enter.js b/packages/ckeditor5-enter/src/enter.js index 8751ee777d8..06f5909aa00 100644 --- a/packages/ckeditor5-enter/src/enter.js +++ b/packages/ckeditor5-enter/src/enter.js @@ -51,6 +51,6 @@ export default class Enter extends Plugin { editor.execute( 'enter' ); view.scrollToTheSelection(); - }, { priority: 'low' } ); + } ); } } diff --git a/packages/ckeditor5-list/src/listediting.js b/packages/ckeditor5-list/src/listediting.js index 11a03f90531..6d8db37f02d 100644 --- a/packages/ckeditor5-list/src/listediting.js +++ b/packages/ckeditor5-list/src/listediting.js @@ -120,11 +120,11 @@ export default class ListEditing extends Plugin { editor.commands.add( 'indentList', new IndentCommand( editor, 'forward' ) ); editor.commands.add( 'outdentList', new IndentCommand( editor, 'backward' ) ); - const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( 'listItem' ); + const enterObserver = editor.editing.getObserver( EnterModelObserver ); // Overwrite default Enter key behavior. // If Enter key is pressed with selection collapsed in empty list item, outdent it instead of breaking it. - this.listenTo( enterObserver, 'enter', ( evt, data ) => { + this.listenTo( enterObserver.for( 'listItem' ), 'enter', ( evt, data ) => { const doc = this.editor.model.document; const positionParent = doc.selection.getLastPosition().parent; @@ -136,11 +136,11 @@ export default class ListEditing extends Plugin { } } ); - const deleteObserver = editor.editing.getObserver( DeleteModelObserver ).for( 'listItem' ); + const deleteObserver = editor.editing.getObserver( DeleteModelObserver ); // Overwrite default Backspace key behavior. // If Backspace key is pressed with selection collapsed on first position in first list item, outdent it. #83 - this.listenTo( deleteObserver, 'delete', ( evt, data ) => { + this.listenTo( deleteObserver.for( 'listItem' ), 'delete', ( evt, data ) => { // Check conditions from those that require less computations like those immediately available. if ( data.direction !== 'backward' ) { return; diff --git a/packages/ckeditor5-list/src/todolistediting.js b/packages/ckeditor5-list/src/todolistediting.js index d470da2e686..b1d5966a024 100644 --- a/packages/ckeditor5-list/src/todolistediting.js +++ b/packages/ckeditor5-list/src/todolistediting.js @@ -12,6 +12,7 @@ import ListEditing from './listediting'; import TodoListCheckCommand from './todolistcheckcommand'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import { dataModelViewInsertion, @@ -95,6 +96,8 @@ export default class TodoListEditing extends Plugin { editing.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) ); data.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) ); + const arrowKeyObserver = this.editor.editing.getObserver( ArrowKeysModelObserver ); + // Jump at the end of the previous node on left arrow key press, when selection is after the checkbox. // //

Foo

@@ -105,7 +108,7 @@ export default class TodoListEditing extends Plugin { //

Foo{}

//
  • Bar
// - this.listenTo( editing.view.document, 'keydown', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ) ); + this.listenTo( arrowKeyObserver.for( 'listItem' ), 'arrowkey', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ) ); // Toggle check state of selected to-do list items on keystroke. editor.keystrokes.set( 'Ctrl+space', () => editor.execute( 'todoListCheck' ) ); diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js index e97bb2854c1..23f8dd3d3d9 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.js +++ b/packages/ckeditor5-table/src/tablekeyboard.js @@ -11,7 +11,7 @@ import TableSelection from './tableselection'; import TableWalker from './tablewalker'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; +import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import { isArrowKeyCode, getLocalizedArrowKeyCodeDirection @@ -43,18 +43,14 @@ export default class TableKeyboard extends Plugin { * @inheritDoc */ init() { - const view = this.editor.editing.view; - const viewDocument = view.document; - // Handle Tab key navigation. this.editor.keystrokes.set( 'Tab', ( ...args ) => this._handleTabOnSelectedTable( ...args ), { priority: 'low' } ); this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } ); this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } ); - // Note: This listener has the "high-10" priority because it should allow the Widget plugin to handle the default - // behavior first ("high") but it should not be "prevent–defaulted" by the Widget plugin ("high-20") because of - // the fake selection retention on the fully selected widget. - this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: priorities.get( 'high' ) - 10 } ); + const arrowKeyObserver = this.editor.editing.getObserver( ArrowKeysModelObserver ); + + this.listenTo( arrowKeyObserver.for( 'table' ), 'arrowkey', ( ...args ) => this._onKeydown( ...args ) ); } /** diff --git a/packages/ckeditor5-typing/src/twostepcaretmovement.js b/packages/ckeditor5-typing/src/twostepcaretmovement.js index 10852bf2ae9..8ced4ad69d3 100644 --- a/packages/ckeditor5-typing/src/twostepcaretmovement.js +++ b/packages/ckeditor5-typing/src/twostepcaretmovement.js @@ -10,7 +10,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; +import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; /** * This plugin enables the two-step caret (phantom) movement behavior for @@ -141,23 +141,13 @@ export default class TwoStepCaretMovement extends Plugin { init() { const editor = this.editor; const model = editor.model; - const view = editor.editing.view; const locale = editor.locale; const modelSelection = model.document.selection; + const arrowKeyObserver = editor.editing.getObserver( ArrowKeysModelObserver ); // Listen to keyboard events and handle the caret movement according to the 2-step caret logic. - // - // Note: This listener has the "high+1" priority: - // * "high" because of the filler logic implemented in the renderer which also engages on #keydown. - // When the gravity is overridden the attributes of the (model) selection attributes are reset. - // It may end up with the filler kicking in and breaking the selection. - // * "+1" because we would like to avoid collisions with other features (like Widgets), which - // take over the keydown events with the "high" priority. Two-step caret movement takes precedence - // over Widgets in that matter. - // - // Find out more in https://github.com/ckeditor/ckeditor5-engine/issues/1301. - this.listenTo( view.document, 'keydown', ( evt, data ) => { + this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', ( evt, data ) => { // This implementation works only for collapsed selection. if ( !modelSelection.isCollapsed ) { return; @@ -191,7 +181,7 @@ export default class TwoStepCaretMovement extends Plugin { if ( isMovementHandled === true ) { evt.stop(); } - }, { priority: priorities.get( 'high' ) + 1 } ); + }, { priority: 'highest' } ); /** * A flag indicating that the automatic gravity restoration should not happen upon the next diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index 469ba83e08e..3abcc455657 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -11,6 +11,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import WidgetTypeAround from './widgettypearound/widgettypearound'; import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; +import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils'; import { isArrowKeyCode, @@ -19,7 +20,6 @@ import { import env from '@ckeditor/ckeditor5-utils/src/env'; import '../theme/widget.css'; -import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; import verticalNavigationHandler from './verticalnavigation'; /** @@ -102,6 +102,8 @@ export default class Widget extends Plugin { view.addObserver( MouseObserver ); this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) ); + const arrowKeyObserver = this.editor.editing.getObserver( ArrowKeysModelObserver ); + // There are two keydown listeners working on different priorities. This allows other // features such as WidgetTypeAround or TableKeyboard to attach their listeners in between // and customize the behavior even further in different content/selection scenarios. @@ -113,20 +115,25 @@ export default class Widget extends Plugin { // * The second (late) listener makes sure the default browser action on arrow key press is // prevented when a widget is selected. This prevents the selection from being moved // from a fake selection container. - this.listenTo( viewDocument, 'keydown', ( ...args ) => { + // TODO split into 2 separate handlers + this.listenTo( arrowKeyObserver.for( '$object' ), 'arrowkey', ( ...args ) => { this._handleSelectionChangeOnArrowKeyPress( ...args ); - }, { priority: 'high' } ); + } ); + + this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', ( ...args ) => { + this._handleSelectionChangeOnArrowKeyPress( ...args ); + } ); - this.listenTo( viewDocument, 'keydown', ( ...args ) => { + this.listenTo( arrowKeyObserver.for( '$object' ), 'arrowkey', ( ...args ) => { this._preventDefaultOnArrowKeyPress( ...args ); - }, { priority: priorities.get( 'high' ) - 20 } ); + }, { priority: 'lowest' } ); - this.listenTo( viewDocument, 'keydown', verticalNavigationHandler( this.editor.editing ) ); + this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', verticalNavigationHandler( this.editor.editing ) ); - const deleteObserver = this.editor.editing.getObserver( DeleteModelObserver ).for( '$text' ); + const deleteObserver = this.editor.editing.getObserver( DeleteModelObserver ); // Handle custom delete behaviour. - this.listenTo( deleteObserver, 'delete', ( evt, data ) => { + this.listenTo( deleteObserver.for( '$text' ), 'delete', ( evt, data ) => { if ( this._handleDelete( data.direction == 'forward' ) ) { data.preventDefault(); evt.stop(); diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 2547e0605b1..788707ac239 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -13,8 +13,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Template from '@ckeditor/ckeditor5-ui/src/template'; import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; +import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import { - isArrowKeyCode, isForwardArrowKeyCode, keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -256,16 +256,19 @@ export default class WidgetTypeAround extends Plugin { const model = editor.model; const modelSelection = model.document.selection; const schema = model.schema; - const editingView = editor.editing.view; + + const arrowKeyObserver = editor.editing.getObserver( ArrowKeysModelObserver ); // This is the main listener responsible for the fake caret. - // Note: The priority must precede the default Widget class keydown handler ("high") and the - // TableKeyboard keydown handler ("high-10"). - this._listenToIfEnabled( editingView.document, 'keydown', ( evt, domEventData ) => { - if ( isArrowKeyCode( domEventData.keyCode ) ) { - this._handleArrowKeyPress( evt, domEventData ); - } - }, { priority: priorities.get( 'high' ) + 10 } ); + // Note: The priority must precede the default Widget class keydown handler ("high"). + // TODO split into 2 separate handlers + this._listenToIfEnabled( arrowKeyObserver.for( '$object' ), 'arrowkey', ( evt, domEventData ) => { + this._handleArrowKeyPress( evt, domEventData ); + }, { priority: 'high' } ); + + this._listenToIfEnabled( arrowKeyObserver.for( '$text' ), 'arrowkey', ( evt, domEventData ) => { + this._handleArrowKeyPress( evt, domEventData ); + }, { priority: 'high' } ); // This listener makes sure the widget type around selection attribute will be gone from the model // selection as soon as the model range changes. This attribute only makes sense when a widget is selected @@ -535,9 +538,9 @@ export default class WidgetTypeAround extends Plugin { const editor = this.editor; const selection = editor.model.document.selection; - const enterObserver = editor.editing.getObserver( EnterModelObserver ).for( '$object' ); + const enterObserver = editor.editing.getObserver( EnterModelObserver ); - this._listenToIfEnabled( enterObserver, 'enter', ( evt, domEventData ) => { + this._listenToIfEnabled( enterObserver.for( '$object' ), 'enter', ( evt, domEventData ) => { const selectedModelElement = selection.getSelectedElement(); if ( !selectedModelElement ) { @@ -622,10 +625,10 @@ export default class WidgetTypeAround extends Plugin { const model = editor.model; const schema = model.schema; - const deleteObserver = editor.editing.getObserver( DeleteModelObserver ).for( '$object' ); + const deleteObserver = editor.editing.getObserver( DeleteModelObserver ); // Note: The priority must precede the default Widget class delete handler. - this._listenToIfEnabled( deleteObserver, 'delete', ( evt, domEventData ) => { + this._listenToIfEnabled( deleteObserver.for( '$object' ), 'delete', ( evt, domEventData ) => { const selectedModelWidget = model.document.selection.getSelectedElement(); if ( !selectedModelWidget ) { From 8165c938e4ffd89f3569f6fce87f36174b68cea1 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sun, 13 Dec 2020 16:30:34 +0100 Subject: [PATCH 03/43] Added tests for features affected by bubbling events. --- .../tests/integration.js | 56 +++++++++++++++++++ .../tests/codeblockediting.js | 22 ++++++++ .../src/model/observer/modelobserver.js | 39 ++++++++----- .../view/observer/fakeselectionobserver.js | 6 ++ packages/ckeditor5-enter/src/enter.js | 5 +- packages/ckeditor5-enter/src/shiftenter.js | 5 +- packages/ckeditor5-enter/tests/enter.js | 17 ++++++ packages/ckeditor5-enter/tests/shiftenter.js | 17 ++++++ packages/ckeditor5-list/tests/listediting.js | 41 +++++++++++--- .../ckeditor5-list/tests/todolistediting.js | 11 ++++ packages/ckeditor5-table/src/tablekeyboard.js | 13 +---- .../ckeditor5-table/tests/tablekeyboard.js | 13 +++++ packages/ckeditor5-typing/src/delete.js | 6 +- packages/ckeditor5-typing/tests/delete.js | 16 ++++++ packages/ckeditor5-typing/tests/input.js | 10 ++-- .../tests/twostepcaretmovement.js | 19 +++++-- packages/ckeditor5-widget/src/widget.js | 26 ++------- .../src/widgettypearound/widgettypearound.js | 10 +++- packages/ckeditor5-widget/tests/widget.js | 9 +-- .../widgettypearound/widgettypearound.js | 43 ++++++++------ 20 files changed, 294 insertions(+), 90 deletions(-) diff --git a/packages/ckeditor5-block-quote/tests/integration.js b/packages/ckeditor5-block-quote/tests/integration.js index 91555ff4beb..7e2255bd9c2 100644 --- a/packages/ckeditor5-block-quote/tests/integration.js +++ b/packages/ckeditor5-block-quote/tests/integration.js @@ -355,6 +355,62 @@ describe( 'BlockQuote integration', () => { 'y' ); } ); + + it( 'does nothing if selection is in an empty block but not in a block quote', () => { + const data = fakeEventData(); + const execSpy = sinon.spy( editor, 'execute' ); + + setModelData( model, 'x[]x' ); + + viewDocument.fire( 'delete', data ); + + // Only enter command should be executed. + expect( data.preventDefault.called ).to.be.true; + expect( execSpy.calledOnce ).to.be.true; + expect( execSpy.args[ 0 ][ 0 ] ).to.equal( 'delete' ); + } ); + + it( 'does nothing if selection is in a non-empty block (at the end) in a block quote', () => { + const data = fakeEventData(); + const execSpy = sinon.spy( editor, 'execute' ); + + setModelData( model, '
xx[]
' ); + + viewDocument.fire( 'delete', data ); + + // Only enter command should be executed. + expect( data.preventDefault.called ).to.be.true; + expect( execSpy.calledOnce ).to.be.true; + expect( execSpy.args[ 0 ][ 0 ] ).to.equal( 'delete' ); + } ); + + it( 'does nothing if selection is in a non-empty block (at the beginning) in a block quote', () => { + const data = fakeEventData(); + const execSpy = sinon.spy( editor, 'execute' ); + + setModelData( model, '
[]xx
' ); + + viewDocument.fire( 'delete', data ); + + // Only enter command should be executed. + expect( data.preventDefault.called ).to.be.true; + expect( execSpy.calledOnce ).to.be.true; + expect( execSpy.args[ 0 ][ 0 ] ).to.equal( 'delete' ); + } ); + + it( 'does nothing if selection is not collapsed', () => { + const data = fakeEventData(); + const execSpy = sinon.spy( editor, 'execute' ); + + setModelData( model, '
[]
' ); + + viewDocument.fire( 'delete', data ); + + // Only enter command should be executed. + expect( data.preventDefault.called ).to.be.true; + expect( execSpy.calledOnce ).to.be.true; + expect( execSpy.args[ 0 ][ 0 ] ).to.equal( 'delete' ); + } ); } ); // Historically, due to problems with schema, images were not quotable. diff --git a/packages/ckeditor5-code-block/tests/codeblockediting.js b/packages/ckeditor5-code-block/tests/codeblockediting.js index 738f7d93898..6cac45b6b27 100644 --- a/packages/ckeditor5-code-block/tests/codeblockediting.js +++ b/packages/ckeditor5-code-block/tests/codeblockediting.js @@ -242,6 +242,28 @@ describe( 'CodeBlockEditing', () => { sinon.assert.notCalled( shiftEnterCommand.execute ); } ); + it( 'should execute enter command when pressing enter in an element nested inside a codeBlock', () => { + model.schema.register( 'codeBlockSub', { allowIn: 'codeBlock', isInline: true } ); + model.schema.extend( '$text', { allowIn: 'codeBlockSub' } ); + editor.conversion.elementToElement( { model: 'codeBlockSub', view: 'codeBlockSub' } ); + + const enterCommand = editor.commands.get( 'enter' ); + const shiftEnterCommand = editor.commands.get( 'shiftEnter' ); + + sinon.spy( enterCommand, 'execute' ); + sinon.spy( shiftEnterCommand, 'execute' ); + + setModelData( model, 'foob[]ar' ); + + viewDoc.fire( 'enter', getEvent() ); + + expect( getModelData( model ) ).to.equal( + 'foob[]ar' + ); + sinon.assert.calledOnce( enterCommand.execute ); + sinon.assert.notCalled( shiftEnterCommand.execute ); + } ); + describe( 'indentation retention', () => { it( 'should work when indentation is with spaces', () => { setModelData( model, 'foo[]' ); diff --git a/packages/ckeditor5-engine/src/model/observer/modelobserver.js b/packages/ckeditor5-engine/src/model/observer/modelobserver.js index 9373483da9c..765c5dd1ca1 100644 --- a/packages/ckeditor5-engine/src/model/observer/modelobserver.js +++ b/packages/ckeditor5-engine/src/model/observer/modelobserver.js @@ -125,46 +125,54 @@ export default class ModelObserver { const eventInfo = new EventInfo( this, this.modelEventType ); const position = selection.focus.path.length < selection.anchor.path.length ? selection.anchor : selection.focus; + const acceptsText = selection.isCollapsed && schema.checkChild( position, '$text' ); + let node = selection.getSelectedElement() || position.textNode || position.parent; let bubbling = false; while ( node ) { + // Element node handling. if ( node.is( 'element' ) ) { - if ( !bubbling && selection.isCollapsed && schema.checkChild( position, '$text' ) ) { - this._fireListenerFor( '$text', eventInfo, ...eventArgs ); + // For the not yet bubbling event trigger for $text node if it's accepted by the selection position. + if ( !bubbling && acceptsText && this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { + break; } - if ( eventInfo.stop.called ) { + // Default handler for specified element. + if ( this._fireListenerFor( node.name, eventInfo, ...eventArgs ) ) { break; } - this._fireListenerFor( node.name, eventInfo, ...eventArgs ); - - if ( eventInfo.stop.called ) { + // Generic handler for $object. + if ( schema.isObject( node ) && this._fireListenerFor( '$object', eventInfo, ...eventArgs ) ) { break; } + } - if ( schema.isObject( node ) ) { - this._fireListenerFor( '$object', eventInfo, ...eventArgs ); + // Text node handling. + else if ( node.is( '$text' ) ) { + if ( this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { + break; } - } else if ( node.is( '$text' ) ) { - this._fireListenerFor( '$text', eventInfo, ...eventArgs ); - } else if ( node.is( 'rootElement' ) ) { - this._fireListenerFor( '$root', eventInfo, ...eventArgs ); } - if ( eventInfo.stop.called ) { - break; + // Root node handling. + else if ( node.is( 'rootElement' ) ) { + if ( this._fireListenerFor( '$root', eventInfo, ...eventArgs ) ) { + break; + } } node = node.parent; bubbling = true; } + // Fire generic handler (not assigned to any element). if ( !eventInfo.stop.called ) { this.fire( eventInfo, ...eventArgs ); } + // Stop the event if generic handler stopped it. if ( eventInfo.stop.called ) { event.stop(); } @@ -210,6 +218,7 @@ export default class ModelObserver { * @param {String} name * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. * @param {...*} [args] Additional arguments to be passed to the callbacks. + * @returns {Boolean} True if event stop was called. */ _fireListenerFor( name, eventInfo, ...args ) { const listener = this._elementMap.get( name ); @@ -217,6 +226,8 @@ export default class ModelObserver { if ( listener ) { listener.fire( eventInfo, ...args ); } + + return eventInfo.stop.called; } } diff --git a/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js b/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js index 32409ac48f0..d017a998177 100644 --- a/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js @@ -52,7 +52,13 @@ export default class FakeSelectionObserver extends Observer { if ( selection.isFake && isArrowKeyCode( data.keyCode ) && this.isEnabled ) { // Prevents default key down handling - no selection change will occur. data.preventDefault(); + } + }, { priority: 'highest' } ); + document.on( 'keydown', ( eventInfo, data ) => { + const selection = document.selection; + + if ( selection.isFake && isArrowKeyCode( data.keyCode ) && this.isEnabled ) { this._handleSelectionMove( data.keyCode ); } }, { priority: 'lowest' } ); diff --git a/packages/ckeditor5-enter/src/enter.js b/packages/ckeditor5-enter/src/enter.js index 06f5909aa00..8ae2f0e3601 100644 --- a/packages/ckeditor5-enter/src/enter.js +++ b/packages/ckeditor5-enter/src/enter.js @@ -50,7 +50,10 @@ export default class Enter extends Plugin { } editor.execute( 'enter' ); - view.scrollToTheSelection(); + + if ( editor.ui ) { + view.scrollToTheSelection(); + } } ); } } diff --git a/packages/ckeditor5-enter/src/shiftenter.js b/packages/ckeditor5-enter/src/shiftenter.js index 498921407a3..69c8c967f37 100644 --- a/packages/ckeditor5-enter/src/shiftenter.js +++ b/packages/ckeditor5-enter/src/shiftenter.js @@ -70,7 +70,10 @@ export default class ShiftEnter extends Plugin { } editor.execute( 'shiftEnter' ); - view.scrollToTheSelection(); + + if ( editor.ui ) { + view.scrollToTheSelection(); + } }, { priority: 'low' } ); } } diff --git a/packages/ckeditor5-enter/tests/enter.js b/packages/ckeditor5-enter/tests/enter.js index f7b1fa72077..0ebef0c5667 100644 --- a/packages/ckeditor5-enter/tests/enter.js +++ b/packages/ckeditor5-enter/tests/enter.js @@ -10,6 +10,7 @@ import Enter from '../src/enter'; import EnterCommand from '../src/entercommand'; import EnterObserver from '../src/enterobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; describe( 'Enter feature', () => { let element, editor, viewDocument; @@ -86,6 +87,22 @@ describe( 'Enter feature', () => { sinon.assert.calledOnce( domEvt.preventDefault ); } ); + it( 'should not crash in virtual editor', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Enter ] + } ); + + const domEvt = getDomEvent(); + const commandExecuteSpy = sinon.stub( editor.commands.get( 'enter' ), 'execute' ); + const viewDocument = editor.editing.view.document; + + viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt ) ); + + sinon.assert.calledOnce( commandExecuteSpy ); + + await editor.destroy(); + } ); + function getDomEvent() { return { preventDefault: sinon.spy() diff --git a/packages/ckeditor5-enter/tests/shiftenter.js b/packages/ckeditor5-enter/tests/shiftenter.js index f63030bfb15..51898817a67 100644 --- a/packages/ckeditor5-enter/tests/shiftenter.js +++ b/packages/ckeditor5-enter/tests/shiftenter.js @@ -10,6 +10,7 @@ import ShiftEnter from '../src/shiftenter'; import ShiftEnterCommand from '../src/shiftentercommand'; import EnterObserver from '../src/enterobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; describe( 'ShiftEnter feature', () => { let element, editor, viewDocument; @@ -94,6 +95,22 @@ describe( 'ShiftEnter feature', () => { sinon.assert.calledOnce( domEvt.preventDefault ); } ); + it( 'should not crash in virtual editor', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ ShiftEnter ] + } ); + + const domEvt = getDomEvent(); + const commandExecuteSpy = sinon.stub( editor.commands.get( 'shiftEnter' ), 'execute' ); + const viewDocument = editor.editing.view.document; + + viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt, { isSoft: true } ) ); + + sinon.assert.calledOnce( commandExecuteSpy ); + + await editor.destroy(); + } ); + function getDomEvent() { return { preventDefault: sinon.spy() diff --git a/packages/ckeditor5-list/tests/listediting.js b/packages/ckeditor5-list/tests/listediting.js index 26813e9c5d1..78235bcd94e 100644 --- a/packages/ckeditor5-list/tests/listediting.js +++ b/packages/ckeditor5-list/tests/listediting.js @@ -164,13 +164,15 @@ describe( 'ListEditing', () => { it( 'should not execute outdentList command on enter key in non-empty list', () => { const domEvtDataStub = { preventDefault() {} }; - sinon.spy( editor, 'execute' ); + const enterCommandExecuteSpy = sinon.stub( editor.commands.get( 'enter' ), 'execute' ); + const outdentCommandExecuteSpy = sinon.stub( editor.commands.get( 'outdentList' ), 'execute' ); setModelData( model, 'foo[]' ); editor.editing.view.document.fire( 'enter', domEvtDataStub ); - sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( enterCommandExecuteSpy ); + sinon.assert.notCalled( outdentCommandExecuteSpy ); } ); } ); @@ -208,7 +210,8 @@ describe( 'ListEditing', () => { editor.editing.view.document.fire( 'delete', domEvtDataStub ); - sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWith( editor.execute, 'forwardDelete' ); } ); it( 'should not execute outdentList command when selection is not collapsed', () => { @@ -220,7 +223,8 @@ describe( 'ListEditing', () => { editor.editing.view.document.fire( 'delete', domEvtDataStub ); - sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWith( editor.execute, 'delete' ); } ); it( 'should not execute outdentList command if not in list item', () => { @@ -232,7 +236,8 @@ describe( 'ListEditing', () => { editor.editing.view.document.fire( 'delete', domEvtDataStub ); - sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWith( editor.execute, 'delete' ); } ); it( 'should not execute outdentList command if not in first list item', () => { @@ -247,7 +252,8 @@ describe( 'ListEditing', () => { editor.editing.view.document.fire( 'delete', domEvtDataStub ); - sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWith( editor.execute, 'delete' ); } ); it( 'should not execute outdentList command when selection is not on first position', () => { @@ -259,7 +265,8 @@ describe( 'ListEditing', () => { editor.editing.view.document.fire( 'delete', domEvtDataStub ); - sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWith( editor.execute, 'delete' ); } ); it( 'should outdent list when previous element is nested in block quote', () => { @@ -306,6 +313,26 @@ describe( 'ListEditing', () => { sinon.assert.calledWithExactly( editor.execute, 'outdentList' ); } ); + + it( 'should not outdent list when the selection is in an element nested inside a list item', () => { + model.schema.register( 'listItemSub', { allowIn: 'listItem', isInline: true } ); + model.schema.extend( '$text', { allowIn: 'listItemSub' } ); + editor.conversion.elementToElement( { model: 'listItemSub', view: 'listItemSub' } ); + + const domEvtDataStub = { preventDefault() {}, direction: 'backward' }; + + sinon.spy( editor, 'execute' ); + + setModelData( model, + 'foo' + + '[]foo' + ); + + editor.editing.view.document.fire( 'delete', domEvtDataStub ); + + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWith( editor.execute, 'delete' ); + } ); } ); describe( 'tab key handling callback', () => { diff --git a/packages/ckeditor5-list/tests/todolistediting.js b/packages/ckeditor5-list/tests/todolistediting.js index 7e69d51bb1c..a0da04344b1 100644 --- a/packages/ckeditor5-list/tests/todolistediting.js +++ b/packages/ckeditor5-list/tests/todolistediting.js @@ -1149,6 +1149,17 @@ describe( 'TodoListEditing', () => { sinon.assert.notCalled( domEvtDataStub.preventDefault ); sinon.assert.notCalled( domEvtDataStub.stopPropagation ); } ); + + it( 'should do nothing when other arrow key was pressed', () => { + domEvtDataStub.keyCode = getCode( 'arrowUp' ); + + setModelData( model, 'b[]ar' ); + + viewDoc.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + } ); } } ); diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js index 23f8dd3d3d9..b7bd3d2c4aa 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.js +++ b/packages/ckeditor5-table/src/tablekeyboard.js @@ -12,10 +12,7 @@ import TableWalker from './tablewalker'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; -import { - isArrowKeyCode, - getLocalizedArrowKeyCodeDirection -} from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { getLocalizedArrowKeyCodeDirection } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { getSelectedTableCells, getTableCellsContainingSelection } from './utils/selection'; /** @@ -50,7 +47,7 @@ export default class TableKeyboard extends Plugin { const arrowKeyObserver = this.editor.editing.getObserver( ArrowKeysModelObserver ); - this.listenTo( arrowKeyObserver.for( 'table' ), 'arrowkey', ( ...args ) => this._onKeydown( ...args ) ); + this.listenTo( arrowKeyObserver.for( 'table' ), 'arrowkey', ( ...args ) => this._onArrowKey( ...args ) ); } /** @@ -167,14 +164,10 @@ export default class TableKeyboard extends Plugin { * @param {module:utils/eventinfo~EventInfo} eventInfo * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ - _onKeydown( eventInfo, domEventData ) { + _onArrowKey( eventInfo, domEventData ) { const editor = this.editor; const keyCode = domEventData.keyCode; - if ( !isArrowKeyCode( keyCode ) ) { - return; - } - const direction = getLocalizedArrowKeyCodeDirection( keyCode, editor.locale.contentLanguageDirection ); const wasHandled = this._handleArrowKeys( direction, domEventData.shiftKey ); diff --git a/packages/ckeditor5-table/tests/tablekeyboard.js b/packages/ckeditor5-table/tests/tablekeyboard.js index 1f5baea69e4..49db3f22ce7 100644 --- a/packages/ckeditor5-table/tests/tablekeyboard.js +++ b/packages/ckeditor5-table/tests/tablekeyboard.js @@ -445,6 +445,19 @@ describe( 'TableKeyboard', () => { assertEqualMarkup( getModelData( model ), modelData ); } ); + it( 'should do nothing if the selection is on a table', () => { + const modelData = 'foobar[' + modelTable( [ [ '00', '01' ] ] ) + ']'; + + setModelData( model, modelData ); + + editor.editing.view.document.fire( 'keydown', leftArrowDomEvtDataStub ); + + sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault ); + sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation ); + + assertEqualMarkup( getModelData( model ), modelData ); + } ); + describe( '#_navigateFromCellInDirection (finding a proper cell to move the selection to)', () => { describe( 'with no col/row-spanned cells', () => { beforeEach( () => { diff --git a/packages/ckeditor5-typing/src/delete.js b/packages/ckeditor5-typing/src/delete.js index 535ca470304..6bb46d0b2b6 100644 --- a/packages/ckeditor5-typing/src/delete.js +++ b/packages/ckeditor5-typing/src/delete.js @@ -60,7 +60,9 @@ export default class Delete extends Plugin { data.preventDefault(); - view.scrollToTheSelection(); + if ( editor.ui ) { + view.scrollToTheSelection(); + } } ); // Android IMEs have a quirk - they change DOM selection after the input changes were performed by the browser. @@ -74,7 +76,7 @@ export default class Delete extends Plugin { if ( env.isAndroid ) { let domSelectionAfterDeletion = null; - this.listenTo( deleteObserver, 'delete', ( evt, data ) => { + this.listenTo( viewDocument, 'delete', ( evt, data ) => { const domSelection = data.domTarget.ownerDocument.defaultView.getSelection(); domSelectionAfterDeletion = { diff --git a/packages/ckeditor5-typing/tests/delete.js b/packages/ckeditor5-typing/tests/delete.js index 30df289e8e2..144477623d9 100644 --- a/packages/ckeditor5-typing/tests/delete.js +++ b/packages/ckeditor5-typing/tests/delete.js @@ -7,6 +7,7 @@ import Delete from '../src/delete'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import env from '@ckeditor/ckeditor5-utils/src/env'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -103,6 +104,21 @@ describe( 'Delete feature', () => { sinon.assert.callOrder( executeSpy, scrollSpy ); } ); + it( 'should not crash in virtual editor', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Delete ] + } ); + + const commandExecuteSpy = sinon.stub( editor.commands.get( 'delete' ), 'execute' ); + const viewDocument = editor.editing.view.document; + + viewDocument.fire( 'delete', new DomEventData( viewDocument, getDomEvent(), { direction: 'backward' } ) ); + + sinon.assert.calledOnce( commandExecuteSpy ); + + await editor.destroy(); + } ); + function getDomEvent() { return { preventDefault: sinon.spy() diff --git a/packages/ckeditor5-typing/tests/input.js b/packages/ckeditor5-typing/tests/input.js index 7d92c78e0d5..8fa71c635f4 100644 --- a/packages/ckeditor5-typing/tests/input.js +++ b/packages/ckeditor5-typing/tests/input.js @@ -1176,7 +1176,7 @@ describe( 'Input feature - Android', () => { }, { priority: 'lowest' } ); // On Android, `keycode` is set to `229` (in scenarios when `keydown` event is not send). - viewDocument.fire( 'beforeinput', { keyCode: 229 } ); + viewDocument.fire( 'beforeinput', { keyCode: 229, domEvent: { inputType: 'insertText' } } ); } ); it( 'should remove contents and merge blocks', () => { @@ -1187,11 +1187,11 @@ describe( 'Input feature - Android', () => { }, { priority: 'lowest' } ); // On Android, `keycode` is set to `229` (in scenarios when `keydown` event is not send). - viewDocument.fire( 'beforeinput', { keyCode: 229 } ); + viewDocument.fire( 'beforeinput', { keyCode: 229, domEvent: { inputType: 'insertText' } } ); } ); it( 'should do nothing if selection is collapsed', () => { - viewDocument.fire( 'beforeinput', { keyCode: 229 } ); + viewDocument.fire( 'beforeinput', { keyCode: 229, domEvent: { inputType: 'insertText' } } ); expect( getModelData( model ) ).to.equal( 'foo[]bar' ); } ); @@ -1202,7 +1202,7 @@ describe( 'Input feature - Android', () => { editor.commands.get( 'input' ).isEnabled = false; // On Android, `keycode` is set to `229` (in scenarios when `keydown` event is not send). - viewDocument.fire( 'beforeinput', { keyCode: 229 } ); + viewDocument.fire( 'beforeinput', { keyCode: 229, domEvent: { inputType: 'insertText' } } ); expect( getModelData( model ) ).to.equal( 'foo[]bar' ); } ); @@ -1213,7 +1213,7 @@ describe( 'Input feature - Android', () => { editor.commands.get( 'input' ).isEnabled = false; // On Android, `keycode` is set to `229` (in scenarios when `keydown` event is not send). - viewDocument.fire( 'beforeinput', { keyCode: 229 } ); + viewDocument.fire( 'beforeinput', { keyCode: 229, domEvent: { inputType: 'insertText' } } ); expect( getModelData( model ) ).to.equal( 'fo[ob]ar' ); } ); diff --git a/packages/ckeditor5-typing/tests/twostepcaretmovement.js b/packages/ckeditor5-typing/tests/twostepcaretmovement.js index 8ee13ad2b7c..680157c6902 100644 --- a/packages/ckeditor5-typing/tests/twostepcaretmovement.js +++ b/packages/ckeditor5-typing/tests/twostepcaretmovement.js @@ -9,12 +9,14 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import TwoStepCaretMovement from '../src/twostepcaretmovement'; import Position from '@ckeditor/ckeditor5-engine/src/model/position'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; +import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute'; @@ -736,25 +738,30 @@ describe( 'TwoStepCaretMovement()', () => { } ); } ); - it( 'should listen with the high+1 priority on view.document#keydown', () => { + it( 'should listen with the higher priority than widget type around', () => { + const highestPlusPrioritySpy = sinon.spy().named( 'highestPrioritySpy' ); const highestPrioritySpy = sinon.spy().named( 'highestPrioritySpy' ); const highPrioritySpy = sinon.spy().named( 'highPrioritySpy' ); const normalPrioritySpy = sinon.spy().named( 'normalPrioritySpy' ); setData( model, '<$text c="true">foo[]<$text a="true" b="true">bar' ); - emitter.listenTo( view.document, 'keydown', highestPrioritySpy, { priority: 'highest' } ); - emitter.listenTo( view.document, 'keydown', highPrioritySpy, { priority: 'high' } ); - emitter.listenTo( view.document, 'keydown', normalPrioritySpy, { priority: 'normal' } ); + const observer = editor.editing.getObserver( ArrowKeysModelObserver ).for( '$text' ); + + emitter.listenTo( observer, 'arrowkey', highestPlusPrioritySpy, { priority: priorities.highest + 1 } ); + emitter.listenTo( observer, 'arrowkey', highestPrioritySpy, { priority: 'highest' } ); + emitter.listenTo( observer, 'arrowkey', highPrioritySpy, { priority: 'high' } ); + emitter.listenTo( observer, 'arrowkey', normalPrioritySpy, { priority: 'normal' } ); fireKeyDownEvent( { keyCode: keyCodes.arrowright, preventDefault: preventDefaultSpy } ); - expect( highestPrioritySpy ).to.be.calledOnce; - expect( preventDefaultSpy ).to.be.calledImmediatelyAfter( highestPrioritySpy ); + expect( highestPlusPrioritySpy ).to.be.calledOnce; + expect( preventDefaultSpy ).to.be.calledImmediatelyAfter( highestPlusPrioritySpy ); + expect( highestPrioritySpy ).not.to.be.called; expect( highPrioritySpy ).not.to.be.called; expect( normalPrioritySpy ).not.to.be.called; } ); diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index 3abcc455657..f6fb895ac81 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -10,13 +10,11 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import WidgetTypeAround from './widgettypearound/widgettypearound'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils'; -import { - isArrowKeyCode, - isForwardArrowKeyCode -} from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; import env from '@ckeditor/ckeditor5-utils/src/env'; import '../theme/widget.css'; @@ -49,7 +47,7 @@ export default class Widget extends Plugin { * @inheritDoc */ static get requires() { - return [ WidgetTypeAround ]; + return [ WidgetTypeAround, Delete ]; } /** @@ -124,9 +122,9 @@ export default class Widget extends Plugin { this._handleSelectionChangeOnArrowKeyPress( ...args ); } ); - this.listenTo( arrowKeyObserver.for( '$object' ), 'arrowkey', ( ...args ) => { + this.listenTo( arrowKeyObserver.for( '$root' ), 'arrowkey', ( ...args ) => { this._preventDefaultOnArrowKeyPress( ...args ); - }, { priority: 'lowest' } ); + } ); this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', verticalNavigationHandler( this.editor.editing ) ); @@ -213,12 +211,6 @@ export default class Widget extends Plugin { _handleSelectionChangeOnArrowKeyPress( eventInfo, domEventData ) { const keyCode = domEventData.keyCode; - // Checks if the keys were handled and then prevents the default event behaviour and stops - // the propagation. - if ( !isArrowKeyCode( keyCode ) ) { - return; - } - const model = this.editor.model; const schema = model.schema; const modelSelection = model.document.selection; @@ -270,14 +262,6 @@ export default class Widget extends Plugin { * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ _preventDefaultOnArrowKeyPress( eventInfo, domEventData ) { - const keyCode = domEventData.keyCode; - - // Checks if the keys were handled and then prevents the default event behaviour and stops - // the propagation. - if ( !isArrowKeyCode( keyCode ) ) { - return; - } - const model = this.editor.model; const schema = model.schema; const objectElement = model.document.selection.getSelectedElement(); diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 788707ac239..06b71ca70c2 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -11,6 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Template from '@ckeditor/ckeditor5-ui/src/template'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; @@ -63,6 +65,13 @@ export default class WidgetTypeAround extends Plugin { return 'WidgetTypeAround'; } + /** + * @inheritDoc + */ + static get requires() { + return [ Enter, Delete ]; + } + /** * @inheritDoc */ @@ -627,7 +636,6 @@ export default class WidgetTypeAround extends Plugin { const deleteObserver = editor.editing.getObserver( DeleteModelObserver ); - // Note: The priority must precede the default Widget class delete handler. this._listenToIfEnabled( deleteObserver.for( '$object' ), 'delete', ( evt, domEventData ) => { const selectedModelWidget = model.document.selection.getSelectedElement(); diff --git a/packages/ckeditor5-widget/tests/widget.js b/packages/ckeditor5-widget/tests/widget.js index 066eef7de12..56675f16757 100644 --- a/packages/ckeditor5-widget/tests/widget.js +++ b/packages/ckeditor5-widget/tests/widget.js @@ -11,6 +11,7 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Widget from '../src/widget'; import WidgetTypeAround from '../src/widgettypearound/widgettypearound'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import { toWidget } from '../src/utils'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; @@ -132,8 +133,8 @@ describe( 'Widget', () => { expect( view.getObserver( MouseObserver ) ).to.be.instanceof( MouseObserver ); } ); - it( 'should require the WidgetTypeAround plugin', () => { - expect( Widget.requires ).to.have.members( [ WidgetTypeAround ] ); + it( 'should require the WidgetTypeAround and Delete plugins', () => { + expect( Widget.requires ).to.have.members( [ WidgetTypeAround, Delete ] ); } ); it( 'should create selection over clicked widget', () => { @@ -433,7 +434,7 @@ describe( 'Widget', () => { viewDocument.fire( 'keydown', domEventDataMock ); expect( getModelData( model ) ).to.equal( 'foo[]' ); - sinon.assert.calledTwice( domEventDataMock.preventDefault ); + sinon.assert.called( domEventDataMock.preventDefault ); sinon.assert.notCalled( keydownHandler ); } ); @@ -451,7 +452,7 @@ describe( 'Widget', () => { viewDocument.fire( 'keydown', domEventDataMock ); expect( getModelData( model ) ).to.equal( '[]foo' ); - sinon.assert.calledTwice( domEventDataMock.preventDefault ); + sinon.assert.called( domEventDataMock.preventDefault ); sinon.assert.notCalled( keydownHandler ); } ); diff --git a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js index 41aa6ad03ad..38286544130 100644 --- a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js @@ -366,7 +366,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.true; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should activate before when the widget is selected and the navigation is backward', () => { @@ -383,7 +383,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should activate if an arrow key is pressed along with Shift', () => { @@ -508,7 +508,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'before' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); fireKeyboardEvent( 'arrowleft' ); @@ -521,7 +521,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should deactivate when the widget is selected and the navigation is forward to a valid position', () => { @@ -533,7 +533,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'after' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); fireKeyboardEvent( 'arrowright' ); @@ -546,7 +546,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should deactivate if an arrow key is pressed along with Shift', () => { @@ -558,7 +558,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'before' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); fireKeyboardEvent( 'arrowleft', { shiftKey: true } ); @@ -566,7 +566,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.be.undefined; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should not deactivate when the widget is selected and the navigation is backward but there is nowhere to go', () => { @@ -578,7 +578,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'before' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); fireKeyboardEvent( 'arrowleft' ); @@ -586,7 +586,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'before' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); const viewWidget = viewRoot.getChild( 0 ); @@ -594,7 +594,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should not deactivate when the widget is selected and the navigation is forward but there is nowhere to go', () => { @@ -606,7 +606,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'after' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); fireKeyboardEvent( 'arrowright' ); @@ -614,7 +614,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'after' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); const viewWidget = viewRoot.getChild( 0 ); @@ -622,7 +622,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.true; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should deactivate when the widget is selected and the navigation is against the fake caret (backward)', () => { @@ -634,7 +634,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'before' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); fireKeyboardEvent( 'arrowright' ); @@ -647,7 +647,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); it( 'should deactivate when the widget is selected and the navigation is against the fake caret (forward)', () => { @@ -659,7 +659,7 @@ describe( 'WidgetTypeAround', () => { expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.equal( 'after' ); sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); fireKeyboardEvent( 'arrowleft' ); @@ -672,7 +672,7 @@ describe( 'WidgetTypeAround', () => { expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; sinon.assert.calledOnce( eventInfoStub.stop ); - sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + sinon.assert.called( domEventDataStub.domEvent.preventDefault ); } ); } ); @@ -1209,6 +1209,13 @@ describe( 'WidgetTypeAround', () => { sinon.assert.calledOnce( eventInfoStub.stop ); sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); } ); + + it( 'should do nothing if some content inside widget is deleted', () => { + setModelData( editor.model, '[foo] bar' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( '[] bar' ); + } ); } ); describe( 'forward delete', () => { From 79b8efbbc705f64abc3e5c62bed48e5ee5a227aa Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sun, 13 Dec 2020 17:14:53 +0100 Subject: [PATCH 04/43] Removed unnecessary priority manipulation. --- .../src/widgettypearound/widgettypearound.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 06b71ca70c2..286a0422194 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -20,7 +20,6 @@ import { isForwardArrowKeyCode, keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; import { isTypeAroundWidget, @@ -609,14 +608,13 @@ export default class WidgetTypeAround extends Plugin { keyCodes.backspace ]; - // Note: The priority must precede the default Widget class keydown handler ("high") and the - // TableKeyboard keydown handler ("high + 1"). + // Note: The priority must precede the default model observers. this._listenToIfEnabled( editingView.document, 'keydown', ( evt, domEventData ) => { // Don't handle enter/backspace/delete here. They are handled in dedicated listeners. if ( !keyCodesHandledSomewhereElse.includes( domEventData.keyCode ) && !isNonTypingKeystroke( domEventData ) ) { this._insertParagraphAccordingToFakeCaretPosition(); } - }, { priority: priorities.get( 'high' ) + 1 } ); + }, { priority: 'high' } ); } /** From 69db918f29c9ea9f876213e9cd10b1ecbbe819e2 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 14 Dec 2020 12:53:23 +0100 Subject: [PATCH 05/43] Updated JsDocs. --- .../ckeditor5-engine/src/model/observer/modelobserver.js | 6 +++--- packages/ckeditor5-enter/src/entermodelobserver.js | 2 +- packages/ckeditor5-typing/src/deletemodelobserver.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ckeditor5-engine/src/model/observer/modelobserver.js b/packages/ckeditor5-engine/src/model/observer/modelobserver.js index 765c5dd1ca1..4d90994c279 100644 --- a/packages/ckeditor5-engine/src/model/observer/modelobserver.js +++ b/packages/ckeditor5-engine/src/model/observer/modelobserver.js @@ -71,7 +71,7 @@ export default class ModelObserver { * Enables the observer. This method is called when the observer is registered to the * {@link module:engine/model/model~Model}. * - * @see module:engine/model/observer/modelobserver~Observer#disable + * @see module:engine/model/observer/modelobserver~ModelObserver#disable */ enable() { this.isEnabled = true; @@ -80,7 +80,7 @@ export default class ModelObserver { /** * Disables the observer. * - * @see module:engine/model/observer/modelobserver~Observer#enable + * @see module:engine/model/observer/modelobserver~ModelObserver#enable */ disable() { this.isEnabled = false; @@ -205,7 +205,7 @@ export default class ModelObserver { * observer {@link #isEnabled is not enabled}. * * @param {...*} [args] - * @returns {Array.<*>|false} + * @returns {Array.<*>|Boolean} False if event should not be handled. */ translateViewEvent( ...args ) { return args; diff --git a/packages/ckeditor5-enter/src/entermodelobserver.js b/packages/ckeditor5-enter/src/entermodelobserver.js index 6c621c67755..e8ad3632bc2 100644 --- a/packages/ckeditor5-enter/src/entermodelobserver.js +++ b/packages/ckeditor5-enter/src/entermodelobserver.js @@ -12,7 +12,7 @@ import ModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/modelob /** * Observes... TODO * - * @extends module:engine/model/observer/modelobserver~Observer + * @extends module:engine/model/observer/modelobserver~ModelObserver */ export default class EnterModelObserver extends ModelObserver { /** diff --git a/packages/ckeditor5-typing/src/deletemodelobserver.js b/packages/ckeditor5-typing/src/deletemodelobserver.js index 878448ef40a..39b3c54f1b0 100644 --- a/packages/ckeditor5-typing/src/deletemodelobserver.js +++ b/packages/ckeditor5-typing/src/deletemodelobserver.js @@ -12,7 +12,7 @@ import ModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/modelob /** * Observes... TODO * - * @extends module:engine/model/observer/modelobserver~Observer + * @extends module:engine/model/observer/modelobserver~ModelObserver */ export default class DeleteModelObserver extends ModelObserver { /** From ee7aab583dcc3e9bc87fdbe09a11ad6b35acf565 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 21 Dec 2020 14:42:57 +0100 Subject: [PATCH 06/43] Fixed delete handler for widget neighborhood. --- packages/ckeditor5-widget/src/widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index f6fb895ac81..95b7079b06e 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -131,7 +131,7 @@ export default class Widget extends Plugin { const deleteObserver = this.editor.editing.getObserver( DeleteModelObserver ); // Handle custom delete behaviour. - this.listenTo( deleteObserver.for( '$text' ), 'delete', ( evt, data ) => { + this.listenTo( deleteObserver.for( '$root' ), 'delete', ( evt, data ) => { if ( this._handleDelete( data.direction == 'forward' ) ) { data.preventDefault(); evt.stop(); From a275562570ea191c67514284830a2cc31ec65e2e Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sun, 7 Feb 2021 14:12:47 +0100 Subject: [PATCH 07/43] Merge conflicts. --- packages/ckeditor5-block-quote/src/blockquoteediting.js | 6 +++--- packages/ckeditor5-code-block/src/codeblockediting.js | 4 ++-- packages/ckeditor5-engine/src/index.js | 1 + packages/ckeditor5-enter/src/index.js | 1 + packages/ckeditor5-list/src/listediting.js | 6 +++--- packages/ckeditor5-typing/src/index.js | 1 + .../src/widgettypearound/widgettypearound.js | 4 +++- 7 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/ckeditor5-block-quote/src/blockquoteediting.js b/packages/ckeditor5-block-quote/src/blockquoteediting.js index c4c3b09334c..d975301ec50 100644 --- a/packages/ckeditor5-block-quote/src/blockquoteediting.js +++ b/packages/ckeditor5-block-quote/src/blockquoteediting.js @@ -8,10 +8,10 @@ */ import { Plugin } from 'ckeditor5/src/core'; +import { Enter, EnterModelObserver } from 'ckeditor5/src/enter'; +import { Delete, DeleteModelObserver } from 'ckeditor5/src/typing'; import BlockQuoteCommand from './blockquotecommand'; -import { EnterModelObserver } from 'ckeditor5/src/enter'; -import { DeleteModelObserver } from 'ckeditor5/src/typing'; /** * The block quote editing. @@ -32,7 +32,7 @@ export default class BlockQuoteEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ 'Enter', 'Delete' ]; + return [ Enter, Delete ]; } /** diff --git a/packages/ckeditor5-code-block/src/codeblockediting.js b/packages/ckeditor5-code-block/src/codeblockediting.js index 8ff056f2261..06697726a2d 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.js +++ b/packages/ckeditor5-code-block/src/codeblockediting.js @@ -8,7 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import { EnterModelObserver } from 'ckeditor5/src/enter'; +import { ShiftEnter, EnterModelObserver } from 'ckeditor5/src/enter'; import CodeBlockCommand from './codeblockcommand'; import IndentCodeBlockCommand from './indentcodeblockcommand'; @@ -45,7 +45,7 @@ export default class CodeBlockEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ 'ShiftEnter' ]; + return [ ShiftEnter ]; } /** diff --git a/packages/ckeditor5-engine/src/index.js b/packages/ckeditor5-engine/src/index.js index 4e1971d6441..b41247cb5e1 100644 --- a/packages/ckeditor5-engine/src/index.js +++ b/packages/ckeditor5-engine/src/index.js @@ -28,6 +28,7 @@ export { default as LivePosition } from './model/liveposition'; export { default as Model } from './model/model'; export { default as TreeWalker } from './model/treewalker'; export { default as Element } from './model/element'; +export { default as ArrowKeysModelObserver } from './model/observer/arrowkeysmodelobserver'; export { default as DomConverter } from './view/domconverter'; export { default as ViewDocument } from './view/document'; diff --git a/packages/ckeditor5-enter/src/index.js b/packages/ckeditor5-enter/src/index.js index 15684484c13..676bb6d8cf0 100644 --- a/packages/ckeditor5-enter/src/index.js +++ b/packages/ckeditor5-enter/src/index.js @@ -9,3 +9,4 @@ export { default as Enter } from './enter'; export { default as ShiftEnter } from './shiftenter'; +export { default as EnterModelObserver } from './entermodelobserver'; diff --git a/packages/ckeditor5-list/src/listediting.js b/packages/ckeditor5-list/src/listediting.js index 558dd0b32e8..ecad5feaad0 100644 --- a/packages/ckeditor5-list/src/listediting.js +++ b/packages/ckeditor5-list/src/listediting.js @@ -11,8 +11,8 @@ import ListCommand from './listcommand'; import IndentCommand from './indentcommand'; import { Plugin } from 'ckeditor5/src/core'; -import { EnterModelObserver } from 'ckeditor5/src/enter'; -import { DeleteModelObserver } from 'ckeditor5/src/typing'; +import { Enter, EnterModelObserver } from 'ckeditor5/src/enter'; +import { Delete, DeleteModelObserver } from 'ckeditor5/src/typing'; import { cleanList, @@ -50,7 +50,7 @@ export default class ListEditing extends Plugin { * @inheritDoc */ static get requires() { - return [ 'Paragraph', 'Enter', 'Delete' ]; + return [ Enter, Delete ]; } /** diff --git a/packages/ckeditor5-typing/src/index.js b/packages/ckeditor5-typing/src/index.js index 57068bc26c4..6dec66eddf4 100644 --- a/packages/ckeditor5-typing/src/index.js +++ b/packages/ckeditor5-typing/src/index.js @@ -14,6 +14,7 @@ export { default as Delete } from './delete'; export { default as TextWatcher } from './textwatcher'; export { default as TwoStepCaretMovement } from './twostepcaretmovement'; export { default as TextTransformation } from './texttransformation'; +export { default as DeleteModelObserver } from './deletemodelobserver'; export { default as inlineHighlight } from './utils/inlinehighlight'; export { default as findAttributeRange } from './utils/findattributerange'; diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 9f238032330..6bc7356af09 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -14,6 +14,8 @@ import Template from '@ckeditor/ckeditor5-ui/src/template'; import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; import { isForwardArrowKeyCode, keyCodes @@ -66,7 +68,7 @@ export default class WidgetTypeAround extends Plugin { * @inheritDoc */ static get requires() { - return [ 'Enter', 'Delete' ]; + return [ Enter, Delete ]; } /** From a3860583ed6364f580e08373add2ba05f8ff5c1d Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sun, 7 Feb 2021 14:41:27 +0100 Subject: [PATCH 08/43] EmitterMixin should have a separate methods for listener and emitter. --- packages/ckeditor5-utils/src/emittermixin.js | 130 +++++++++++-------- 1 file changed, 79 insertions(+), 51 deletions(-) diff --git a/packages/ckeditor5-utils/src/emittermixin.js b/packages/ckeditor5-utils/src/emittermixin.js index fdd54838d74..9e0df121d44 100644 --- a/packages/ckeditor5-utils/src/emittermixin.js +++ b/packages/ckeditor5-utils/src/emittermixin.js @@ -109,34 +109,7 @@ const EmitterMixin = { eventCallbacks.push( callback ); // Finally register the callback to the event. - createEventNamespace( emitter, event ); - const lists = getCallbacksListsForNamespace( emitter, event ); - const priority = priorities.get( options.priority ); - - const callbackDefinition = { - callback, - priority - }; - - // Add the callback to all callbacks list. - for ( const callbacks of lists ) { - // Add the callback to the list in the right priority position. - let added = false; - - for ( let i = 0; i < callbacks.length; i++ ) { - if ( callbacks[ i ].priority < priority ) { - callbacks.splice( i, 0, callbackDefinition ); - added = true; - - break; - } - } - - // Add at the end, if right place was not found. - if ( !added ) { - callbacks.push( callbackDefinition ); - } - } + emitter._addEventListener( event, callback, options ); }, /** @@ -155,7 +128,7 @@ const EmitterMixin = { // All params provided. off() that single callback. if ( callback ) { - removeCallback( emitter, event, callback ); + emitter._removeEventListener( event, callback ); // We must remove callbacks as well in order to prevent memory leaks. // See https://github.com/ckeditor/ckeditor5/pull/8480 @@ -165,14 +138,14 @@ const EmitterMixin = { if ( eventCallbacks.length === 1 ) { delete emitterInfo.callbacks[ event ]; } else { - removeCallback( emitter, event, callback ); + emitter._removeEventListener( event, callback ); } } } // Only `emitter` and `event` provided. off() all callbacks for that event. else if ( eventCallbacks ) { while ( ( callback = eventCallbacks.pop() ) ) { - removeCallback( emitter, event, callback ); + emitter._removeEventListener( event, callback ); } delete emitterInfo.callbacks[ event ]; @@ -225,7 +198,7 @@ const EmitterMixin = { // Remove the called mark for the next calls. delete eventInfo.off.called; - removeCallback( this, event, callbacks[ i ].callback ); + this._removeEventListener( event, callbacks[ i ].callback ); } // Do not execute next callbacks if stop() was called. @@ -301,6 +274,58 @@ const EmitterMixin = { destinations.delete( emitter ); } } + }, + + /** + * @inheritDoc + */ + _addEventListener( event, callback, options ) { + createEventNamespace( this, event ); + + const lists = getCallbacksListsForNamespace( this, event ); + const priority = priorities.get( options.priority ); + + const callbackDefinition = { + callback, + priority + }; + + // Add the callback to all callbacks list. + for ( const callbacks of lists ) { + // Add the callback to the list in the right priority position. + let added = false; + + for ( let i = 0; i < callbacks.length; i++ ) { + if ( callbacks[ i ].priority < priority ) { + callbacks.splice( i, 0, callbackDefinition ); + added = true; + + break; + } + } + + // Add at the end, if right place was not found. + if ( !added ) { + callbacks.push( callbackDefinition ); + } + } + }, + + /** + * @inheritDoc + */ + _removeEventListener( event, callback ) { + const lists = getCallbacksListsForNamespace( this, event ); + + for ( const callbacks of lists ) { + for ( let i = 0; i < callbacks.length; i++ ) { + if ( callbacks[ i ].callback == callback ) { + // Remove the callback from the list (fixing the next index). + callbacks.splice( i, 1 ); + i--; + } + } + } } }; @@ -443,6 +468,28 @@ export default EmitterMixin; * If omitted, stops delegation of `event` to all emitters. */ +/** + * Adds callback to emitter for given event. + * + * @protected + * @method #_addEventListener + * @param {String} event The name of the event. + * @param {Function} callback The function to be called on event. + * @param {Object} [options={}] Additional options. + * @param {module:utils/priorities~PriorityString|Number} [options.priority='normal'] The priority of this event callback. The higher + * the priority value the sooner the callback will be fired. Events having the same priority are called in the + * order they were added. + */ + +/** + * Removes callback from emitter for given event. + * + * @protected + * @method #_removeEventListener + * @param {String} event The name of the event. + * @param {Function} callback The function to stop being called. + */ + /** * Checks if `listeningEmitter` listens to an emitter with given `listenedToEmitterId` and if so, returns that emitter. * If not, returns `null`. @@ -638,25 +685,6 @@ function fireDelegatedEvents( destinations, eventInfo, fireArgs ) { } } -// Removes callback from emitter for given event. -// -// @param {module:utils/emittermixin~Emitter} emitter -// @param {String} event -// @param {Function} callback -function removeCallback( emitter, event, callback ) { - const lists = getCallbacksListsForNamespace( emitter, event ); - - for ( const callbacks of lists ) { - for ( let i = 0; i < callbacks.length; i++ ) { - if ( callbacks[ i ].callback == callback ) { - // Remove the callback from the list (fixing the next index). - callbacks.splice( i, 1 ); - i--; - } - } - } -} - /** * The return value of {@link ~EmitterMixin#delegate}. * From fa95beaafec20e162b54000bfe68f047e01efbe8 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sun, 7 Feb 2021 19:43:29 +0100 Subject: [PATCH 09/43] Introducing BubblingObserver. --- .../src/blockquoteediting.js | 17 +- .../src/codeblockediting.js | 8 +- .../src/model/observer/modelobserver.js | 2 +- .../ckeditor5-engine/src/view/document.js | 38 +++- .../src/view/observer/bubblingobserver.js | 205 ++++++++++++++++++ packages/ckeditor5-engine/src/view/view.js | 19 +- packages/ckeditor5-enter/src/enter.js | 14 +- .../ckeditor5-enter/src/entermodelobserver.js | 24 -- packages/ckeditor5-enter/src/enterobserver.js | 8 +- packages/ckeditor5-enter/src/index.js | 1 - packages/ckeditor5-enter/src/shiftenter.js | 14 +- packages/ckeditor5-list/src/listediting.js | 16 +- packages/ckeditor5-typing/src/delete.js | 6 +- .../src/deletemodelobserver.js | 24 -- .../ckeditor5-typing/src/deleteobserver.js | 8 +- packages/ckeditor5-typing/src/index.js | 1 - packages/ckeditor5-widget/src/widget.js | 7 +- .../src/widgettypearound/widgettypearound.js | 18 +- 18 files changed, 297 insertions(+), 133 deletions(-) create mode 100644 packages/ckeditor5-engine/src/view/observer/bubblingobserver.js delete mode 100644 packages/ckeditor5-enter/src/entermodelobserver.js delete mode 100644 packages/ckeditor5-typing/src/deletemodelobserver.js diff --git a/packages/ckeditor5-block-quote/src/blockquoteediting.js b/packages/ckeditor5-block-quote/src/blockquoteediting.js index d975301ec50..fea393d9b23 100644 --- a/packages/ckeditor5-block-quote/src/blockquoteediting.js +++ b/packages/ckeditor5-block-quote/src/blockquoteediting.js @@ -8,8 +8,8 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import { Enter, EnterModelObserver } from 'ckeditor5/src/enter'; -import { Delete, DeleteModelObserver } from 'ckeditor5/src/typing'; +import { Enter } from 'ckeditor5/src/enter'; +import { Delete } from 'ckeditor5/src/typing'; import BlockQuoteCommand from './blockquotecommand'; @@ -112,14 +112,13 @@ export default class BlockQuoteEditing extends Plugin { return false; } ); + const viewDocument = this.editor.editing.view.document; const selection = editor.model.document.selection; const blockQuoteCommand = editor.commands.get( 'blockQuote' ); - const enterObserver = editor.editing.getObserver( EnterModelObserver ); - // Overwrite default Enter key behavior. // If Enter key is pressed with selection collapsed in empty block inside a quote, break the quote. - this.listenTo( enterObserver.for( 'blockQuote' ), 'enter', ( evt, data ) => { + this.listenTo( viewDocument, 'enter', ( evt, data ) => { if ( !selection.isCollapsed || !blockQuoteCommand.value ) { return; } @@ -133,13 +132,11 @@ export default class BlockQuoteEditing extends Plugin { data.preventDefault(); evt.stop(); } - } ); - - const deleteObserver = editor.editing.getObserver( DeleteModelObserver ); + }, { context: 'blockquote' } ); // Overwrite default Backspace key behavior. // If Backspace key is pressed with selection collapsed in first empty block inside a quote, break the quote. - this.listenTo( deleteObserver.for( 'blockQuote' ), 'delete', ( evt, data ) => { + this.listenTo( viewDocument, 'delete', ( evt, data ) => { if ( data.direction != 'backward' || !selection.isCollapsed || !blockQuoteCommand.value ) { return; } @@ -153,6 +150,6 @@ export default class BlockQuoteEditing extends Plugin { data.preventDefault(); evt.stop(); } - } ); + }, { context: 'blockquote' } ); } } diff --git a/packages/ckeditor5-code-block/src/codeblockediting.js b/packages/ckeditor5-code-block/src/codeblockediting.js index 06697726a2d..c583271efa0 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.js +++ b/packages/ckeditor5-code-block/src/codeblockediting.js @@ -8,7 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import { ShiftEnter, EnterModelObserver } from 'ckeditor5/src/enter'; +import { ShiftEnter } from 'ckeditor5/src/enter'; import CodeBlockCommand from './codeblockcommand'; import IndentCodeBlockCommand from './indentcodeblockcommand'; @@ -204,13 +204,11 @@ export default class CodeBlockEditing extends Plugin { outdent.registerChildCommand( commands.get( 'outdentCodeBlock' ) ); } - const enterObserver = editor.editing.getObserver( EnterModelObserver ); - // Customize the response to the Enter and Shift+Enter // key press when the selection is in the code block. Upon enter key press we can either // leave the block if it's "two enters" in a row or create a new code block line, preserving // previous line's indentation. - this.listenTo( enterObserver.for( 'codeBlock' ), 'enter', ( evt, data ) => { + this.listenTo( editor.editing.view.document, 'enter', ( evt, data ) => { const positionParent = editor.model.document.selection.getLastPosition().parent; if ( !positionParent.is( 'element', 'codeBlock' ) ) { @@ -223,7 +221,7 @@ export default class CodeBlockEditing extends Plugin { data.preventDefault(); evt.stop(); - } ); + }, { context: 'pre' } ); } } diff --git a/packages/ckeditor5-engine/src/model/observer/modelobserver.js b/packages/ckeditor5-engine/src/model/observer/modelobserver.js index 4d90994c279..f32e718abc1 100644 --- a/packages/ckeditor5-engine/src/model/observer/modelobserver.js +++ b/packages/ckeditor5-engine/src/model/observer/modelobserver.js @@ -124,7 +124,7 @@ export default class ModelObserver { const eventInfo = new EventInfo( this, this.modelEventType ); - const position = selection.focus.path.length < selection.anchor.path.length ? selection.anchor : selection.focus; + const position = selection.anchor.path.length > selection.focus.path.length ? selection.anchor : selection.focus; const acceptsText = selection.isCollapsed && schema.checkChild( position, '$text' ); let node = selection.getSelectedElement() || position.textNode || position.parent; diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/src/view/document.js index e786c568e30..eb495ed26e6 100644 --- a/packages/ckeditor5-engine/src/view/document.js +++ b/packages/ckeditor5-engine/src/view/document.js @@ -11,6 +11,7 @@ import DocumentSelection from './documentselection'; import Collection from '@ckeditor/ckeditor5-utils/src/collection'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import BubblingObserver from './observer/bubblingobserver'; // @if CK_DEBUG_ENGINE // const { logDocument } = require( '../dev-utils/utils' ); @@ -25,8 +26,9 @@ export default class Document { * Creates a Document instance. * * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor The styles processor instance. + * @param {Map} observers TODO */ - constructor( stylesProcessor ) { + constructor( stylesProcessor, observers ) { /** * Selection done on this document. * @@ -97,6 +99,14 @@ export default class Document { * @member {Set} */ this._postFixers = new Set(); + + /** + * TODO + * + * @private + * @member {Map} + */ + this._observers = observers; } /** @@ -190,6 +200,32 @@ export default class Document { } while ( wasFixed ); } + /** + * TODO + * + * @protected + */ + _addEventListener( event, callback, options = {} ) { + if ( options.context ) { + for ( const observer of this._observers.values() ) { + if ( observer instanceof BubblingObserver && observer.eventType == event ) { + observer._addListener( options.context, callback, options ); + } + } + } else { + ObservableMixin._addEventListener.call( this, event, callback, options ); + } + } + + /** + * TODO + * @protected + */ + _removeEventListener( event, callback ) { + // TODO + return ObservableMixin._removeEventListener.call( this, event, callback ); + } + /** * Event fired whenever document content layout changes. It is fired whenever content is * {@link module:engine/view/view~View#event:render rendered}, but should be also fired by observers in case of diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js new file mode 100644 index 00000000000..ca59181f6f8 --- /dev/null +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -0,0 +1,205 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/view/observer/bubblingobserver + */ + +import Observer from './observer'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; + +/** + * Abstract base bubbling observer class. Observers are classes which listen to events, do the preliminary + * processing and fire events on the {@link module:engine/view/document~Document} objects. + * + * TODO + * + * @abstract + */ +export default class BubblingObserver extends Observer { + /** + * Creates an instance of the bubbling observer. + * + * @param {module:engine/view/view~View} view + * @param {String} eventType TODO + */ + constructor( view, eventType ) { + super( view ); + + /** + * Type of the event the observer should listen to. + * + * @readonly + * @member {String} + */ + this.eventType = eventType; + + /** + * TODO + * + * @private + * @member {Map.} + */ + this._listeners = new Map(); + + /** + * TODO + * + * @private + */ + this._customContexts = new Map(); + + this._setupListener(); + } + + /** + * @inheritDoc + */ + destroy() { + for ( const listener of this._listeners.values() ) { + listener.stopListening(); + } + + super.destroy(); + } + + /** + * @inheritDoc + */ + observe() {} + + /** + * TODO + * + * @protected + */ + _addListener( context, callback, options ) { + let listener = this._listeners.get( context ); + + if ( !listener ) { + this._listeners.set( context, listener = Object.create( EmitterMixin ) ); + } + + if ( options.contextMatcher ) { + this._customContexts.set( options.context, options.contextMatcher ); + } + + listener._addEventListener( this.eventType, callback, options ); + } + + /** + * TODO + * + * @protected + */ + _removeListener( callback, options ) { + // TODO + } + + /** + * TODO + * + * @protected + * @param {module:utils/eventinfo~EventInfo} eventInfo + * @param {...*} [args] + * @returns {Array.<*>|Boolean} False if event should not be handled. TODO + */ + _translateEvent( eventName, ...args ) { + return [ new EventInfo( this, eventName ), ...args ]; + } + + /** + * TODO + * + * @private + */ + _setupListener() { + const selection = this.document.selection; + + this.listenTo( this.document, this.eventType, ( event, ...args ) => { + if ( !this.isEnabled ) { + return; + } + + const translatedEvent = this._translateEvent( event.name, ...args ); + + if ( translatedEvent === false ) { + return; + } + + let [ eventInfo, ...eventArgs ] = translatedEvent; + + if ( !Array.isArray( eventArgs ) ) { + eventArgs = [ eventArgs ]; + } + + const selectedElement = selection.getSelectedElement(); + + // TODO selected element could be an attribute element. + + // For the not yet bubbling event trigger for $text node if selection can be there and it's not a widget selected. + if ( !selectedElement && this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { + // Stop the original event. + event.stop(); + + return; + } + + let node = selectedElement || selection.focus.parent; + + while ( node ) { + // Root node handling. + if ( node.is( 'rootElement' ) ) { + if ( this._fireListenerFor( '$root', eventInfo, ...eventArgs ) ) { + break; + } + } + + // Element node handling. + else if ( node.is( 'element' ) ) { + if ( this._fireListenerFor( node.name, eventInfo, ...eventArgs ) ) { + break; + } + } + + // Check custom contexts (i.e., a widget). + for ( const [ context, matcher ] of this._customContexts ) { + if ( matcher( node ) && this._fireListenerFor( context, eventInfo, ...eventArgs ) ) { + break; + } + } + + node = node.parent; + } + + // Stop the event if generic handler stopped it. + if ( eventInfo.stop.called ) { + event.stop(); + } + }, { priority: 'high' } ); + } + + /** + * TODO + * + * @private + * @param {String} name + * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. + * @param {...*} [args] Additional arguments to be passed to the callbacks. + * @returns {Boolean} True if event stop was called. + */ + _fireListenerFor( name, eventInfo, ...args ) { + const listener = this._listeners.get( name ); + + if ( !listener ) { + return false; + } + + listener.fire( eventInfo, ...args ); + + return eventInfo.stop.called; + } +} diff --git a/packages/ckeditor5-engine/src/view/view.js b/packages/ckeditor5-engine/src/view/view.js index 54404ad291b..763e653c9dc 100644 --- a/packages/ckeditor5-engine/src/view/view.js +++ b/packages/ckeditor5-engine/src/view/view.js @@ -66,13 +66,22 @@ export default class View { * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor The styles processor instance. */ constructor( stylesProcessor ) { + + /** + * Map of registered {@link module:engine/view/observer/observer~Observer observers}. + * + * @private + * @type {Map.} + */ + this._observers = new Map(); + /** * Instance of the {@link module:engine/view/document~Document} associated with this view controller. * * @readonly * @type {module:engine/view/document~Document} */ - this.document = new Document( stylesProcessor ); + this.document = new Document( stylesProcessor, this._observers ); /** * Instance of the {@link module:engine/view/domconverter~DomConverter domConverter} used by @@ -128,14 +137,6 @@ export default class View { */ this._initialDomRootAttributes = new WeakMap(); - /** - * Map of registered {@link module:engine/view/observer/observer~Observer observers}. - * - * @private - * @type {Map.} - */ - this._observers = new Map(); - /** * Is set to `true` when {@link #change view changes} are currently in progress. * diff --git a/packages/ckeditor5-enter/src/enter.js b/packages/ckeditor5-enter/src/enter.js index 0b811da7965..e2a3ba05aa4 100644 --- a/packages/ckeditor5-enter/src/enter.js +++ b/packages/ckeditor5-enter/src/enter.js @@ -10,7 +10,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import EnterCommand from './entercommand'; import EnterObserver from './enterobserver'; -import EnterModelObserver from './entermodelobserver'; /** * This plugin handles the Enter key (hard line break) in the editor. @@ -31,17 +30,14 @@ export default class Enter extends Plugin { init() { const editor = this.editor; - const editing = editor.editing; - const view = editing.view; + const view = editor.editing.view; + const viewDocument = view.document; view.addObserver( EnterObserver ); editor.commands.add( 'enter', new EnterCommand( editor ) ); - // Add generic enter model observer (not bound to any element). - const enterObserver = editing.addObserver( EnterModelObserver ); - - this.listenTo( enterObserver, 'enter', ( evt, data ) => { + this.listenTo( viewDocument, 'enter', ( evt, data ) => { data.preventDefault(); // The soft enter key is handled by the ShiftEnter plugin. @@ -51,9 +47,7 @@ export default class Enter extends Plugin { editor.execute( 'enter' ); - if ( editor.ui ) { - view.scrollToTheSelection(); - } + view.scrollToTheSelection(); } ); } } diff --git a/packages/ckeditor5-enter/src/entermodelobserver.js b/packages/ckeditor5-enter/src/entermodelobserver.js deleted file mode 100644 index e8ad3632bc2..00000000000 --- a/packages/ckeditor5-enter/src/entermodelobserver.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module enter/entermodelobserver - */ - -import ModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/modelobserver'; - -/** - * Observes... TODO - * - * @extends module:engine/model/observer/modelobserver~ModelObserver - */ -export default class EnterModelObserver extends ModelObserver { - /** - * @inheritDoc - */ - constructor( model ) { - super( model, 'enter' ); - } -} diff --git a/packages/ckeditor5-enter/src/enterobserver.js b/packages/ckeditor5-enter/src/enterobserver.js index c705a141b6c..8a63211d638 100644 --- a/packages/ckeditor5-enter/src/enterobserver.js +++ b/packages/ckeditor5-enter/src/enterobserver.js @@ -7,18 +7,18 @@ * @module enter/enterobserver */ -import Observer from '@ckeditor/ckeditor5-engine/src/view/observer/observer'; +import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; /** * Enter observer introduces the {@link module:engine/view/document~Document#event:enter} event. * - * @extends module:engine/view/observer/observer~Observer + * @extends module:engine/view/observer/bubblingobserver~BubblingObserver */ -export default class EnterObserver extends Observer { +export default class EnterObserver extends BubblingObserver { constructor( view ) { - super( view ); + super( view, 'enter' ); const doc = this.document; diff --git a/packages/ckeditor5-enter/src/index.js b/packages/ckeditor5-enter/src/index.js index 676bb6d8cf0..15684484c13 100644 --- a/packages/ckeditor5-enter/src/index.js +++ b/packages/ckeditor5-enter/src/index.js @@ -9,4 +9,3 @@ export { default as Enter } from './enter'; export { default as ShiftEnter } from './shiftenter'; -export { default as EnterModelObserver } from './entermodelobserver'; diff --git a/packages/ckeditor5-enter/src/shiftenter.js b/packages/ckeditor5-enter/src/shiftenter.js index 58a08b56d59..f2f90e54e9b 100644 --- a/packages/ckeditor5-enter/src/shiftenter.js +++ b/packages/ckeditor5-enter/src/shiftenter.js @@ -9,7 +9,6 @@ import ShiftEnterCommand from './shiftentercommand'; import EnterObserver from './enterobserver'; -import EnterModelObserver from './entermodelobserver'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; /** @@ -34,6 +33,7 @@ export default class ShiftEnter extends Plugin { const schema = editor.model.schema; const conversion = editor.conversion; const view = editor.editing.view; + const viewDocument = view.document; // Configure the schema. schema.register( 'softBreak', { @@ -58,10 +58,7 @@ export default class ShiftEnter extends Plugin { editor.commands.add( 'shiftEnter', new ShiftEnterCommand( editor ) ); - // Add generic enter model observer (not bound to any element). - const enterObserver = editor.editing.addObserver( EnterModelObserver ); - - this.listenTo( enterObserver, 'enter', ( evt, data ) => { + this.listenTo( viewDocument, 'enter', ( evt, data ) => { data.preventDefault(); // The hard enter key is handled by the Enter plugin. @@ -70,10 +67,7 @@ export default class ShiftEnter extends Plugin { } editor.execute( 'shiftEnter' ); - - if ( editor.ui ) { - view.scrollToTheSelection(); - } - }, { priority: 'low' } ); + view.scrollToTheSelection(); + } ); } } diff --git a/packages/ckeditor5-list/src/listediting.js b/packages/ckeditor5-list/src/listediting.js index ecad5feaad0..7634aa2e64a 100644 --- a/packages/ckeditor5-list/src/listediting.js +++ b/packages/ckeditor5-list/src/listediting.js @@ -11,8 +11,8 @@ import ListCommand from './listcommand'; import IndentCommand from './indentcommand'; import { Plugin } from 'ckeditor5/src/core'; -import { Enter, EnterModelObserver } from 'ckeditor5/src/enter'; -import { Delete, DeleteModelObserver } from 'ckeditor5/src/typing'; +import { Enter } from 'ckeditor5/src/enter'; +import { Delete } from 'ckeditor5/src/typing'; import { cleanList, @@ -117,11 +117,11 @@ export default class ListEditing extends Plugin { editor.commands.add( 'indentList', new IndentCommand( editor, 'forward' ) ); editor.commands.add( 'outdentList', new IndentCommand( editor, 'backward' ) ); - const enterObserver = editor.editing.getObserver( EnterModelObserver ); + const viewDocument = editing.view.document; // Overwrite default Enter key behavior. // If Enter key is pressed with selection collapsed in empty list item, outdent it instead of breaking it. - this.listenTo( enterObserver.for( 'listItem' ), 'enter', ( evt, data ) => { + this.listenTo( viewDocument, 'enter', ( evt, data ) => { const doc = this.editor.model.document; const positionParent = doc.selection.getLastPosition().parent; @@ -131,13 +131,11 @@ export default class ListEditing extends Plugin { data.preventDefault(); evt.stop(); } - } ); - - const deleteObserver = editor.editing.getObserver( DeleteModelObserver ); + }, { context: 'li' } ); // Overwrite default Backspace key behavior. // If Backspace key is pressed with selection collapsed on first position in first list item, outdent it. #83 - this.listenTo( deleteObserver.for( 'listItem' ), 'delete', ( evt, data ) => { + this.listenTo( viewDocument, 'delete', ( evt, data ) => { // Check conditions from those that require less computations like those immediately available. if ( data.direction !== 'backward' ) { return; @@ -171,7 +169,7 @@ export default class ListEditing extends Plugin { data.preventDefault(); evt.stop(); - } ); + }, { context: 'li' } ); const getCommandExecuter = commandName => { return ( data, cancel ) => { diff --git a/packages/ckeditor5-typing/src/delete.js b/packages/ckeditor5-typing/src/delete.js index 6cff502a14f..6895222f65b 100644 --- a/packages/ckeditor5-typing/src/delete.js +++ b/packages/ckeditor5-typing/src/delete.js @@ -10,7 +10,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import DeleteCommand from './deletecommand'; import DeleteObserver from './deleteobserver'; -import DeleteModelObserver from './deletemodelobserver'; import env from '@ckeditor/ckeditor5-utils/src/env'; /** @@ -36,10 +35,7 @@ export default class Delete extends Plugin { editor.commands.add( 'forwardDelete', new DeleteCommand( editor, 'forward' ) ); editor.commands.add( 'delete', new DeleteCommand( editor, 'backward' ) ); - // Add generic delete model observer (not bound to any element). - const deleteObserver = editor.editing.addObserver( DeleteModelObserver ); - - this.listenTo( deleteObserver, 'delete', ( evt, data ) => { + this.listenTo( viewDocument, 'delete', ( evt, data ) => { const deleteCommandParams = { unit: data.unit, sequence: data.sequence }; // If a specific (view) selection to remove was set, convert it to a model selection and set as a parameter for `DeleteCommand`. diff --git a/packages/ckeditor5-typing/src/deletemodelobserver.js b/packages/ckeditor5-typing/src/deletemodelobserver.js deleted file mode 100644 index 39b3c54f1b0..00000000000 --- a/packages/ckeditor5-typing/src/deletemodelobserver.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module delete/deletemodelobserver - */ - -import ModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/modelobserver'; - -/** - * Observes... TODO - * - * @extends module:engine/model/observer/modelobserver~ModelObserver - */ -export default class DeleteModelObserver extends ModelObserver { - /** - * @inheritDoc - */ - constructor( model ) { - super( model, 'delete' ); - } -} diff --git a/packages/ckeditor5-typing/src/deleteobserver.js b/packages/ckeditor5-typing/src/deleteobserver.js index 4dd5579fb18..d90210b666b 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.js +++ b/packages/ckeditor5-typing/src/deleteobserver.js @@ -7,7 +7,7 @@ * @module typing/deleteobserver */ -import Observer from '@ckeditor/ckeditor5-engine/src/view/observer/observer'; +import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import env from '@ckeditor/ckeditor5-utils/src/env'; @@ -15,11 +15,11 @@ import env from '@ckeditor/ckeditor5-utils/src/env'; /** * Delete observer introduces the {@link module:engine/view/document~Document#event:delete} event. * - * @extends module:engine/view/observer/observer~Observer + * @extends module:engine/view/observer/bubblingobserver~BubblingObserver */ -export default class DeleteObserver extends Observer { +export default class DeleteObserver extends BubblingObserver { constructor( view ) { - super( view ); + super( view, 'delete' ); const document = view.document; let sequence = 0; diff --git a/packages/ckeditor5-typing/src/index.js b/packages/ckeditor5-typing/src/index.js index 6dec66eddf4..57068bc26c4 100644 --- a/packages/ckeditor5-typing/src/index.js +++ b/packages/ckeditor5-typing/src/index.js @@ -14,7 +14,6 @@ export { default as Delete } from './delete'; export { default as TextWatcher } from './textwatcher'; export { default as TwoStepCaretMovement } from './twostepcaretmovement'; export { default as TextTransformation } from './texttransformation'; -export { default as DeleteModelObserver } from './deletemodelobserver'; export { default as inlineHighlight } from './utils/inlinehighlight'; export { default as findAttributeRange } from './utils/findattributerange'; diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index 7897b391b2c..acd4a8debc3 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -11,7 +11,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import WidgetTypeAround from './widgettypearound/widgettypearound'; import Delete from '@ckeditor/ckeditor5-typing/src/delete'; -import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils'; import { isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -128,15 +127,13 @@ export default class Widget extends Plugin { this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', verticalNavigationHandler( this.editor.editing ) ); - const deleteObserver = this.editor.editing.getObserver( DeleteModelObserver ); - // Handle custom delete behaviour. - this.listenTo( deleteObserver.for( '$root' ), 'delete', ( evt, data ) => { + this.listenTo( viewDocument, 'delete', ( evt, data ) => { if ( this._handleDelete( data.direction == 'forward' ) ) { data.preventDefault(); evt.stop(); } - } ); + }, { context: '$root' } ); } /** diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 6bc7356af09..f071d51eafa 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -11,8 +11,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Template from '@ckeditor/ckeditor5-ui/src/template'; -import EnterModelObserver from '@ckeditor/ckeditor5-enter/src/entermodelobserver'; -import DeleteModelObserver from '@ckeditor/ckeditor5-typing/src/deletemodelobserver'; import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import Enter from '@ckeditor/ckeditor5-enter/src/enter'; import Delete from '@ckeditor/ckeditor5-typing/src/delete'; @@ -34,6 +32,8 @@ import { isNonTypingKeystroke } from '@ckeditor/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling'; +import { isWidget } from '../utils'; + import returnIcon from '../../theme/icons/return-arrow.svg'; import '../../theme/widgettypearound.css'; @@ -545,10 +545,9 @@ export default class WidgetTypeAround extends Plugin { _enableInsertingParagraphsOnEnterKeypress() { const editor = this.editor; const selection = editor.model.document.selection; + const editingView = editor.editing.view; - const enterObserver = editor.editing.getObserver( EnterModelObserver ); - - this._listenToIfEnabled( enterObserver.for( '$object' ), 'enter', ( evt, domEventData ) => { + this._listenToIfEnabled( editingView.document, 'enter', ( evt, domEventData ) => { const selectedModelElement = selection.getSelectedElement(); if ( !selectedModelElement ) { @@ -577,7 +576,7 @@ export default class WidgetTypeAround extends Plugin { domEventData.preventDefault(); evt.stop(); } - } ); + }, { context: '$widget', contextMatcher: isWidget } ); } /** @@ -629,12 +628,11 @@ export default class WidgetTypeAround extends Plugin { */ _enableDeleteIntegration() { const editor = this.editor; + const editingView = editor.editing.view; const model = editor.model; const schema = model.schema; - const deleteObserver = editor.editing.getObserver( DeleteModelObserver ); - - this._listenToIfEnabled( deleteObserver.for( '$object' ), 'delete', ( evt, domEventData ) => { + this._listenToIfEnabled( editingView.document, 'delete', ( evt, domEventData ) => { const selectedModelWidget = model.document.selection.getSelectedElement(); if ( !selectedModelWidget ) { @@ -702,7 +700,7 @@ export default class WidgetTypeAround extends Plugin { // If nothing was deleted, then the default handler will have nothing to do anyway. domEventData.preventDefault(); evt.stop(); - } ); + }, { context: '$widget', contextMatcher: isWidget } ); } /** From 5a950db7d7af7d0b9b50db89a14e6c4c151b6ac6 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 8 Feb 2021 11:44:04 +0100 Subject: [PATCH 10/43] Introducing ArrowKeysObserver (a BubblingObserver). --- .../src/controller/editingcontroller.js | 56 ----- packages/ckeditor5-engine/src/index.js | 1 - .../model/observer/arrowkeysmodelobserver.js | 39 --- .../src/model/observer/modelobserver.js | 234 ------------------ .../ckeditor5-engine/src/view/document.js | 2 +- .../src/view/observer/arrowkeysobserver.js | 51 ++++ .../src/view/observer/bubblingobserver.js | 26 +- packages/ckeditor5-engine/src/view/view.js | 3 +- .../ckeditor5-list/src/todolistediting.js | 5 +- packages/ckeditor5-table/src/tablekeyboard.js | 8 +- packages/ckeditor5-typing/src/delete.js | 4 +- .../src/twostepcaretmovement.js | 7 +- packages/ckeditor5-widget/src/widget.js | 17 +- .../src/widgettypearound/widgettypearound.js | 12 +- 14 files changed, 92 insertions(+), 373 deletions(-) delete mode 100644 packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js delete mode 100644 packages/ckeditor5-engine/src/model/observer/modelobserver.js create mode 100644 packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js diff --git a/packages/ckeditor5-engine/src/controller/editingcontroller.js b/packages/ckeditor5-engine/src/controller/editingcontroller.js index 3f13f1334e0..06238ab4485 100644 --- a/packages/ckeditor5-engine/src/controller/editingcontroller.js +++ b/packages/ckeditor5-engine/src/controller/editingcontroller.js @@ -11,7 +11,6 @@ import RootEditableElement from '../view/rooteditableelement'; import View from '../view/view'; import Mapper from '../conversion/mapper'; import DowncastDispatcher from '../conversion/downcastdispatcher'; -import ArrowKeysModelObserver from '../model/observer/arrowkeysmodelobserver'; import { clearAttributes, convertCollapsedSelection, convertRangeSelection, insertText, remove } from '../conversion/downcasthelpers'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; @@ -70,14 +69,6 @@ export default class EditingController { schema: model.schema } ); - /** - * Map of registered {@link module:engine/model/observer/modelobserver~ModelObserver observers}. - * - * @private - * @type {Map.} - */ - this._observers = new Map(); - const doc = this.model.document; const selection = doc.selection; const markers = this.model.markers; @@ -134,9 +125,6 @@ export default class EditingController { return viewRoot; } ); - // TODO - this.addObserver( ArrowKeysModelObserver ); - // @if CK_DEBUG_ENGINE // initDocumentDumping( this.model.document ); // @if CK_DEBUG_ENGINE // initDocumentDumping( this.view.document ); @@ -148,55 +136,11 @@ export default class EditingController { // @if CK_DEBUG_ENGINE // }, { priority: 'lowest' } ); } - /** - * Creates observer of the given type if not yet created, - * {@link module:engine/model/observer/modelobserver~ModelObserver#enable enables} it and - * {@link module:engine/model/observer/modelobserver~ModelObserver#observe attaches} to provided view document. - * - * Note: Observers are recognized by their constructor (classes). A single observer will be instantiated and used only - * when registered for the first time. This means that features and other components can register a single observer - * multiple times without caring whether it has been already added or not. - * - * @param {Function} ModelObserver The constructor of an model observer to add. - * Should create an instance inheriting from {@link module:engine/model/observer/modelobserver~ModelObserver}. - * @returns {module:engine/model/observer/modelobserver~ModelObserver} Added observer instance. - */ - addObserver( ModelObserver ) { - let observer = this._observers.get( ModelObserver ); - - if ( observer ) { - return observer; - } - - observer = new ModelObserver( this.model ); - - this._observers.set( ModelObserver, observer ); - - observer.observe( this.view.document ); - observer.enable(); - - return observer; - } - - /** - * Returns observer of the given type or `undefined` if such observer has not been added yet. - * - * @param {Function} Observer The constructor of an observer to get. - * @returns {module:engine/model/observer/modelobserver~ModelObserver|undefined} Observer instance or undefined. - */ - getObserver( Observer ) { - return this._observers.get( Observer ); - } - /** * Removes all event listeners attached to the `EditingController`. Destroys all objects created * by `EditingController` that need to be destroyed. */ destroy() { - for ( const observer of this._observers.values() ) { - observer.destroy(); - } - this.view.destroy(); this.stopListening(); } diff --git a/packages/ckeditor5-engine/src/index.js b/packages/ckeditor5-engine/src/index.js index b41247cb5e1..4e1971d6441 100644 --- a/packages/ckeditor5-engine/src/index.js +++ b/packages/ckeditor5-engine/src/index.js @@ -28,7 +28,6 @@ export { default as LivePosition } from './model/liveposition'; export { default as Model } from './model/model'; export { default as TreeWalker } from './model/treewalker'; export { default as Element } from './model/element'; -export { default as ArrowKeysModelObserver } from './model/observer/arrowkeysmodelobserver'; export { default as DomConverter } from './view/domconverter'; export { default as ViewDocument } from './view/document'; diff --git a/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js b/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js deleted file mode 100644 index 8a94b112971..00000000000 --- a/packages/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module engine/model/observer/arrowkeysmodelobserver - */ - -import ModelObserver from './modelobserver'; -import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; - -/** - * Observes... TODO - * - * @extends module:engine/model/observer/modelobserver~ModelObserver - */ -export default class ArrowKeysModelObserver extends ModelObserver { - /** - * @inheritDoc - */ - constructor( model ) { - super( model, 'keydown', 'arrowkey' ); - } - - /** - * @inheritDoc - */ - translateViewEvent( data ) { - if ( !isArrowKeyCode( data.keyCode ) ) { - return false; - } - - // TODO provide arrow direction - // TODO maybe event type could be namespaced like arrowkey:left ? - - return data; - } -} diff --git a/packages/ckeditor5-engine/src/model/observer/modelobserver.js b/packages/ckeditor5-engine/src/model/observer/modelobserver.js deleted file mode 100644 index f32e718abc1..00000000000 --- a/packages/ckeditor5-engine/src/model/observer/modelobserver.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module engine/model/observer/modelobserver - */ - -import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; -import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; -import mix from '@ckeditor/ckeditor5-utils/src/mix'; - -/** - * Abstract base observer class. Observers are classes which listen to view events, do the preliminary - * processing and fire events on ... TODO - * - * @abstract - */ -export default class ModelObserver { - /** - * Creates an instance of the observer. - * - * @param {module:engine/model/model~Model} model The model. - * @param {String} viewEventType Type of the view event the observer should listen to. - * @param {String} modelEventType Type of the model event the observer should fire. - */ - constructor( model, viewEventType, modelEventType ) { - /** - * An instance of the model. - * - * @readonly - * @member {module:engine/model/model~Model} - */ - this.model = model; - - /** - * State of the observer. If it is disabled no events will be fired. - * - * @readonly - * @member {Boolean} - */ - this.isEnabled = false; - - /** - * Type of the view event the observer should listen to. - * - * @readonly - * @member {String} - */ - this.viewEventType = viewEventType; - - /** - * Type of the model event the observer should fire. - * - * @readonly - * @member {String} - */ - this.modelEventType = modelEventType || viewEventType; - - /** - * TODO - * - * @private - * @member {Map.} - */ - this._elementMap = new Map(); - } - - /** - * Enables the observer. This method is called when the observer is registered to the - * {@link module:engine/model/model~Model}. - * - * @see module:engine/model/observer/modelobserver~ModelObserver#disable - */ - enable() { - this.isEnabled = true; - } - - /** - * Disables the observer. - * - * @see module:engine/model/observer/modelobserver~ModelObserver#enable - */ - disable() { - this.isEnabled = false; - } - - /** - * Disables and destroys the observer, among others removes event listeners created by the observer. - */ - destroy() { - for ( const listener of this._elementMap.values() ) { - listener.stopListening(); - } - - this.disable(); - this.stopListening(); - } - - /** - * Starts observing the given view document object. - * - * @param {module:engine/view/document~Document} viewDocument - */ - observe( viewDocument ) { - const schema = this.model.schema; - const selection = this.model.document.selection; - - this.listenTo( viewDocument, this.viewEventType, ( event, ...args ) => { - if ( !this.isEnabled ) { - return; - } - - let eventArgs = this.translateViewEvent( ...args ); - - if ( eventArgs === false ) { - return; - } - - if ( !Array.isArray( eventArgs ) ) { - eventArgs = [ eventArgs ]; - } - - const eventInfo = new EventInfo( this, this.modelEventType ); - - const position = selection.anchor.path.length > selection.focus.path.length ? selection.anchor : selection.focus; - const acceptsText = selection.isCollapsed && schema.checkChild( position, '$text' ); - - let node = selection.getSelectedElement() || position.textNode || position.parent; - let bubbling = false; - - while ( node ) { - // Element node handling. - if ( node.is( 'element' ) ) { - // For the not yet bubbling event trigger for $text node if it's accepted by the selection position. - if ( !bubbling && acceptsText && this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { - break; - } - - // Default handler for specified element. - if ( this._fireListenerFor( node.name, eventInfo, ...eventArgs ) ) { - break; - } - - // Generic handler for $object. - if ( schema.isObject( node ) && this._fireListenerFor( '$object', eventInfo, ...eventArgs ) ) { - break; - } - } - - // Text node handling. - else if ( node.is( '$text' ) ) { - if ( this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { - break; - } - } - - // Root node handling. - else if ( node.is( 'rootElement' ) ) { - if ( this._fireListenerFor( '$root', eventInfo, ...eventArgs ) ) { - break; - } - } - - node = node.parent; - bubbling = true; - } - - // Fire generic handler (not assigned to any element). - if ( !eventInfo.stop.called ) { - this.fire( eventInfo, ...eventArgs ); - } - - // Stop the event if generic handler stopped it. - if ( eventInfo.stop.called ) { - event.stop(); - } - } ); - } - - /** - * TODO - * - * @param {String} name - * @returns {module:utils/emittermixin~Emitter} - */ - for( name ) { - let listener = this._elementMap.get( name ); - - if ( listener ) { - return listener; - } - - listener = Object.create( EmitterMixin ); - - this._elementMap.set( name, listener ); - - return listener; - } - - /** - * TODO - * Callback which should be called when the view event occurred. Note that the callback will not be called if - * observer {@link #isEnabled is not enabled}. - * - * @param {...*} [args] - * @returns {Array.<*>|Boolean} False if event should not be handled. - */ - translateViewEvent( ...args ) { - return args; - } - - /** - * TODO - * - * @private - * @param {String} name - * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. - * @param {...*} [args] Additional arguments to be passed to the callbacks. - * @returns {Boolean} True if event stop was called. - */ - _fireListenerFor( name, eventInfo, ...args ) { - const listener = this._elementMap.get( name ); - - if ( listener ) { - listener.fire( eventInfo, ...args ); - } - - return eventInfo.stop.called; - } -} - -mix( ModelObserver, EmitterMixin ); diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/src/view/document.js index eb495ed26e6..dc396bd174b 100644 --- a/packages/ckeditor5-engine/src/view/document.js +++ b/packages/ckeditor5-engine/src/view/document.js @@ -208,7 +208,7 @@ export default class Document { _addEventListener( event, callback, options = {} ) { if ( options.context ) { for ( const observer of this._observers.values() ) { - if ( observer instanceof BubblingObserver && observer.eventType == event ) { + if ( observer instanceof BubblingObserver && observer.newEventType == event ) { observer._addListener( options.context, callback, options ); } } diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js new file mode 100644 index 00000000000..5c4d70772ed --- /dev/null +++ b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js @@ -0,0 +1,51 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/view/observer/arrowkeysobserver + */ + +import BubblingObserver from './bubblingobserver'; + +import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils'; + +/** + * Arrow keys observer introduces the {@link module:engine/view/document~Document#event:arrowkey} event. + * + * @extends module:engine/view/observer/bubblingobserver~BubblingObserver + */ +export default class ArrowKeysObserver extends BubblingObserver { + constructor( view ) { + super( view, 'keydown', 'arrowkey' ); + } + + /** + * @inheritDoc + */ + observe() {} + + /** + * @inheritDoc + */ + _translateEvent( data, ...args ) { + if ( !isArrowKeyCode( data.keyCode ) ) { + return false; + } + + return super._translateEvent( data, ...args ); + } +} + +/** + * Event fired when the user presses an arrow keys. + * + * Introduced by {@link module:engine/view/observer/arrowkeysobserver~ArrowKeysObserver}. + * + * Note that because {@link module:engine/view/observer/arrowkeysobserver~ArrowKeysObserver} is attached by the + * {@link module:engine/view/view~View} this event is available by default. + * + * @event module:engine/view/document~Document#event:arrowkey + * @param {module:engine/view/observer/domeventdata~DomEventData} data + */ diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index ca59181f6f8..defad841dd9 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -25,8 +25,9 @@ export default class BubblingObserver extends Observer { * * @param {module:engine/view/view~View} view * @param {String} eventType TODO + * @param {String} [newEventType=eventType] TODO */ - constructor( view, eventType ) { + constructor( view, eventType, newEventType = eventType ) { super( view ); /** @@ -37,6 +38,14 @@ export default class BubblingObserver extends Observer { */ this.eventType = eventType; + /** + * Type of the event the observer will emit. + * + * @readonly + * @member {String} + */ + this.newEventType = newEventType; + /** * TODO * @@ -87,7 +96,7 @@ export default class BubblingObserver extends Observer { this._customContexts.set( options.context, options.contextMatcher ); } - listener._addEventListener( this.eventType, callback, options ); + listener._addEventListener( this.newEventType, callback, options ); } /** @@ -95,7 +104,7 @@ export default class BubblingObserver extends Observer { * * @protected */ - _removeListener( callback, options ) { + _removeListener( /* callback, options */ ) { // TODO } @@ -107,8 +116,8 @@ export default class BubblingObserver extends Observer { * @param {...*} [args] * @returns {Array.<*>|Boolean} False if event should not be handled. TODO */ - _translateEvent( eventName, ...args ) { - return [ new EventInfo( this, eventName ), ...args ]; + _translateEvent( ...args ) { + return args; } /** @@ -124,14 +133,13 @@ export default class BubblingObserver extends Observer { return; } - const translatedEvent = this._translateEvent( event.name, ...args ); + const eventInfo = new EventInfo( this, this.newEventType ); + let eventArgs = this._translateEvent( ...args ); - if ( translatedEvent === false ) { + if ( eventArgs === false ) { return; } - let [ eventInfo, ...eventArgs ] = translatedEvent; - if ( !Array.isArray( eventArgs ) ) { eventArgs = [ eventArgs ]; } diff --git a/packages/ckeditor5-engine/src/view/view.js b/packages/ckeditor5-engine/src/view/view.js index 763e653c9dc..19236a9e06d 100644 --- a/packages/ckeditor5-engine/src/view/view.js +++ b/packages/ckeditor5-engine/src/view/view.js @@ -22,6 +22,7 @@ import SelectionObserver from './observer/selectionobserver'; import FocusObserver from './observer/focusobserver'; import CompositionObserver from './observer/compositionobserver'; import InputObserver from './observer/inputobserver'; +import ArrowKeysObserver from './observer/arrowkeysobserver'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; @@ -66,7 +67,6 @@ export default class View { * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor The styles processor instance. */ constructor( stylesProcessor ) { - /** * Map of registered {@link module:engine/view/observer/observer~Observer observers}. * @@ -185,6 +185,7 @@ export default class View { this.addObserver( KeyObserver ); this.addObserver( FakeSelectionObserver ); this.addObserver( CompositionObserver ); + this.addObserver( ArrowKeysObserver ); if ( env.isAndroid ) { this.addObserver( InputObserver ); diff --git a/packages/ckeditor5-list/src/todolistediting.js b/packages/ckeditor5-list/src/todolistediting.js index 997eb1d7890..2c0cabcb694 100644 --- a/packages/ckeditor5-list/src/todolistediting.js +++ b/packages/ckeditor5-list/src/todolistediting.js @@ -9,7 +9,6 @@ import { Plugin } from 'ckeditor5/src/core'; import { getLocalizedArrowKeyCodeDirection } from 'ckeditor5/src/utils'; -import { ArrowKeysModelObserver } from 'ckeditor5/src/engine'; import ListCommand from './listcommand'; import ListEditing from './listediting'; @@ -96,8 +95,6 @@ export default class TodoListEditing extends Plugin { editing.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) ); data.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) ); - const arrowKeyObserver = this.editor.editing.getObserver( ArrowKeysModelObserver ); - // Jump at the end of the previous node on left arrow key press, when selection is after the checkbox. // //

Foo

@@ -108,7 +105,7 @@ export default class TodoListEditing extends Plugin { //

Foo{}

//
  • Bar
// - this.listenTo( arrowKeyObserver.for( 'listItem' ), 'arrowkey', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ) ); + this.listenTo( editing.view.document, 'arrowkey', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ), { context: 'li' } ); // Toggle check state of selected to-do list items on keystroke. editor.keystrokes.set( 'Ctrl+space', () => editor.execute( 'todoListCheck' ) ); diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js index 3d63fb6090d..972ffa7e4a5 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.js +++ b/packages/ckeditor5-table/src/tablekeyboard.js @@ -13,7 +13,6 @@ import TableWalker from './tablewalker'; import { Plugin } from 'ckeditor5/src/core'; import { getLocalizedArrowKeyCodeDirection } from 'ckeditor5/src/utils'; import { getSelectedTableCells, getTableCellsContainingSelection } from './utils/selection'; -import { ArrowKeysModelObserver } from 'ckeditor5/src/engine'; /** * This plugin enables keyboard navigation for tables. @@ -40,14 +39,15 @@ export default class TableKeyboard extends Plugin { * @inheritDoc */ init() { + const view = this.editor.editing.view; + const viewDocument = view.document; + // Handle Tab key navigation. this.editor.keystrokes.set( 'Tab', ( ...args ) => this._handleTabOnSelectedTable( ...args ), { priority: 'low' } ); this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } ); this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } ); - const arrowKeyObserver = this.editor.editing.getObserver( ArrowKeysModelObserver ); - - this.listenTo( arrowKeyObserver.for( 'table' ), 'arrowkey', ( ...args ) => this._onArrowKey( ...args ) ); + this.listenTo( viewDocument, 'arrowkey', ( ...args ) => this._onArrowKey( ...args ), { context: 'table' } ); } /** diff --git a/packages/ckeditor5-typing/src/delete.js b/packages/ckeditor5-typing/src/delete.js index 6895222f65b..e1c4a082189 100644 --- a/packages/ckeditor5-typing/src/delete.js +++ b/packages/ckeditor5-typing/src/delete.js @@ -56,9 +56,7 @@ export default class Delete extends Plugin { data.preventDefault(); - if ( editor.ui ) { - view.scrollToTheSelection(); - } + view.scrollToTheSelection(); } ); // Android IMEs have a quirk - they change DOM selection after the input changes were performed by the browser. diff --git a/packages/ckeditor5-typing/src/twostepcaretmovement.js b/packages/ckeditor5-typing/src/twostepcaretmovement.js index a8556828031..8577614e18b 100644 --- a/packages/ckeditor5-typing/src/twostepcaretmovement.js +++ b/packages/ckeditor5-typing/src/twostepcaretmovement.js @@ -10,7 +10,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; /** * This plugin enables the two-step caret (phantom) movement behavior for @@ -141,13 +140,13 @@ export default class TwoStepCaretMovement extends Plugin { init() { const editor = this.editor; const model = editor.model; + const view = editor.editing.view; const locale = editor.locale; const modelSelection = model.document.selection; - const arrowKeyObserver = editor.editing.getObserver( ArrowKeysModelObserver ); // Listen to keyboard events and handle the caret movement according to the 2-step caret logic. - this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', ( evt, data ) => { + this.listenTo( view.document, 'arrowkey', ( evt, data ) => { // This implementation works only for collapsed selection. if ( !modelSelection.isCollapsed ) { return; @@ -181,7 +180,7 @@ export default class TwoStepCaretMovement extends Plugin { if ( isMovementHandled === true ) { evt.stop(); } - }, { priority: 'highest' } ); + }, { context: '$text', priority: 'highest' } ); /** * A flag indicating that the automatic gravity restoration should not happen upon the next diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index acd4a8debc3..4a3b0319650 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -11,7 +11,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import WidgetTypeAround from './widgettypearound/widgettypearound'; import Delete from '@ckeditor/ckeditor5-typing/src/delete'; -import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils'; import { isForwardArrowKeyCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; import env from '@ckeditor/ckeditor5-utils/src/env'; @@ -99,8 +98,6 @@ export default class Widget extends Plugin { view.addObserver( MouseObserver ); this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) ); - const arrowKeyObserver = this.editor.editing.getObserver( ArrowKeysModelObserver ); - // There are two keydown listeners working on different priorities. This allows other // features such as WidgetTypeAround or TableKeyboard to attach their listeners in between // and customize the behavior even further in different content/selection scenarios. @@ -113,19 +110,19 @@ export default class Widget extends Plugin { // prevented when a widget is selected. This prevents the selection from being moved // from a fake selection container. // TODO split into 2 separate handlers - this.listenTo( arrowKeyObserver.for( '$object' ), 'arrowkey', ( ...args ) => { + this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { this._handleSelectionChangeOnArrowKeyPress( ...args ); - } ); + }, { context: '$widget', contextMatcher: isWidget } ); - this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', ( ...args ) => { + this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { this._handleSelectionChangeOnArrowKeyPress( ...args ); - } ); + }, { context: '$text' } ); - this.listenTo( arrowKeyObserver.for( '$root' ), 'arrowkey', ( ...args ) => { + this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { this._preventDefaultOnArrowKeyPress( ...args ); - } ); + }, { context: '$root' } ); - this.listenTo( arrowKeyObserver.for( '$text' ), 'arrowkey', verticalNavigationHandler( this.editor.editing ) ); + this.listenTo( viewDocument, 'arrowkey', verticalNavigationHandler( this.editor.editing ), { context: '$text' } ); // Handle custom delete behaviour. this.listenTo( viewDocument, 'delete', ( evt, data ) => { diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index f071d51eafa..b4c3a06a78e 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -11,7 +11,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Template from '@ckeditor/ckeditor5-ui/src/template'; -import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import Enter from '@ckeditor/ckeditor5-enter/src/enter'; import Delete from '@ckeditor/ckeditor5-typing/src/delete'; import { @@ -264,19 +263,18 @@ export default class WidgetTypeAround extends Plugin { const model = editor.model; const modelSelection = model.document.selection; const schema = model.schema; - - const arrowKeyObserver = editor.editing.getObserver( ArrowKeysModelObserver ); + const editingView = editor.editing.view; // This is the main listener responsible for the fake caret. // Note: The priority must precede the default Widget class keydown handler ("high"). // TODO split into 2 separate handlers - this._listenToIfEnabled( arrowKeyObserver.for( '$object' ), 'arrowkey', ( evt, domEventData ) => { + this._listenToIfEnabled( editingView.document, 'arrowkey', ( evt, domEventData ) => { this._handleArrowKeyPress( evt, domEventData ); - }, { priority: 'high' } ); + }, { context: '$widget', contextMatcher: isWidget, priority: 'high' } ); - this._listenToIfEnabled( arrowKeyObserver.for( '$text' ), 'arrowkey', ( evt, domEventData ) => { + this._listenToIfEnabled( editingView.document, 'arrowkey', ( evt, domEventData ) => { this._handleArrowKeyPress( evt, domEventData ); - }, { priority: 'high' } ); + }, { context: '$text', priority: 'high' } ); // This listener makes sure the widget type around selection attribute will be gone from the model // selection as soon as the model range changes. This attribute only makes sense when a widget is selected From 58616f703f37a8f32c825f5aa43de6cf063ca954 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 8 Feb 2021 12:57:45 +0100 Subject: [PATCH 11/43] Refactored custom context matcher. --- .../ckeditor5-engine/src/view/document.js | 12 ++- .../src/view/observer/arrowkeysobserver.js | 3 + .../src/view/observer/bubblingobserver.js | 83 ++++++++++++++----- .../src/widgettypearound/widgettypearound.js | 2 +- 4 files changed, 74 insertions(+), 26 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/src/view/document.js index dc396bd174b..4fae59d056b 100644 --- a/packages/ckeditor5-engine/src/view/document.js +++ b/packages/ckeditor5-engine/src/view/document.js @@ -208,8 +208,8 @@ export default class Document { _addEventListener( event, callback, options = {} ) { if ( options.context ) { for ( const observer of this._observers.values() ) { - if ( observer instanceof BubblingObserver && observer.newEventType == event ) { - observer._addListener( options.context, callback, options ); + if ( observer instanceof BubblingObserver && observer.firedEventType == event ) { + this.listenTo( observer, event, callback, options ); } } } else { @@ -219,10 +219,16 @@ export default class Document { /** * TODO + * * @protected */ _removeEventListener( event, callback ) { - // TODO + for ( const observer of this._observers.values() ) { + if ( observer instanceof BubblingObserver && observer.firedEventType == event ) { + this.stopListening( observer, event, callback ); + } + } + return ObservableMixin._removeEventListener.call( this, event, callback ); } diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js index 5c4d70772ed..2df804293c4 100644 --- a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js @@ -17,6 +17,9 @@ import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils'; * @extends module:engine/view/observer/bubblingobserver~BubblingObserver */ export default class ArrowKeysObserver extends BubblingObserver { + /** + * @inheritDoc + */ constructor( view ) { super( view, 'keydown', 'arrowkey' ); } diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index defad841dd9..086788a86a4 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -25,9 +25,9 @@ export default class BubblingObserver extends Observer { * * @param {module:engine/view/view~View} view * @param {String} eventType TODO - * @param {String} [newEventType=eventType] TODO + * @param {String} [firedEventType=eventType] TODO */ - constructor( view, eventType, newEventType = eventType ) { + constructor( view, eventType, firedEventType = eventType ) { super( view ); /** @@ -39,12 +39,12 @@ export default class BubblingObserver extends Observer { this.eventType = eventType; /** - * Type of the event the observer will emit. + * Type of the event the observer will fire. * * @readonly * @member {String} */ - this.newEventType = newEventType; + this.firedEventType = firedEventType; /** * TODO @@ -85,18 +85,18 @@ export default class BubblingObserver extends Observer { * * @protected */ - _addListener( context, callback, options ) { - let listener = this._listeners.get( context ); + _addEventListener( event, callback, options ) { + let listener = this._listeners.get( options.context ); if ( !listener ) { - this._listeners.set( context, listener = Object.create( EmitterMixin ) ); + this._listeners.set( options.context, listener = Object.create( EmitterMixin ) ); } if ( options.contextMatcher ) { this._customContexts.set( options.context, options.contextMatcher ); } - listener._addEventListener( this.newEventType, callback, options ); + this.listenTo( listener, event, callback, options ); } /** @@ -104,8 +104,10 @@ export default class BubblingObserver extends Observer { * * @protected */ - _removeListener( /* callback, options */ ) { - // TODO + _removeEventListener( event, callback ) { + for ( const listener of this._listeners.values() ) { + this.stopListening( listener, event, callback ); + } } /** @@ -133,7 +135,7 @@ export default class BubblingObserver extends Observer { return; } - const eventInfo = new EventInfo( this, this.newEventType ); + const eventInfo = new EventInfo( this, this.firedEventType ); let eventArgs = this._translateEvent( ...args ); if ( eventArgs === false ) { @@ -145,11 +147,10 @@ export default class BubblingObserver extends Observer { } const selectedElement = selection.getSelectedElement(); + const isCustomContext = this._isCustomContext( selectedElement ); - // TODO selected element could be an attribute element. - - // For the not yet bubbling event trigger for $text node if selection can be there and it's not a widget selected. - if ( !selectedElement && this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { + // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. + if ( !isCustomContext && this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { // Stop the original event. event.stop(); @@ -174,10 +175,8 @@ export default class BubblingObserver extends Observer { } // Check custom contexts (i.e., a widget). - for ( const [ context, matcher ] of this._customContexts ) { - if ( matcher( node ) && this._fireListenerFor( context, eventInfo, ...eventArgs ) ) { - break; - } + if ( this._fireListenerForCustomContext( node, eventInfo, ...eventArgs ) ) { + break; } node = node.parent; @@ -196,18 +195,58 @@ export default class BubblingObserver extends Observer { * @private * @param {String} name * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. - * @param {...*} [args] Additional arguments to be passed to the callbacks. + * @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. * @returns {Boolean} True if event stop was called. */ - _fireListenerFor( name, eventInfo, ...args ) { + _fireListenerFor( name, eventInfo, ...eventArgs ) { const listener = this._listeners.get( name ); if ( !listener ) { return false; } - listener.fire( eventInfo, ...args ); + listener.fire( eventInfo, ...eventArgs ); return eventInfo.stop.called; } + + /** + * TODO + * + * @private + * @param {module:engine/view/element~Element} node + * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. + * @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. + * @returns {Boolean} True if event stop was called. + */ + _fireListenerForCustomContext( node, eventInfo, ...eventArgs ) { + for ( const [ context, matcher ] of this._customContexts ) { + if ( matcher( node ) && this._fireListenerFor( context, eventInfo, ...eventArgs ) ) { + return true; + } + } + + return false; + } + + /** + * TODO + * + * @param {module:engine/view/element~Element} selectedElement + * @returns {Boolean} + * @private + */ + _isCustomContext( selectedElement ) { + if ( !selectedElement ) { + return false; + } + + for ( const matcher of this._customContexts.values() ) { + if ( matcher( selectedElement ) ) { + return true; + } + } + + return false; + } } diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index b4c3a06a78e..97dbdb9b1e8 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -605,7 +605,7 @@ export default class WidgetTypeAround extends Plugin { keyCodes.backspace ]; - // Note: The priority must precede the default model observers. + // Note: The priority must precede the default observers. this._listenToIfEnabled( editingView.document, 'keydown', ( evt, domEventData ) => { // Don't handle enter/backspace/delete here. They are handled in dedicated listeners. if ( !keyCodesHandledSomewhereElse.includes( domEventData.keyCode ) && !isNonTypingKeystroke( domEventData ) ) { From bd2b4cdabd26102b72f2cdb3f2fed84cd408bfc4 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 8 Feb 2021 18:46:46 +0100 Subject: [PATCH 12/43] Refactored the way of intercepting registration of listeners. --- .../ckeditor5-engine/src/view/document.js | 46 ++----------------- .../src/view/observer/bubblingobserver.js | 33 ++++++++++++- packages/ckeditor5-engine/src/view/view.js | 18 ++++---- 3 files changed, 44 insertions(+), 53 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/src/view/document.js index 4fae59d056b..45713729cba 100644 --- a/packages/ckeditor5-engine/src/view/document.js +++ b/packages/ckeditor5-engine/src/view/document.js @@ -11,7 +11,6 @@ import DocumentSelection from './documentselection'; import Collection from '@ckeditor/ckeditor5-utils/src/collection'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; -import BubblingObserver from './observer/bubblingobserver'; // @if CK_DEBUG_ENGINE // const { logDocument } = require( '../dev-utils/utils' ); @@ -26,9 +25,8 @@ export default class Document { * Creates a Document instance. * * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor The styles processor instance. - * @param {Map} observers TODO */ - constructor( stylesProcessor, observers ) { + constructor( stylesProcessor ) { /** * Selection done on this document. * @@ -100,13 +98,9 @@ export default class Document { */ this._postFixers = new Set(); - /** - * TODO - * - * @private - * @member {Map} - */ - this._observers = observers; + // Decorate emitter protected methods to allow BubblingObservers to intercept registering/removing listeners. + this.decorate( '_addEventListener' ); + this.decorate( '_removeEventListener' ); } /** @@ -200,38 +194,6 @@ export default class Document { } while ( wasFixed ); } - /** - * TODO - * - * @protected - */ - _addEventListener( event, callback, options = {} ) { - if ( options.context ) { - for ( const observer of this._observers.values() ) { - if ( observer instanceof BubblingObserver && observer.firedEventType == event ) { - this.listenTo( observer, event, callback, options ); - } - } - } else { - ObservableMixin._addEventListener.call( this, event, callback, options ); - } - } - - /** - * TODO - * - * @protected - */ - _removeEventListener( event, callback ) { - for ( const observer of this._observers.values() ) { - if ( observer instanceof BubblingObserver && observer.firedEventType == event ) { - this.stopListening( observer, event, callback ); - } - } - - return ObservableMixin._removeEventListener.call( this, event, callback ); - } - /** * Event fired whenever document content layout changes. It is fired whenever content is * {@link module:engine/view/view~View#event:render rendered}, but should be also fired by observers in case of diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index 086788a86a4..f9dac2072b3 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -61,7 +61,8 @@ export default class BubblingObserver extends Observer { */ this._customContexts = new Map(); - this._setupListener(); + this._setupListenerInterception(); + this._setupEventListener(); } /** @@ -122,12 +123,40 @@ export default class BubblingObserver extends Observer { return args; } + /** + * Intercept adding listeners for view document for bubbling observers. + * + * @private + */ + _setupListenerInterception() { + this.listenTo( this.document, '_addEventListener', ( evt, [ event, callback, options ] ) => { + if ( !options.context || event != this.firedEventType ) { + return; + } + + // Prevent registering a default listener. + evt.stop(); + + this.document.listenTo( this, event, callback, options ); + }, { priority: 'high' } ); + + this.listenTo( this.document, '_removeEventListener', ( evt, [ event, callback ] ) => { + if ( event != this.firedEventType ) { + return; + } + + // We don't want to prevent removing a default listener - remove it if it's registered. + + this.document.stopListening( this, event, callback ); + }, { priority: 'high' } ); + } + /** * TODO * * @private */ - _setupListener() { + _setupEventListener() { const selection = this.document.selection; this.listenTo( this.document, this.eventType, ( event, ...args ) => { diff --git a/packages/ckeditor5-engine/src/view/view.js b/packages/ckeditor5-engine/src/view/view.js index 19236a9e06d..ef02665fcea 100644 --- a/packages/ckeditor5-engine/src/view/view.js +++ b/packages/ckeditor5-engine/src/view/view.js @@ -67,21 +67,13 @@ export default class View { * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor The styles processor instance. */ constructor( stylesProcessor ) { - /** - * Map of registered {@link module:engine/view/observer/observer~Observer observers}. - * - * @private - * @type {Map.} - */ - this._observers = new Map(); - /** * Instance of the {@link module:engine/view/document~Document} associated with this view controller. * * @readonly * @type {module:engine/view/document~Document} */ - this.document = new Document( stylesProcessor, this._observers ); + this.document = new Document( stylesProcessor ); /** * Instance of the {@link module:engine/view/domconverter~DomConverter domConverter} used by @@ -137,6 +129,14 @@ export default class View { */ this._initialDomRootAttributes = new WeakMap(); + /** + * Map of registered {@link module:engine/view/observer/observer~Observer observers}. + * + * @private + * @type {Map.} + */ + this._observers = new Map(); + /** * Is set to `true` when {@link #change view changes} are currently in progress. * From caf718b951d7049bb032a2c8dbc258fb838eedad Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 8 Feb 2021 20:05:11 +0100 Subject: [PATCH 13/43] Cleaning tests. --- .../src/view/observer/bubblingobserver.js | 2 +- packages/ckeditor5-enter/tests/enter.js | 17 ----------------- packages/ckeditor5-enter/tests/shiftenter.js | 17 ----------------- packages/ckeditor5-list/tests/listediting.js | 6 ++++++ packages/ckeditor5-typing/tests/delete.js | 16 ---------------- .../tests/twostepcaretmovement.js | 11 ++++------- 6 files changed, 11 insertions(+), 58 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index f9dac2072b3..f5957bd95a4 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -160,7 +160,7 @@ export default class BubblingObserver extends Observer { const selection = this.document.selection; this.listenTo( this.document, this.eventType, ( event, ...args ) => { - if ( !this.isEnabled ) { + if ( !this.isEnabled || !this._listeners.size ) { return; } diff --git a/packages/ckeditor5-enter/tests/enter.js b/packages/ckeditor5-enter/tests/enter.js index 971d0f14d29..afb6145f230 100644 --- a/packages/ckeditor5-enter/tests/enter.js +++ b/packages/ckeditor5-enter/tests/enter.js @@ -10,7 +10,6 @@ import Enter from '../src/enter'; import EnterCommand from '../src/entercommand'; import EnterObserver from '../src/enterobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; describe( 'Enter feature', () => { let element, editor, viewDocument; @@ -87,22 +86,6 @@ describe( 'Enter feature', () => { sinon.assert.calledOnce( domEvt.preventDefault ); } ); - it( 'should not crash in virtual editor', async () => { - const editor = await VirtualTestEditor.create( { - plugins: [ Enter ] - } ); - - const domEvt = getDomEvent(); - const commandExecuteSpy = sinon.stub( editor.commands.get( 'enter' ), 'execute' ); - const viewDocument = editor.editing.view.document; - - viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt ) ); - - sinon.assert.calledOnce( commandExecuteSpy ); - - await editor.destroy(); - } ); - function getDomEvent() { return { preventDefault: sinon.spy() diff --git a/packages/ckeditor5-enter/tests/shiftenter.js b/packages/ckeditor5-enter/tests/shiftenter.js index 5828cde74c5..3533840c583 100644 --- a/packages/ckeditor5-enter/tests/shiftenter.js +++ b/packages/ckeditor5-enter/tests/shiftenter.js @@ -10,7 +10,6 @@ import ShiftEnter from '../src/shiftenter'; import ShiftEnterCommand from '../src/shiftentercommand'; import EnterObserver from '../src/enterobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; describe( 'ShiftEnter feature', () => { let element, editor, viewDocument; @@ -95,22 +94,6 @@ describe( 'ShiftEnter feature', () => { sinon.assert.calledOnce( domEvt.preventDefault ); } ); - it( 'should not crash in virtual editor', async () => { - const editor = await VirtualTestEditor.create( { - plugins: [ ShiftEnter ] - } ); - - const domEvt = getDomEvent(); - const commandExecuteSpy = sinon.stub( editor.commands.get( 'shiftEnter' ), 'execute' ); - const viewDocument = editor.editing.view.document; - - viewDocument.fire( 'enter', new DomEventData( viewDocument, domEvt, { isSoft: true } ) ); - - sinon.assert.calledOnce( commandExecuteSpy ); - - await editor.destroy(); - } ); - function getDomEvent() { return { preventDefault: sinon.spy() diff --git a/packages/ckeditor5-list/tests/listediting.js b/packages/ckeditor5-list/tests/listediting.js index d5a39ebdf75..03a29aee11d 100644 --- a/packages/ckeditor5-list/tests/listediting.js +++ b/packages/ckeditor5-list/tests/listediting.js @@ -24,10 +24,13 @@ import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; describe( 'ListEditing', () => { let editor, model, modelDoc, modelRoot, view, viewDoc, viewRoot; + testUtils.createSinonSandbox(); + beforeEach( () => { return VirtualTestEditor .create( { @@ -50,6 +53,9 @@ describe( 'ListEditing', () => { isBlock: true, isObject: true } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); } ); } ); diff --git a/packages/ckeditor5-typing/tests/delete.js b/packages/ckeditor5-typing/tests/delete.js index 7458113c056..27404bc91ad 100644 --- a/packages/ckeditor5-typing/tests/delete.js +++ b/packages/ckeditor5-typing/tests/delete.js @@ -7,7 +7,6 @@ import Delete from '../src/delete'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import env from '@ckeditor/ckeditor5-utils/src/env'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -104,21 +103,6 @@ describe( 'Delete feature', () => { sinon.assert.callOrder( executeSpy, scrollSpy ); } ); - it( 'should not crash in virtual editor', async () => { - const editor = await VirtualTestEditor.create( { - plugins: [ Delete ] - } ); - - const commandExecuteSpy = sinon.stub( editor.commands.get( 'delete' ), 'execute' ); - const viewDocument = editor.editing.view.document; - - viewDocument.fire( 'delete', new DomEventData( viewDocument, getDomEvent(), { direction: 'backward' } ) ); - - sinon.assert.calledOnce( commandExecuteSpy ); - - await editor.destroy(); - } ); - function getDomEvent() { return { preventDefault: sinon.spy() diff --git a/packages/ckeditor5-typing/tests/twostepcaretmovement.js b/packages/ckeditor5-typing/tests/twostepcaretmovement.js index 69eac4f8c9e..e1465a4257d 100644 --- a/packages/ckeditor5-typing/tests/twostepcaretmovement.js +++ b/packages/ckeditor5-typing/tests/twostepcaretmovement.js @@ -9,7 +9,6 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; -import ArrowKeysModelObserver from '@ckeditor/ckeditor5-engine/src/model/observer/arrowkeysmodelobserver'; import TwoStepCaretMovement from '../src/twostepcaretmovement'; import Position from '@ckeditor/ckeditor5-engine/src/model/position'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; @@ -746,12 +745,10 @@ describe( 'TwoStepCaretMovement()', () => { setData( model, '<$text c="true">foo[]<$text a="true" b="true">bar' ); - const observer = editor.editing.getObserver( ArrowKeysModelObserver ).for( '$text' ); - - emitter.listenTo( observer, 'arrowkey', highestPlusPrioritySpy, { priority: priorities.highest + 1 } ); - emitter.listenTo( observer, 'arrowkey', highestPrioritySpy, { priority: 'highest' } ); - emitter.listenTo( observer, 'arrowkey', highPrioritySpy, { priority: 'high' } ); - emitter.listenTo( observer, 'arrowkey', normalPrioritySpy, { priority: 'normal' } ); + emitter.listenTo( view.document, 'arrowkey', highestPlusPrioritySpy, { context: '$text', priority: priorities.highest + 1 } ); + emitter.listenTo( view.document, 'arrowkey', highestPrioritySpy, { context: '$text', priority: 'highest' } ); + emitter.listenTo( view.document, 'arrowkey', highPrioritySpy, { context: '$text', priority: 'high' } ); + emitter.listenTo( view.document, 'arrowkey', normalPrioritySpy, { context: '$text', priority: 'normal' } ); fireKeyDownEvent( { keyCode: keyCodes.arrowright, From 4d4ac0e63dcb5b5726f5fa5fd026253b70702206 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 8 Feb 2021 20:08:13 +0100 Subject: [PATCH 14/43] Cleaning tests. --- packages/ckeditor5-engine/tests/view/view/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-engine/tests/view/view/view.js b/packages/ckeditor5-engine/tests/view/view/view.js index 1e36233aa76..c66000eec90 100644 --- a/packages/ckeditor5-engine/tests/view/view/view.js +++ b/packages/ckeditor5-engine/tests/view/view/view.js @@ -31,7 +31,7 @@ import env from '@ckeditor/ckeditor5-utils/src/env'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; describe( 'view', () => { - const DEFAULT_OBSERVERS_COUNT = 6; + const DEFAULT_OBSERVERS_COUNT = 7; let domRoot, view, viewDocument, ObserverMock, instantiated, enabled, ObserverMockGlobalCount; beforeEach( () => { From 41a4505708411e1529ce14325f1113160531b951 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 8 Feb 2021 20:24:29 +0100 Subject: [PATCH 15/43] Properly recover decorated methods on destroying. --- .../ckeditor5-utils/src/observablemixin.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/ckeditor5-utils/src/observablemixin.js b/packages/ckeditor5-utils/src/observablemixin.js index 45f3c93d534..033886a0765 100644 --- a/packages/ckeditor5-utils/src/observablemixin.js +++ b/packages/ckeditor5-utils/src/observablemixin.js @@ -15,6 +15,9 @@ const observablePropertiesSymbol = Symbol( 'observableProperties' ); const boundObservablesSymbol = Symbol( 'boundObservables' ); const boundPropertiesSymbol = Symbol( 'boundProperties' ); +const _decoratedMethods = Symbol( 'decoratedMethods' ); +const _decoratedOriginal = Symbol( 'decoratedOriginal' ); + /** * A mixin that injects the "observable properties" and data binding functionality described in the * {@link ~Observable} interface. @@ -258,11 +261,33 @@ const ObservableMixin = { this[ methodName ] = function( ...args ) { return this.fire( methodName, args ); }; + + this[ methodName ][ _decoratedOriginal ] = originalMethod; + + if ( !this[ _decoratedMethods ] ) { + this[ _decoratedMethods ] = []; + } + + this[ _decoratedMethods ].push( methodName ); } }; extend( ObservableMixin, EmitterMixin ); +// Override the EmitterMixin stopListening method to be able to clean decorated methods. +ObservableMixin.stopListening = function( emitter, event, callback ) { + // Removing all listeners so let's clean the decorated methods to the original state. + if ( !emitter && this[ _decoratedMethods ] ) { + for ( const methodName of this[ _decoratedMethods ] ) { + this[ methodName ] = this[ methodName ][ _decoratedOriginal ]; + } + + delete this[ _decoratedMethods ]; + } + + EmitterMixin.stopListening.call( this, emitter, event, callback ); +}; + export default ObservableMixin; // Init symbol properties needed for the observable mechanism to work. From c7ed8739e81f6ddea1a4f89bd7e4caca6ad18e53 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 9 Feb 2021 10:46:01 +0100 Subject: [PATCH 16/43] Bubbling should start from the deepest node for non collapsed selection. --- .../src/view/observer/bubblingobserver.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index f5957bd95a4..26e48e4ce1f 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -186,7 +186,7 @@ export default class BubblingObserver extends Observer { return; } - let node = selectedElement || selection.focus.parent; + let node = selectedElement || getDeeperParent( selection ); while ( node ) { // Root node handling. @@ -279,3 +279,14 @@ export default class BubblingObserver extends Observer { return false; } } + +// TODO +function getDeeperParent( selection ) { + const focusParent = selection.focus.parent; + const anchorParent = selection.anchor.parent; + + const focusPath = focusParent.getPath(); + const anchorPath = anchorParent.getPath(); + + return focusPath.length > anchorPath.length ? focusParent : anchorParent; +} From 6454502d3c6fca8211c1462533daa06016e68184 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Wed, 10 Feb 2021 20:57:12 +0100 Subject: [PATCH 17/43] Simplified API by reducing redundant name for the custom context. --- .../src/view/observer/bubblingobserver.js | 23 ++++++++++--------- packages/ckeditor5-widget/src/widget.js | 2 +- .../src/widgettypearound/widgettypearound.js | 6 ++--- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index 26e48e4ce1f..b9c2ed84f39 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -50,7 +50,7 @@ export default class BubblingObserver extends Observer { * TODO * * @private - * @member {Map.} + * @member {Map.} */ this._listeners = new Map(); @@ -59,7 +59,7 @@ export default class BubblingObserver extends Observer { * * @private */ - this._customContexts = new Map(); + this._customContexts = new Set(); this._setupListenerInterception(); this._setupEventListener(); @@ -90,11 +90,12 @@ export default class BubblingObserver extends Observer { let listener = this._listeners.get( options.context ); if ( !listener ) { - this._listeners.set( options.context, listener = Object.create( EmitterMixin ) ); + listener = Object.create( EmitterMixin ); + this._listeners.set( options.context, listener ); } - if ( options.contextMatcher ) { - this._customContexts.set( options.context, options.contextMatcher ); + if ( typeof options.context != 'string' ) { + this._customContexts.add( options.context ); } this.listenTo( listener, event, callback, options ); @@ -222,13 +223,13 @@ export default class BubblingObserver extends Observer { * TODO * * @private - * @param {String} name + * @param {String|Function} context * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. * @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. * @returns {Boolean} True if event stop was called. */ - _fireListenerFor( name, eventInfo, ...eventArgs ) { - const listener = this._listeners.get( name ); + _fireListenerFor( context, eventInfo, ...eventArgs ) { + const listener = this._listeners.get( context ); if ( !listener ) { return false; @@ -249,8 +250,8 @@ export default class BubblingObserver extends Observer { * @returns {Boolean} True if event stop was called. */ _fireListenerForCustomContext( node, eventInfo, ...eventArgs ) { - for ( const [ context, matcher ] of this._customContexts ) { - if ( matcher( node ) && this._fireListenerFor( context, eventInfo, ...eventArgs ) ) { + for ( const contextMatcher of this._customContexts ) { + if ( contextMatcher( node ) && this._fireListenerFor( contextMatcher, eventInfo, ...eventArgs ) ) { return true; } } @@ -270,7 +271,7 @@ export default class BubblingObserver extends Observer { return false; } - for ( const matcher of this._customContexts.values() ) { + for ( const matcher of this._customContexts ) { if ( matcher( selectedElement ) ) { return true; } diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index 4a3b0319650..7a1fcde8bc8 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -112,7 +112,7 @@ export default class Widget extends Plugin { // TODO split into 2 separate handlers this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { this._handleSelectionChangeOnArrowKeyPress( ...args ); - }, { context: '$widget', contextMatcher: isWidget } ); + }, { context: isWidget } ); this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { this._handleSelectionChangeOnArrowKeyPress( ...args ); diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 97dbdb9b1e8..c3b720af1ea 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -270,7 +270,7 @@ export default class WidgetTypeAround extends Plugin { // TODO split into 2 separate handlers this._listenToIfEnabled( editingView.document, 'arrowkey', ( evt, domEventData ) => { this._handleArrowKeyPress( evt, domEventData ); - }, { context: '$widget', contextMatcher: isWidget, priority: 'high' } ); + }, { context: isWidget, priority: 'high' } ); this._listenToIfEnabled( editingView.document, 'arrowkey', ( evt, domEventData ) => { this._handleArrowKeyPress( evt, domEventData ); @@ -574,7 +574,7 @@ export default class WidgetTypeAround extends Plugin { domEventData.preventDefault(); evt.stop(); } - }, { context: '$widget', contextMatcher: isWidget } ); + }, { context: isWidget } ); } /** @@ -698,7 +698,7 @@ export default class WidgetTypeAround extends Plugin { // If nothing was deleted, then the default handler will have nothing to do anyway. domEventData.preventDefault(); evt.stop(); - }, { context: '$widget', contextMatcher: isWidget } ); + }, { context: isWidget } ); } /** From 18558b10ce5770410cb56af98d1999b04cc06308 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 11 Feb 2021 09:16:10 +0100 Subject: [PATCH 18/43] Simplified internals of custom contexts. --- .../src/view/observer/bubblingobserver.js | 60 ++++--------------- 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index b9c2ed84f39..69de76f9322 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -54,13 +54,6 @@ export default class BubblingObserver extends Observer { */ this._listeners = new Map(); - /** - * TODO - * - * @private - */ - this._customContexts = new Set(); - this._setupListenerInterception(); this._setupEventListener(); } @@ -94,10 +87,6 @@ export default class BubblingObserver extends Observer { this._listeners.set( options.context, listener ); } - if ( typeof options.context != 'string' ) { - this._customContexts.add( options.context ); - } - this.listenTo( listener, event, callback, options ); } @@ -177,7 +166,7 @@ export default class BubblingObserver extends Observer { } const selectedElement = selection.getSelectedElement(); - const isCustomContext = this._isCustomContext( selectedElement ); + const isCustomContext = Boolean( selectedElement && this._getCustomContext( selectedElement ) ); // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. if ( !isCustomContext && this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { @@ -187,7 +176,7 @@ export default class BubblingObserver extends Observer { return; } - let node = selectedElement || getDeeperParent( selection ); + let node = selectedElement || getDeeperSelectionParent( selection ); while ( node ) { // Root node handling. @@ -205,7 +194,7 @@ export default class BubblingObserver extends Observer { } // Check custom contexts (i.e., a widget). - if ( this._fireListenerForCustomContext( node, eventInfo, ...eventArgs ) ) { + if ( this._fireListenerFor( node, eventInfo, ...eventArgs ) ) { break; } @@ -223,13 +212,13 @@ export default class BubblingObserver extends Observer { * TODO * * @private - * @param {String|Function} context + * @param {String|module:engine/view/node~Node} context * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. * @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. * @returns {Boolean} True if event stop was called. */ _fireListenerFor( context, eventInfo, ...eventArgs ) { - const listener = this._listeners.get( context ); + const listener = typeof context == 'string' ? this._listeners.get( context ) : this._getCustomContext( context ); if ( !listener ) { return false; @@ -243,46 +232,23 @@ export default class BubblingObserver extends Observer { /** * TODO * - * @private - * @param {module:engine/view/element~Element} node - * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. - * @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. - * @returns {Boolean} True if event stop was called. - */ - _fireListenerForCustomContext( node, eventInfo, ...eventArgs ) { - for ( const contextMatcher of this._customContexts ) { - if ( contextMatcher( node ) && this._fireListenerFor( contextMatcher, eventInfo, ...eventArgs ) ) { - return true; - } - } - - return false; - } - - /** - * TODO - * - * @param {module:engine/view/element~Element} selectedElement - * @returns {Boolean} + * @param {module:engine/view/node~Node} node + * @returns {module:utils/emittermixin~Emitter|null} * @private */ - _isCustomContext( selectedElement ) { - if ( !selectedElement ) { - return false; - } - - for ( const matcher of this._customContexts ) { - if ( matcher( selectedElement ) ) { - return true; + _getCustomContext( node ) { + for ( const [ context, listener ] of this._listeners ) { + if ( typeof context == 'function' && context( node ) ) { + return listener; } } - return false; + return null; } } // TODO -function getDeeperParent( selection ) { +function getDeeperSelectionParent( selection ) { const focusParent = selection.focus.parent; const anchorParent = selection.anchor.parent; From 6d9622f8dd47f965c49ae32c45b5095e69498641 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 11 Feb 2021 10:20:18 +0100 Subject: [PATCH 19/43] Added docs for BubblingObserver. --- .../src/view/observer/bubblingobserver.js | 96 +++++++++++++++++-- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index 69de76f9322..84983ff23bd 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -7,15 +7,96 @@ * @module engine/view/observer/bubblingobserver */ -import Observer from './observer'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import Observer from './observer'; + /** * Abstract base bubbling observer class. Observers are classes which listen to events, do the preliminary * processing and fire events on the {@link module:engine/view/document~Document} objects. * - * TODO + * Bubbling observers are triggering events in the context of specified {@link module:engine/view/element~Element view element} name, + * predefined `'$text'` and `'$root'` contexts, and context matchers provided as a function. + * + * The bubbling starts from the deeper selection position (by firing event on the `'$text'` context) and propagates + * the view document tree up to the `'$root'`. + * + * Examples: + * + * // Listeners registered in the context of the view element names: + * this.listenTo( viewDocument, 'enter', ( evt, data ) => { + * // ... + * }, { context: 'blockquote' } ); + * + * this.listenTo( viewDocument, 'enter', ( evt, data ) => { + * // ... + * }, { context: 'li' } ); + * + * // Listeners registered in the context of the '$text' and '$root' nodes. + * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * // ... + * }, { context: '$text', priority: 'high' } ); + * + * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * // ... + * }, { context: '$root' } ); + * + * // Listeners registered in the context of custom callback function. + * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * // ... + * }, { context: isWidget } ); + * + * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * // ... + * }, { context: isWidget, priority: 'high' } ); + * + * The bubbling observer itself is listening on the `'high'` priority so there could be listeners that are triggered + * no matter the context on lower or higher priorities. For example `'enter'` and `'delete'` commands are triggered + * on the `'normal'` priority without checking the context. + * + * Example flow for selection in text: + * + *

Foo[]bar

+ * + * Fired events on contexts: + * * `'$text'` + * * `'p'` + * * `'blockquote'` + * * `'$root'` + * + * Example flow for selection on element (i.e., Widget): + * + *

Foo[]bar

+ * + * Fired events on contexts: + * * *widget* (custom matcher) + * * `'p'` + * * `'blockquote'` + * * `'$root'` + * + * There could be multiple listeners registered for the same context and at different priority levels: + * + *

Foo[]bar

+ * + * * `'$text'` at priorities: + * * `'highest'` + * * `'high'` + * * `'normal'` + * * `'low'` + * * `'lowest'` + * * `'p'` at priorities: + * * `'highest'` + * * `'high'` + * * `'normal'` + * * `'low'` + * * `'lowest'` + * * `'$root'` at priorities: + * * `'highest'` + * * `'high'` + * * `'normal'` + * * `'low'` + * * `'lowest'` * * @abstract */ @@ -24,14 +105,14 @@ export default class BubblingObserver extends Observer { * Creates an instance of the bubbling observer. * * @param {module:engine/view/view~View} view - * @param {String} eventType TODO - * @param {String} [firedEventType=eventType] TODO + * @param {String} eventType The type of the event the observer should listen to. + * @param {String} [firedEventType=eventType] The type of the event the observer will fire. */ constructor( view, eventType, firedEventType = eventType ) { super( view ); /** - * Type of the event the observer should listen to. + * The type of the event the observer should listen to. * * @readonly * @member {String} @@ -39,7 +120,7 @@ export default class BubblingObserver extends Observer { this.eventType = eventType; /** - * Type of the event the observer will fire. + * The type of the event the observer will fire. * * @readonly * @member {String} @@ -47,7 +128,7 @@ export default class BubblingObserver extends Observer { this.firedEventType = firedEventType; /** - * TODO + * Map of context definitions to emitters. * * @private * @member {Map.} @@ -105,7 +186,6 @@ export default class BubblingObserver extends Observer { * TODO * * @protected - * @param {module:utils/eventinfo~EventInfo} eventInfo * @param {...*} [args] * @returns {Array.<*>|Boolean} False if event should not be handled. TODO */ From e77f95398ac109002aff933c67a941aac9c265f7 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 11 Feb 2021 14:58:09 +0100 Subject: [PATCH 20/43] Added docs for BubblingObserver. --- .../src/view/observer/bubblingobserver.js | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index 84983ff23bd..d11ad9758ae 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -60,43 +60,43 @@ import Observer from './observer'; *

Foo[]bar

* * Fired events on contexts: - * * `'$text'` - * * `'p'` - * * `'blockquote'` - * * `'$root'` + * 1. `'$text'` + * 2. `'p'` + * 3. `'blockquote'` + * 4. `'$root'` * * Example flow for selection on element (i.e., Widget): * *

Foo[]bar

* * Fired events on contexts: - * * *widget* (custom matcher) - * * `'p'` - * * `'blockquote'` - * * `'$root'` + * 1. *widget* (custom matcher) + * 2. `'p'` + * 3. `'blockquote'` + * 4. `'$root'` * * There could be multiple listeners registered for the same context and at different priority levels: * *

Foo[]bar

* - * * `'$text'` at priorities: - * * `'highest'` - * * `'high'` - * * `'normal'` - * * `'low'` - * * `'lowest'` - * * `'p'` at priorities: - * * `'highest'` - * * `'high'` - * * `'normal'` - * * `'low'` - * * `'lowest'` - * * `'$root'` at priorities: - * * `'highest'` - * * `'high'` - * * `'normal'` - * * `'low'` - * * `'lowest'` + * 1. `'$text'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 2. `'p'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 3. `'$root'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` * * @abstract */ @@ -156,7 +156,8 @@ export default class BubblingObserver extends Observer { observe() {} /** - * TODO + * Overrides the default implementation of EmitterMixin to intercept event bindings + * and redirect them to the emitter for a specified context. * * @protected */ @@ -172,7 +173,8 @@ export default class BubblingObserver extends Observer { } /** - * TODO + * Overrides the default implementation of EmitterMixin to intercept event unbinding + * and redirect them to emitters for all contexts. * * @protected */ @@ -183,11 +185,11 @@ export default class BubblingObserver extends Observer { } /** - * TODO + * Translates event data. It could also disable event bubbling by returning `false`. * * @protected * @param {...*} [args] - * @returns {Array.<*>|Boolean} False if event should not be handled. TODO + * @returns {Array.<*>|Boolean} False if event should not be handled. */ _translateEvent( ...args ) { return args; @@ -222,7 +224,7 @@ export default class BubblingObserver extends Observer { } /** - * TODO + * Sets main listener on the view document to intercept event and start bubbling it. * * @private */ @@ -289,7 +291,7 @@ export default class BubblingObserver extends Observer { } /** - * TODO + * Fires the listener for the specified context. Returns `true` if event was stopped. * * @private * @param {String|module:engine/view/node~Node} context @@ -310,7 +312,7 @@ export default class BubblingObserver extends Observer { } /** - * TODO + * Returns an emitter for a specified view node. * * @param {module:engine/view/node~Node} node * @returns {module:utils/emittermixin~Emitter|null} @@ -327,7 +329,7 @@ export default class BubblingObserver extends Observer { } } -// TODO +// Returns the deeper parent element for the selection. function getDeeperSelectionParent( selection ) { const focusParent = selection.focus.parent; const anchorParent = selection.anchor.parent; From 8be3a2b771e9bd95acf365ca513586263ea4de72 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 11 Feb 2021 15:24:45 +0100 Subject: [PATCH 21/43] Added option to register one handler for multiple contexts. --- .../src/view/observer/bubblingobserver.js | 16 ++++++++++------ packages/ckeditor5-widget/src/widget.js | 7 +------ .../src/widgettypearound/widgettypearound.js | 11 +++++------ 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index d11ad9758ae..4318e8ec7f3 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -162,14 +162,18 @@ export default class BubblingObserver extends Observer { * @protected */ _addEventListener( event, callback, options ) { - let listener = this._listeners.get( options.context ); + const contexts = Array.isArray( options.context ) ? options.context : [ options.context ]; - if ( !listener ) { - listener = Object.create( EmitterMixin ); - this._listeners.set( options.context, listener ); - } + for ( const context of contexts ) { + let listener = this._listeners.get( context ); - this.listenTo( listener, event, callback, options ); + if ( !listener ) { + listener = Object.create( EmitterMixin ); + this._listeners.set( context, listener ); + } + + this.listenTo( listener, event, callback, options ); + } } /** diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index 7a1fcde8bc8..e650505ba16 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -109,14 +109,9 @@ export default class Widget extends Plugin { // * The second (late) listener makes sure the default browser action on arrow key press is // prevented when a widget is selected. This prevents the selection from being moved // from a fake selection container. - // TODO split into 2 separate handlers this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { this._handleSelectionChangeOnArrowKeyPress( ...args ); - }, { context: isWidget } ); - - this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { - this._handleSelectionChangeOnArrowKeyPress( ...args ); - }, { context: '$text' } ); + }, { context: [ isWidget, '$text' ] } ); this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { this._preventDefaultOnArrowKeyPress( ...args ); diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index c3b720af1ea..514488ef0aa 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -267,14 +267,9 @@ export default class WidgetTypeAround extends Plugin { // This is the main listener responsible for the fake caret. // Note: The priority must precede the default Widget class keydown handler ("high"). - // TODO split into 2 separate handlers this._listenToIfEnabled( editingView.document, 'arrowkey', ( evt, domEventData ) => { this._handleArrowKeyPress( evt, domEventData ); - }, { context: isWidget, priority: 'high' } ); - - this._listenToIfEnabled( editingView.document, 'arrowkey', ( evt, domEventData ) => { - this._handleArrowKeyPress( evt, domEventData ); - }, { context: '$text', priority: 'high' } ); + }, { context: [ isWidget, '$text' ], priority: 'high' } ); // This listener makes sure the widget type around selection attribute will be gone from the model // selection as soon as the model range changes. This attribute only makes sense when a widget is selected @@ -548,6 +543,8 @@ export default class WidgetTypeAround extends Plugin { this._listenToIfEnabled( editingView.document, 'enter', ( evt, domEventData ) => { const selectedModelElement = selection.getSelectedElement(); + // This event could be triggered from inside the widget but we are interested + // only when the widget is selected itself. if ( !selectedModelElement ) { return; } @@ -633,6 +630,8 @@ export default class WidgetTypeAround extends Plugin { this._listenToIfEnabled( editingView.document, 'delete', ( evt, domEventData ) => { const selectedModelWidget = model.document.selection.getSelectedElement(); + // This event could be triggered from inside the widget but we are interested + // only when the widget is selected itself. if ( !selectedModelWidget ) { return; } From 5474e7d230a9ffd07b47785aa8f38bce79246af3 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 11 Feb 2021 18:11:00 +0100 Subject: [PATCH 22/43] Added missing tests. --- packages/ckeditor5-table/src/tablekeyboard.js | 1 + .../ckeditor5-utils/tests/observablemixin.js | 38 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js index 972ffa7e4a5..54061b77bdb 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.js +++ b/packages/ckeditor5-table/src/tablekeyboard.js @@ -212,6 +212,7 @@ export default class TableKeyboard extends Plugin { // Abort if we're not in a table cell. const tableCell = selection.focus.findAncestor( 'tableCell' ); + /* istanbul ignore if: paranoid check */ if ( !tableCell ) { return false; } diff --git a/packages/ckeditor5-utils/tests/observablemixin.js b/packages/ckeditor5-utils/tests/observablemixin.js index c3c2bc6743c..23b807e2cf9 100644 --- a/packages/ckeditor5-utils/tests/observablemixin.js +++ b/packages/ckeditor5-utils/tests/observablemixin.js @@ -1028,7 +1028,7 @@ describe( 'Observable', () => { foo.method( 1 ); } ); - it( 'supports stopping the event (which prevents execution of the orignal method', () => { + it( 'supports stopping the event (which prevents execution of the original method)', () => { class Foo extends Observable { method() { throw new Error( 'this should not be executed' ); @@ -1055,5 +1055,41 @@ describe( 'Observable', () => { foo.decorate( 'method' ); }, 'observablemixin-cannot-decorate-undefined' ); } ); + + it( 'should reverts decorated methods to the original method on stopListening for all events', () => { + class Foo extends Observable { + method() { + } + } + + const foo = new Foo(); + const originalMethod = foo.method; + + foo.decorate( 'method' ); + + expect( foo.method ).to.not.equal( originalMethod ); + + foo.stopListening(); + + expect( foo.method ).to.equal( originalMethod ); + } ); + + it( 'should not revert decorated methods to the original method on stopListening for specific emitter', () => { + class Foo extends Observable { + method() { + } + } + + const foo = new Foo(); + const originalMethod = foo.method; + + foo.decorate( 'method' ); + + expect( foo.method ).to.not.equal( originalMethod ); + + foo.stopListening( Object.create( ObservableMixin ) ); + + expect( foo.method ).to.not.equal( originalMethod ); + } ); } ); } ); From ea98da1b5ede442a7984d057b0de0b3b76577e3d Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 11 Feb 2021 19:30:45 +0100 Subject: [PATCH 23/43] Added missing tests. --- .../src/view/observer/arrowkeysobserver.js | 5 - .../tests/view/observer/arrowkeysobserver.js | 142 ++++++++++++++++++ packages/ckeditor5-enter/src/enterobserver.js | 5 - .../ckeditor5-typing/src/deleteobserver.js | 5 - 4 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js index 2df804293c4..1288d229568 100644 --- a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js @@ -24,11 +24,6 @@ export default class ArrowKeysObserver extends BubblingObserver { super( view, 'keydown', 'arrowkey' ); } - /** - * @inheritDoc - */ - observe() {} - /** * @inheritDoc */ diff --git a/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js new file mode 100644 index 00000000000..49ff169b681 --- /dev/null +++ b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js @@ -0,0 +1,142 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import ArrowKeysObserver from '../../../src/view/observer/arrowkeysobserver'; +import { setData as setModelData } from '../../../src/dev-utils/model'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +describe( 'ArrowKeysObserver', () => { + let editor, model, view, viewDocument, observer; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { plugins: [ Paragraph ] } ); + + model = editor.model; + view = editor.editing.view; + viewDocument = view.document; + observer = view.getObserver( ArrowKeysObserver ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should define eventType', () => { + expect( observer.eventType ).to.equal( 'keydown' ); + } ); + + it( 'should define firedEventType', () => { + expect( observer.firedEventType ).to.equal( 'arrowkey' ); + } ); + + describe( '#_translateEvent()', () => { + it( 'should fire arrowkey event with the same data as keydown event (arrow right)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowright }; + + viewDocument.on( 'arrowkey', spy, { context: '$root' } ); + + // Prevent other listeners (especially jump over UI element because it required DOM). + viewDocument.on( 'keydown', event => event.stop() ); + + viewDocument.fire( 'keydown', data ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should fire arrowkey event with the same data as keydown event (arrow left)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowleft }; + + viewDocument.on( 'arrowkey', spy, { context: '$root' } ); + + // Prevent other listeners (especially jump over inline filler because it required DOM). + viewDocument.on( 'keydown', event => event.stop() ); + + viewDocument.fire( 'keydown', data ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should fire arrowkey event with the same data as keydown event (arrow up)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowup }; + + viewDocument.on( 'arrowkey', spy, { context: '$root' } ); + + viewDocument.fire( 'keydown', data ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should fire arrowkey event with the same data as keydown event (arrow down)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; + + viewDocument.on( 'arrowkey', spy, { context: '$root' } ); + + viewDocument.fire( 'keydown', data ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should not fire arrowkey event on non arrow key press', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.delete }; + + viewDocument.on( 'arrowkey', spy, { context: '$root' } ); + + viewDocument.fire( 'keydown', data ); + + expect( spy.notCalled ).to.be.true; + } ); + } ); + + describe( '#_addEventListener()', () => { + it( 'should allow providing multiple context in one listener binding', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; + + viewDocument.on( 'arrowkey', spy, { context: [ '$text', 'p' ] } ); + + viewDocument.fire( 'keydown', data ); + + expect( spy.calledTwice ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + expect( spy.args[ 1 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should reuse existing context', () => { + setModelData( model, 'foo[]bar' ); + + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; + + viewDocument.on( 'arrowkey', spy1, { context: 'p' } ); + viewDocument.on( 'arrowkey', spy2, { context: 'p' } ); + + viewDocument.fire( 'keydown', data ); + + expect( spy1.calledOnce ).to.be.true; + expect( spy1.args[ 0 ][ 1 ] ).to.equal( data ); + expect( spy2.calledOnce ).to.be.true; + expect( spy2.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + } ); + + describe( '#_removeEventListener()', () => { + } ); +} ); diff --git a/packages/ckeditor5-enter/src/enterobserver.js b/packages/ckeditor5-enter/src/enterobserver.js index 8a63211d638..6d14934cc34 100644 --- a/packages/ckeditor5-enter/src/enterobserver.js +++ b/packages/ckeditor5-enter/src/enterobserver.js @@ -40,11 +40,6 @@ export default class EnterObserver extends BubblingObserver { } } ); } - - /** - * @inheritDoc - */ - observe() {} } /** diff --git a/packages/ckeditor5-typing/src/deleteobserver.js b/packages/ckeditor5-typing/src/deleteobserver.js index d90210b666b..7e15733bdbd 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.js +++ b/packages/ckeditor5-typing/src/deleteobserver.js @@ -93,11 +93,6 @@ export default class DeleteObserver extends BubblingObserver { } } } - - /** - * @inheritDoc - */ - observe() {} } /** From a8ab3c38cb4a6206d6906801f254efb0bd1ffc50 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Fri, 12 Feb 2021 16:05:43 +0100 Subject: [PATCH 24/43] Added missing tests. --- .../src/view/observer/bubblingobserver.js | 6 +- .../tests/view/observer/arrowkeysobserver.js | 281 +++++++++++- .../tests/view/observer/bubblingsobserver.js | 401 ++++++++++++++++++ 3 files changed, 681 insertions(+), 7 deletions(-) create mode 100644 packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index 4318e8ec7f3..febfe2503ca 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -241,16 +241,12 @@ export default class BubblingObserver extends Observer { } const eventInfo = new EventInfo( this, this.firedEventType ); - let eventArgs = this._translateEvent( ...args ); + const eventArgs = this._translateEvent( ...args ); if ( eventArgs === false ) { return; } - if ( !Array.isArray( eventArgs ) ) { - eventArgs = [ eventArgs ]; - } - const selectedElement = selection.getSelectedElement(); const isCustomContext = Boolean( selectedElement && this._getCustomContext( selectedElement ) ); diff --git a/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js index 49ff169b681..12931cb3e42 100644 --- a/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js @@ -8,14 +8,16 @@ import { setData as setModelData } from '../../../src/dev-utils/model'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { priorities } from '@ckeditor/ckeditor5-utils'; describe( 'ArrowKeysObserver', () => { let editor, model, view, viewDocument, observer; beforeEach( async () => { - editor = await VirtualTestEditor.create( { plugins: [ Paragraph ] } ); + editor = await VirtualTestEditor.create( { plugins: [ Paragraph, BlockQuoteEditing ] } ); model = editor.model; view = editor.editing.view; @@ -92,7 +94,7 @@ describe( 'ArrowKeysObserver', () => { it( 'should not fire arrowkey event on non arrow key press', () => { const spy = sinon.spy(); - const data = { keyCode: keyCodes.delete }; + const data = { keyCode: keyCodes.space }; viewDocument.on( 'arrowkey', spy, { context: '$root' } ); @@ -135,8 +137,283 @@ describe( 'ArrowKeysObserver', () => { expect( spy2.calledOnce ).to.be.true; expect( spy2.args[ 0 ][ 1 ] ).to.equal( data ); } ); + + it( 'should prevent registering a default listener', () => { + setModelData( model, 'foo[]bar' ); + + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; + + viewDocument.on( 'arrowkey', event => { + spy1(); + event.stop(); + }, { context: 'p' } ); + + viewDocument.on( 'keydown', spy2 ); + + viewDocument.fire( 'keydown', data ); + + expect( spy1.calledOnce ).to.be.true; + expect( spy2.notCalled ).to.be.true; + } ); } ); describe( '#_removeEventListener()', () => { + it( 'should unbind from contexts', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; + + viewDocument.on( 'arrowkey', spy, { context: 'p' } ); + viewDocument.fire( 'keydown', data ); + + expect( spy.calledOnce ).to.be.true; + + viewDocument.off( 'arrowkey', spy ); + viewDocument.fire( 'keydown', data ); + + expect( spy.calledOnce ).to.be.true; + } ); + + it( 'should not unbind from contexts if other event is off', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; + + viewDocument.on( 'arrowkey', spy, { context: 'p' } ); + viewDocument.fire( 'keydown', data ); + + expect( spy.calledOnce ).to.be.true; + + viewDocument.off( 'keydown', spy ); + viewDocument.fire( 'keydown', data ); + + expect( spy.calledTwice ).to.be.true; + } ); + } ); + + describe( 'event bubbling', () => { + it( 'should not bubble events if observer is disabled', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; + + viewDocument.on( 'arrowkey', spy, { context: 'p' } ); + + observer.disable(); + viewDocument.fire( 'keydown', data ); + + expect( spy.notCalled ).to.be.true; + } ); + + describe( 'bubbling starting from non collapsed selection', () => { + it( 'should start bubbling from the selection anchor position', () => { + setModelData( model, + '
fo[o
' + + 'b]ar' + ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + + it( 'should start bubbling from the selection focus position', () => { + setModelData( model, + '
fo[o
' + + 'b]ar', + { lastRangeBackward: true } + ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + } ); + + describe( 'while the selection in the text node', () => { + it( 'should bubble events from $text to $root and to default handlers if not stopped', () => { + setModelData( model, '
foo[]bar
' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + + it( 'should not start bubbling events if stopped before entering high priority', () => { + setModelData( model, '
foo[]bar
' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'keydown', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); + } ); + + it( 'should stop bubbling events if stopped on the $text context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: '$text' } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text' ] ); + } ); + + it( 'should stop bubbling events if stopped on the p context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: 'p' } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p' ] ); + } ); + + it( 'should stop bubbling events if stopped on the blockquote context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: 'blockquote' } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote' ] ); + } ); + + it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: '$root' } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root' ] ); + } ); + } ); + + describe( 'while the selection in on some object node', () => { + beforeEach( () => { + model.schema.register( 'object', { + allowWhere: '$text', + isInline: true + } ); + + editor.conversion.elementToElement( { model: 'object', view: 'obj' } ); + } ); + + it( 'should bubble events from $custom to $root (but without $text) and to default handlers if not stopped', () => { + setModelData( model, '
foo[]bar' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + + it( 'should not start bubbling events if stopped before entering high priority', () => { + setModelData( model, '
foo[]bar' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'keydown', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); + } ); + + it( 'should stop bubbling events if stopped on the custom context', () => { + setModelData( model, '
foo[]bar' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: isCustomObject } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom' ] ); + } ); + + it( 'should stop bubbling events if stopped on the p context', () => { + setModelData( model, '
foo[]bar' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: 'p' } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p' ] ); + } ); + + it( 'should stop bubbling events if stopped on the blockquote context', () => { + setModelData( model, '
foo[]bar' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: 'blockquote' } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote' ] ); + } ); + + it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { + setModelData( model, '
foo[]bar' ); + + const data = { keyCode: keyCodes.arrowdown }; + const events = setListeners(); + + viewDocument.on( 'arrowkey', event => event.stop(), { context: '$root' } ); + viewDocument.fire( 'keydown', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root' ] ); + } ); + } ); + + function setListeners() { + const events = []; + + viewDocument.on( 'arrowkey', () => events.push( '$root' ), { context: '$root' } ); + viewDocument.on( 'arrowkey', () => events.push( '$text' ), { context: '$text' } ); + viewDocument.on( 'arrowkey', () => events.push( '$custom' ), { context: isCustomObject } ); + + viewDocument.on( 'arrowkey', () => events.push( 'p' ), { context: 'p' } ); + viewDocument.on( 'arrowkey', () => events.push( 'blockquote' ), { context: 'blockquote' } ); + + viewDocument.on( 'keydown', () => events.push( 'keydown@high+10' ), { priority: priorities.get( 'high' ) + 10 } ); + viewDocument.on( 'keydown', () => events.push( 'keydown@high-10' ), { priority: priorities.get( 'high' ) - 10 } ); + + return events; + } + + function isCustomObject( node ) { + return node.is( 'element', 'obj' ); + } } ); } ); diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js b/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js new file mode 100644 index 00000000000..e654b125f47 --- /dev/null +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js @@ -0,0 +1,401 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import BubblingObserver from '../../../src/view/observer/bubblingobserver'; +import { setData as setModelData } from '../../../src/dev-utils/model'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; + +import { priorities } from '@ckeditor/ckeditor5-utils'; + +describe( 'BubblingObserver', () => { + let editor, model, view, viewDocument, observer; + + class MockedBubblingObserver extends BubblingObserver { + constructor( view ) { + super( view, 'fakeEvent' ); + } + + _translateEvent( data, ...args ) { + if ( data.disable ) { + return false; + } + + return super._translateEvent( data, ...args ); + } + } + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { plugins: [ Paragraph, BlockQuoteEditing ] } ); + + model = editor.model; + view = editor.editing.view; + viewDocument = view.document; + observer = view.addObserver( MockedBubblingObserver ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should define eventType', () => { + expect( observer.eventType ).to.equal( 'fakeEvent' ); + } ); + + it( 'should define firedEventType', () => { + expect( observer.firedEventType ).to.equal( 'fakeEvent' ); + } ); + + describe( '#_translateEvent()', () => { + it( 'should fire bubbling event with the same data as original event', () => { + const spy = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should not fire fakeEvent event on other event fired', () => { + const spy = sinon.spy(); + + viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); + viewDocument.fire( 'otherEvent', {} ); + + expect( spy.notCalled ).to.be.true; + } ); + + it( 'should not start bubbling if #_translateEvent() returned false', () => { + const spy = sinon.spy(); + + viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); + viewDocument.fire( 'fakeEvent', { disable: true } ); + + expect( spy.notCalled ).to.be.true; + } ); + } ); + + describe( '#_addEventListener()', () => { + it( 'should allow providing multiple contexts in one listener binding', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', spy, { context: [ '$text', 'p' ] } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.calledTwice ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + expect( spy.args[ 1 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should reuse existing context', () => { + setModelData( model, 'foo[]bar' ); + + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', spy1, { context: 'p' } ); + viewDocument.on( 'fakeEvent', spy2, { context: 'p' } ); + + viewDocument.fire( 'fakeEvent', data ); + + expect( spy1.calledOnce ).to.be.true; + expect( spy1.args[ 0 ][ 1 ] ).to.equal( data ); + expect( spy2.calledOnce ).to.be.true; + expect( spy2.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should prevent registering a default listener', () => { + setModelData( model, 'foo[]bar' ); + + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', event => { + spy1(); + event.stop(); + }, { context: 'p' } ); + + viewDocument.on( 'fakeEvent', spy2 ); + + viewDocument.fire( 'fakeEvent', data ); + + expect( spy1.calledOnce ).to.be.true; + expect( spy2.notCalled ).to.be.true; + } ); + } ); + + describe( '#_removeEventListener()', () => { + it( 'should unbind from contexts', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.calledOnce ).to.be.true; + + viewDocument.off( 'fakeEvent', spy ); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.calledOnce ).to.be.true; + } ); + + it( 'should not unbind from contexts if other event is off', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.calledOnce ).to.be.true; + + viewDocument.off( 'otherEvent', spy ); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.calledTwice ).to.be.true; + } ); + } ); + + describe( 'event bubbling', () => { + it( 'should not bubble events if observer is disabled', () => { + setModelData( model, 'foo[]bar' ); + + const spy = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); + + observer.disable(); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.notCalled ).to.be.true; + } ); + + describe( 'bubbling starting from non collapsed selection', () => { + it( 'should start bubbling from the selection anchor position', () => { + setModelData( model, + '
fo[o
' + + 'b]ar' + ); + + const data = {}; + const events = setListeners(); + + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + + it( 'should start bubbling from the selection focus position', () => { + setModelData( model, + '
fo[o
' + + 'b]ar', + { lastRangeBackward: true } + ); + + const data = {}; + const events = setListeners(); + + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + } ); + + describe( 'while the selection in the text node', () => { + it( 'should bubble events from $text to $root and to default handlers if not stopped', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners(); + + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + + it( 'should not start bubbling events if stopped before entering high priority', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); + } ); + + it( 'should stop bubbling events if stopped on the $text context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$text' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text' ] ); + } ); + + it( 'should stop bubbling events if stopped on the p context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'p' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p' ] ); + } ); + + it( 'should stop bubbling events if stopped on the blockquote context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote' ] ); + } ); + + it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root' ] ); + } ); + } ); + + describe( 'while the selection in on some object node', () => { + beforeEach( () => { + model.schema.register( 'object', { + allowWhere: '$text', + isInline: true + } ); + + editor.conversion.elementToElement( { model: 'object', view: 'obj' } ); + } ); + + it( 'should bubble events from $custom to $root (but without $text) and to default handlers if not stopped', () => { + setModelData( model, '
foo[]bar' ); + + const data = {}; + const events = setListeners(); + + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + } ); + + it( 'should not start bubbling events if stopped before entering high priority', () => { + setModelData( model, '
foo[]bar' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); + } ); + + it( 'should stop bubbling events if stopped on the custom context', () => { + setModelData( model, '
foo[]bar' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: isCustomObject } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom' ] ); + } ); + + it( 'should stop bubbling events if stopped on the p context', () => { + setModelData( model, '
foo[]bar' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'p' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p' ] ); + } ); + + it( 'should stop bubbling events if stopped on the blockquote context', () => { + setModelData( model, '
foo[]bar' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote' ] ); + } ); + + it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { + setModelData( model, '
foo[]bar' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root' ] ); + } ); + } ); + + function setListeners() { + const events = []; + + viewDocument.on( 'fakeEvent', () => events.push( '$root' ), { context: '$root' } ); + viewDocument.on( 'fakeEvent', () => events.push( '$text' ), { context: '$text' } ); + viewDocument.on( 'fakeEvent', () => events.push( '$custom' ), { context: isCustomObject } ); + + viewDocument.on( 'fakeEvent', () => events.push( 'p' ), { context: 'p' } ); + viewDocument.on( 'fakeEvent', () => events.push( 'blockquote' ), { context: 'blockquote' } ); + + viewDocument.on( 'fakeEvent', () => events.push( 'keydown@high+10' ), { priority: priorities.get( 'high' ) + 10 } ); + viewDocument.on( 'fakeEvent', () => events.push( 'keydown@high-10' ), { priority: priorities.get( 'high' ) - 10 } ); + + return events; + } + + function isCustomObject( node ) { + return node.is( 'element', 'obj' ); + } + } ); + + it( 'should implement empty #observe() method', () => { + expect( () => { + observer.observe(); + } ).to.not.throw(); + } ); +} ); From 32364d8b534abe67f8aad966ba564de55dbaf5f1 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Fri, 12 Feb 2021 18:02:01 +0100 Subject: [PATCH 25/43] ArrowKeysObserver extracted as a separate observer. --- packages/ckeditor5-engine/src/view/filler.js | 2 +- .../src/view/observer/arrowkeysobserver.js | 28 +- .../src/view/observer/bubblingobserver.js | 43 +- .../view/observer/fakeselectionobserver.js | 10 +- .../ckeditor5-engine/src/view/uielement.js | 2 +- .../tests/view/observer/arrowkeysobserver.js | 411 +++--------------- .../tests/view/observer/bubblingsobserver.js | 167 +++---- packages/ckeditor5-enter/src/enterobserver.js | 9 +- .../ckeditor5-enter/tests/enterobserver.js | 9 + .../ckeditor5-list/src/todolistediting.js | 2 +- packages/ckeditor5-table/src/tablekeyboard.js | 2 +- .../ckeditor5-typing/src/deleteobserver.js | 9 +- .../src/twostepcaretmovement.js | 2 +- .../ckeditor5-typing/tests/deleteobserver.js | 11 +- .../tests/twostepcaretmovement.js | 8 +- packages/ckeditor5-widget/src/widget.js | 6 +- .../src/widgettypearound/widgettypearound.js | 2 +- 17 files changed, 191 insertions(+), 532 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/filler.js b/packages/ckeditor5-engine/src/view/filler.js index 9ec01c5e813..aaed7d1f282 100644 --- a/packages/ckeditor5-engine/src/view/filler.js +++ b/packages/ckeditor5-engine/src/view/filler.js @@ -130,7 +130,7 @@ export function getDataWithoutFiller( domText ) { * @param {module:engine/view/view~View} view View controller instance we should inject quirks handling on. */ export function injectQuirksHandling( view ) { - view.document.on( 'keydown', jumpOverInlineFiller, { priority: 'low' } ); + view.document.on( 'arrowKey', jumpOverInlineFiller, { priority: 'low' } ); } // Move cursor from the end of the inline filler to the beginning of it when, so the filler does not break navigation. diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js index 1288d229568..191f8c25802 100644 --- a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js @@ -8,11 +8,11 @@ */ import BubblingObserver from './bubblingobserver'; - +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils'; /** - * Arrow keys observer introduces the {@link module:engine/view/document~Document#event:arrowkey} event. + * Arrow keys observer introduces the {@link module:engine/view/document~Document#event:arrowKey} event. * * @extends module:engine/view/observer/bubblingobserver~BubblingObserver */ @@ -21,19 +21,25 @@ export default class ArrowKeysObserver extends BubblingObserver { * @inheritDoc */ constructor( view ) { - super( view, 'keydown', 'arrowkey' ); + super( view, 'arrowKey' ); + + this.document.on( 'keydown', ( event, data ) => { + if ( this.isEnabled && isArrowKeyCode( data.keyCode ) ) { + const eventInfo = new EventInfo( this.document, 'arrowKey' ); + + this.document.fire( eventInfo, data ); + + if ( eventInfo.stop.called ) { + event.stop(); + } + } + } ); } /** * @inheritDoc */ - _translateEvent( data, ...args ) { - if ( !isArrowKeyCode( data.keyCode ) ) { - return false; - } - - return super._translateEvent( data, ...args ); - } + observe() {} } /** @@ -44,6 +50,6 @@ export default class ArrowKeysObserver extends BubblingObserver { * Note that because {@link module:engine/view/observer/arrowkeysobserver~ArrowKeysObserver} is attached by the * {@link module:engine/view/view~View} this event is available by default. * - * @event module:engine/view/document~Document#event:arrowkey + * @event module:engine/view/document~Document#event:arrowKey * @param {module:engine/view/observer/domeventdata~DomEventData} data */ diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index febfe2503ca..6a7532a8b40 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -34,20 +34,20 @@ import Observer from './observer'; * }, { context: 'li' } ); * * // Listeners registered in the context of the '$text' and '$root' nodes. - * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { * // ... * }, { context: '$text', priority: 'high' } ); * - * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { * // ... * }, { context: '$root' } ); * * // Listeners registered in the context of custom callback function. - * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { * // ... * }, { context: isWidget } ); * - * this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { * // ... * }, { context: isWidget, priority: 'high' } ); * @@ -106,9 +106,8 @@ export default class BubblingObserver extends Observer { * * @param {module:engine/view/view~View} view * @param {String} eventType The type of the event the observer should listen to. - * @param {String} [firedEventType=eventType] The type of the event the observer will fire. */ - constructor( view, eventType, firedEventType = eventType ) { + constructor( view, eventType ) { super( view ); /** @@ -119,14 +118,6 @@ export default class BubblingObserver extends Observer { */ this.eventType = eventType; - /** - * The type of the event the observer will fire. - * - * @readonly - * @member {String} - */ - this.firedEventType = firedEventType; - /** * Map of context definitions to emitters. * @@ -188,17 +179,6 @@ export default class BubblingObserver extends Observer { } } - /** - * Translates event data. It could also disable event bubbling by returning `false`. - * - * @protected - * @param {...*} [args] - * @returns {Array.<*>|Boolean} False if event should not be handled. - */ - _translateEvent( ...args ) { - return args; - } - /** * Intercept adding listeners for view document for bubbling observers. * @@ -206,7 +186,7 @@ export default class BubblingObserver extends Observer { */ _setupListenerInterception() { this.listenTo( this.document, '_addEventListener', ( evt, [ event, callback, options ] ) => { - if ( !options.context || event != this.firedEventType ) { + if ( !options.context || event != this.eventType ) { return; } @@ -217,7 +197,7 @@ export default class BubblingObserver extends Observer { }, { priority: 'high' } ); this.listenTo( this.document, '_removeEventListener', ( evt, [ event, callback ] ) => { - if ( event != this.firedEventType ) { + if ( event != this.eventType ) { return; } @@ -235,17 +215,12 @@ export default class BubblingObserver extends Observer { _setupEventListener() { const selection = this.document.selection; - this.listenTo( this.document, this.eventType, ( event, ...args ) => { + this.listenTo( this.document, this.eventType, ( event, ...eventArgs ) => { if ( !this.isEnabled || !this._listeners.size ) { return; } - const eventInfo = new EventInfo( this, this.firedEventType ); - const eventArgs = this._translateEvent( ...args ); - - if ( eventArgs === false ) { - return; - } + const eventInfo = new EventInfo( this, this.eventType ); const selectedElement = selection.getSelectedElement(); const isCustomContext = Boolean( selectedElement && this._getCustomContext( selectedElement ) ); diff --git a/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js b/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js index 8aebe3cd98e..5f7b953f38f 100644 --- a/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js @@ -9,7 +9,7 @@ import Observer from './observer'; import ViewSelection from '../selection'; -import { keyCodes, isArrowKeyCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { debounce } from 'lodash-es'; /** @@ -46,19 +46,19 @@ export default class FakeSelectionObserver extends Observer { observe() { const document = this.document; - document.on( 'keydown', ( eventInfo, data ) => { + document.on( 'arrowKey', ( eventInfo, data ) => { const selection = document.selection; - if ( selection.isFake && isArrowKeyCode( data.keyCode ) && this.isEnabled ) { + if ( selection.isFake && this.isEnabled ) { // Prevents default key down handling - no selection change will occur. data.preventDefault(); } }, { priority: 'highest' } ); - document.on( 'keydown', ( eventInfo, data ) => { + document.on( 'arrowKey', ( eventInfo, data ) => { const selection = document.selection; - if ( selection.isFake && isArrowKeyCode( data.keyCode ) && this.isEnabled ) { + if ( selection.isFake && this.isEnabled ) { this._handleSelectionMove( data.keyCode ); } }, { priority: 'lowest' } ); diff --git a/packages/ckeditor5-engine/src/view/uielement.js b/packages/ckeditor5-engine/src/view/uielement.js index ec0274e0b7e..c92adfe79dc 100644 --- a/packages/ckeditor5-engine/src/view/uielement.js +++ b/packages/ckeditor5-engine/src/view/uielement.js @@ -169,7 +169,7 @@ export default class UIElement extends Element { * @param {module:engine/view/view~View} view View controller to which the quirks handling will be injected. */ export function injectUiElementHandling( view ) { - view.document.on( 'keydown', ( evt, data ) => jumpOverUiElement( evt, data, view.domConverter ), { priority: 'low' } ); + view.document.on( 'arrowKey', ( evt, data ) => jumpOverUiElement( evt, data, view.domConverter ), { priority: 'low' } ); } // Returns `null` because block filler is not needed for UIElements. diff --git a/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js index 12931cb3e42..9a7ba808144 100644 --- a/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js @@ -4,22 +4,20 @@ */ import ArrowKeysObserver from '../../../src/view/observer/arrowkeysobserver'; -import { setData as setModelData } from '../../../src/dev-utils/model'; +import BubblingObserver from '../../../src/view/observer/bubblingobserver'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import { priorities } from '@ckeditor/ckeditor5-utils'; describe( 'ArrowKeysObserver', () => { - let editor, model, view, viewDocument, observer; + let editor, view, viewDocument, observer; beforeEach( async () => { editor = await VirtualTestEditor.create( { plugins: [ Paragraph, BlockQuoteEditing ] } ); - model = editor.model; view = editor.editing.view; viewDocument = view.document; observer = view.getObserver( ArrowKeysObserver ); @@ -29,391 +27,82 @@ describe( 'ArrowKeysObserver', () => { await editor.destroy(); } ); - it( 'should define eventType', () => { - expect( observer.eventType ).to.equal( 'keydown' ); + it( 'should extend BubblingObserver', () => { + expect( observer instanceof BubblingObserver ).to.be.true; } ); - it( 'should define firedEventType', () => { - expect( observer.firedEventType ).to.equal( 'arrowkey' ); + it( 'should define eventType', () => { + expect( observer.eventType ).to.equal( 'arrowKey' ); } ); - describe( '#_translateEvent()', () => { - it( 'should fire arrowkey event with the same data as keydown event (arrow right)', () => { - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowright }; - - viewDocument.on( 'arrowkey', spy, { context: '$root' } ); - - // Prevent other listeners (especially jump over UI element because it required DOM). - viewDocument.on( 'keydown', event => event.stop() ); - - viewDocument.fire( 'keydown', data ); - - expect( spy.calledOnce ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - } ); - - it( 'should fire arrowkey event with the same data as keydown event (arrow left)', () => { - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowleft }; - - viewDocument.on( 'arrowkey', spy, { context: '$root' } ); - - // Prevent other listeners (especially jump over inline filler because it required DOM). - viewDocument.on( 'keydown', event => event.stop() ); - - viewDocument.fire( 'keydown', data ); - - expect( spy.calledOnce ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - } ); - - it( 'should fire arrowkey event with the same data as keydown event (arrow up)', () => { - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowup }; - - viewDocument.on( 'arrowkey', spy, { context: '$root' } ); + it( 'should fire arrowKey event with the same data as keydown event (arrow right)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowright }; - viewDocument.fire( 'keydown', data ); + viewDocument.on( 'arrowKey', spy ); - expect( spy.calledOnce ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - } ); + // Prevent other listeners (especially jump over UI element because it required DOM). + viewDocument.on( 'arrowKey', event => event.stop() ); - it( 'should fire arrowkey event with the same data as keydown event (arrow down)', () => { - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowdown }; + viewDocument.fire( 'keydown', data ); - viewDocument.on( 'arrowkey', spy, { context: '$root' } ); - - viewDocument.fire( 'keydown', data ); - - expect( spy.calledOnce ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - } ); - - it( 'should not fire arrowkey event on non arrow key press', () => { - const spy = sinon.spy(); - const data = { keyCode: keyCodes.space }; - - viewDocument.on( 'arrowkey', spy, { context: '$root' } ); - - viewDocument.fire( 'keydown', data ); - - expect( spy.notCalled ).to.be.true; - } ); + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); } ); - describe( '#_addEventListener()', () => { - it( 'should allow providing multiple context in one listener binding', () => { - setModelData( model, 'foo[]bar' ); - - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowdown }; - - viewDocument.on( 'arrowkey', spy, { context: [ '$text', 'p' ] } ); - - viewDocument.fire( 'keydown', data ); - - expect( spy.calledTwice ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - expect( spy.args[ 1 ][ 1 ] ).to.equal( data ); - } ); - - it( 'should reuse existing context', () => { - setModelData( model, 'foo[]bar' ); - - const spy1 = sinon.spy(); - const spy2 = sinon.spy(); - const data = { keyCode: keyCodes.arrowdown }; + it( 'should fire arrowKey event with the same data as keydown event (arrow left)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowleft }; - viewDocument.on( 'arrowkey', spy1, { context: 'p' } ); - viewDocument.on( 'arrowkey', spy2, { context: 'p' } ); + viewDocument.on( 'arrowKey', spy ); - viewDocument.fire( 'keydown', data ); + // Prevent other listeners (especially jump over inline filler because it required DOM). + viewDocument.on( 'arrowKey', event => event.stop() ); - expect( spy1.calledOnce ).to.be.true; - expect( spy1.args[ 0 ][ 1 ] ).to.equal( data ); - expect( spy2.calledOnce ).to.be.true; - expect( spy2.args[ 0 ][ 1 ] ).to.equal( data ); - } ); + viewDocument.fire( 'keydown', data ); - it( 'should prevent registering a default listener', () => { - setModelData( model, 'foo[]bar' ); - - const spy1 = sinon.spy(); - const spy2 = sinon.spy(); - const data = { keyCode: keyCodes.arrowdown }; - - viewDocument.on( 'arrowkey', event => { - spy1(); - event.stop(); - }, { context: 'p' } ); - - viewDocument.on( 'keydown', spy2 ); - - viewDocument.fire( 'keydown', data ); - - expect( spy1.calledOnce ).to.be.true; - expect( spy2.notCalled ).to.be.true; - } ); + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); } ); - describe( '#_removeEventListener()', () => { - it( 'should unbind from contexts', () => { - setModelData( model, 'foo[]bar' ); - - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowdown }; - - viewDocument.on( 'arrowkey', spy, { context: 'p' } ); - viewDocument.fire( 'keydown', data ); - - expect( spy.calledOnce ).to.be.true; - - viewDocument.off( 'arrowkey', spy ); - viewDocument.fire( 'keydown', data ); - - expect( spy.calledOnce ).to.be.true; - } ); - - it( 'should not unbind from contexts if other event is off', () => { - setModelData( model, 'foo[]bar' ); - - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowdown }; - - viewDocument.on( 'arrowkey', spy, { context: 'p' } ); - viewDocument.fire( 'keydown', data ); + it( 'should fire arrowKey event with the same data as keydown event (arrow up)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowup }; - expect( spy.calledOnce ).to.be.true; + viewDocument.on( 'arrowKey', spy ); - viewDocument.off( 'keydown', spy ); - viewDocument.fire( 'keydown', data ); + viewDocument.fire( 'keydown', data ); - expect( spy.calledTwice ).to.be.true; - } ); + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); } ); - describe( 'event bubbling', () => { - it( 'should not bubble events if observer is disabled', () => { - setModelData( model, 'foo[]bar' ); - - const spy = sinon.spy(); - const data = { keyCode: keyCodes.arrowdown }; - - viewDocument.on( 'arrowkey', spy, { context: 'p' } ); - - observer.disable(); - viewDocument.fire( 'keydown', data ); - - expect( spy.notCalled ).to.be.true; - } ); - - describe( 'bubbling starting from non collapsed selection', () => { - it( 'should start bubbling from the selection anchor position', () => { - setModelData( model, - '
fo[o
' + - 'b]ar' - ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); - } ); - - it( 'should start bubbling from the selection focus position', () => { - setModelData( model, - '
fo[o
' + - 'b]ar', - { lastRangeBackward: true } - ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); - } ); - } ); + it( 'should fire arrowKey event with the same data as keydown event (arrow down)', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.arrowdown }; - describe( 'while the selection in the text node', () => { - it( 'should bubble events from $text to $root and to default handlers if not stopped', () => { - setModelData( model, '
foo[]bar
' ); + viewDocument.on( 'arrowKey', spy ); - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); + viewDocument.fire( 'keydown', data ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); - } ); - - it( 'should not start bubbling events if stopped before entering high priority', () => { - setModelData( model, '
foo[]bar
' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'keydown', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); - } ); - - it( 'should stop bubbling events if stopped on the $text context', () => { - setModelData( model, '
foo[]bar
' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: '$text' } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text' ] ); - } ); - - it( 'should stop bubbling events if stopped on the p context', () => { - setModelData( model, '
foo[]bar
' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: 'p' } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p' ] ); - } ); - - it( 'should stop bubbling events if stopped on the blockquote context', () => { - setModelData( model, '
foo[]bar
' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: 'blockquote' } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote' ] ); - } ); - - it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { - setModelData( model, '
foo[]bar
' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: '$root' } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root' ] ); - } ); - } ); - - describe( 'while the selection in on some object node', () => { - beforeEach( () => { - model.schema.register( 'object', { - allowWhere: '$text', - isInline: true - } ); - - editor.conversion.elementToElement( { model: 'object', view: 'obj' } ); - } ); - - it( 'should bubble events from $custom to $root (but without $text) and to default handlers if not stopped', () => { - setModelData( model, '
foo[]bar' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); - } ); - - it( 'should not start bubbling events if stopped before entering high priority', () => { - setModelData( model, '
foo[]bar' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'keydown', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); - } ); - - it( 'should stop bubbling events if stopped on the custom context', () => { - setModelData( model, '
foo[]bar' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: isCustomObject } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom' ] ); - } ); - - it( 'should stop bubbling events if stopped on the p context', () => { - setModelData( model, '
foo[]bar' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: 'p' } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p' ] ); - } ); - - it( 'should stop bubbling events if stopped on the blockquote context', () => { - setModelData( model, '
foo[]bar' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: 'blockquote' } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote' ] ); - } ); - - it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { - setModelData( model, '
foo[]bar' ); - - const data = { keyCode: keyCodes.arrowdown }; - const events = setListeners(); - - viewDocument.on( 'arrowkey', event => event.stop(), { context: '$root' } ); - viewDocument.fire( 'keydown', data ); - - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root' ] ); - } ); - } ); - - function setListeners() { - const events = []; + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); - viewDocument.on( 'arrowkey', () => events.push( '$root' ), { context: '$root' } ); - viewDocument.on( 'arrowkey', () => events.push( '$text' ), { context: '$text' } ); - viewDocument.on( 'arrowkey', () => events.push( '$custom' ), { context: isCustomObject } ); + it( 'should not fire arrowKey event on non arrow key press', () => { + const spy = sinon.spy(); + const data = { keyCode: keyCodes.space }; - viewDocument.on( 'arrowkey', () => events.push( 'p' ), { context: 'p' } ); - viewDocument.on( 'arrowkey', () => events.push( 'blockquote' ), { context: 'blockquote' } ); + viewDocument.on( 'arrowKey', spy ); - viewDocument.on( 'keydown', () => events.push( 'keydown@high+10' ), { priority: priorities.get( 'high' ) + 10 } ); - viewDocument.on( 'keydown', () => events.push( 'keydown@high-10' ), { priority: priorities.get( 'high' ) - 10 } ); + viewDocument.fire( 'keydown', data ); - return events; - } + expect( spy.notCalled ).to.be.true; + } ); - function isCustomObject( node ) { - return node.is( 'element', 'obj' ); - } + it( 'should implement empty #observe() method', () => { + expect( () => { + observer.observe(); + } ).to.not.throw(); } ); } ); diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js b/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js index e654b125f47..d3483c2106b 100644 --- a/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js @@ -19,14 +19,6 @@ describe( 'BubblingObserver', () => { constructor( view ) { super( view, 'fakeEvent' ); } - - _translateEvent( data, ...args ) { - if ( data.disable ) { - return false; - } - - return super._translateEvent( data, ...args ); - } } beforeEach( async () => { @@ -46,129 +38,110 @@ describe( 'BubblingObserver', () => { expect( observer.eventType ).to.equal( 'fakeEvent' ); } ); - it( 'should define firedEventType', () => { - expect( observer.firedEventType ).to.equal( 'fakeEvent' ); - } ); + it( 'should fire bubbling event with the same data as original event', () => { + const spy = sinon.spy(); + const data = {}; - describe( '#_translateEvent()', () => { - it( 'should fire bubbling event with the same data as original event', () => { - const spy = sinon.spy(); - const data = {}; + viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); + viewDocument.fire( 'fakeEvent', data ); - viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); - viewDocument.fire( 'fakeEvent', data ); + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); - expect( spy.calledOnce ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - } ); + it( 'should not fire fakeEvent event on other event fired', () => { + const spy = sinon.spy(); - it( 'should not fire fakeEvent event on other event fired', () => { - const spy = sinon.spy(); + viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); + viewDocument.fire( 'otherEvent', {} ); - viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); - viewDocument.fire( 'otherEvent', {} ); + expect( spy.notCalled ).to.be.true; + } ); - expect( spy.notCalled ).to.be.true; - } ); + it( 'should allow providing multiple contexts in one listener binding', () => { + setModelData( model, 'foo[]bar' ); - it( 'should not start bubbling if #_translateEvent() returned false', () => { - const spy = sinon.spy(); + const spy = sinon.spy(); + const data = {}; - viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); - viewDocument.fire( 'fakeEvent', { disable: true } ); + viewDocument.on( 'fakeEvent', spy, { context: [ '$text', 'p' ] } ); + viewDocument.fire( 'fakeEvent', data ); - expect( spy.notCalled ).to.be.true; - } ); + expect( spy.calledTwice ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + expect( spy.args[ 1 ][ 1 ] ).to.equal( data ); } ); - describe( '#_addEventListener()', () => { - it( 'should allow providing multiple contexts in one listener binding', () => { - setModelData( model, 'foo[]bar' ); - - const spy = sinon.spy(); - const data = {}; - - viewDocument.on( 'fakeEvent', spy, { context: [ '$text', 'p' ] } ); - viewDocument.fire( 'fakeEvent', data ); + it( 'should reuse existing context', () => { + setModelData( model, 'foo[]bar' ); - expect( spy.calledTwice ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - expect( spy.args[ 1 ][ 1 ] ).to.equal( data ); - } ); + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const data = {}; - it( 'should reuse existing context', () => { - setModelData( model, 'foo[]bar' ); + viewDocument.on( 'fakeEvent', spy1, { context: 'p' } ); + viewDocument.on( 'fakeEvent', spy2, { context: 'p' } ); - const spy1 = sinon.spy(); - const spy2 = sinon.spy(); - const data = {}; + viewDocument.fire( 'fakeEvent', data ); - viewDocument.on( 'fakeEvent', spy1, { context: 'p' } ); - viewDocument.on( 'fakeEvent', spy2, { context: 'p' } ); - - viewDocument.fire( 'fakeEvent', data ); - - expect( spy1.calledOnce ).to.be.true; - expect( spy1.args[ 0 ][ 1 ] ).to.equal( data ); - expect( spy2.calledOnce ).to.be.true; - expect( spy2.args[ 0 ][ 1 ] ).to.equal( data ); - } ); + expect( spy1.calledOnce ).to.be.true; + expect( spy1.args[ 0 ][ 1 ] ).to.equal( data ); + expect( spy2.calledOnce ).to.be.true; + expect( spy2.args[ 0 ][ 1 ] ).to.equal( data ); + } ); - it( 'should prevent registering a default listener', () => { - setModelData( model, 'foo[]bar' ); + it( 'should prevent registering a default listener', () => { + setModelData( model, 'foo[]bar' ); - const spy1 = sinon.spy(); - const spy2 = sinon.spy(); - const data = {}; + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const data = {}; - viewDocument.on( 'fakeEvent', event => { - spy1(); - event.stop(); - }, { context: 'p' } ); + viewDocument.on( 'fakeEvent', event => { + spy1(); + event.stop(); + }, { context: 'p' } ); - viewDocument.on( 'fakeEvent', spy2 ); + viewDocument.on( 'fakeEvent', spy2 ); - viewDocument.fire( 'fakeEvent', data ); + viewDocument.fire( 'fakeEvent', data ); - expect( spy1.calledOnce ).to.be.true; - expect( spy2.notCalled ).to.be.true; - } ); + expect( spy1.calledOnce ).to.be.true; + expect( spy2.notCalled ).to.be.true; } ); - describe( '#_removeEventListener()', () => { - it( 'should unbind from contexts', () => { - setModelData( model, 'foo[]bar' ); + it( 'should unbind from contexts', () => { + setModelData( model, 'foo[]bar' ); - const spy = sinon.spy(); - const data = {}; + const spy = sinon.spy(); + const data = {}; - viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); - viewDocument.fire( 'fakeEvent', data ); + viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); + viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledOnce ).to.be.true; + expect( spy.calledOnce ).to.be.true; - viewDocument.off( 'fakeEvent', spy ); - viewDocument.fire( 'fakeEvent', data ); + viewDocument.off( 'fakeEvent', spy ); + viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledOnce ).to.be.true; - } ); + expect( spy.calledOnce ).to.be.true; + } ); - it( 'should not unbind from contexts if other event is off', () => { - setModelData( model, 'foo[]bar' ); + it( 'should not unbind from contexts if other event is off', () => { + setModelData( model, 'foo[]bar' ); - const spy = sinon.spy(); - const data = {}; + const spy = sinon.spy(); + const data = {}; - viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); - viewDocument.fire( 'fakeEvent', data ); + viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); + viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledOnce ).to.be.true; + expect( spy.calledOnce ).to.be.true; - viewDocument.off( 'otherEvent', spy ); - viewDocument.fire( 'fakeEvent', data ); + viewDocument.off( 'otherEvent', spy ); + viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledTwice ).to.be.true; - } ); + expect( spy.calledTwice ).to.be.true; } ); describe( 'event bubbling', () => { diff --git a/packages/ckeditor5-enter/src/enterobserver.js b/packages/ckeditor5-enter/src/enterobserver.js index 6d14934cc34..987f2ca6aeb 100644 --- a/packages/ckeditor5-enter/src/enterobserver.js +++ b/packages/ckeditor5-enter/src/enterobserver.js @@ -9,6 +9,7 @@ import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; /** @@ -24,17 +25,15 @@ export default class EnterObserver extends BubblingObserver { doc.on( 'keydown', ( evt, data ) => { if ( this.isEnabled && data.keyCode == keyCodes.enter ) { - // Save the event object to check later if it was stopped or not. - let event; - doc.once( 'enter', evt => ( event = evt ), { priority: 'highest' } ); + const event = new EventInfo( doc, 'enter' ); - doc.fire( 'enter', new DomEventData( doc, data.domEvent, { + doc.fire( event, new DomEventData( doc, data.domEvent, { isSoft: data.shiftKey } ) ); // Stop `keydown` event if `enter` event was stopped. // https://github.com/ckeditor/ckeditor5/issues/753 - if ( event && event.stop.called ) { + if ( event.stop.called ) { evt.stop(); } } diff --git a/packages/ckeditor5-enter/tests/enterobserver.js b/packages/ckeditor5-enter/tests/enterobserver.js index b123c0e71cf..ced67a9ae48 100644 --- a/packages/ckeditor5-enter/tests/enterobserver.js +++ b/packages/ckeditor5-enter/tests/enterobserver.js @@ -7,6 +7,7 @@ import View from '@ckeditor/ckeditor5-engine/src/view/view'; import EnterObserver from '../src/enterobserver'; +import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import createViewRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -28,6 +29,14 @@ describe( 'EnterObserver', () => { } ).to.not.throw(); } ); + it( 'should extend BubblingObserver', () => { + expect( view.getObserver( EnterObserver ) instanceof BubblingObserver ).to.be.true; + } ); + + it( 'should define eventType', () => { + expect( view.getObserver( EnterObserver ).eventType ).to.equal( 'enter' ); + } ); + describe( 'enter event', () => { it( 'is fired on keydown', () => { const spy = sinon.spy(); diff --git a/packages/ckeditor5-list/src/todolistediting.js b/packages/ckeditor5-list/src/todolistediting.js index 2c0cabcb694..26425bd9068 100644 --- a/packages/ckeditor5-list/src/todolistediting.js +++ b/packages/ckeditor5-list/src/todolistediting.js @@ -105,7 +105,7 @@ export default class TodoListEditing extends Plugin { //

Foo{}

//
  • Bar
// - this.listenTo( editing.view.document, 'arrowkey', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ), { context: 'li' } ); + this.listenTo( editing.view.document, 'arrowKey', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ), { context: 'li' } ); // Toggle check state of selected to-do list items on keystroke. editor.keystrokes.set( 'Ctrl+space', () => editor.execute( 'todoListCheck' ) ); diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js index 54061b77bdb..475a2448d1a 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.js +++ b/packages/ckeditor5-table/src/tablekeyboard.js @@ -47,7 +47,7 @@ export default class TableKeyboard extends Plugin { this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } ); this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } ); - this.listenTo( viewDocument, 'arrowkey', ( ...args ) => this._onArrowKey( ...args ), { context: 'table' } ); + this.listenTo( viewDocument, 'arrowKey', ( ...args ) => this._onArrowKey( ...args ), { context: 'table' } ); } /** diff --git a/packages/ckeditor5-typing/src/deleteobserver.js b/packages/ckeditor5-typing/src/deleteobserver.js index 7e15733bdbd..e6171006026 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.js +++ b/packages/ckeditor5-typing/src/deleteobserver.js @@ -9,6 +9,7 @@ import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import env from '@ckeditor/ckeditor5-utils/src/env'; @@ -80,15 +81,13 @@ export default class DeleteObserver extends BubblingObserver { } function fireViewDeleteEvent( originalEvent, domEvent, deleteData ) { - // Save the event object to check later if it was stopped or not. - let event; - document.once( 'delete', evt => ( event = evt ), { priority: Number.POSITIVE_INFINITY } ); + const event = new EventInfo( document, 'delete' ); - document.fire( 'delete', new DomEventData( document, domEvent, deleteData ) ); + document.fire( event, new DomEventData( document, domEvent, deleteData ) ); // Stop the original event if `delete` event was stopped. // https://github.com/ckeditor/ckeditor5/issues/753 - if ( event && event.stop.called ) { + if ( event.stop.called ) { originalEvent.stop(); } } diff --git a/packages/ckeditor5-typing/src/twostepcaretmovement.js b/packages/ckeditor5-typing/src/twostepcaretmovement.js index 8577614e18b..0e901b391ed 100644 --- a/packages/ckeditor5-typing/src/twostepcaretmovement.js +++ b/packages/ckeditor5-typing/src/twostepcaretmovement.js @@ -146,7 +146,7 @@ export default class TwoStepCaretMovement extends Plugin { const modelSelection = model.document.selection; // Listen to keyboard events and handle the caret movement according to the 2-step caret logic. - this.listenTo( view.document, 'arrowkey', ( evt, data ) => { + this.listenTo( view.document, 'arrowKey', ( evt, data ) => { // This implementation works only for collapsed selection. if ( !modelSelection.isCollapsed ) { return; diff --git a/packages/ckeditor5-typing/tests/deleteobserver.js b/packages/ckeditor5-typing/tests/deleteobserver.js index d2aaf2ead7d..2bc5182b1bb 100644 --- a/packages/ckeditor5-typing/tests/deleteobserver.js +++ b/packages/ckeditor5-typing/tests/deleteobserver.js @@ -8,10 +8,11 @@ import DeleteObserver from '../src/deleteobserver'; import View from '@ckeditor/ckeditor5-engine/src/view/view'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import createViewRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import env from '@ckeditor/ckeditor5-utils/src/env'; +import env from '@ckeditor/ckeditor5-utils/src/env'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; describe( 'DeleteObserver', () => { @@ -37,6 +38,14 @@ describe( 'DeleteObserver', () => { } ).to.not.throw(); } ); + it( 'should extend BubblingObserver', () => { + expect( view.getObserver( DeleteObserver ) instanceof BubblingObserver ).to.be.true; + } ); + + it( 'should define eventType', () => { + expect( view.getObserver( DeleteObserver ).eventType ).to.equal( 'delete' ); + } ); + describe( 'delete event', () => { it( 'is fired on keydown', () => { const spy = sinon.spy(); diff --git a/packages/ckeditor5-typing/tests/twostepcaretmovement.js b/packages/ckeditor5-typing/tests/twostepcaretmovement.js index e1465a4257d..ae476e33136 100644 --- a/packages/ckeditor5-typing/tests/twostepcaretmovement.js +++ b/packages/ckeditor5-typing/tests/twostepcaretmovement.js @@ -745,10 +745,10 @@ describe( 'TwoStepCaretMovement()', () => { setData( model, '<$text c="true">foo[]<$text a="true" b="true">bar' ); - emitter.listenTo( view.document, 'arrowkey', highestPlusPrioritySpy, { context: '$text', priority: priorities.highest + 1 } ); - emitter.listenTo( view.document, 'arrowkey', highestPrioritySpy, { context: '$text', priority: 'highest' } ); - emitter.listenTo( view.document, 'arrowkey', highPrioritySpy, { context: '$text', priority: 'high' } ); - emitter.listenTo( view.document, 'arrowkey', normalPrioritySpy, { context: '$text', priority: 'normal' } ); + emitter.listenTo( view.document, 'arrowKey', highestPlusPrioritySpy, { context: '$text', priority: priorities.highest + 1 } ); + emitter.listenTo( view.document, 'arrowKey', highestPrioritySpy, { context: '$text', priority: 'highest' } ); + emitter.listenTo( view.document, 'arrowKey', highPrioritySpy, { context: '$text', priority: 'high' } ); + emitter.listenTo( view.document, 'arrowKey', normalPrioritySpy, { context: '$text', priority: 'normal' } ); fireKeyDownEvent( { keyCode: keyCodes.arrowright, diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index e650505ba16..1416e22ef59 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -109,15 +109,15 @@ export default class Widget extends Plugin { // * The second (late) listener makes sure the default browser action on arrow key press is // prevented when a widget is selected. This prevents the selection from being moved // from a fake selection container. - this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { + this.listenTo( viewDocument, 'arrowKey', ( ...args ) => { this._handleSelectionChangeOnArrowKeyPress( ...args ); }, { context: [ isWidget, '$text' ] } ); - this.listenTo( viewDocument, 'arrowkey', ( ...args ) => { + this.listenTo( viewDocument, 'arrowKey', ( ...args ) => { this._preventDefaultOnArrowKeyPress( ...args ); }, { context: '$root' } ); - this.listenTo( viewDocument, 'arrowkey', verticalNavigationHandler( this.editor.editing ), { context: '$text' } ); + this.listenTo( viewDocument, 'arrowKey', verticalNavigationHandler( this.editor.editing ), { context: '$text' } ); // Handle custom delete behaviour. this.listenTo( viewDocument, 'delete', ( evt, data ) => { diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 514488ef0aa..5d2c2e2f32d 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -267,7 +267,7 @@ export default class WidgetTypeAround extends Plugin { // This is the main listener responsible for the fake caret. // Note: The priority must precede the default Widget class keydown handler ("high"). - this._listenToIfEnabled( editingView.document, 'arrowkey', ( evt, domEventData ) => { + this._listenToIfEnabled( editingView.document, 'arrowKey', ( evt, domEventData ) => { this._handleArrowKeyPress( evt, domEventData ); }, { context: [ isWidget, '$text' ], priority: 'high' } ); From 5401c35dbcfe6d4b13fc0e712fe6d7d96e9d9d13 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Fri, 12 Feb 2021 19:04:32 +0100 Subject: [PATCH 26/43] Refactored event binding interception. Updated tests. --- .../src/view/observer/bubblingobserver.js | 50 +-- .../tests/view/observer/bubblingsobserver.js | 361 ++++++++++++++---- 2 files changed, 312 insertions(+), 99 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js index 6a7532a8b40..10d763ecaef 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js @@ -146,39 +146,6 @@ export default class BubblingObserver extends Observer { */ observe() {} - /** - * Overrides the default implementation of EmitterMixin to intercept event bindings - * and redirect them to the emitter for a specified context. - * - * @protected - */ - _addEventListener( event, callback, options ) { - const contexts = Array.isArray( options.context ) ? options.context : [ options.context ]; - - for ( const context of contexts ) { - let listener = this._listeners.get( context ); - - if ( !listener ) { - listener = Object.create( EmitterMixin ); - this._listeners.set( context, listener ); - } - - this.listenTo( listener, event, callback, options ); - } - } - - /** - * Overrides the default implementation of EmitterMixin to intercept event unbinding - * and redirect them to emitters for all contexts. - * - * @protected - */ - _removeEventListener( event, callback ) { - for ( const listener of this._listeners.values() ) { - this.stopListening( listener, event, callback ); - } - } - /** * Intercept adding listeners for view document for bubbling observers. * @@ -193,7 +160,18 @@ export default class BubblingObserver extends Observer { // Prevent registering a default listener. evt.stop(); - this.document.listenTo( this, event, callback, options ); + const contexts = Array.isArray( options.context ) ? options.context : [ options.context ]; + + for ( const context of contexts ) { + let listener = this._listeners.get( context ); + + if ( !listener ) { + listener = Object.create( EmitterMixin ); + this._listeners.set( context, listener ); + } + + this.document.listenTo( listener, event, callback, options ); + } }, { priority: 'high' } ); this.listenTo( this.document, '_removeEventListener', ( evt, [ event, callback ] ) => { @@ -203,7 +181,9 @@ export default class BubblingObserver extends Observer { // We don't want to prevent removing a default listener - remove it if it's registered. - this.document.stopListening( this, event, callback ); + for ( const listener of this._listeners.values() ) { + this.document.stopListening( listener, event, callback ); + } }, { priority: 'high' } ); } diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js b/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js index d3483c2106b..38ef5d97b76 100644 --- a/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js @@ -90,41 +90,31 @@ describe( 'BubblingObserver', () => { expect( spy2.args[ 0 ][ 1 ] ).to.equal( data ); } ); - it( 'should prevent registering a default listener', () => { + it( 'should unbind from contexts', () => { setModelData( model, 'foo[]bar' ); - const spy1 = sinon.spy(); - const spy2 = sinon.spy(); + const spyContext = sinon.spy(); + const spyGlobal = sinon.spy(); const data = {}; - viewDocument.on( 'fakeEvent', event => { - spy1(); - event.stop(); - }, { context: 'p' } ); - - viewDocument.on( 'fakeEvent', spy2 ); - + viewDocument.on( 'fakeEvent', spyContext, { context: 'p' } ); + viewDocument.on( 'fakeEvent', spyGlobal ); viewDocument.fire( 'fakeEvent', data ); - expect( spy1.calledOnce ).to.be.true; - expect( spy2.notCalled ).to.be.true; - } ); + expect( spyContext.callCount ).to.equal( 1 ); + expect( spyGlobal.callCount ).to.equal( 1 ); - it( 'should unbind from contexts', () => { - setModelData( model, 'foo[]bar' ); - - const spy = sinon.spy(); - const data = {}; - - viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); + viewDocument.off( 'fakeEvent', spyContext ); viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledOnce ).to.be.true; + expect( spyContext.callCount ).to.equal( 1 ); + expect( spyGlobal.callCount ).to.equal( 2 ); - viewDocument.off( 'fakeEvent', spy ); + viewDocument.off( 'fakeEvent', spyGlobal ); viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledOnce ).to.be.true; + expect( spyContext.callCount ).to.equal( 1 ); + expect( spyGlobal.callCount ).to.equal( 2 ); } ); it( 'should not unbind from contexts if other event is off', () => { @@ -136,12 +126,12 @@ describe( 'BubblingObserver', () => { viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledOnce ).to.be.true; + expect( spy.callCount ).to.equal( 1 ); viewDocument.off( 'otherEvent', spy ); viewDocument.fire( 'fakeEvent', data ); - expect( spy.calledTwice ).to.be.true; + expect( spy.callCount ).to.equal( 2 ); } ); describe( 'event bubbling', () => { @@ -171,7 +161,35 @@ describe( 'BubblingObserver', () => { viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + '$text @ highest', + '$text @ high', + '$text @ normal', + '$text @ low', + '$text @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal', + '$root @ low', + '$root @ lowest', + + 'fakeEvent @ high-10' + ] ); } ); it( 'should start bubbling from the selection focus position', () => { @@ -186,7 +204,35 @@ describe( 'BubblingObserver', () => { viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + '$text @ highest', + '$text @ high', + '$text @ normal', + '$text @ low', + '$text @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal', + '$root @ low', + '$root @ lowest', + + 'fakeEvent @ high-10' + ] ); } ); } ); @@ -199,31 +245,101 @@ describe( 'BubblingObserver', () => { viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + '$text @ highest', + '$text @ high', + '$text @ normal', + '$text @ low', + '$text @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal', + '$root @ low', + '$root @ lowest', + + 'fakeEvent @ high-10' + ] ); } ); - it( 'should not start bubbling events if stopped before entering high priority', () => { + it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { setModelData( model, '
foo[]bar
' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + '$text @ highest', + '$text @ high', + '$text @ normal', + '$text @ low', + '$text @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal' + ] ); } ); - it( 'should stop bubbling events if stopped on the $text context', () => { + it( 'should stop bubbling events if stopped on the blockquote context', () => { setModelData( model, '
foo[]bar
' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$text' } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + '$text @ highest', + '$text @ high', + '$text @ normal', + '$text @ low', + '$text @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal' + ] ); } ); it( 'should stop bubbling events if stopped on the p context', () => { @@ -235,31 +351,51 @@ describe( 'BubblingObserver', () => { viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'p' } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + '$text @ highest', + '$text @ high', + '$text @ normal', + '$text @ low', + '$text @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal' + ] ); } ); - it( 'should stop bubbling events if stopped on the blockquote context', () => { + it( 'should stop bubbling events if stopped on the $text context', () => { setModelData( model, '
foo[]bar
' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$text' } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + '$text @ highest', + '$text @ high', + '$text @ normal' + ] ); } ); - it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { + it( 'should not start bubbling events if stopped before entering high priority', () => { setModelData( model, '
foo[]bar
' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$text', 'p', 'blockquote', '$root' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10' + ] ); } ); } ); @@ -281,31 +417,101 @@ describe( 'BubblingObserver', () => { viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root', 'keydown@high-10' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + 'isCustomObject @ highest', + 'isCustomObject @ high', + 'isCustomObject @ normal', + 'isCustomObject @ low', + 'isCustomObject @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal', + '$root @ low', + '$root @ lowest', + + 'fakeEvent @ high-10' + ] ); } ); - it( 'should not start bubbling events if stopped before entering high priority', () => { + it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { setModelData( model, '
foo[]bar' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + 'isCustomObject @ highest', + 'isCustomObject @ high', + 'isCustomObject @ normal', + 'isCustomObject @ low', + 'isCustomObject @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal' + ] ); } ); - it( 'should stop bubbling events if stopped on the custom context', () => { + it( 'should stop bubbling events if stopped on the blockquote context', () => { setModelData( model, '
foo[]bar' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { context: isCustomObject } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + 'isCustomObject @ highest', + 'isCustomObject @ high', + 'isCustomObject @ normal', + 'isCustomObject @ low', + 'isCustomObject @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal' + ] ); } ); it( 'should stop bubbling events if stopped on the p context', () => { @@ -317,46 +523,73 @@ describe( 'BubblingObserver', () => { viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'p' } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + 'isCustomObject @ highest', + 'isCustomObject @ high', + 'isCustomObject @ normal', + 'isCustomObject @ low', + 'isCustomObject @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal' + ] ); } ); - it( 'should stop bubbling events if stopped on the blockquote context', () => { + it( 'should stop bubbling events if stopped on the custom context', () => { setModelData( model, '
foo[]bar' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: isCustomObject } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10', + + 'isCustomObject @ highest', + 'isCustomObject @ high', + 'isCustomObject @ normal' + ] ); } ); - it( 'should not trigger listeners on the lower priority if stopped on the $root context', () => { + it( 'should not start bubbling events if stopped before entering high priority', () => { setModelData( model, '
foo[]bar' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); viewDocument.fire( 'fakeEvent', data ); - expect( events ).to.deep.equal( [ 'keydown@high+10', '$custom', 'p', 'blockquote', '$root' ] ); + expect( events ).to.deep.equal( [ + 'fakeEvent @ high+10' + ] ); } ); } ); function setListeners() { const events = []; - viewDocument.on( 'fakeEvent', () => events.push( '$root' ), { context: '$root' } ); - viewDocument.on( 'fakeEvent', () => events.push( '$text' ), { context: '$text' } ); - viewDocument.on( 'fakeEvent', () => events.push( '$custom' ), { context: isCustomObject } ); - - viewDocument.on( 'fakeEvent', () => events.push( 'p' ), { context: 'p' } ); - viewDocument.on( 'fakeEvent', () => events.push( 'blockquote' ), { context: 'blockquote' } ); - - viewDocument.on( 'fakeEvent', () => events.push( 'keydown@high+10' ), { priority: priorities.get( 'high' ) + 10 } ); - viewDocument.on( 'fakeEvent', () => events.push( 'keydown@high-10' ), { priority: priorities.get( 'high' ) - 10 } ); + function setListenersForContext( context ) { + for ( const priority of [ 'highest', 'high', 'normal', 'low', 'lowest' ] ) { + viewDocument.on( 'fakeEvent', () => { + events.push( `${ typeof context == 'string' ? context : context.name } @ ${ priority }` ); + }, { context, priority } ); + } + } + + setListenersForContext( '$root' ); + setListenersForContext( '$text' ); + setListenersForContext( 'p' ); + setListenersForContext( 'blockquote' ); + setListenersForContext( isCustomObject ); + + viewDocument.on( 'fakeEvent', () => events.push( 'fakeEvent @ high+10' ), { priority: priorities.get( 'high' ) + 10 } ); + viewDocument.on( 'fakeEvent', () => events.push( 'fakeEvent @ high-10' ), { priority: priorities.get( 'high' ) - 10 } ); return events; } From a6f864dd15f5b82aff419e3ac44aa40f07d5c200 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Fri, 12 Feb 2021 20:23:42 +0100 Subject: [PATCH 27/43] Added option to the EmitterMixin to attach to the emitter that does not implement _addEventListener method. --- packages/ckeditor5-list/tests/listediting.js | 2 +- packages/ckeditor5-utils/src/emittermixin.js | 28 ++++++-- .../ckeditor5-utils/tests/emittermixin.js | 64 +++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/ckeditor5-list/tests/listediting.js b/packages/ckeditor5-list/tests/listediting.js index 03a29aee11d..2a2ec3d41e0 100644 --- a/packages/ckeditor5-list/tests/listediting.js +++ b/packages/ckeditor5-list/tests/listediting.js @@ -218,7 +218,7 @@ describe( 'ListEditing', () => { editor.editing.view.document.fire( 'delete', domEvtDataStub ); sinon.assert.calledOnce( editor.execute ); - sinon.assert.calledWith( editor.execute, 'forwardDelete' ); + sinon.assert.calledWith( editor.execute, 'deleteForward' ); } ); it( 'should not execute outdentList command when selection is not collapsed', () => { diff --git a/packages/ckeditor5-utils/src/emittermixin.js b/packages/ckeditor5-utils/src/emittermixin.js index 9e0df121d44..d57ce1e2850 100644 --- a/packages/ckeditor5-utils/src/emittermixin.js +++ b/packages/ckeditor5-utils/src/emittermixin.js @@ -109,7 +109,7 @@ const EmitterMixin = { eventCallbacks.push( callback ); // Finally register the callback to the event. - emitter._addEventListener( event, callback, options ); + addEventListener( this, emitter, event, callback, options ); }, /** @@ -128,7 +128,7 @@ const EmitterMixin = { // All params provided. off() that single callback. if ( callback ) { - emitter._removeEventListener( event, callback ); + removeEventListener( this, emitter, event, callback ); // We must remove callbacks as well in order to prevent memory leaks. // See https://github.com/ckeditor/ckeditor5/pull/8480 @@ -138,14 +138,14 @@ const EmitterMixin = { if ( eventCallbacks.length === 1 ) { delete emitterInfo.callbacks[ event ]; } else { - emitter._removeEventListener( event, callback ); + removeEventListener( this, emitter, event, callback ); } } } // Only `emitter` and `event` provided. off() all callbacks for that event. else if ( eventCallbacks ) { while ( ( callback = eventCallbacks.pop() ) ) { - emitter._removeEventListener( event, callback ); + removeEventListener( this, emitter, event, callback ); } delete emitterInfo.callbacks[ event ]; @@ -685,6 +685,26 @@ function fireDelegatedEvents( destinations, eventInfo, fireArgs ) { } } +// Helper for registering event callback on the emitter. +function addEventListener( listener, emitter, event, callback, options ) { + if ( emitter._addEventListener ) { + emitter._addEventListener( event, callback, options ); + } else { + // Allow listening on objects that do not implement Emitter interface. + listener._addEventListener.call( emitter, event, callback, options ); + } +} + +// Helper for removing event callback from the emitter. +function removeEventListener( listener, emitter, event, callback ) { + if ( emitter._removeEventListener ) { + emitter._removeEventListener( event, callback ); + } else { + // Allow listening on objects that do not implement Emitter interface. + listener._removeEventListener.call( emitter, event, callback ); + } +} + /** * The return value of {@link ~EmitterMixin#delegate}. * diff --git a/packages/ckeditor5-utils/tests/emittermixin.js b/packages/ckeditor5-utils/tests/emittermixin.js index 88c7107740c..3c4dcaf3213 100644 --- a/packages/ckeditor5-utils/tests/emittermixin.js +++ b/packages/ckeditor5-utils/tests/emittermixin.js @@ -544,6 +544,38 @@ describe( 'EmitterMixin', () => { sinon.assert.calledTwice( spyBar ); sinon.assert.calledOnce( spyBaz ); } ); + + it( 'should use _addEventListener() on emitter object', () => { + const emitter = { + _addEventListener() {} + }; + + const spy = sinon.spy( emitter, '_addEventListener' ); + + const callbackFunc = () => {}; + const optionsObj = {}; + + listener.listenTo( emitter, 'test', callbackFunc, optionsObj ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledOn( spy, emitter ); + sinon.assert.calledWithExactly( spy, 'test', callbackFunc, optionsObj ); + } ); + + it( 'should use listener\'s _addEventListener() if emitter is not implementing it', () => { + const emitter = {}; + + const spy = sinon.spy( listener, '_addEventListener' ); + + const callbackFunc = () => {}; + const optionsObj = {}; + + listener.listenTo( emitter, 'test', callbackFunc, optionsObj ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledOn( spy, emitter ); + sinon.assert.calledWithExactly( spy, 'test', callbackFunc, optionsObj ); + } ); } ); describe( 'stopListening', () => { @@ -727,6 +759,38 @@ describe( 'EmitterMixin', () => { sinon.assert.calledOnce( spy ); } ); + + it( 'should use _removeEventListener() on emitter object', () => { + const emitter = { + _removeEventListener() {} + }; + + const spy = sinon.spy( emitter, '_removeEventListener' ); + + const callbackFunc = () => {}; + + listener.listenTo( emitter, 'test', callbackFunc ); + listener.stopListening( emitter, 'test', callbackFunc ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledOn( spy, emitter ); + sinon.assert.calledWithExactly( spy, 'test', callbackFunc ); + } ); + + it( 'should use listener\'s _removeEventListener() if emitter is not implementing it', () => { + const emitter = {}; + + const spy = sinon.spy( listener, '_removeEventListener' ); + + const callbackFunc = () => {}; + + listener.listenTo( emitter, 'test', callbackFunc ); + listener.stopListening( emitter, 'test', callbackFunc ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledOn( spy, emitter ); + sinon.assert.calledWithExactly( spy, 'test', callbackFunc ); + } ); } ); describe( 'delegate', () => { From a27fe5c76a42cd255a8b36a904ce728392285cd4 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 15 Feb 2021 18:43:25 +0100 Subject: [PATCH 28/43] BubblingObserver refactored as an BubblingEmitterMixin. --- .../ckeditor5-engine/src/view/document.js | 8 +- .../src/view/observer/arrowkeysobserver.js | 8 +- .../src/view/observer/bubblingemittermixin.js | 270 ++++++++++++++++ .../src/view/observer/bubblingobserver.js | 296 ------------------ .../tests/view/observer/arrowkeysobserver.js | 9 - ...ngsobserver.js => bubblingemittermixin.js} | 36 +-- packages/ckeditor5-enter/src/enterobserver.js | 16 +- .../ckeditor5-enter/tests/enterobserver.js | 9 - .../ckeditor5-typing/src/deleteobserver.js | 16 +- .../ckeditor5-typing/tests/deleteobserver.js | 9 - packages/ckeditor5-utils/src/emittermixin.js | 18 +- 11 files changed, 315 insertions(+), 380 deletions(-) create mode 100644 packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js delete mode 100644 packages/ckeditor5-engine/src/view/observer/bubblingobserver.js rename packages/ckeditor5-engine/tests/view/observer/{bubblingsobserver.js => bubblingemittermixin.js} (94%) diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/src/view/document.js index 45713729cba..c697d4a680a 100644 --- a/packages/ckeditor5-engine/src/view/document.js +++ b/packages/ckeditor5-engine/src/view/document.js @@ -10,7 +10,7 @@ import DocumentSelection from './documentselection'; import Collection from '@ckeditor/ckeditor5-utils/src/collection'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; -import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import { BubblingObservableMixin } from './observer/bubblingemittermixin'; // @if CK_DEBUG_ENGINE // const { logDocument } = require( '../dev-utils/utils' ); @@ -97,10 +97,6 @@ export default class Document { * @member {Set} */ this._postFixers = new Set(); - - // Decorate emitter protected methods to allow BubblingObservers to intercept registering/removing listeners. - this.decorate( '_addEventListener' ); - this.decorate( '_removeEventListener' ); } /** @@ -207,7 +203,7 @@ export default class Document { // @if CK_DEBUG_ENGINE // } } -mix( Document, ObservableMixin ); +mix( Document, BubblingObservableMixin ); /** * Enum representing type of the change. diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js index 191f8c25802..20d64387198 100644 --- a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js @@ -7,21 +7,21 @@ * @module engine/view/observer/arrowkeysobserver */ -import BubblingObserver from './bubblingobserver'; +import Observer from './observer'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils'; /** * Arrow keys observer introduces the {@link module:engine/view/document~Document#event:arrowKey} event. * - * @extends module:engine/view/observer/bubblingobserver~BubblingObserver + * @extends module:engine/view/observer/observer~Observer */ -export default class ArrowKeysObserver extends BubblingObserver { +export default class ArrowKeysObserver extends Observer { /** * @inheritDoc */ constructor( view ) { - super( view, 'arrowKey' ); + super( view ); this.document.on( 'keydown', ( event, data ) => { if ( this.isEnabled && isArrowKeyCode( data.keyCode ) ) { diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js new file mode 100644 index 00000000000..4d58d572b6d --- /dev/null +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -0,0 +1,270 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/view/observer/bubblingemittermixin + */ + +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import EmitterMixin, { getEvents, makeEventNode } from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import { extend } from 'lodash-es'; + +const BubblingEmitterMixinMethods = { + _addEventListener( event, callback, options ) { + if ( !options.context ) { + return EmitterMixin._addEventListener.call( this, event, callback, options ); + } + + const contexts = Array.isArray( options.context ) ? options.context : [ options.context ]; + const eventContexts = getBubblingContexts( this, event ); + + for ( const context of contexts ) { + let emitter = eventContexts.get( context ); + + if ( !emitter ) { + emitter = Object.create( EmitterMixin ); + eventContexts.set( context, emitter ); + } + + this.listenTo( emitter, event, callback, options ); + } + }, + + _removeEventListener( event, callback ) { + // We don't want to prevent removing a default listener - remove it if it's registered. + EmitterMixin._removeEventListener.call( this, event, callback ); + + const eventContexts = getBubblingContexts( this, event ); + + for ( const emitter of eventContexts.values() ) { + this.stopListening( emitter, event, callback ); + } + } +}; + +/** + * Bubbling emitter mixin for the view document. + * + * Bubbling emitter mixin is triggering events in the context of specified {@link module:engine/view/element~Element view element} name, + * predefined `'$text'` and `'$root'` contexts, and context matchers provided as a function. + * + * The bubbling starts from the deeper selection position (by firing event on the `'$text'` context) and propagates + * the view document tree up to the `'$root'`. + * + * Examples: + * + * // Listeners registered in the context of the view element names: + * this.listenTo( viewDocument, 'enter', ( evt, data ) => { + * // ... + * }, { context: 'blockquote' } ); + * + * this.listenTo( viewDocument, 'enter', ( evt, data ) => { + * // ... + * }, { context: 'li' } ); + * + * // Listeners registered in the context of the '$text' and '$root' nodes. + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: '$text', priority: 'high' } ); + * + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: '$root' } ); + * + * // Listeners registered in the context of custom callback function. + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: isWidget } ); + * + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: isWidget, priority: 'high' } ); + * + * The bubbling emitter itself is listening on the `'high'` priority so there could be listeners that are triggered + * no matter the context on lower or higher priorities. For example `'enter'` and `'delete'` commands are triggered + * on the `'normal'` priority without checking the context. + * + * Example flow for selection in text: + * + *

Foo[]bar

+ * + * Fired events on contexts: + * 1. `'$text'` + * 2. `'p'` + * 3. `'blockquote'` + * 4. `'$root'` + * + * Example flow for selection on element (i.e., Widget): + * + *

Foo[]bar

+ * + * Fired events on contexts: + * 1. *widget* (custom matcher) + * 2. `'p'` + * 3. `'blockquote'` + * 4. `'$root'` + * + * There could be multiple listeners registered for the same context and at different priority levels: + * + *

Foo[]bar

+ * + * 1. `'$text'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 2. `'p'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 3. `'$root'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * + * @mixin BubblingEmitterMixin + */ +export const BubblingEmitterMixin = {}; +extend( BubblingEmitterMixin, EmitterMixin, BubblingEmitterMixinMethods ); + +/** + * TODO + * + * @mixin BubblingObservableMixin + */ +export const BubblingObservableMixin = {}; +extend( BubblingObservableMixin, ObservableMixin, BubblingEmitterMixinMethods ); + +// TODO +function injectBubblingListener( source, eventName ) { + source.on( eventName, ( event, ...eventArgs ) => { + // TODO maybe there should be a special field in EventInfo that would enable bubbling + // TODO also maybe we could add eventPhase to EventInfo (at-target, bubbling) + // maybe also "capturing" phase to indicate that it's before bubbling + // while adding listener we could provide in options what phase we want (capture, at-target or bubbling (includes at-target) ) + + const eventContexts = getBubblingContexts( source, eventName ); + + if ( !eventContexts.size ) { + return; + } + + const eventInfo = new EventInfo( source, eventName ); + + const selection = source.selection; + const selectedElement = selection.getSelectedElement(); + const isCustomContext = Boolean( selectedElement && getCustomContext( eventContexts, selectedElement ) ); + + // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. + if ( !isCustomContext && fireListenerFor( eventContexts, '$text', eventInfo, ...eventArgs ) ) { + // Stop the original event. + event.stop(); + + return; + } + + let node = selectedElement || getDeeperSelectionParent( selection ); + + while ( node ) { + // Root node handling. + if ( node.is( 'rootElement' ) ) { + if ( fireListenerFor( eventContexts, '$root', eventInfo, ...eventArgs ) ) { + break; + } + } + + // Element node handling. + else if ( node.is( 'element' ) ) { + if ( fireListenerFor( eventContexts, node.name, eventInfo, ...eventArgs ) ) { + break; + } + } + + // Check custom contexts (i.e., a widget). + if ( fireListenerFor( eventContexts, node, eventInfo, ...eventArgs ) ) { + break; + } + + node = node.parent; + } + + // Stop the event if bubbling listener stopped it. + if ( eventInfo.stop.called ) { + event.stop(); + } + }, { priority: 'high' } ); +} + +// Fires the listener for the specified context. Returns `true` if event was stopped. +// +// @private +// @param {Map.} eventContexts +// @param {String|module:engine/view/node~Node} context +// @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. +// @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. +// @returns {Boolean} True if event stop was called. +function fireListenerFor( eventContexts, context, eventInfo, ...eventArgs ) { + const listener = typeof context == 'string' ? eventContexts.get( context ) : getCustomContext( eventContexts, context ); + + if ( !listener ) { + return false; + } + + listener.fire( eventInfo, ...eventArgs ); + + return eventInfo.stop.called; +} + +// Returns an emitter for a specified view node. +// +// @private +// @param {Map.} eventContexts +// @param {module:engine/view/node~Node} node +// @returns {module:utils/emittermixin~Emitter|null} +function getCustomContext( eventContexts, node ) { + for ( const [ context, listener ] of eventContexts ) { + if ( typeof context == 'function' && context( node ) ) { + return listener; + } + } + + return null; +} + +// Returns bubbling contexts map for the source (emitter). +function getBubblingContexts( source, eventName ) { + const events = getEvents( source ); + + if ( !events[ eventName ] ) { + events[ eventName ] = makeEventNode(); + } + + const eventNode = events[ eventName ]; + + if ( !eventNode.bubblingContexts ) { + eventNode.bubblingContexts = new Map(); + + injectBubblingListener( source, eventName ); + } + + return eventNode.bubblingContexts; +} + +// Returns the deeper parent element for the selection. +function getDeeperSelectionParent( selection ) { + const focusParent = selection.focus.parent; + const anchorParent = selection.anchor.parent; + + const focusPath = focusParent.getPath(); + const anchorPath = anchorParent.getPath(); + + return focusPath.length > anchorPath.length ? focusParent : anchorParent; +} diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js b/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js deleted file mode 100644 index 10d763ecaef..00000000000 --- a/packages/ckeditor5-engine/src/view/observer/bubblingobserver.js +++ /dev/null @@ -1,296 +0,0 @@ -/** - * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module engine/view/observer/bubblingobserver - */ - -import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; -import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; - -import Observer from './observer'; - -/** - * Abstract base bubbling observer class. Observers are classes which listen to events, do the preliminary - * processing and fire events on the {@link module:engine/view/document~Document} objects. - * - * Bubbling observers are triggering events in the context of specified {@link module:engine/view/element~Element view element} name, - * predefined `'$text'` and `'$root'` contexts, and context matchers provided as a function. - * - * The bubbling starts from the deeper selection position (by firing event on the `'$text'` context) and propagates - * the view document tree up to the `'$root'`. - * - * Examples: - * - * // Listeners registered in the context of the view element names: - * this.listenTo( viewDocument, 'enter', ( evt, data ) => { - * // ... - * }, { context: 'blockquote' } ); - * - * this.listenTo( viewDocument, 'enter', ( evt, data ) => { - * // ... - * }, { context: 'li' } ); - * - * // Listeners registered in the context of the '$text' and '$root' nodes. - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: '$text', priority: 'high' } ); - * - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: '$root' } ); - * - * // Listeners registered in the context of custom callback function. - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: isWidget } ); - * - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: isWidget, priority: 'high' } ); - * - * The bubbling observer itself is listening on the `'high'` priority so there could be listeners that are triggered - * no matter the context on lower or higher priorities. For example `'enter'` and `'delete'` commands are triggered - * on the `'normal'` priority without checking the context. - * - * Example flow for selection in text: - * - *

Foo[]bar

- * - * Fired events on contexts: - * 1. `'$text'` - * 2. `'p'` - * 3. `'blockquote'` - * 4. `'$root'` - * - * Example flow for selection on element (i.e., Widget): - * - *

Foo[]bar

- * - * Fired events on contexts: - * 1. *widget* (custom matcher) - * 2. `'p'` - * 3. `'blockquote'` - * 4. `'$root'` - * - * There could be multiple listeners registered for the same context and at different priority levels: - * - *

Foo[]bar

- * - * 1. `'$text'` at priorities: - * 1. `'highest'` - * 2. `'high'` - * 3. `'normal'` - * 4. `'low'` - * 5. `'lowest'` - * 2. `'p'` at priorities: - * 1. `'highest'` - * 2. `'high'` - * 3. `'normal'` - * 4. `'low'` - * 5. `'lowest'` - * 3. `'$root'` at priorities: - * 1. `'highest'` - * 2. `'high'` - * 3. `'normal'` - * 4. `'low'` - * 5. `'lowest'` - * - * @abstract - */ -export default class BubblingObserver extends Observer { - /** - * Creates an instance of the bubbling observer. - * - * @param {module:engine/view/view~View} view - * @param {String} eventType The type of the event the observer should listen to. - */ - constructor( view, eventType ) { - super( view ); - - /** - * The type of the event the observer should listen to. - * - * @readonly - * @member {String} - */ - this.eventType = eventType; - - /** - * Map of context definitions to emitters. - * - * @private - * @member {Map.} - */ - this._listeners = new Map(); - - this._setupListenerInterception(); - this._setupEventListener(); - } - - /** - * @inheritDoc - */ - destroy() { - for ( const listener of this._listeners.values() ) { - listener.stopListening(); - } - - super.destroy(); - } - - /** - * @inheritDoc - */ - observe() {} - - /** - * Intercept adding listeners for view document for bubbling observers. - * - * @private - */ - _setupListenerInterception() { - this.listenTo( this.document, '_addEventListener', ( evt, [ event, callback, options ] ) => { - if ( !options.context || event != this.eventType ) { - return; - } - - // Prevent registering a default listener. - evt.stop(); - - const contexts = Array.isArray( options.context ) ? options.context : [ options.context ]; - - for ( const context of contexts ) { - let listener = this._listeners.get( context ); - - if ( !listener ) { - listener = Object.create( EmitterMixin ); - this._listeners.set( context, listener ); - } - - this.document.listenTo( listener, event, callback, options ); - } - }, { priority: 'high' } ); - - this.listenTo( this.document, '_removeEventListener', ( evt, [ event, callback ] ) => { - if ( event != this.eventType ) { - return; - } - - // We don't want to prevent removing a default listener - remove it if it's registered. - - for ( const listener of this._listeners.values() ) { - this.document.stopListening( listener, event, callback ); - } - }, { priority: 'high' } ); - } - - /** - * Sets main listener on the view document to intercept event and start bubbling it. - * - * @private - */ - _setupEventListener() { - const selection = this.document.selection; - - this.listenTo( this.document, this.eventType, ( event, ...eventArgs ) => { - if ( !this.isEnabled || !this._listeners.size ) { - return; - } - - const eventInfo = new EventInfo( this, this.eventType ); - - const selectedElement = selection.getSelectedElement(); - const isCustomContext = Boolean( selectedElement && this._getCustomContext( selectedElement ) ); - - // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. - if ( !isCustomContext && this._fireListenerFor( '$text', eventInfo, ...eventArgs ) ) { - // Stop the original event. - event.stop(); - - return; - } - - let node = selectedElement || getDeeperSelectionParent( selection ); - - while ( node ) { - // Root node handling. - if ( node.is( 'rootElement' ) ) { - if ( this._fireListenerFor( '$root', eventInfo, ...eventArgs ) ) { - break; - } - } - - // Element node handling. - else if ( node.is( 'element' ) ) { - if ( this._fireListenerFor( node.name, eventInfo, ...eventArgs ) ) { - break; - } - } - - // Check custom contexts (i.e., a widget). - if ( this._fireListenerFor( node, eventInfo, ...eventArgs ) ) { - break; - } - - node = node.parent; - } - - // Stop the event if generic handler stopped it. - if ( eventInfo.stop.called ) { - event.stop(); - } - }, { priority: 'high' } ); - } - - /** - * Fires the listener for the specified context. Returns `true` if event was stopped. - * - * @private - * @param {String|module:engine/view/node~Node} context - * @param {module:utils/eventinfo~EventInfo} eventInfo The `EventInfo` object. - * @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. - * @returns {Boolean} True if event stop was called. - */ - _fireListenerFor( context, eventInfo, ...eventArgs ) { - const listener = typeof context == 'string' ? this._listeners.get( context ) : this._getCustomContext( context ); - - if ( !listener ) { - return false; - } - - listener.fire( eventInfo, ...eventArgs ); - - return eventInfo.stop.called; - } - - /** - * Returns an emitter for a specified view node. - * - * @param {module:engine/view/node~Node} node - * @returns {module:utils/emittermixin~Emitter|null} - * @private - */ - _getCustomContext( node ) { - for ( const [ context, listener ] of this._listeners ) { - if ( typeof context == 'function' && context( node ) ) { - return listener; - } - } - - return null; - } -} - -// Returns the deeper parent element for the selection. -function getDeeperSelectionParent( selection ) { - const focusParent = selection.focus.parent; - const anchorParent = selection.anchor.parent; - - const focusPath = focusParent.getPath(); - const anchorPath = anchorParent.getPath(); - - return focusPath.length > anchorPath.length ? focusParent : anchorParent; -} diff --git a/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js index 9a7ba808144..a44ab804243 100644 --- a/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/tests/view/observer/arrowkeysobserver.js @@ -4,7 +4,6 @@ */ import ArrowKeysObserver from '../../../src/view/observer/arrowkeysobserver'; -import BubblingObserver from '../../../src/view/observer/bubblingobserver'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; @@ -27,14 +26,6 @@ describe( 'ArrowKeysObserver', () => { await editor.destroy(); } ); - it( 'should extend BubblingObserver', () => { - expect( observer instanceof BubblingObserver ).to.be.true; - } ); - - it( 'should define eventType', () => { - expect( observer.eventType ).to.equal( 'arrowKey' ); - } ); - it( 'should fire arrowKey event with the same data as keydown event (arrow right)', () => { const spy = sinon.spy(); const data = { keyCode: keyCodes.arrowright }; diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js b/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js similarity index 94% rename from packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js rename to packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js index 38ef5d97b76..2b591afc433 100644 --- a/packages/ckeditor5-engine/tests/view/observer/bubblingsobserver.js +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js @@ -3,7 +3,6 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import BubblingObserver from '../../../src/view/observer/bubblingobserver'; import { setData as setModelData } from '../../../src/dev-utils/model'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; @@ -12,14 +11,8 @@ import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteedi import { priorities } from '@ckeditor/ckeditor5-utils'; -describe( 'BubblingObserver', () => { - let editor, model, view, viewDocument, observer; - - class MockedBubblingObserver extends BubblingObserver { - constructor( view ) { - super( view, 'fakeEvent' ); - } - } +describe( 'BubblingEmitterMixin', () => { + let editor, model, view, viewDocument; beforeEach( async () => { editor = await VirtualTestEditor.create( { plugins: [ Paragraph, BlockQuoteEditing ] } ); @@ -27,17 +20,12 @@ describe( 'BubblingObserver', () => { model = editor.model; view = editor.editing.view; viewDocument = view.document; - observer = view.addObserver( MockedBubblingObserver ); } ); afterEach( async () => { await editor.destroy(); } ); - it( 'should define eventType', () => { - expect( observer.eventType ).to.equal( 'fakeEvent' ); - } ); - it( 'should fire bubbling event with the same data as original event', () => { const spy = sinon.spy(); const data = {}; @@ -135,20 +123,6 @@ describe( 'BubblingObserver', () => { } ); describe( 'event bubbling', () => { - it( 'should not bubble events if observer is disabled', () => { - setModelData( model, 'foo[]bar' ); - - const spy = sinon.spy(); - const data = {}; - - viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); - - observer.disable(); - viewDocument.fire( 'fakeEvent', data ); - - expect( spy.notCalled ).to.be.true; - } ); - describe( 'bubbling starting from non collapsed selection', () => { it( 'should start bubbling from the selection anchor position', () => { setModelData( model, @@ -598,10 +572,4 @@ describe( 'BubblingObserver', () => { return node.is( 'element', 'obj' ); } } ); - - it( 'should implement empty #observe() method', () => { - expect( () => { - observer.observe(); - } ).to.not.throw(); - } ); } ); diff --git a/packages/ckeditor5-enter/src/enterobserver.js b/packages/ckeditor5-enter/src/enterobserver.js index 987f2ca6aeb..0524385734e 100644 --- a/packages/ckeditor5-enter/src/enterobserver.js +++ b/packages/ckeditor5-enter/src/enterobserver.js @@ -7,7 +7,7 @@ * @module enter/enterobserver */ -import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; +import Observer from '@ckeditor/ckeditor5-engine/src/view/observer/observer'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -15,11 +15,14 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; /** * Enter observer introduces the {@link module:engine/view/document~Document#event:enter} event. * - * @extends module:engine/view/observer/bubblingobserver~BubblingObserver + * @extends module:engine/view/observer/observer~Observer */ -export default class EnterObserver extends BubblingObserver { +export default class EnterObserver extends Observer { + /** + * @inheritDoc + */ constructor( view ) { - super( view, 'enter' ); + super( view ); const doc = this.document; @@ -39,6 +42,11 @@ export default class EnterObserver extends BubblingObserver { } } ); } + + /** + * @inheritDoc + */ + observe() {} } /** diff --git a/packages/ckeditor5-enter/tests/enterobserver.js b/packages/ckeditor5-enter/tests/enterobserver.js index ced67a9ae48..b123c0e71cf 100644 --- a/packages/ckeditor5-enter/tests/enterobserver.js +++ b/packages/ckeditor5-enter/tests/enterobserver.js @@ -7,7 +7,6 @@ import View from '@ckeditor/ckeditor5-engine/src/view/view'; import EnterObserver from '../src/enterobserver'; -import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import createViewRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -29,14 +28,6 @@ describe( 'EnterObserver', () => { } ).to.not.throw(); } ); - it( 'should extend BubblingObserver', () => { - expect( view.getObserver( EnterObserver ) instanceof BubblingObserver ).to.be.true; - } ); - - it( 'should define eventType', () => { - expect( view.getObserver( EnterObserver ).eventType ).to.equal( 'enter' ); - } ); - describe( 'enter event', () => { it( 'is fired on keydown', () => { const spy = sinon.spy(); diff --git a/packages/ckeditor5-typing/src/deleteobserver.js b/packages/ckeditor5-typing/src/deleteobserver.js index e6171006026..01d37e19675 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.js +++ b/packages/ckeditor5-typing/src/deleteobserver.js @@ -7,7 +7,7 @@ * @module typing/deleteobserver */ -import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; +import Observer from '@ckeditor/ckeditor5-engine/src/view/observer/observer'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -16,11 +16,14 @@ import env from '@ckeditor/ckeditor5-utils/src/env'; /** * Delete observer introduces the {@link module:engine/view/document~Document#event:delete} event. * - * @extends module:engine/view/observer/bubblingobserver~BubblingObserver + * @extends module:engine/view/observer/observer~Observer */ -export default class DeleteObserver extends BubblingObserver { +export default class DeleteObserver extends Observer { + /** + * @inheritDoc + */ constructor( view ) { - super( view, 'delete' ); + super( view ); const document = view.document; let sequence = 0; @@ -92,6 +95,11 @@ export default class DeleteObserver extends BubblingObserver { } } } + + /** + * @inheritDoc + */ + observe() {} } /** diff --git a/packages/ckeditor5-typing/tests/deleteobserver.js b/packages/ckeditor5-typing/tests/deleteobserver.js index 2bc5182b1bb..51ac5bfb245 100644 --- a/packages/ckeditor5-typing/tests/deleteobserver.js +++ b/packages/ckeditor5-typing/tests/deleteobserver.js @@ -8,7 +8,6 @@ import DeleteObserver from '../src/deleteobserver'; import View from '@ckeditor/ckeditor5-engine/src/view/view'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; -import BubblingObserver from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingobserver'; import createViewRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -38,14 +37,6 @@ describe( 'DeleteObserver', () => { } ).to.not.throw(); } ); - it( 'should extend BubblingObserver', () => { - expect( view.getObserver( DeleteObserver ) instanceof BubblingObserver ).to.be.true; - } ); - - it( 'should define eventType', () => { - expect( view.getObserver( DeleteObserver ).eventType ).to.equal( 'delete' ); - } ); - describe( 'delete event', () => { it( 'is fired on keydown', () => { const spy = sinon.spy(); diff --git a/packages/ckeditor5-utils/src/emittermixin.js b/packages/ckeditor5-utils/src/emittermixin.js index d57ce1e2850..d063638a28a 100644 --- a/packages/ckeditor5-utils/src/emittermixin.js +++ b/packages/ckeditor5-utils/src/emittermixin.js @@ -532,10 +532,17 @@ export function _getEmitterId( emitter ) { return emitter[ _emitterId ]; } -// Gets the internal `_events` property of the given object. -// `_events` property store all lists with callbacks for registered event names. -// If there were no events registered on the object, empty `_events` object is created. -function getEvents( source ) { +/** + * Gets the internal `_events` property of the given object. + * `_events` property store all lists with callbacks for registered event names. + * If there were no events registered on the object, empty `_events` object is created. + * + * @public + * @param {module:utils/emittermixin~Emitter} source + * + * TODO maybe this should be one of mixed methods (and protected to allow access in subclasses). + */ +export function getEvents( source ) { if ( !source._events ) { Object.defineProperty( source, '_events', { value: {} @@ -546,7 +553,8 @@ function getEvents( source ) { } // Creates event node for generic-specific events relation architecture. -function makeEventNode() { +// TODO +export function makeEventNode() { return { callbacks: [], childEvents: [] From 28ba9884540deca9ed80585f2ab8137b0c94671e Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 15 Feb 2021 20:06:41 +0100 Subject: [PATCH 29/43] BubblingEmitterMixin refactor. --- .../src/view/observer/bubblingemittermixin.js | 149 +++++++++--------- .../view/observer/fakeselectionobserver.js | 2 +- 2 files changed, 76 insertions(+), 75 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js index 4d58d572b6d..fbeae3dd963 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -9,16 +9,80 @@ import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + import EmitterMixin, { getEvents, makeEventNode } from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; + import { extend } from 'lodash-es'; const BubblingEmitterMixinMethods = { - _addEventListener( event, callback, options ) { - if ( !options.context ) { - return EmitterMixin._addEventListener.call( this, event, callback, options ); + fire( eventOrInfo, ...eventArgs ) { + try { + const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo ); + const eventName = eventInfo.name; + + // TODO maybe there should be a special field in EventInfo that would enable bubbling + // TODO also maybe we could add eventPhase to EventInfo (at-target, bubbling) + // maybe also "capturing" phase to indicate that it's before bubbling + // while adding listener we could provide in options what phase we want (capture, at-target or bubbling (includes at-target) ) + const eventContexts = getBubblingContexts( this, eventName ); + + if ( !eventContexts.size ) { + return; + } + + if ( fireListenerFor( eventContexts, '$capture', eventInfo, ...eventArgs ) ) { + return eventInfo.return; + } + + const selection = this.selection; + const selectedElement = selection.getSelectedElement(); + const isCustomContext = Boolean( selectedElement && getCustomContext( eventContexts, selectedElement ) ); + + // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. + if ( !isCustomContext && fireListenerFor( eventContexts, '$text', eventInfo, ...eventArgs ) ) { + return eventInfo.return; + } + + let node = selectedElement || getDeeperSelectionParent( selection ); + + while ( node ) { + // Root node handling. + if ( node.is( 'rootElement' ) ) { + if ( fireListenerFor( eventContexts, '$root', eventInfo, ...eventArgs ) ) { + return eventInfo.return; + } + } + + // Element node handling. + else if ( node.is( 'element' ) ) { + if ( fireListenerFor( eventContexts, node.name, eventInfo, ...eventArgs ) ) { + return eventInfo.return; + } + } + + // Check custom contexts (i.e., a widget). + if ( fireListenerFor( eventContexts, node, eventInfo, ...eventArgs ) ) { + return eventInfo.return; + } + + node = node.parent; + } + + // Fire for document context. + fireListenerFor( eventContexts, '$document', eventInfo, ...eventArgs ); + + return eventInfo.return; + } catch ( err ) { + // @if CK_DEBUG // throw err; + /* istanbul ignore next */ + CKEditorError.rethrowUnexpectedError( err, this ); } + }, - const contexts = Array.isArray( options.context ) ? options.context : [ options.context ]; + _addEventListener( event, callback, options ) { + const contexts = toArray( options.context || '$document' ); const eventContexts = getBubblingContexts( this, event ); for ( const context of contexts ) { @@ -34,9 +98,6 @@ const BubblingEmitterMixinMethods = { }, _removeEventListener( event, callback ) { - // We don't want to prevent removing a default listener - remove it if it's registered. - EmitterMixin._removeEventListener.call( this, event, callback ); - const eventContexts = getBubblingContexts( this, event ); for ( const emitter of eventContexts.values() ) { @@ -143,66 +204,6 @@ extend( BubblingEmitterMixin, EmitterMixin, BubblingEmitterMixinMethods ); export const BubblingObservableMixin = {}; extend( BubblingObservableMixin, ObservableMixin, BubblingEmitterMixinMethods ); -// TODO -function injectBubblingListener( source, eventName ) { - source.on( eventName, ( event, ...eventArgs ) => { - // TODO maybe there should be a special field in EventInfo that would enable bubbling - // TODO also maybe we could add eventPhase to EventInfo (at-target, bubbling) - // maybe also "capturing" phase to indicate that it's before bubbling - // while adding listener we could provide in options what phase we want (capture, at-target or bubbling (includes at-target) ) - - const eventContexts = getBubblingContexts( source, eventName ); - - if ( !eventContexts.size ) { - return; - } - - const eventInfo = new EventInfo( source, eventName ); - - const selection = source.selection; - const selectedElement = selection.getSelectedElement(); - const isCustomContext = Boolean( selectedElement && getCustomContext( eventContexts, selectedElement ) ); - - // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. - if ( !isCustomContext && fireListenerFor( eventContexts, '$text', eventInfo, ...eventArgs ) ) { - // Stop the original event. - event.stop(); - - return; - } - - let node = selectedElement || getDeeperSelectionParent( selection ); - - while ( node ) { - // Root node handling. - if ( node.is( 'rootElement' ) ) { - if ( fireListenerFor( eventContexts, '$root', eventInfo, ...eventArgs ) ) { - break; - } - } - - // Element node handling. - else if ( node.is( 'element' ) ) { - if ( fireListenerFor( eventContexts, node.name, eventInfo, ...eventArgs ) ) { - break; - } - } - - // Check custom contexts (i.e., a widget). - if ( fireListenerFor( eventContexts, node, eventInfo, ...eventArgs ) ) { - break; - } - - node = node.parent; - } - - // Stop the event if bubbling listener stopped it. - if ( eventInfo.stop.called ) { - event.stop(); - } - }, { priority: 'high' } ); -} - // Fires the listener for the specified context. Returns `true` if event was stopped. // // @private @@ -212,13 +213,13 @@ function injectBubblingListener( source, eventName ) { // @param {...*} [eventArgs] Additional arguments to be passed to the callbacks. // @returns {Boolean} True if event stop was called. function fireListenerFor( eventContexts, context, eventInfo, ...eventArgs ) { - const listener = typeof context == 'string' ? eventContexts.get( context ) : getCustomContext( eventContexts, context ); + const emitter = typeof context == 'string' ? eventContexts.get( context ) : getCustomContext( eventContexts, context ); - if ( !listener ) { + if ( !emitter ) { return false; } - listener.fire( eventInfo, ...eventArgs ); + emitter.fire( eventInfo, ...eventArgs ); return eventInfo.stop.called; } @@ -230,9 +231,9 @@ function fireListenerFor( eventContexts, context, eventInfo, ...eventArgs ) { // @param {module:engine/view/node~Node} node // @returns {module:utils/emittermixin~Emitter|null} function getCustomContext( eventContexts, node ) { - for ( const [ context, listener ] of eventContexts ) { + for ( const [ context, emitter ] of eventContexts ) { if ( typeof context == 'function' && context( node ) ) { - return listener; + return emitter; } } @@ -241,18 +242,18 @@ function getCustomContext( eventContexts, node ) { // Returns bubbling contexts map for the source (emitter). function getBubblingContexts( source, eventName ) { + // TODO this could use it's own property to store contexts const events = getEvents( source ); if ( !events[ eventName ] ) { events[ eventName ] = makeEventNode(); } + // TODO this should get all namespaced events const eventNode = events[ eventName ]; if ( !eventNode.bubblingContexts ) { eventNode.bubblingContexts = new Map(); - - injectBubblingListener( source, eventName ); } return eventNode.bubblingContexts; diff --git a/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js b/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js index 5f7b953f38f..27fe98bfedd 100644 --- a/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js @@ -53,7 +53,7 @@ export default class FakeSelectionObserver extends Observer { // Prevents default key down handling - no selection change will occur. data.preventDefault(); } - }, { priority: 'highest' } ); + }, { context: '$capture' } ); document.on( 'arrowKey', ( eventInfo, data ) => { const selection = document.selection; From 9fe86fb213e6580eb5eec6407e14b436394fb9a6 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 15 Feb 2021 21:52:42 +0100 Subject: [PATCH 30/43] Added tests. --- .../src/view/observer/bubblingemittermixin.js | 33 +- .../view/observer/bubblingemittermixin.js | 476 ++++++++++++++++-- packages/ckeditor5-utils/src/emittermixin.js | 18 +- 3 files changed, 449 insertions(+), 78 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js index fbeae3dd963..d7f15c9763d 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -11,22 +11,23 @@ import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; -import EmitterMixin, { getEvents, makeEventNode } from '@ckeditor/ckeditor5-utils/src/emittermixin'; +import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; import { extend } from 'lodash-es'; +const contextsSymbol = Symbol( 'bubbling contexts' ); + const BubblingEmitterMixinMethods = { fire( eventOrInfo, ...eventArgs ) { try { const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo ); - const eventName = eventInfo.name; // TODO maybe there should be a special field in EventInfo that would enable bubbling // TODO also maybe we could add eventPhase to EventInfo (at-target, bubbling) // maybe also "capturing" phase to indicate that it's before bubbling // while adding listener we could provide in options what phase we want (capture, at-target or bubbling (includes at-target) ) - const eventContexts = getBubblingContexts( this, eventName ); + const eventContexts = getBubblingContexts( this ); if ( !eventContexts.size ) { return; @@ -83,7 +84,7 @@ const BubblingEmitterMixinMethods = { _addEventListener( event, callback, options ) { const contexts = toArray( options.context || '$document' ); - const eventContexts = getBubblingContexts( this, event ); + const eventContexts = getBubblingContexts( this ); for ( const context of contexts ) { let emitter = eventContexts.get( context ); @@ -98,7 +99,7 @@ const BubblingEmitterMixinMethods = { }, _removeEventListener( event, callback ) { - const eventContexts = getBubblingContexts( this, event ); + const eventContexts = getBubblingContexts( this ); for ( const emitter of eventContexts.values() ) { this.stopListening( emitter, event, callback ); @@ -241,26 +242,20 @@ function getCustomContext( eventContexts, node ) { } // Returns bubbling contexts map for the source (emitter). -function getBubblingContexts( source, eventName ) { - // TODO this could use it's own property to store contexts - const events = getEvents( source ); - - if ( !events[ eventName ] ) { - events[ eventName ] = makeEventNode(); +function getBubblingContexts( source ) { + if ( !source[ contextsSymbol ] ) { + source[ contextsSymbol ] = new Map(); } - // TODO this should get all namespaced events - const eventNode = events[ eventName ]; - - if ( !eventNode.bubblingContexts ) { - eventNode.bubblingContexts = new Map(); - } - - return eventNode.bubblingContexts; + return source[ contextsSymbol ]; } // Returns the deeper parent element for the selection. function getDeeperSelectionParent( selection ) { + if ( !selection.rangeCount ) { + return null; + } + const focusParent = selection.focus.parent; const anchorParent = selection.anchor.parent; diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js index 2b591afc433..4e5f2a72169 100644 --- a/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js @@ -9,7 +9,9 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; -import { priorities } from '@ckeditor/ckeditor5-utils'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; describe( 'BubblingEmitterMixin', () => { let editor, model, view, viewDocument; @@ -26,26 +28,6 @@ describe( 'BubblingEmitterMixin', () => { await editor.destroy(); } ); - it( 'should fire bubbling event with the same data as original event', () => { - const spy = sinon.spy(); - const data = {}; - - viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); - viewDocument.fire( 'fakeEvent', data ); - - expect( spy.calledOnce ).to.be.true; - expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); - } ); - - it( 'should not fire fakeEvent event on other event fired', () => { - const spy = sinon.spy(); - - viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); - viewDocument.fire( 'otherEvent', {} ); - - expect( spy.notCalled ).to.be.true; - } ); - it( 'should allow providing multiple contexts in one listener binding', () => { setModelData( model, 'foo[]bar' ); @@ -122,6 +104,249 @@ describe( 'BubblingEmitterMixin', () => { expect( spy.callCount ).to.equal( 2 ); } ); + describe( '#fire()', () => { + it( 'should fire bubbling event with the same data as original event', () => { + const spy = sinon.spy(); + const data = {}; + + viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); + viewDocument.fire( 'fakeEvent', data ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); + } ); + + it( 'should not fire fakeEvent event on other event fired', () => { + const spy = sinon.spy(); + + viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); + viewDocument.fire( 'otherEvent', {} ); + + expect( spy.notCalled ).to.be.true; + } ); + + it( 'should accept EventInfo instance as an argument', () => { + const spy = sinon.spy(); + + viewDocument.on( 'fakeEvent', spy ); + viewDocument.fire( new EventInfo( viewDocument, 'fakeEvent' ) ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should execute callbacks in the right order without priority', () => { + const spy1 = sinon.spy().named( 1 ); + const spy2 = sinon.spy().named( 2 ); + const spy3 = sinon.spy().named( 3 ); + + viewDocument.on( 'test', spy1 ); + viewDocument.on( 'test', spy2 ); + viewDocument.on( 'test', spy3 ); + + viewDocument.fire( 'test' ); + + sinon.assert.callOrder( spy1, spy2, spy3 ); + } ); + + it( 'should execute callbacks in the right order with priority defined', () => { + const spy1 = sinon.spy().named( 1 ); + const spy2 = sinon.spy().named( 2 ); + const spy3 = sinon.spy().named( 3 ); + const spy4 = sinon.spy().named( 4 ); + const spy5 = sinon.spy().named( 5 ); + + viewDocument.on( 'test', spy2, { priority: 'high' } ); + viewDocument.on( 'test', spy3 ); // Defaults to 'normal'. + viewDocument.on( 'test', spy4, { priority: 'low' } ); + viewDocument.on( 'test', spy1, { priority: 'highest' } ); + viewDocument.on( 'test', spy5, { priority: 'lowest' } ); + + viewDocument.fire( 'test' ); + + sinon.assert.callOrder( spy1, spy2, spy3, spy4, spy5 ); + } ); + + it( 'should pass arguments to callbacks', () => { + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + + viewDocument.on( 'test', spy1 ); + viewDocument.on( 'test', spy2 ); + + viewDocument.fire( 'test', 1, 'b', true ); + + sinon.assert.calledWithExactly( spy1, sinon.match.instanceOf( EventInfo ), 1, 'b', true ); + sinon.assert.calledWithExactly( spy2, sinon.match.instanceOf( EventInfo ), 1, 'b', true ); + } ); + + it( 'should fire the right event', () => { + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + + viewDocument.on( '1', spy1 ); + viewDocument.on( '2', spy2 ); + + viewDocument.fire( '2' ); + + sinon.assert.notCalled( spy1 ); + sinon.assert.called( spy2 ); + } ); + + it( 'should execute callbacks many times', () => { + const spy = sinon.spy(); + + viewDocument.on( 'test', spy ); + + viewDocument.fire( 'test' ); + viewDocument.fire( 'test' ); + viewDocument.fire( 'test' ); + + sinon.assert.calledThrice( spy ); + } ); + + it( 'should do nothing for a non listened event', () => { + viewDocument.fire( 'test' ); + } ); + + it( 'should accept the same callback many times', () => { + const spy = sinon.spy(); + + viewDocument.on( 'test', spy ); + viewDocument.on( 'test', spy ); + viewDocument.on( 'test', spy ); + + viewDocument.fire( 'test' ); + + sinon.assert.calledThrice( spy ); + } ); + + it( 'should not fire callbacks for an event that were added while firing that event', () => { + const spy = sinon.spy(); + + viewDocument.on( 'test', () => { + viewDocument.on( 'test', spy ); + } ); + + viewDocument.fire( 'test' ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should correctly fire callbacks for namespaced events', () => { + const spyFoo = sinon.spy(); + const spyBar = sinon.spy(); + const spyAbc = sinon.spy(); + const spyFoo2 = sinon.spy(); + + // Mess up with callbacks order to check whether they are called in adding order. + viewDocument.on( 'foo', spyFoo ); + viewDocument.on( 'foo:bar:abc', spyAbc ); + viewDocument.on( 'foo:bar', spyBar ); + + // This tests whether generic callbacks are also added to specific callbacks lists. + viewDocument.on( 'foo', spyFoo2 ); + + // All four callbacks should be fired. + viewDocument.fire( 'foo:bar:abc' ); + + sinon.assert.callOrder( spyFoo, spyAbc, spyBar, spyFoo2 ); + sinon.assert.calledOnce( spyFoo ); + sinon.assert.calledOnce( spyAbc ); + sinon.assert.calledOnce( spyBar ); + sinon.assert.calledOnce( spyFoo2 ); + + // Only callbacks for foo and foo:bar event should be called. + viewDocument.fire( 'foo:bar' ); + + sinon.assert.calledOnce( spyAbc ); + sinon.assert.calledTwice( spyFoo ); + sinon.assert.calledTwice( spyBar ); + sinon.assert.calledTwice( spyFoo2 ); + + // Only callback for foo should be called as foo:abc has not been registered. + // Still, foo is a valid, existing namespace. + viewDocument.fire( 'foo:abc' ); + + sinon.assert.calledOnce( spyAbc ); + sinon.assert.calledTwice( spyBar ); + sinon.assert.calledThrice( spyFoo ); + sinon.assert.calledThrice( spyFoo2 ); + } ); + + it( 'should rethrow the CKEditorError error', () => { + viewDocument.on( 'test', () => { + // eslint-disable-next-line ckeditor5-rules/ckeditor-error-message + throw new CKEditorError( 'foo', null ); + } ); + + expectToThrowCKEditorError( () => { + viewDocument.fire( 'test' ); + }, /foo/, null ); + } ); + + it( 'should rethrow the native errors as they are in the dubug=true mode', () => { + const error = new TypeError( 'foo' ); + + viewDocument.on( 'test', () => { + throw error; + } ); + + expect( () => { + viewDocument.fire( 'test' ); + } ).to.throw( TypeError, /foo/ ); + } ); + + describe( 'return value', () => { + it( 'is undefined by default', () => { + expect( viewDocument.fire( 'foo' ) ).to.be.undefined; + } ); + + it( 'is undefined if none of the listeners modified EventInfo#return', () => { + viewDocument.on( 'foo', () => {} ); + + expect( viewDocument.fire( 'foo' ) ).to.be.undefined; + } ); + + it( 'equals EventInfo#return\'s value', () => { + viewDocument.on( 'foo', evt => { + evt.return = 1; + } ); + + expect( viewDocument.fire( 'foo' ) ).to.equal( 1 ); + } ); + + it( 'equals EventInfo#return\'s value even if the event was stopped', () => { + viewDocument.on( 'foo', evt => { + evt.return = 1; + } ); + viewDocument.on( 'foo', evt => { + evt.stop(); + } ); + + expect( viewDocument.fire( 'foo' ) ).to.equal( 1 ); + } ); + + it( 'equals EventInfo#return\'s value when it was set in a namespaced event', () => { + viewDocument.on( 'foo', evt => { + evt.return = 1; + } ); + + expect( viewDocument.fire( 'foo:bar' ) ).to.equal( 1 ); + } ); + + it( 'equals the value set by the last callback', () => { + viewDocument.on( 'foo', evt => { + evt.return = 1; + } ); + viewDocument.on( 'foo', evt => { + evt.return = 2; + }, { priority: 'high' } ); + + expect( viewDocument.fire( 'foo' ) ).to.equal( 1 ); + } ); + } ); + } ); + describe( 'event bubbling', () => { describe( 'bubbling starting from non collapsed selection', () => { it( 'should start bubbling from the selection anchor position', () => { @@ -136,7 +361,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', '$text @ highest', '$text @ high', @@ -162,7 +391,11 @@ describe( 'BubblingEmitterMixin', () => { '$root @ low', '$root @ lowest', - 'fakeEvent @ high-10' + '$document @ highest', + '$document @ high', + '$document @ normal', + '$document @ low', + '$document @ lowest' ] ); } ); @@ -179,7 +412,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', '$text @ highest', '$text @ high', @@ -205,7 +442,11 @@ describe( 'BubblingEmitterMixin', () => { '$root @ low', '$root @ lowest', - 'fakeEvent @ high-10' + '$document @ highest', + '$document @ high', + '$document @ normal', + '$document @ low', + '$document @ lowest' ] ); } ); } ); @@ -220,7 +461,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', '$text @ highest', '$text @ high', @@ -246,7 +491,57 @@ describe( 'BubblingEmitterMixin', () => { '$root @ low', '$root @ lowest', - 'fakeEvent @ high-10' + '$document @ highest', + '$document @ high', + '$document @ normal', + '$document @ low', + '$document @ lowest' + ] ); + } ); + + it( 'should not trigger listeners on the lower priority if stopped on the $document (default) context', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop() ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', + + '$text @ highest', + '$text @ high', + '$text @ normal', + '$text @ low', + '$text @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal', + '$root @ low', + '$root @ lowest', + + '$document @ highest', + '$document @ high', + '$document @ normal' ] ); } ); @@ -260,7 +555,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', '$text @ highest', '$text @ high', @@ -296,7 +595,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', '$text @ highest', '$text @ high', @@ -326,7 +629,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', '$text @ highest', '$text @ high', @@ -350,7 +657,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', '$text @ highest', '$text @ high', @@ -358,17 +669,19 @@ describe( 'BubblingEmitterMixin', () => { ] ); } ); - it( 'should not start bubbling events if stopped before entering high priority', () => { + it( 'should not start bubbling events if stopped on the $capture context', () => { setModelData( model, '
foo[]bar
' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$capture' } ); viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10' + '$capture @ highest', + '$capture @ high', + '$capture @ normal' ] ); } ); } ); @@ -392,7 +705,59 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', + + 'isCustomObject @ highest', + 'isCustomObject @ high', + 'isCustomObject @ normal', + 'isCustomObject @ low', + 'isCustomObject @ lowest', + + 'p @ highest', + 'p @ high', + 'p @ normal', + 'p @ low', + 'p @ lowest', + + 'blockquote @ highest', + 'blockquote @ high', + 'blockquote @ normal', + 'blockquote @ low', + 'blockquote @ lowest', + + '$root @ highest', + '$root @ high', + '$root @ normal', + '$root @ low', + '$root @ lowest', + + '$document @ highest', + '$document @ high', + '$document @ normal', + '$document @ low', + '$document @ lowest' + ] ); + } ); + + it( 'should not trigger listeners on the lower priority if stopped on the $document (default) context', () => { + setModelData( model, '
foo[]bar' ); + + const data = {}; + const events = setListeners(); + + viewDocument.on( 'fakeEvent', event => event.stop() ); + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', 'isCustomObject @ highest', 'isCustomObject @ high', @@ -418,7 +783,9 @@ describe( 'BubblingEmitterMixin', () => { '$root @ low', '$root @ lowest', - 'fakeEvent @ high-10' + '$document @ highest', + '$document @ high', + '$document @ normal' ] ); } ); @@ -432,7 +799,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', 'isCustomObject @ highest', 'isCustomObject @ high', @@ -468,7 +839,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', 'isCustomObject @ highest', 'isCustomObject @ high', @@ -498,7 +873,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', 'isCustomObject @ highest', 'isCustomObject @ high', @@ -522,7 +901,11 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10', + '$capture @ highest', + '$capture @ high', + '$capture @ normal', + '$capture @ low', + '$capture @ lowest', 'isCustomObject @ highest', 'isCustomObject @ high', @@ -530,17 +913,19 @@ describe( 'BubblingEmitterMixin', () => { ] ); } ); - it( 'should not start bubbling events if stopped before entering high priority', () => { + it( 'should not start bubbling events if stopped on the $capture context', () => { setModelData( model, '
foo[]bar' ); const data = {}; const events = setListeners(); - viewDocument.on( 'fakeEvent', event => event.stop(), { priority: priorities.get( 'high' ) + 1 } ); + viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$capture' } ); viewDocument.fire( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - 'fakeEvent @ high+10' + '$capture @ highest', + '$capture @ high', + '$capture @ normal' ] ); } ); } ); @@ -556,15 +941,14 @@ describe( 'BubblingEmitterMixin', () => { } } + setListenersForContext( '$capture' ); setListenersForContext( '$root' ); + setListenersForContext( '$document' ); setListenersForContext( '$text' ); setListenersForContext( 'p' ); setListenersForContext( 'blockquote' ); setListenersForContext( isCustomObject ); - viewDocument.on( 'fakeEvent', () => events.push( 'fakeEvent @ high+10' ), { priority: priorities.get( 'high' ) + 10 } ); - viewDocument.on( 'fakeEvent', () => events.push( 'fakeEvent @ high-10' ), { priority: priorities.get( 'high' ) - 10 } ); - return events; } diff --git a/packages/ckeditor5-utils/src/emittermixin.js b/packages/ckeditor5-utils/src/emittermixin.js index d063638a28a..d57ce1e2850 100644 --- a/packages/ckeditor5-utils/src/emittermixin.js +++ b/packages/ckeditor5-utils/src/emittermixin.js @@ -532,17 +532,10 @@ export function _getEmitterId( emitter ) { return emitter[ _emitterId ]; } -/** - * Gets the internal `_events` property of the given object. - * `_events` property store all lists with callbacks for registered event names. - * If there were no events registered on the object, empty `_events` object is created. - * - * @public - * @param {module:utils/emittermixin~Emitter} source - * - * TODO maybe this should be one of mixed methods (and protected to allow access in subclasses). - */ -export function getEvents( source ) { +// Gets the internal `_events` property of the given object. +// `_events` property store all lists with callbacks for registered event names. +// If there were no events registered on the object, empty `_events` object is created. +function getEvents( source ) { if ( !source._events ) { Object.defineProperty( source, '_events', { value: {} @@ -553,8 +546,7 @@ export function getEvents( source ) { } // Creates event node for generic-specific events relation architecture. -// TODO -export function makeEventNode() { +function makeEventNode() { return { callbacks: [], childEvents: [] From cef93fb4ffa5d938ebfc1311bc9339924af5655d Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 15 Feb 2021 22:57:46 +0100 Subject: [PATCH 31/43] Added docs. --- .../src/view/observer/bubblingemittermixin.js | 236 +++++++++++------- 1 file changed, 143 insertions(+), 93 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js index d7f15c9763d..44674309005 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -18,7 +18,19 @@ import { extend } from 'lodash-es'; const contextsSymbol = Symbol( 'bubbling contexts' ); +// Overridden methods injected into BubblingEmitterMixin and BubblingObservableMixin. +// @private const BubblingEmitterMixinMethods = { + // Fires an event, executing all callbacks registered for it. + // + // The first parameter passed to callbacks is an {@link module:utils/eventinfo~EventInfo} object, + // followed by the optional `args` provided in the `fire()` method call. + // + // @param {String|module:utils/eventinfo~EventInfo} eventOrInfo The name of the event or `EventInfo` object. + // @param {...*} [args] Additional arguments to be passed to the callbacks. + // @returns {*} By default the method returns `undefined`. However, the return value can be changed by listeners + // through modification of the {@link module:utils/eventinfo~EventInfo#return `evt.return`}'s property (the event info + // is the first param of every callback). fire( eventOrInfo, ...eventArgs ) { try { const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo ); @@ -33,20 +45,22 @@ const BubblingEmitterMixinMethods = { return; } + // The capture phase of the event. if ( fireListenerFor( eventContexts, '$capture', eventInfo, ...eventArgs ) ) { return eventInfo.return; } - const selection = this.selection; - const selectedElement = selection.getSelectedElement(); - const isCustomContext = Boolean( selectedElement && getCustomContext( eventContexts, selectedElement ) ); + // TODO instead of using this.selection we could pass range in EventInfo. + const selectionRange = this.selection.getFirstRange(); + const selectedElement = selectionRange ? selectionRange.getContainedElement() : null; + const isCustomContext = selectedElement ? Boolean( getCustomContext( eventContexts, selectedElement ) ) : false; // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. if ( !isCustomContext && fireListenerFor( eventContexts, '$text', eventInfo, ...eventArgs ) ) { return eventInfo.return; } - let node = selectedElement || getDeeperSelectionParent( selection ); + let node = selectedElement || getDeeperRangeParent( selectionRange ); while ( node ) { // Root node handling. @@ -71,7 +85,7 @@ const BubblingEmitterMixinMethods = { node = node.parent; } - // Fire for document context. + // Document context. fireListenerFor( eventContexts, '$document', eventInfo, ...eventArgs ); return eventInfo.return; @@ -82,6 +96,14 @@ const BubblingEmitterMixinMethods = { } }, + // Adds callback to emitter for given event. + // @param {String} event The name of the event. + // @param {Function} callback The function to be called on event. + // @param {Object} [options={}] Additional options. + // @param {String} [options.context='$document'] The listener context while bubbling up the view document tree. + // @param {module:utils/priorities~PriorityString|Number} [options.priority='normal'] The priority of this event callback. The higher + // the priority value the sooner the callback will be fired. Events having the same priority are called in the + // order they were added. _addEventListener( event, callback, options ) { const contexts = toArray( options.context || '$document' ); const eventContexts = getBubblingContexts( this ); @@ -98,6 +120,9 @@ const BubblingEmitterMixinMethods = { } }, + // Removes callback from emitter for given event. + // @param {String} event The name of the event. + // @param {Function} callback The function to stop being called. _removeEventListener( event, callback ) { const eventContexts = getBubblingContexts( this ); @@ -108,99 +133,22 @@ const BubblingEmitterMixinMethods = { }; /** - * Bubbling emitter mixin for the view document. - * - * Bubbling emitter mixin is triggering events in the context of specified {@link module:engine/view/element~Element view element} name, - * predefined `'$text'` and `'$root'` contexts, and context matchers provided as a function. - * - * The bubbling starts from the deeper selection position (by firing event on the `'$text'` context) and propagates - * the view document tree up to the `'$root'`. - * - * Examples: - * - * // Listeners registered in the context of the view element names: - * this.listenTo( viewDocument, 'enter', ( evt, data ) => { - * // ... - * }, { context: 'blockquote' } ); - * - * this.listenTo( viewDocument, 'enter', ( evt, data ) => { - * // ... - * }, { context: 'li' } ); - * - * // Listeners registered in the context of the '$text' and '$root' nodes. - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: '$text', priority: 'high' } ); - * - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: '$root' } ); - * - * // Listeners registered in the context of custom callback function. - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: isWidget } ); - * - * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { - * // ... - * }, { context: isWidget, priority: 'high' } ); - * - * The bubbling emitter itself is listening on the `'high'` priority so there could be listeners that are triggered - * no matter the context on lower or higher priorities. For example `'enter'` and `'delete'` commands are triggered - * on the `'normal'` priority without checking the context. - * - * Example flow for selection in text: - * - *

Foo[]bar

- * - * Fired events on contexts: - * 1. `'$text'` - * 2. `'p'` - * 3. `'blockquote'` - * 4. `'$root'` - * - * Example flow for selection on element (i.e., Widget): - * - *

Foo[]bar

- * - * Fired events on contexts: - * 1. *widget* (custom matcher) - * 2. `'p'` - * 3. `'blockquote'` - * 4. `'$root'` - * - * There could be multiple listeners registered for the same context and at different priority levels: - * - *

Foo[]bar

- * - * 1. `'$text'` at priorities: - * 1. `'highest'` - * 2. `'high'` - * 3. `'normal'` - * 4. `'low'` - * 5. `'lowest'` - * 2. `'p'` at priorities: - * 1. `'highest'` - * 2. `'high'` - * 3. `'normal'` - * 4. `'low'` - * 5. `'lowest'` - * 3. `'$root'` at priorities: - * 1. `'highest'` - * 2. `'high'` - * 3. `'normal'` - * 4. `'low'` - * 5. `'lowest'` + * Bubbling emitter mixin for the view document as described in the + * {@link ~BubblingEmitter} interface. * * @mixin BubblingEmitterMixin + * @mixes module:utils/emittermixin~EmitterMixin + * @implements module:engine/view/observer/bubblingemittermixin~BubblingEmitter */ export const BubblingEmitterMixin = {}; extend( BubblingEmitterMixin, EmitterMixin, BubblingEmitterMixinMethods ); /** - * TODO + * A mixin that extends {@link module:utils/observablemixin~ObservableMixin} with {@link ~BubblingEmitter} capabilities. * * @mixin BubblingObservableMixin + * @mixes module:utils/observablemixin~ObservableMixin + * @implements module:engine/view/observer/bubblingemittermixin~BubblingEmitter */ export const BubblingObservableMixin = {}; extend( BubblingObservableMixin, ObservableMixin, BubblingEmitterMixinMethods ); @@ -251,16 +199,118 @@ function getBubblingContexts( source ) { } // Returns the deeper parent element for the selection. -function getDeeperSelectionParent( selection ) { - if ( !selection.rangeCount ) { +function getDeeperRangeParent( selectionRange ) { + if ( !selectionRange ) { return null; } - const focusParent = selection.focus.parent; - const anchorParent = selection.anchor.parent; + const focusParent = selectionRange.start.parent; + const anchorParent = selectionRange.end.parent; const focusPath = focusParent.getPath(); const anchorPath = anchorParent.getPath(); return focusPath.length > anchorPath.length ? focusParent : anchorParent; } + +/** + * Bubbling emitter for the view document. + * + * Bubbling emitter is triggering events in the context of specified {@link module:engine/view/element~Element view element} name, + * predefined `'$text'`, `'$root'`, `'$document'` and `'$capture'` contexts, and context matchers provided as a function. + * + * Before bubbling starts, listeners for `'$capture'` context are triggered. Then the bubbling starts from the deeper selection + * position (by firing event on the `'$text'` context) and propagates the view document tree up to the `'$root'` and finally + * the listeners at `'$document'` context are fired (this is the default context). + * + * Examples: + * + * // Listeners registered in the context of the view element names: + * this.listenTo( viewDocument, 'enter', ( evt, data ) => { + * // ... + * }, { context: 'blockquote' } ); + * + * this.listenTo( viewDocument, 'enter', ( evt, data ) => { + * // ... + * }, { context: 'li' } ); + * + * // Listeners registered in the context of the '$text' and '$root' nodes. + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: '$text', priority: 'high' } ); + * + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: '$root' } ); + * + * // Listeners registered in the context of custom callback function. + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: isWidget } ); + * + * this.listenTo( view.document, 'arrowKey', ( evt, data ) => { + * // ... + * }, { context: isWidget, priority: 'high' } ); + * + * Example flow for selection in text: + * + *

Foo[]bar

+ * + * Fired events on contexts: + * 1. `'$capture'` + * 2. `'$text'` + * 3. `'p'` + * 4. `'blockquote'` + * 5. `'$root'` + * 6. `'$document'` + * + * Example flow for selection on element (i.e., Widget): + * + *

Foo[]bar

+ * + * Fired events on contexts: + * 1. `'$capture'` + * 2. *widget* (custom matcher) + * 3. `'p'` + * 4. `'blockquote'` + * 5. `'$root'` + * 6. `'$document'` + * + * There could be multiple listeners registered for the same context and at different priority levels: + * + *

Foo[]bar

+ * + * 1. `'$capture'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 2. `'$text'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 3. `'p'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 4. `'$root'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * 5. `'$document'` at priorities: + * 1. `'highest'` + * 2. `'high'` + * 3. `'normal'` + * 4. `'low'` + * 5. `'lowest'` + * + * @interface BubblingEmitter + * @extends module:utils/emittermixin~Emitter + */ From 318dd20d105436d888fb9a2f6f13f1d6ca3d56bf Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 16 Feb 2021 09:26:11 +0100 Subject: [PATCH 32/43] Added missing test. --- .../ckeditor5-utils/tests/observablemixin.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/ckeditor5-utils/tests/observablemixin.js b/packages/ckeditor5-utils/tests/observablemixin.js index 23b807e2cf9..d910e8b78e9 100644 --- a/packages/ckeditor5-utils/tests/observablemixin.js +++ b/packages/ckeditor5-utils/tests/observablemixin.js @@ -1056,6 +1056,33 @@ describe( 'Observable', () => { }, 'observablemixin-cannot-decorate-undefined' ); } ); + it( 'should allow decorating multiple methods', () => { + const spyFoo = sinon.spy(); + const spyBar = sinon.spy(); + + class Foo extends Observable { + methodFoo() {} + methodBar() {} + } + + const foo = new Foo(); + + foo.decorate( 'methodFoo' ); + foo.decorate( 'methodBar' ); + + foo.on( 'methodFoo', spyFoo ); + foo.on( 'methodBar', spyBar ); + + foo.methodFoo( 'abc' ); + foo.methodBar( '123' ); + + expect( spyFoo.calledOnce ).to.be.true; + expect( spyFoo.args[ 0 ][ 1 ] ).to.deep.equal( [ 'abc' ] ); + + expect( spyBar.calledOnce ).to.be.true; + expect( spyBar.args[ 0 ][ 1 ] ).to.deep.equal( [ '123' ] ); + } ); + it( 'should reverts decorated methods to the original method on stopListening for all events', () => { class Foo extends Observable { method() { From c55619467042283adb41be7c9fa9c84dca131e10 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 16 Feb 2021 16:14:44 +0100 Subject: [PATCH 33/43] Simplified mixing of BubblingEmitterMixin. --- .../ckeditor5-engine/src/view/document.js | 7 +- .../src/view/observer/bubblingemittermixin.js | 66 ++++++------------- 2 files changed, 24 insertions(+), 49 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/document.js b/packages/ckeditor5-engine/src/view/document.js index c697d4a680a..6a594c494b9 100644 --- a/packages/ckeditor5-engine/src/view/document.js +++ b/packages/ckeditor5-engine/src/view/document.js @@ -10,7 +10,8 @@ import DocumentSelection from './documentselection'; import Collection from '@ckeditor/ckeditor5-utils/src/collection'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; -import { BubblingObservableMixin } from './observer/bubblingemittermixin'; +import BubblingEmitterMixin from './observer/bubblingemittermixin'; +import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; // @if CK_DEBUG_ENGINE // const { logDocument } = require( '../dev-utils/utils' ); @@ -18,6 +19,7 @@ import { BubblingObservableMixin } from './observer/bubblingemittermixin'; * Document class creates an abstract layer over the content editable area, contains a tree of view elements and * {@link module:engine/view/documentselection~DocumentSelection view selection} associated with this document. * + * @mixes module:engine/view/observer/bubblingemittermixin~BubblingEmitterMixin * @mixes module:utils/observablemixin~ObservableMixin */ export default class Document { @@ -203,7 +205,8 @@ export default class Document { // @if CK_DEBUG_ENGINE // } } -mix( Document, BubblingObservableMixin ); +mix( Document, BubblingEmitterMixin ); +mix( Document, ObservableMixin ); /** * Enum representing type of the change. diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js index 44674309005..6686b32ee47 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -8,29 +8,25 @@ */ import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; -import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; -import { extend } from 'lodash-es'; - const contextsSymbol = Symbol( 'bubbling contexts' ); -// Overridden methods injected into BubblingEmitterMixin and BubblingObservableMixin. -// @private -const BubblingEmitterMixinMethods = { - // Fires an event, executing all callbacks registered for it. - // - // The first parameter passed to callbacks is an {@link module:utils/eventinfo~EventInfo} object, - // followed by the optional `args` provided in the `fire()` method call. - // - // @param {String|module:utils/eventinfo~EventInfo} eventOrInfo The name of the event or `EventInfo` object. - // @param {...*} [args] Additional arguments to be passed to the callbacks. - // @returns {*} By default the method returns `undefined`. However, the return value can be changed by listeners - // through modification of the {@link module:utils/eventinfo~EventInfo#return `evt.return`}'s property (the event info - // is the first param of every callback). +/** + * Bubbling emitter mixin for the view document as described in the + * {@link ~BubblingEmitter} interface. + * + * @mixin BubblingEmitterMixin + * @mixes module:utils/emittermixin~EmitterMixin + * @implements module:engine/view/observer/bubblingemittermixin~BubblingEmitter + */ +const BubblingEmitterMixin = { + /** + * @inheritDoc + */ fire( eventOrInfo, ...eventArgs ) { try { const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo ); @@ -96,14 +92,9 @@ const BubblingEmitterMixinMethods = { } }, - // Adds callback to emitter for given event. - // @param {String} event The name of the event. - // @param {Function} callback The function to be called on event. - // @param {Object} [options={}] Additional options. - // @param {String} [options.context='$document'] The listener context while bubbling up the view document tree. - // @param {module:utils/priorities~PriorityString|Number} [options.priority='normal'] The priority of this event callback. The higher - // the priority value the sooner the callback will be fired. Events having the same priority are called in the - // order they were added. + /** + * @inheritDoc + */ _addEventListener( event, callback, options ) { const contexts = toArray( options.context || '$document' ); const eventContexts = getBubblingContexts( this ); @@ -120,9 +111,9 @@ const BubblingEmitterMixinMethods = { } }, - // Removes callback from emitter for given event. - // @param {String} event The name of the event. - // @param {Function} callback The function to stop being called. + /** + * @inheritDoc + */ _removeEventListener( event, callback ) { const eventContexts = getBubblingContexts( this ); @@ -132,26 +123,7 @@ const BubblingEmitterMixinMethods = { } }; -/** - * Bubbling emitter mixin for the view document as described in the - * {@link ~BubblingEmitter} interface. - * - * @mixin BubblingEmitterMixin - * @mixes module:utils/emittermixin~EmitterMixin - * @implements module:engine/view/observer/bubblingemittermixin~BubblingEmitter - */ -export const BubblingEmitterMixin = {}; -extend( BubblingEmitterMixin, EmitterMixin, BubblingEmitterMixinMethods ); - -/** - * A mixin that extends {@link module:utils/observablemixin~ObservableMixin} with {@link ~BubblingEmitter} capabilities. - * - * @mixin BubblingObservableMixin - * @mixes module:utils/observablemixin~ObservableMixin - * @implements module:engine/view/observer/bubblingemittermixin~BubblingEmitter - */ -export const BubblingObservableMixin = {}; -extend( BubblingObservableMixin, ObservableMixin, BubblingEmitterMixinMethods ); +export default BubblingEmitterMixin; // Fires the listener for the specified context. Returns `true` if event was stopped. // From 8c9497da74acfeecafdf9d086a6050868c01d825 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 16 Feb 2021 16:19:08 +0100 Subject: [PATCH 34/43] Updated JsDoc. --- .../ckeditor5-engine/src/view/observer/bubblingemittermixin.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js index 6686b32ee47..3fcc4cf83bb 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -20,7 +20,6 @@ const contextsSymbol = Symbol( 'bubbling contexts' ); * {@link ~BubblingEmitter} interface. * * @mixin BubblingEmitterMixin - * @mixes module:utils/emittermixin~EmitterMixin * @implements module:engine/view/observer/bubblingemittermixin~BubblingEmitter */ const BubblingEmitterMixin = { From 965ea5c4b99b2ef66cbb9cfb6c4ff34f2b8d7b36 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Tue, 16 Feb 2021 18:05:15 +0100 Subject: [PATCH 35/43] Updated comment. --- .../src/view/observer/bubblingemittermixin.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js index 3fcc4cf83bb..acc2c5390ba 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -30,10 +30,9 @@ const BubblingEmitterMixin = { try { const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo ); - // TODO maybe there should be a special field in EventInfo that would enable bubbling - // TODO also maybe we could add eventPhase to EventInfo (at-target, bubbling) - // maybe also "capturing" phase to indicate that it's before bubbling - // while adding listener we could provide in options what phase we want (capture, at-target or bubbling (includes at-target) ) + // TODO Maybe there should be a special field in EventInfo that would enable bubbling. + // TODO Maybe we could add eventPhase to EventInfo (at-target, bubbling) to make some listeners simpler. + const eventContexts = getBubblingContexts( this ); if ( !eventContexts.size ) { @@ -45,7 +44,7 @@ const BubblingEmitterMixin = { return eventInfo.return; } - // TODO instead of using this.selection we could pass range in EventInfo. + // TODO Instead of using this.selection we could pass range in EventInfo. const selectionRange = this.selection.getFirstRange(); const selectedElement = selectionRange ? selectionRange.getContainedElement() : null; const isCustomContext = selectedElement ? Boolean( getCustomContext( eventContexts, selectedElement ) ) : false; From 03d23626cef93e03cca53e21425b1d8c5de038cb Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sun, 21 Feb 2021 18:21:22 +0100 Subject: [PATCH 36/43] Introducing BubblingEventInfo. --- .../src/view/observer/arrowkeysobserver.js | 5 +- .../src/view/observer/bubblingemittermixin.js | 59 ++- .../src/view/observer/bubblingeventinfo.js | 71 ++++ .../view/observer/bubblingemittermixin.js | 370 ++++++++++++------ .../tests/view/observer/bubblingeventinfo.js | 23 ++ packages/ckeditor5-enter/src/enterobserver.js | 4 +- .../ckeditor5-typing/src/deleteobserver.js | 4 +- .../src/widgettypearound/widgettypearound.js | 10 +- .../widgettypearound/widgettypearound.js | 3 +- 9 files changed, 393 insertions(+), 156 deletions(-) create mode 100644 packages/ckeditor5-engine/src/view/observer/bubblingeventinfo.js create mode 100644 packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js index 20d64387198..4fbe4e7cd6a 100644 --- a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js @@ -8,7 +8,8 @@ */ import Observer from './observer'; -import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import BubblingEventInfo from './bubblingeventinfo'; + import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils'; /** @@ -25,7 +26,7 @@ export default class ArrowKeysObserver extends Observer { this.document.on( 'keydown', ( event, data ) => { if ( this.isEnabled && isArrowKeyCode( data.keyCode ) ) { - const eventInfo = new EventInfo( this.document, 'arrowKey' ); + const eventInfo = new BubblingEventInfo( this.document, 'arrowKey', this.document.selection.getFirstRange() ); this.document.fire( eventInfo, data ); diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js index acc2c5390ba..012907ea290 100644 --- a/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/src/view/observer/bubblingemittermixin.js @@ -13,6 +13,8 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin'; import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; +import BubblingEventInfo from './bubblingeventinfo'; + const contextsSymbol = Symbol( 'bubbling contexts' ); /** @@ -29,32 +31,35 @@ const BubblingEmitterMixin = { fire( eventOrInfo, ...eventArgs ) { try { const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo ); - - // TODO Maybe there should be a special field in EventInfo that would enable bubbling. - // TODO Maybe we could add eventPhase to EventInfo (at-target, bubbling) to make some listeners simpler. - const eventContexts = getBubblingContexts( this ); if ( !eventContexts.size ) { return; } + updateEventInfo( eventInfo, 'capturing', this ); + // The capture phase of the event. if ( fireListenerFor( eventContexts, '$capture', eventInfo, ...eventArgs ) ) { return eventInfo.return; } - // TODO Instead of using this.selection we could pass range in EventInfo. - const selectionRange = this.selection.getFirstRange(); - const selectedElement = selectionRange ? selectionRange.getContainedElement() : null; + const startRange = eventInfo.startRange || this.selection.getFirstRange(); + const selectedElement = startRange ? startRange.getContainedElement() : null; const isCustomContext = selectedElement ? Boolean( getCustomContext( eventContexts, selectedElement ) ) : false; + let node = selectedElement || getDeeperRangeParent( startRange ); + + updateEventInfo( eventInfo, 'atTarget', node ); + // For the not yet bubbling event trigger for $text node if selection can be there and it's not a custom context selected. - if ( !isCustomContext && fireListenerFor( eventContexts, '$text', eventInfo, ...eventArgs ) ) { - return eventInfo.return; - } + if ( !isCustomContext ) { + if ( fireListenerFor( eventContexts, '$text', eventInfo, ...eventArgs ) ) { + return eventInfo.return; + } - let node = selectedElement || getDeeperRangeParent( selectionRange ); + updateEventInfo( eventInfo, 'bubbling', node ); + } while ( node ) { // Root node handling. @@ -77,8 +82,12 @@ const BubblingEmitterMixin = { } node = node.parent; + + updateEventInfo( eventInfo, 'bubbling', node ); } + updateEventInfo( eventInfo, 'bubbling', this ); + // Document context. fireListenerFor( eventContexts, '$document', eventInfo, ...eventArgs ); @@ -123,6 +132,18 @@ const BubblingEmitterMixin = { export default BubblingEmitterMixin; +// Update the event info bubbling fields. +// +// @param {module:utils/eventinfo~EventInfo} eventInfo The event info object to update. +// @param {'none'|'capturing'|'atTarget'|'bubbling'} eventPhase The current event phase. +// @param {module:engine/view/document~Document|module:engine/view/node~Node} currentTarget The current bubbling target. +function updateEventInfo( eventInfo, eventPhase, currentTarget ) { + if ( eventInfo instanceof BubblingEventInfo ) { + eventInfo._eventPhase = eventPhase; + eventInfo._currentTarget = currentTarget; + } +} + // Fires the listener for the specified context. Returns `true` if event was stopped. // // @private @@ -168,19 +189,19 @@ function getBubblingContexts( source ) { return source[ contextsSymbol ]; } -// Returns the deeper parent element for the selection. -function getDeeperRangeParent( selectionRange ) { - if ( !selectionRange ) { +// Returns the deeper parent element for the range. +function getDeeperRangeParent( range ) { + if ( !range ) { return null; } - const focusParent = selectionRange.start.parent; - const anchorParent = selectionRange.end.parent; + const startParent = range.start.parent; + const endParent = range.end.parent; - const focusPath = focusParent.getPath(); - const anchorPath = anchorParent.getPath(); + const startPath = startParent.getPath(); + const endPath = endParent.getPath(); - return focusPath.length > anchorPath.length ? focusParent : anchorParent; + return startPath.length > endPath.length ? startParent : endParent; } /** diff --git a/packages/ckeditor5-engine/src/view/observer/bubblingeventinfo.js b/packages/ckeditor5-engine/src/view/observer/bubblingeventinfo.js new file mode 100644 index 00000000000..2cd20ead56f --- /dev/null +++ b/packages/ckeditor5-engine/src/view/observer/bubblingeventinfo.js @@ -0,0 +1,71 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module engine/view/observer/bubblingeventinfo + */ + +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; + +/** + * The event object passed to bubbling event callbacks. It is used to provide information about the event as well as a tool to + * manipulate it. + * + * @extends module:utils/eventinfo~EventInfo + */ +export default class BubblingEventInfo extends EventInfo { + /** + * @param {Object} source The emitter. + * @param {String} name The event name. + * @param {module:engine/view/range~Range} startRange The view range that the bubbling should start from. + */ + constructor( source, name, startRange ) { + super( source, name ); + + /** + * The view range that the bubbling should start from. + * + * @readonly + * @member {module:engine/view/range~Range} + */ + this.startRange = startRange; + + /** + * The current event phase. + * + * @protected + * @member {'none'|'capturing'|'atTarget'|'bubbling'} + */ + this._eventPhase = 'none'; + + /** + * The current bubbling target. + * + * @protected + * @member {module:engine/view/document~Document|module:engine/view/node~Node|null} + */ + this._currentTarget = null; + } + + /** + * The current event phase. + * + * @readonly + * @member {'none'|'capturing'|'atTarget'|'bubbling'} + */ + get eventPhase() { + return this._eventPhase; + } + + /** + * The current bubbling target. + * + * @readonly + * @member {module:engine/view/document~Document|module:engine/view/node~Node|null} + */ + get currentTarget() { + return this._currentTarget; + } +} diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js b/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js index 4e5f2a72169..8c72341ae84 100644 --- a/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingemittermixin.js @@ -3,12 +3,13 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +import BubblingEventInfo from '../../../src/view/observer/bubblingeventinfo'; import { setData as setModelData } from '../../../src/dev-utils/model'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; @@ -35,7 +36,7 @@ describe( 'BubblingEmitterMixin', () => { const data = {}; viewDocument.on( 'fakeEvent', spy, { context: [ '$text', 'p' ] } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spy.calledTwice ).to.be.true; expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); @@ -52,7 +53,7 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'fakeEvent', spy1, { context: 'p' } ); viewDocument.on( 'fakeEvent', spy2, { context: 'p' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spy1.calledOnce ).to.be.true; expect( spy1.args[ 0 ][ 1 ] ).to.equal( data ); @@ -69,19 +70,19 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'fakeEvent', spyContext, { context: 'p' } ); viewDocument.on( 'fakeEvent', spyGlobal ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spyContext.callCount ).to.equal( 1 ); expect( spyGlobal.callCount ).to.equal( 1 ); viewDocument.off( 'fakeEvent', spyContext ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spyContext.callCount ).to.equal( 1 ); expect( spyGlobal.callCount ).to.equal( 2 ); viewDocument.off( 'fakeEvent', spyGlobal ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spyContext.callCount ).to.equal( 1 ); expect( spyGlobal.callCount ).to.equal( 2 ); @@ -94,12 +95,12 @@ describe( 'BubblingEmitterMixin', () => { const data = {}; viewDocument.on( 'fakeEvent', spy, { context: 'p' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spy.callCount ).to.equal( 1 ); viewDocument.off( 'otherEvent', spy ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spy.callCount ).to.equal( 2 ); } ); @@ -110,7 +111,7 @@ describe( 'BubblingEmitterMixin', () => { const data = {}; viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( spy.calledOnce ).to.be.true; expect( spy.args[ 0 ][ 1 ] ).to.equal( data ); @@ -120,7 +121,7 @@ describe( 'BubblingEmitterMixin', () => { const spy = sinon.spy(); viewDocument.on( 'fakeEvent', spy, { context: '$root' } ); - viewDocument.fire( 'otherEvent', {} ); + fireBubblingEvent( 'otherEvent', {} ); expect( spy.notCalled ).to.be.true; } ); @@ -143,7 +144,7 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'test', spy2 ); viewDocument.on( 'test', spy3 ); - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); sinon.assert.callOrder( spy1, spy2, spy3 ); } ); @@ -161,7 +162,7 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'test', spy1, { priority: 'highest' } ); viewDocument.on( 'test', spy5, { priority: 'lowest' } ); - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); sinon.assert.callOrder( spy1, spy2, spy3, spy4, spy5 ); } ); @@ -173,10 +174,10 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'test', spy1 ); viewDocument.on( 'test', spy2 ); - viewDocument.fire( 'test', 1, 'b', true ); + fireBubblingEvent( 'test', 1, 'b', true ); - sinon.assert.calledWithExactly( spy1, sinon.match.instanceOf( EventInfo ), 1, 'b', true ); - sinon.assert.calledWithExactly( spy2, sinon.match.instanceOf( EventInfo ), 1, 'b', true ); + sinon.assert.calledWithExactly( spy1, sinon.match.instanceOf( BubblingEventInfo ), 1, 'b', true ); + sinon.assert.calledWithExactly( spy2, sinon.match.instanceOf( BubblingEventInfo ), 1, 'b', true ); } ); it( 'should fire the right event', () => { @@ -186,7 +187,7 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( '1', spy1 ); viewDocument.on( '2', spy2 ); - viewDocument.fire( '2' ); + fireBubblingEvent( '2' ); sinon.assert.notCalled( spy1 ); sinon.assert.called( spy2 ); @@ -197,15 +198,15 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'test', spy ); - viewDocument.fire( 'test' ); - viewDocument.fire( 'test' ); - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); + fireBubblingEvent( 'test' ); + fireBubblingEvent( 'test' ); sinon.assert.calledThrice( spy ); } ); it( 'should do nothing for a non listened event', () => { - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); } ); it( 'should accept the same callback many times', () => { @@ -215,7 +216,7 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'test', spy ); viewDocument.on( 'test', spy ); - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); sinon.assert.calledThrice( spy ); } ); @@ -227,7 +228,7 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'test', spy ); } ); - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); sinon.assert.notCalled( spy ); } ); @@ -247,7 +248,7 @@ describe( 'BubblingEmitterMixin', () => { viewDocument.on( 'foo', spyFoo2 ); // All four callbacks should be fired. - viewDocument.fire( 'foo:bar:abc' ); + fireBubblingEvent( 'foo:bar:abc' ); sinon.assert.callOrder( spyFoo, spyAbc, spyBar, spyFoo2 ); sinon.assert.calledOnce( spyFoo ); @@ -256,7 +257,7 @@ describe( 'BubblingEmitterMixin', () => { sinon.assert.calledOnce( spyFoo2 ); // Only callbacks for foo and foo:bar event should be called. - viewDocument.fire( 'foo:bar' ); + fireBubblingEvent( 'foo:bar' ); sinon.assert.calledOnce( spyAbc ); sinon.assert.calledTwice( spyFoo ); @@ -265,7 +266,7 @@ describe( 'BubblingEmitterMixin', () => { // Only callback for foo should be called as foo:abc has not been registered. // Still, foo is a valid, existing namespace. - viewDocument.fire( 'foo:abc' ); + fireBubblingEvent( 'foo:abc' ); sinon.assert.calledOnce( spyAbc ); sinon.assert.calledTwice( spyBar ); @@ -280,7 +281,7 @@ describe( 'BubblingEmitterMixin', () => { } ); expectToThrowCKEditorError( () => { - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); }, /foo/, null ); } ); @@ -292,19 +293,19 @@ describe( 'BubblingEmitterMixin', () => { } ); expect( () => { - viewDocument.fire( 'test' ); + fireBubblingEvent( 'test' ); } ).to.throw( TypeError, /foo/ ); } ); describe( 'return value', () => { it( 'is undefined by default', () => { - expect( viewDocument.fire( 'foo' ) ).to.be.undefined; + expect( fireBubblingEvent( 'foo' ) ).to.be.undefined; } ); it( 'is undefined if none of the listeners modified EventInfo#return', () => { viewDocument.on( 'foo', () => {} ); - expect( viewDocument.fire( 'foo' ) ).to.be.undefined; + expect( fireBubblingEvent( 'foo' ) ).to.be.undefined; } ); it( 'equals EventInfo#return\'s value', () => { @@ -312,7 +313,7 @@ describe( 'BubblingEmitterMixin', () => { evt.return = 1; } ); - expect( viewDocument.fire( 'foo' ) ).to.equal( 1 ); + expect( fireBubblingEvent( 'foo' ) ).to.equal( 1 ); } ); it( 'equals EventInfo#return\'s value even if the event was stopped', () => { @@ -323,7 +324,7 @@ describe( 'BubblingEmitterMixin', () => { evt.stop(); } ); - expect( viewDocument.fire( 'foo' ) ).to.equal( 1 ); + expect( fireBubblingEvent( 'foo' ) ).to.equal( 1 ); } ); it( 'equals EventInfo#return\'s value when it was set in a namespaced event', () => { @@ -331,7 +332,7 @@ describe( 'BubblingEmitterMixin', () => { evt.return = 1; } ); - expect( viewDocument.fire( 'foo:bar' ) ).to.equal( 1 ); + expect( fireBubblingEvent( 'foo:bar' ) ).to.equal( 1 ); } ); it( 'equals the value set by the last callback', () => { @@ -342,7 +343,7 @@ describe( 'BubblingEmitterMixin', () => { evt.return = 2; }, { priority: 'high' } ); - expect( viewDocument.fire( 'foo' ) ).to.equal( 1 ); + expect( fireBubblingEvent( 'foo' ) ).to.equal( 1 ); } ); } ); } ); @@ -358,7 +359,7 @@ describe( 'BubblingEmitterMixin', () => { const data = {}; const events = setListeners(); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -409,7 +410,7 @@ describe( 'BubblingEmitterMixin', () => { const data = {}; const events = setListeners(); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -456,46 +457,46 @@ describe( 'BubblingEmitterMixin', () => { setModelData( model, '
foo[]bar
' ); const data = {}; - const events = setListeners(); + const events = setListeners( true ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - '$capture @ highest', - '$capture @ high', - '$capture @ normal', - '$capture @ low', - '$capture @ lowest', - - '$text @ highest', - '$text @ high', - '$text @ normal', - '$text @ low', - '$text @ lowest', - - 'p @ highest', - 'p @ high', - 'p @ normal', - 'p @ low', - 'p @ lowest', - - 'blockquote @ highest', - 'blockquote @ high', - 'blockquote @ normal', - 'blockquote @ low', - 'blockquote @ lowest', - - '$root @ highest', - '$root @ high', - '$root @ normal', - '$root @ low', - '$root @ lowest', - - '$document @ highest', - '$document @ high', - '$document @ normal', - '$document @ low', - '$document @ lowest' + '$capture @ highest (capturing @ $document)', + '$capture @ high (capturing @ $document)', + '$capture @ normal (capturing @ $document)', + '$capture @ low (capturing @ $document)', + '$capture @ lowest (capturing @ $document)', + + '$text @ highest (atTarget @ $text)', + '$text @ high (atTarget @ $text)', + '$text @ normal (atTarget @ $text)', + '$text @ low (atTarget @ $text)', + '$text @ lowest (atTarget @ $text)', + + 'p @ highest (bubbling @ p)', + 'p @ high (bubbling @ p)', + 'p @ normal (bubbling @ p)', + 'p @ low (bubbling @ p)', + 'p @ lowest (bubbling @ p)', + + 'blockquote @ highest (bubbling @ blockquote)', + 'blockquote @ high (bubbling @ blockquote)', + 'blockquote @ normal (bubbling @ blockquote)', + 'blockquote @ low (bubbling @ blockquote)', + 'blockquote @ lowest (bubbling @ blockquote)', + + '$root @ highest (bubbling @ $root)', + '$root @ high (bubbling @ $root)', + '$root @ normal (bubbling @ $root)', + '$root @ low (bubbling @ $root)', + '$root @ lowest (bubbling @ $root)', + + '$document @ highest (bubbling @ $document)', + '$document @ high (bubbling @ $document)', + '$document @ normal (bubbling @ $document)', + '$document @ low (bubbling @ $document)', + '$document @ lowest (bubbling @ $document)' ] ); } ); @@ -506,7 +507,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop() ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -552,7 +553,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -592,7 +593,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -626,7 +627,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'p' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -654,7 +655,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$text' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -676,7 +677,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$capture' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -700,46 +701,46 @@ describe( 'BubblingEmitterMixin', () => { setModelData( model, '
foo[]bar' ); const data = {}; - const events = setListeners(); + const events = setListeners( true ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ - '$capture @ highest', - '$capture @ high', - '$capture @ normal', - '$capture @ low', - '$capture @ lowest', - - 'isCustomObject @ highest', - 'isCustomObject @ high', - 'isCustomObject @ normal', - 'isCustomObject @ low', - 'isCustomObject @ lowest', - - 'p @ highest', - 'p @ high', - 'p @ normal', - 'p @ low', - 'p @ lowest', - - 'blockquote @ highest', - 'blockquote @ high', - 'blockquote @ normal', - 'blockquote @ low', - 'blockquote @ lowest', - - '$root @ highest', - '$root @ high', - '$root @ normal', - '$root @ low', - '$root @ lowest', - - '$document @ highest', - '$document @ high', - '$document @ normal', - '$document @ low', - '$document @ lowest' + '$capture @ highest (capturing @ $document)', + '$capture @ high (capturing @ $document)', + '$capture @ normal (capturing @ $document)', + '$capture @ low (capturing @ $document)', + '$capture @ lowest (capturing @ $document)', + + 'isCustomObject @ highest (atTarget @ obj)', + 'isCustomObject @ high (atTarget @ obj)', + 'isCustomObject @ normal (atTarget @ obj)', + 'isCustomObject @ low (atTarget @ obj)', + 'isCustomObject @ lowest (atTarget @ obj)', + + 'p @ highest (bubbling @ p)', + 'p @ high (bubbling @ p)', + 'p @ normal (bubbling @ p)', + 'p @ low (bubbling @ p)', + 'p @ lowest (bubbling @ p)', + + 'blockquote @ highest (bubbling @ blockquote)', + 'blockquote @ high (bubbling @ blockquote)', + 'blockquote @ normal (bubbling @ blockquote)', + 'blockquote @ low (bubbling @ blockquote)', + 'blockquote @ lowest (bubbling @ blockquote)', + + '$root @ highest (bubbling @ $root)', + '$root @ high (bubbling @ $root)', + '$root @ normal (bubbling @ $root)', + '$root @ low (bubbling @ $root)', + '$root @ lowest (bubbling @ $root)', + + '$document @ highest (bubbling @ $document)', + '$document @ high (bubbling @ $document)', + '$document @ normal (bubbling @ $document)', + '$document @ low (bubbling @ $document)', + '$document @ lowest (bubbling @ $document)' ] ); } ); @@ -750,7 +751,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop() ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -796,7 +797,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$root' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -836,7 +837,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'blockquote' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -870,7 +871,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: 'p' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -898,7 +899,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: isCustomObject } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -920,7 +921,7 @@ describe( 'BubblingEmitterMixin', () => { const events = setListeners(); viewDocument.on( 'fakeEvent', event => event.stop(), { context: '$capture' } ); - viewDocument.fire( 'fakeEvent', data ); + fireBubblingEvent( 'fakeEvent', data ); expect( events ).to.deep.equal( [ '$capture @ highest', @@ -930,13 +931,127 @@ describe( 'BubblingEmitterMixin', () => { } ); } ); - function setListeners() { + it( 'should bubble non bubbling event (but without event info bubbling data)', () => { + setModelData( model, '
foo[]bar
' ); + + const data = {}; + const events = setListeners( true ); + + viewDocument.fire( 'fakeEvent', data ); + + expect( events ).to.deep.equal( [ + '$capture @ highest (undefined @ undefined)', + '$capture @ high (undefined @ undefined)', + '$capture @ normal (undefined @ undefined)', + '$capture @ low (undefined @ undefined)', + '$capture @ lowest (undefined @ undefined)', + + '$text @ highest (undefined @ undefined)', + '$text @ high (undefined @ undefined)', + '$text @ normal (undefined @ undefined)', + '$text @ low (undefined @ undefined)', + '$text @ lowest (undefined @ undefined)', + + 'p @ highest (undefined @ undefined)', + 'p @ high (undefined @ undefined)', + 'p @ normal (undefined @ undefined)', + 'p @ low (undefined @ undefined)', + 'p @ lowest (undefined @ undefined)', + + 'blockquote @ highest (undefined @ undefined)', + 'blockquote @ high (undefined @ undefined)', + 'blockquote @ normal (undefined @ undefined)', + 'blockquote @ low (undefined @ undefined)', + 'blockquote @ lowest (undefined @ undefined)', + + '$root @ highest (undefined @ undefined)', + '$root @ high (undefined @ undefined)', + '$root @ normal (undefined @ undefined)', + '$root @ low (undefined @ undefined)', + '$root @ lowest (undefined @ undefined)', + + '$document @ highest (undefined @ undefined)', + '$document @ high (undefined @ undefined)', + '$document @ normal (undefined @ undefined)', + '$document @ low (undefined @ undefined)', + '$document @ lowest (undefined @ undefined)' + ] ); + } ); + + it( 'should bubble from the provided view range', () => { + setModelData( model, 'a[]bc
foobar
' ); + + const data = {}; + const events = setListeners( true ); + + const range = view.createRangeIn( viewDocument.getRoot().getChild( 1 ).getChild( 0 ) ); + + viewDocument.fire( new BubblingEventInfo( viewDocument, 'fakeEvent', range ), data ); + + expect( events ).to.deep.equal( [ + '$capture @ highest (capturing @ $document)', + '$capture @ high (capturing @ $document)', + '$capture @ normal (capturing @ $document)', + '$capture @ low (capturing @ $document)', + '$capture @ lowest (capturing @ $document)', + + '$text @ highest (atTarget @ p)', + '$text @ high (atTarget @ p)', + '$text @ normal (atTarget @ p)', + '$text @ low (atTarget @ p)', + '$text @ lowest (atTarget @ p)', + + 'p @ highest (bubbling @ p)', + 'p @ high (bubbling @ p)', + 'p @ normal (bubbling @ p)', + 'p @ low (bubbling @ p)', + 'p @ lowest (bubbling @ p)', + + 'blockquote @ highest (bubbling @ blockquote)', + 'blockquote @ high (bubbling @ blockquote)', + 'blockquote @ normal (bubbling @ blockquote)', + 'blockquote @ low (bubbling @ blockquote)', + 'blockquote @ lowest (bubbling @ blockquote)', + + '$root @ highest (bubbling @ $root)', + '$root @ high (bubbling @ $root)', + '$root @ normal (bubbling @ $root)', + '$root @ low (bubbling @ $root)', + '$root @ lowest (bubbling @ $root)', + + '$document @ highest (bubbling @ $document)', + '$document @ high (bubbling @ $document)', + '$document @ normal (bubbling @ $document)', + '$document @ low (bubbling @ $document)', + '$document @ lowest (bubbling @ $document)' + ] ); + } ); + + function setListeners( useDetails ) { const events = []; function setListenersForContext( context ) { for ( const priority of [ 'highest', 'high', 'normal', 'low', 'lowest' ] ) { - viewDocument.on( 'fakeEvent', () => { - events.push( `${ typeof context == 'string' ? context : context.name } @ ${ priority }` ); + viewDocument.on( 'fakeEvent', evt => { + const contextName = typeof context == 'string' ? context : context.name; + + if ( useDetails ) { + let currentTarget = ''; + + if ( !evt.currentTarget ) { + currentTarget = 'undefined'; + } else if ( evt.currentTarget == viewDocument ) { + currentTarget = '$document'; + } else if ( evt.currentTarget.is( '$text' ) ) { + currentTarget = '$text'; + } else if ( evt.currentTarget.is( 'node' ) ) { + currentTarget = evt.currentTarget.name; + } + + events.push( `${ contextName } @ ${ priority } (${ evt.eventPhase } @ ${ currentTarget })` ); + } else { + events.push( `${ contextName } @ ${ priority }` ); + } }, { context, priority } ); } } @@ -956,4 +1071,11 @@ describe( 'BubblingEmitterMixin', () => { return node.is( 'element', 'obj' ); } } ); + + function fireBubblingEvent( name, ...args ) { + const selection = viewDocument.selection; + const eventInfo = new BubblingEventInfo( viewDocument, name, selection.getFirstRange() ); + + return viewDocument.fire( eventInfo, ...args ); + } } ); diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js b/packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js new file mode 100644 index 00000000000..01f285d3c8e --- /dev/null +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js @@ -0,0 +1,23 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import BubblingEventInfo from '../../../src/view/observer/bubblingeventinfo'; + +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; + +describe( 'EventInfo', () => { + it( 'should be created properly', () => { + const range = {}; + const event = new BubblingEventInfo( this, 'test', range ); + + expect( event ).to.be.instanceOf( EventInfo ); + expect( event.source ).to.equal( this ); + expect( event.name ).to.equal( 'test' ); + expect( event.path ).to.deep.equal( [] ); + expect( event.startRange ).to.equal( range ); + expect( event.eventPhase ).to.equal( 'none' ); + expect( event.currentTarget ).to.be.null; + } ); +} ); diff --git a/packages/ckeditor5-enter/src/enterobserver.js b/packages/ckeditor5-enter/src/enterobserver.js index 0524385734e..ea67497cc18 100644 --- a/packages/ckeditor5-enter/src/enterobserver.js +++ b/packages/ckeditor5-enter/src/enterobserver.js @@ -9,7 +9,7 @@ import Observer from '@ckeditor/ckeditor5-engine/src/view/observer/observer'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; -import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import BubblingEventInfo from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingeventinfo'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; /** @@ -28,7 +28,7 @@ export default class EnterObserver extends Observer { doc.on( 'keydown', ( evt, data ) => { if ( this.isEnabled && data.keyCode == keyCodes.enter ) { - const event = new EventInfo( doc, 'enter' ); + const event = new BubblingEventInfo( doc, 'enter', doc.selection.getFirstRange() ); doc.fire( event, new DomEventData( doc, data.domEvent, { isSoft: data.shiftKey diff --git a/packages/ckeditor5-typing/src/deleteobserver.js b/packages/ckeditor5-typing/src/deleteobserver.js index 01d37e19675..b8258a89d36 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.js +++ b/packages/ckeditor5-typing/src/deleteobserver.js @@ -9,7 +9,7 @@ import Observer from '@ckeditor/ckeditor5-engine/src/view/observer/observer'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; -import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import BubblingEventInfo from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingeventinfo'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import env from '@ckeditor/ckeditor5-utils/src/env'; @@ -84,7 +84,7 @@ export default class DeleteObserver extends Observer { } function fireViewDeleteEvent( originalEvent, domEvent, deleteData ) { - const event = new EventInfo( document, 'delete' ); + const event = new BubblingEventInfo( document, 'delete', document.selection.getFirstRange() ); document.fire( event, new DomEventData( document, domEvent, deleteData ) ); diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 8eb82d23261..5664651733e 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -541,14 +541,13 @@ export default class WidgetTypeAround extends Plugin { const editingView = editor.editing.view; this._listenToIfEnabled( editingView.document, 'enter', ( evt, domEventData ) => { - const selectedModelElement = selection.getSelectedElement(); - // This event could be triggered from inside the widget but we are interested // only when the widget is selected itself. - if ( !selectedModelElement ) { + if ( evt.eventPhase != 'atTarget' ) { return; } + const selectedModelElement = selection.getSelectedElement(); const selectedViewElement = editor.editing.mapper.toViewElement( selectedModelElement ); const schema = editor.model.schema; @@ -628,11 +627,9 @@ export default class WidgetTypeAround extends Plugin { const schema = model.schema; this._listenToIfEnabled( editingView.document, 'delete', ( evt, domEventData ) => { - const selectedModelWidget = model.document.selection.getSelectedElement(); - // This event could be triggered from inside the widget but we are interested // only when the widget is selected itself. - if ( !selectedModelWidget ) { + if ( evt.eventPhase != 'atTarget' ) { return; } @@ -644,6 +641,7 @@ export default class WidgetTypeAround extends Plugin { } const direction = domEventData.direction; + const selectedModelWidget = model.document.selection.getSelectedElement(); const isFakeCaretBefore = typeAroundFakeCaretPosition === 'before'; const isDeleteForward = direction == 'forward'; diff --git a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js index d928291c0ad..0eecb10d8dc 100644 --- a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js @@ -9,6 +9,7 @@ import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventd import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import ViewText from '@ckeditor/ckeditor5-engine/src/view/text'; +import BubblingEventInfo from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingeventinfo'; import Widget from '../../src/widget'; import WidgetTypeAround from '../../src/widgettypearound/widgettypearound'; @@ -1398,7 +1399,7 @@ describe( 'WidgetTypeAround', () => { } ); function fireDeleteEvent( isForward = false ) { - eventInfoStub = new EventInfo( viewDocument, 'delete' ); + eventInfoStub = new BubblingEventInfo( viewDocument, 'delete' ); sinon.spy( eventInfoStub, 'stop' ); const data = { From 75d754d82e941bf8426efcade3e379e43cdb92f8 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 22 Feb 2021 18:02:06 +0100 Subject: [PATCH 37/43] Updated JSDoc. --- .../ckeditor5-engine/src/view/observer/arrowkeysobserver.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js index 4fbe4e7cd6a..d5da2e5b309 100644 --- a/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js +++ b/packages/ckeditor5-engine/src/view/observer/arrowkeysobserver.js @@ -13,7 +13,9 @@ import BubblingEventInfo from './bubblingeventinfo'; import { isArrowKeyCode } from '@ckeditor/ckeditor5-utils'; /** - * Arrow keys observer introduces the {@link module:engine/view/document~Document#event:arrowKey} event. + * Arrow keys observer introduces the {@link module:engine/view/document~Document#event:arrowKey `Document#arrowKey`} event. + * + * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default. * * @extends module:engine/view/observer/observer~Observer */ From 296674a5ae36b2edb6441587284adc4c59c2489a Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 22 Feb 2021 18:02:38 +0100 Subject: [PATCH 38/43] Updated test name. --- .../ckeditor5-engine/tests/view/observer/bubblingeventinfo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js b/packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js index 01f285d3c8e..6aec2e6bc15 100644 --- a/packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js +++ b/packages/ckeditor5-engine/tests/view/observer/bubblingeventinfo.js @@ -7,7 +7,7 @@ import BubblingEventInfo from '../../../src/view/observer/bubblingeventinfo'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; -describe( 'EventInfo', () => { +describe( 'BubblingEventInfo', () => { it( 'should be created properly', () => { const range = {}; const event = new BubblingEventInfo( this, 'test', range ); From b4e48f005bb41e4d6ca3ef382a19e848c6b1b3cb Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 22 Feb 2021 18:05:36 +0100 Subject: [PATCH 39/43] The enter default listeners back to low priority. --- packages/ckeditor5-enter/src/enter.js | 2 +- packages/ckeditor5-enter/src/shiftenter.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ckeditor5-enter/src/enter.js b/packages/ckeditor5-enter/src/enter.js index e2a3ba05aa4..7916b4599db 100644 --- a/packages/ckeditor5-enter/src/enter.js +++ b/packages/ckeditor5-enter/src/enter.js @@ -48,6 +48,6 @@ export default class Enter extends Plugin { editor.execute( 'enter' ); view.scrollToTheSelection(); - } ); + }, { priority: 'low' } ); } } diff --git a/packages/ckeditor5-enter/src/shiftenter.js b/packages/ckeditor5-enter/src/shiftenter.js index f2f90e54e9b..37b087f82da 100644 --- a/packages/ckeditor5-enter/src/shiftenter.js +++ b/packages/ckeditor5-enter/src/shiftenter.js @@ -68,6 +68,6 @@ export default class ShiftEnter extends Plugin { editor.execute( 'shiftEnter' ); view.scrollToTheSelection(); - } ); + }, { priority: 'low' } ); } } From ecd9392f2b4d4432100c619ca6ae2cfd7bdc108d Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 22 Feb 2021 18:08:55 +0100 Subject: [PATCH 40/43] The delete default listeners moved to low priority. --- packages/ckeditor5-typing/src/delete.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-typing/src/delete.js b/packages/ckeditor5-typing/src/delete.js index 695cf840b6a..0d921748261 100644 --- a/packages/ckeditor5-typing/src/delete.js +++ b/packages/ckeditor5-typing/src/delete.js @@ -62,7 +62,7 @@ export default class Delete extends Plugin { data.preventDefault(); view.scrollToTheSelection(); - } ); + }, { priority: 'low' } ); // Android IMEs have a quirk - they change DOM selection after the input changes were performed by the browser. // This happens on `keyup` event. Android doesn't know anything about our deletion and selection handling. Even if the selection From 09ffe9172bef266a5a6dc682adb00e0376885309 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 22 Feb 2021 18:14:13 +0100 Subject: [PATCH 41/43] Added details about mocked objects in tests. --- packages/ckeditor5-utils/src/emittermixin.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ckeditor5-utils/src/emittermixin.js b/packages/ckeditor5-utils/src/emittermixin.js index d57ce1e2850..278d706bf09 100644 --- a/packages/ckeditor5-utils/src/emittermixin.js +++ b/packages/ckeditor5-utils/src/emittermixin.js @@ -691,6 +691,7 @@ function addEventListener( listener, emitter, event, callback, options ) { emitter._addEventListener( event, callback, options ); } else { // Allow listening on objects that do not implement Emitter interface. + // This is needed in some tests that are using mocks instead of the real objects with EmitterMixin mixed. listener._addEventListener.call( emitter, event, callback, options ); } } @@ -701,6 +702,7 @@ function removeEventListener( listener, emitter, event, callback ) { emitter._removeEventListener( event, callback ); } else { // Allow listening on objects that do not implement Emitter interface. + // This is needed in some tests that are using mocks instead of the real objects with EmitterMixin mixed. listener._removeEventListener.call( emitter, event, callback ); } } From b4942cfc041fbab183eec62c93e937e43c69a96f Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 22 Feb 2021 18:22:09 +0100 Subject: [PATCH 42/43] Updated code comment. --- packages/ckeditor5-utils/src/observablemixin.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-utils/src/observablemixin.js b/packages/ckeditor5-utils/src/observablemixin.js index 033886a0765..e82441f1618 100644 --- a/packages/ckeditor5-utils/src/observablemixin.js +++ b/packages/ckeditor5-utils/src/observablemixin.js @@ -274,7 +274,11 @@ const ObservableMixin = { extend( ObservableMixin, EmitterMixin ); -// Override the EmitterMixin stopListening method to be able to clean decorated methods. +// Override the EmitterMixin stopListening method to be able to clean (and restore) decorated methods. +// This is needed in case of: +// 1. Have x.foo() decorated. +// 2. Call x.stopListening() +// 3. Call x.foo(). Problem: nothing happens (the original foo() method is not executed) ObservableMixin.stopListening = function( emitter, event, callback ) { // Removing all listeners so let's clean the decorated methods to the original state. if ( !emitter && this[ _decoratedMethods ] ) { From af200e65ac2f23ea4bb56992f15961bbc3a270c2 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 22 Feb 2021 18:34:43 +0100 Subject: [PATCH 43/43] Updated editing engine guide to include ArrowKeysObserver. --- docs/framework/guides/architecture/editing-engine.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/framework/guides/architecture/editing-engine.md b/docs/framework/guides/architecture/editing-engine.md index 139797c8d6f..362bd1f7562 100644 --- a/docs/framework/guides/architecture/editing-engine.md +++ b/docs/framework/guides/architecture/editing-engine.md @@ -325,6 +325,7 @@ By default, the view adds the following observers: * {@link module:engine/view/observer/keyobserver~KeyObserver} * {@link module:engine/view/observer/fakeselectionobserver~FakeSelectionObserver} * {@link module:engine/view/observer/compositionobserver~CompositionObserver} +* {@link module:engine/view/observer/arrowkeysobserver~ArrowKeysObserver} Additionally, some features add their own observers. For instance, the {@link module:clipboard/clipboard~Clipboard clipboard feature} adds {@link module:clipboard/clipboardobserver~ClipboardObserver}.