UNPKG

@ckeditor/ckeditor5-typing

Version:

Typing feature for CKEditor 5.

662 lines (661 loc) 26.3 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module typing/twostepcaretmovement */ import { Plugin } from '@ckeditor/ckeditor5-core'; import { keyCodes } from '@ckeditor/ckeditor5-utils'; import { MouseObserver, TouchObserver } from '@ckeditor/ckeditor5-engine'; /** * This plugin enables the two-step caret (phantom) movement behavior for * {@link module:typing/twostepcaretmovement~TwoStepCaretMovement#registerAttribute registered attributes} * on arrow right (<kbd>→</kbd>) and left (<kbd>←</kbd>) key press. * * Thanks to this (phantom) caret movement the user is able to type before/after as well as at the * beginning/end of an attribute. * * **Note:** This plugin support right–to–left (Arabic, Hebrew, etc.) content by mirroring its behavior * but for the sake of simplicity examples showcase only left–to–right use–cases. * * # Forward movement * * ## "Entering" an attribute: * * When this plugin is enabled and registered for the `a` attribute and the selection is right before it * (at the attribute boundary), pressing the right arrow key will not move the selection but update its * attributes accordingly: * * * When enabled: * * ```xml * foo{}<$text a="true">bar</$text> * ``` * * <kbd>→</kbd> * * ```xml * foo<$text a="true">{}bar</$text> * ``` * * * When disabled: * * ```xml * foo{}<$text a="true">bar</$text> * ``` * * <kbd>→</kbd> * * ```xml * foo<$text a="true">b{}ar</$text> * ``` * * * ## "Leaving" an attribute: * * * When enabled: * * ```xml * <$text a="true">bar{}</$text>baz * ``` * * <kbd>→</kbd> * * ```xml * <$text a="true">bar</$text>{}baz * ``` * * * When disabled: * * ```xml * <$text a="true">bar{}</$text>baz * ``` * * <kbd>→</kbd> * * ```xml * <$text a="true">bar</$text>b{}az * ``` * * # Backward movement * * * When enabled: * * ```xml * <$text a="true">bar</$text>{}baz * ``` * * <kbd>←</kbd> * * ```xml * <$text a="true">bar{}</$text>baz * ``` * * * When disabled: * * ```xml * <$text a="true">bar</$text>{}baz * ``` * * <kbd>←</kbd> * * ```xml * <$text a="true">ba{}r</$text>b{}az * ``` * * # Multiple attributes * * * When enabled and many attributes starts or ends at the same position: * * ```xml * <$text a="true" b="true">bar</$text>{}baz * ``` * * <kbd>←</kbd> * * ```xml * <$text a="true" b="true">bar{}</$text>baz * ``` * * * When enabled and one procedes another: * * ```xml * <$text a="true">bar</$text><$text b="true">{}bar</$text> * ``` * * <kbd>←</kbd> * * ```xml * <$text a="true">bar{}</$text><$text b="true">bar</$text> * ``` * */ export default class TwoStepCaretMovement extends Plugin { /** * A set of attributes to handle. */ attributes; /** * The current UID of the overridden gravity, as returned by * {@link module:engine/model/writer~Writer#overrideSelectionGravity}. */ _overrideUid; /** * A flag indicating that the automatic gravity restoration should not happen upon the next * gravity restoration. * {@link module:engine/model/selection~Selection#event:change:range} event. */ _isNextGravityRestorationSkipped = false; /** * @inheritDoc */ static get pluginName() { return 'TwoStepCaretMovement'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ constructor(editor) { super(editor); this.attributes = new Set(); this._overrideUid = null; } /** * @inheritDoc */ init() { const editor = this.editor; const model = editor.model; const view = editor.editing.view; const locale = editor.locale; 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 implementation works only for collapsed selection. if (!modelSelection.isCollapsed) { return; } // When user tries to expand the selection or jump over the whole word or to the beginning/end then // two-steps movement is not necessary. if (data.shiftKey || data.altKey || data.ctrlKey) { return; } const arrowRightPressed = data.keyCode == keyCodes.arrowright; const arrowLeftPressed = data.keyCode == keyCodes.arrowleft; // When neither left or right arrow has been pressed then do noting. if (!arrowRightPressed && !arrowLeftPressed) { return; } const contentDirection = locale.contentLanguageDirection; let isMovementHandled = false; if ((contentDirection === 'ltr' && arrowRightPressed) || (contentDirection === 'rtl' && arrowLeftPressed)) { isMovementHandled = this._handleForwardMovement(data); } else { isMovementHandled = this._handleBackwardMovement(data); } // Stop the keydown event if the two-step caret movement handled it. Avoid collisions // with other features which may also take over the caret movement (e.g. Widget). if (isMovementHandled === true) { evt.stop(); } }, { context: '$text', priority: 'highest' }); // The automatic gravity restoration logic. this.listenTo(modelSelection, 'change:range', (evt, data) => { // Skipping the automatic restoration is needed if the selection should change // but the gravity must remain overridden afterwards. See the #handleBackwardMovement // to learn more. if (this._isNextGravityRestorationSkipped) { this._isNextGravityRestorationSkipped = false; return; } // Skip automatic restore when the gravity is not overridden — simply, there's nothing to restore // at this moment. if (!this._isGravityOverridden) { return; } // Skip automatic restore when the change is indirect AND the selection is at the attribute boundary. // It means that e.g. if the change was external (collaboration) and the user had their // selection around the link, its gravity should remain intact in this change:range event. if (!data.directChange && isBetweenDifferentAttributes(modelSelection.getFirstPosition(), this.attributes)) { return; } this._restoreGravity(); }); // Handle a click at the beginning/end of a two-step element. this._enableClickingAfterNode(); // Change the attributes of the selection in certain situations after the two-step node was inserted into the document. this._enableInsertContentSelectionAttributesFixer(); // Handle removing the content after the two-step node. this._handleDeleteContentAfterNode(); } /** * Registers a given attribute for the two-step caret movement. * * @param attribute Name of the attribute to handle. */ registerAttribute(attribute) { this.attributes.add(attribute); } /** * Updates the document selection and the view according to the two–step caret movement state * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}. * * @internal * @param eventData Data of the key press. * @returns `true` when the handler prevented caret movement. */ _handleForwardMovement(eventData) { const attributes = this.attributes; const model = this.editor.model; const selection = model.document.selection; const position = selection.getFirstPosition(); // DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered // // <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph> // // or left the attribute // // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph> // // and the gravity will be restored automatically. if (this._isGravityOverridden) { return false; } // DON'T ENGAGE 2-SCM when the selection is at the beginning of the block AND already has the // attribute: // * when the selection was initially set there using the mouse, // * when the editor has just started // // <paragraph><$text attribute>{}bar</$text>baz</paragraph> // if (position.isAtStart && hasAnyAttribute(selection, attributes)) { return false; } // ENGAGE 2-SCM When at least one of the observed attributes changes its value (incl. starts, ends). // // <paragraph>foo<$text attribute>bar{}</$text>baz</paragraph> // <paragraph>foo<$text attribute>bar{}</$text><$text otherAttribute>baz</$text></paragraph> // <paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>baz</$text></paragraph> // <paragraph>foo{}<$text attribute>bar</$text>baz</paragraph> // if (isBetweenDifferentAttributes(position, attributes)) { if (eventData) { preventCaretMovement(eventData); } // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before // the next one with a different value of the same attribute. // // <paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>bar</$text>baz</paragraph> // if (hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes, true)) { clearSelectionAttributes(model, attributes); } else { this._overrideGravity(); } return true; } return false; } /** * Updates the document selection and the view according to the two–step caret movement state * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}. * * @internal * @param eventData Data of the key press. * @returns `true` when the handler prevented caret movement */ _handleBackwardMovement(eventData) { const attributes = this.attributes; const model = this.editor.model; const selection = model.document.selection; const position = selection.getFirstPosition(); // When the gravity is already overridden (by this plugin), it means we are on the two-step position. // Prevent the movement, restore the gravity and update selection attributes. // // <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}baz</$text></paragraph> // <paragraph>foo<$text attribute>bar</$text><$text otherAttribute>{}baz</$text></paragraph> // <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph> // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph> // if (this._isGravityOverridden) { if (eventData) { preventCaretMovement(eventData); } this._restoreGravity(); // CLEAR 2-SCM attributes if we are at the end of one 2-SCM and before // the next one with a different value of the same attribute. // // <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}bar</$text>baz</paragraph> // if (isBetweenDifferentAttributes(position, attributes, true)) { clearSelectionAttributes(model, attributes); } else { setSelectionAttributesFromTheNodeBefore(model, attributes, position); } return true; } else { // REMOVE SELECTION ATTRIBUTE when restoring gravity towards a non-existent content at the // beginning of the block. // // <paragraph>{}<$text attribute>bar</$text></paragraph> // if (position.isAtStart) { if (hasAnyAttribute(selection, attributes)) { if (eventData) { preventCaretMovement(eventData); } setSelectionAttributesFromTheNodeBefore(model, attributes, position); return true; } return false; } // SET 2-SCM attributes if we are between nodes with the same attribute but with different values. // // <paragraph>foo<$text attribute=1>bar</$text>[]<$text attribute=2>bar</$text>baz</paragraph> // if (!hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes, true)) { if (eventData) { preventCaretMovement(eventData); } setSelectionAttributesFromTheNodeBefore(model, attributes, position); return true; } // When we are moving from natural gravity, to the position of the 2SCM, we need to override the gravity, // and make sure it won't be restored. Unless it's at the end of the block and an observed attribute. // We need to check if the caret is a one position before the attribute boundary: // // <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>b{}az</$text></paragraph> // <paragraph>foo<$text attribute>bar</$text><$text otherAttribute>b{}az</$text></paragraph> // <paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph> // <paragraph>foo<$text attribute>bar</$text>b{}az</paragraph> // if (isStepAfterAnyAttributeBoundary(position, attributes)) { // ENGAGE 2-SCM if the selection has no attribute. This may happen when the user // left the attribute using a FORWARD 2-SCM. // // <paragraph><$text attribute>bar</$text>{}</paragraph> // if (position.isAtEnd && !hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes)) { if (eventData) { preventCaretMovement(eventData); } setSelectionAttributesFromTheNodeBefore(model, attributes, position); return true; } // Skip the automatic gravity restore upon the next selection#change:range event. // If not skipped, it would automatically restore the gravity, which should remain // overridden. this._isNextGravityRestorationSkipped = true; this._overrideGravity(); // Don't return "true" here because we didn't call _preventCaretMovement. // Returning here will destabilize the filler logic, which also listens to // keydown (and the event would be stopped). return false; } } return false; } /** * Starts listening to {@link module:engine/view/document~Document#event:mousedown} and * {@link module:engine/view/document~Document#event:selectionChange} and puts the selection before/after a 2-step node * if clicked at the beginning/ending of the 2-step node. * * The purpose of this action is to allow typing around the 2-step node directly after a click. * * See https://github.com/ckeditor/ckeditor5/issues/1016. */ _enableClickingAfterNode() { const editor = this.editor; const model = editor.model; const selection = model.document.selection; const document = editor.editing.view.document; editor.editing.view.addObserver(MouseObserver); editor.editing.view.addObserver(TouchObserver); let touched = false; let clicked = false; // This event should be fired before selection on mobile devices. this.listenTo(document, 'touchstart', () => { clicked = false; touched = true; }); // Track mouse click event. // Keep in mind that it's often called after the selection change on iOS devices. // On the Android devices, it's called before the selection change. // That's why we watch `touchstart` event on mobile and set `touched` flag, as it's fired before the selection change. // See more: https://github.com/ckeditor/ckeditor5/issues/17171 this.listenTo(document, 'mousedown', () => { clicked = true; }); // When the selection has changed... this.listenTo(document, 'selectionChange', () => { const attributes = this.attributes; if (!clicked && !touched) { return; } // ...and it was caused by the click or touch... clicked = false; touched = false; // ...and no text is selected... if (!selection.isCollapsed) { return; } // ...and clicked text is the 2-step node... if (!hasAnyAttribute(selection, attributes)) { return; } const position = selection.getFirstPosition(); if (!isBetweenDifferentAttributes(position, attributes)) { return; } // The selection at the start of a block would use surrounding attributes // from text after the selection so just clear 2-SCM attributes. // // Also, clear attributes for selection between same attribute with different values. if (position.isAtStart || isBetweenDifferentAttributes(position, attributes, true)) { clearSelectionAttributes(model, attributes); } else if (!this._isGravityOverridden) { this._overrideGravity(); } }); } /** * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model * selection attributes if the selection is at the end of a two-step node after inserting the content. * * The purpose of this action is to improve the overall UX because the user is no longer "trapped" by the * two-step attribute of the selection, and they can type a "clean" (`linkHref`–less) text right away. * * See https://github.com/ckeditor/ckeditor5/issues/6053. */ _enableInsertContentSelectionAttributesFixer() { const editor = this.editor; const model = editor.model; const selection = model.document.selection; const attributes = this.attributes; this.listenTo(model, 'insertContent', () => { const position = selection.getFirstPosition(); if (hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes)) { clearSelectionAttributes(model, attributes); } }, { priority: 'low' }); } /** * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether * removing a content right after the tow-step attribute. * * If so, the selection should not preserve the two-step attribute. However, if * the {@link module:typing/twostepcaretmovement~TwoStepCaretMovement} plugin is active and * the selection has the two-step attribute due to overridden gravity (at the end), the two-step attribute should stay untouched. * * The purpose of this action is to allow removing the link text and keep the selection outside the link. * * See https://github.com/ckeditor/ckeditor5/issues/7521. */ _handleDeleteContentAfterNode() { const editor = this.editor; const model = editor.model; const selection = model.document.selection; const view = editor.editing.view; let isBackspace = false; let shouldPreserveAttributes = false; // Detect pressing `Backspace`. this.listenTo(view.document, 'delete', (evt, data) => { isBackspace = data.direction === 'backward'; }, { priority: 'high' }); // Before removing the content, check whether the selection is inside a two-step attribute. // If so, we want to preserve those attributes. this.listenTo(model, 'deleteContent', () => { if (!isBackspace) { return; } const position = selection.getFirstPosition(); shouldPreserveAttributes = hasAnyAttribute(selection, this.attributes) && !isStepAfterAnyAttributeBoundary(position, this.attributes); }, { priority: 'high' }); // After removing the content, check whether the current selection should preserve the `linkHref` attribute. this.listenTo(model, 'deleteContent', () => { if (!isBackspace) { return; } isBackspace = false; // Do not escape two-step attribute if it was inside it before content deletion. if (shouldPreserveAttributes) { return; } // Use `model.enqueueChange()` in order to execute the callback at the end of the changes process. editor.model.enqueueChange(() => { const position = selection.getFirstPosition(); if (hasAnyAttribute(selection, this.attributes) && isBetweenDifferentAttributes(position, this.attributes)) { if (position.isAtStart || isBetweenDifferentAttributes(position, this.attributes, true)) { clearSelectionAttributes(model, this.attributes); } else if (!this._isGravityOverridden) { this._overrideGravity(); } } }); }, { priority: 'low' }); } /** * `true` when the gravity is overridden for the plugin. */ get _isGravityOverridden() { return !!this._overrideUid; } /** * Overrides the gravity using the {@link module:engine/model/writer~Writer model writer} * and stores the information about this fact in the {@link #_overrideUid}. * * A shorthand for {@link module:engine/model/writer~Writer#overrideSelectionGravity}. */ _overrideGravity() { this._overrideUid = this.editor.model.change(writer => { return writer.overrideSelectionGravity(); }); } /** * Restores the gravity using the {@link module:engine/model/writer~Writer model writer}. * * A shorthand for {@link module:engine/model/writer~Writer#restoreSelectionGravity}. */ _restoreGravity() { this.editor.model.change(writer => { writer.restoreSelectionGravity(this._overrideUid); this._overrideUid = null; }); } } /** * Checks whether the selection has any of given attributes. */ function hasAnyAttribute(selection, attributes) { for (const observedAttribute of attributes) { if (selection.hasAttribute(observedAttribute)) { return true; } } return false; } /** * Applies the given attributes to the current selection using using the * values from the node before the current position. Uses * the {@link module:engine/model/writer~Writer model writer}. */ function setSelectionAttributesFromTheNodeBefore(model, attributes, position) { const nodeBefore = position.nodeBefore; model.change(writer => { if (nodeBefore) { const attributes = []; const isInlineObject = model.schema.isObject(nodeBefore) && model.schema.isInline(nodeBefore); for (const [key, value] of nodeBefore.getAttributes()) { if (model.schema.checkAttribute('$text', key) && (!isInlineObject || model.schema.getAttributeProperties(key).copyFromObject !== false)) { attributes.push([key, value]); } } writer.setSelectionAttribute(attributes); } else { writer.removeSelectionAttribute(attributes); } }); } /** * Removes 2-SCM attributes from the selection. */ function clearSelectionAttributes(model, attributes) { model.change(writer => { writer.removeSelectionAttribute(attributes); }); } /** * Prevents the caret movement in the view by calling `preventDefault` on the event data. * * @alias data.preventDefault */ function preventCaretMovement(data) { data.preventDefault(); } /** * Checks whether the step before `isBetweenDifferentAttributes()`. */ function isStepAfterAnyAttributeBoundary(position, attributes) { const positionBefore = position.getShiftedBy(-1); return isBetweenDifferentAttributes(positionBefore, attributes); } /** * Checks whether the given position is between different values of given attributes. */ function isBetweenDifferentAttributes(position, attributes, isStrict = false) { const { nodeBefore, nodeAfter } = position; for (const observedAttribute of attributes) { const attrBefore = nodeBefore ? nodeBefore.getAttribute(observedAttribute) : undefined; const attrAfter = nodeAfter ? nodeAfter.getAttribute(observedAttribute) : undefined; if (isStrict && (attrBefore === undefined || attrAfter === undefined)) { continue; } if (attrAfter !== attrBefore) { return true; } } return false; }