@ckeditor/ckeditor5-typing
Version:
Typing feature for CKEditor 5.
662 lines (661 loc) • 26.3 kB
JavaScript
/**
* @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;
}