@ckeditor/ckeditor5-typing
Version:
Typing feature for CKEditor 5.
1,518 lines (1,507 loc) • 66.3 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Command, Plugin } from "@ckeditor/ckeditor5-core";
import { EventInfo, ObservableMixin, count, env, isInsideCombinedSymbol, isInsideEmojiSequence, isInsideSurrogatePair, keyCodes } from "@ckeditor/ckeditor5-utils";
import { BubblingEventInfo, FocusObserver, ModelLiveRange, MouseObserver, Observer, TouchObserver, ViewDocumentDomEventData, _tryFixingModelRange } from "@ckeditor/ckeditor5-engine";
import { debounce, escapeRegExp } from "es-toolkit/compat";
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Change buffer allows to group atomic changes (like characters that have been typed) into
* {@link module:engine/model/batch~Batch batches}.
*
* Batches represent single undo steps, hence changes added to one single batch are undone together.
*
* The buffer has a configurable limit of atomic changes that it can accommodate. After the limit was
* exceeded (see {@link ~TypingChangeBuffer#input}), a new batch is created in {@link ~TypingChangeBuffer#batch}.
*
* To use the change buffer you need to let it know about the number of changes that were added to the batch:
*
* ```ts
* const buffer = new ChangeBuffer( model, LIMIT );
*
* // Later on in your feature:
* buffer.batch.insert( pos, insertedCharacters );
* buffer.input( insertedCharacters.length );
* ```
*/
var TypingChangeBuffer = class {
/**
* The model instance.
*/
model;
/**
* The maximum number of atomic changes which can be contained in one batch.
*/
limit;
/**
* Whether the buffer is locked. A locked buffer cannot be reset unless it gets unlocked.
*/
_isLocked;
/**
* The number of atomic changes in the buffer. Once it exceeds the {@link #limit},
* the {@link #batch batch} is set to a new one.
*/
_size;
/**
* The current batch instance.
*/
_batch = null;
/**
* The callback to document the change event which later needs to be removed.
*/
_changeCallback;
/**
* The callback to document selection `change:attribute` and `change:range` events which resets the buffer.
*/
_selectionChangeCallback;
/**
* Creates a new instance of the change buffer.
*
* @param limit The maximum number of atomic changes which can be contained in one batch.
*/
constructor(model, limit = 20) {
this.model = model;
this._size = 0;
this.limit = limit;
this._isLocked = false;
this._changeCallback = (evt, batch) => {
if (batch.isLocal && batch.isUndoable && batch !== this._batch) this._reset(true);
};
this._selectionChangeCallback = () => {
this._reset();
};
this.model.document.on("change", this._changeCallback);
this.model.document.selection.on("change:range", this._selectionChangeCallback);
this.model.document.selection.on("change:attribute", this._selectionChangeCallback);
}
/**
* The current batch to which a feature should add its operations. Once the {@link #size}
* is reached or exceeds the {@link #limit}, the batch is set to a new instance and the size is reset.
*/
get batch() {
if (!this._batch) this._batch = this.model.createBatch({ isTyping: true });
return this._batch;
}
/**
* The number of atomic changes in the buffer. Once it exceeds the {@link #limit},
* the {@link #batch batch} is set to a new one.
*/
get size() {
return this._size;
}
/**
* The input number of changes into the buffer. Once the {@link #size} is
* reached or exceeds the {@link #limit}, the batch is set to a new instance and the size is reset.
*
* @param changeCount The number of atomic changes to input.
*/
input(changeCount) {
this._size += changeCount;
if (this._size >= this.limit) this._reset(true);
}
/**
* Whether the buffer is locked. A locked buffer cannot be reset unless it gets unlocked.
*/
get isLocked() {
return this._isLocked;
}
/**
* Locks the buffer.
*/
lock() {
this._isLocked = true;
}
/**
* Unlocks the buffer.
*/
unlock() {
this._isLocked = false;
}
/**
* Destroys the buffer.
*/
destroy() {
this.model.document.off("change", this._changeCallback);
this.model.document.selection.off("change:range", this._selectionChangeCallback);
this.model.document.selection.off("change:attribute", this._selectionChangeCallback);
}
/**
* Resets the change buffer.
*
* @param ignoreLock Whether internal lock {@link #isLocked} should be ignored.
*/
_reset(ignoreLock = false) {
if (!this.isLocked || ignoreLock) {
this._batch = null;
this._size = 0;
}
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/inserttextcommand
*/
/**
* The insert text command. Used by the {@link module:typing/input~Input input feature} to handle typing.
*/
var InsertTextCommand = class extends Command {
/**
* Typing's change buffer used to group subsequent changes into batches.
*/
_buffer;
/**
* Creates an instance of the command.
*
* @param undoStepSize The maximum number of atomic changes
* which can be contained in one batch in the command buffer.
*/
constructor(editor, undoStepSize) {
super(editor);
this._buffer = new TypingChangeBuffer(editor.model, undoStepSize);
this._isEnabledBasedOnSelection = false;
}
/**
* The current change buffer.
*/
get buffer() {
return this._buffer;
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this._buffer.destroy();
}
/**
* Executes the input command. It replaces the content within the given range with the given text.
* Replacing is a two step process, first the content within the range is removed and then the new text is inserted
* at the beginning of the range (which after the removal is a collapsed range).
*
* @fires execute
* @param options The command options.
*/
execute(options = {}) {
const model = this.editor.model;
const doc = model.document;
const text = options.text || "";
const textInsertions = text.length;
let selection = doc.selection;
if (options.selection) selection = options.selection;
else if (options.range) selection = model.createSelection(options.range);
if (!model.canEditAt(selection)) return;
const resultRange = options.resultRange;
model.enqueueChange(this._buffer.batch, (writer) => {
this._buffer.lock();
const selectionAttributes = Array.from(doc.selection.getAttributes());
model.deleteContent(selection);
if (text) model.insertContent(writer.createText(text, selectionAttributes), selection);
if (resultRange) writer.setSelection(resultRange);
else if (!selection.is("documentSelection")) writer.setSelection(selection);
this._buffer.unlock();
this._buffer.input(textInsertions);
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/inserttextobserver
*/
const TYPING_INPUT_TYPES = ["insertText", "insertReplacementText"];
const TYPING_INPUT_TYPES_ANDROID = [...TYPING_INPUT_TYPES, "insertCompositionText"];
/**
* Text insertion observer introduces the {@link module:engine/view/document~ViewDocument#event:insertText} event.
*/
var InsertTextObserver = class extends Observer {
/**
* Instance of the focus observer. Insert text observer calls
* {@link module:engine/view/observer/focusobserver~FocusObserver#flush} to mark the latest focus change as complete.
*/
focusObserver;
/**
* @inheritDoc
*/
constructor(view) {
super(view);
this.focusObserver = view.getObserver(FocusObserver);
const typingInputTypes = env.isAndroid ? TYPING_INPUT_TYPES_ANDROID : TYPING_INPUT_TYPES;
const viewDocument = view.document;
viewDocument.on("beforeinput", (evt, data) => {
if (!this.isEnabled) return;
const { data: text, targetRanges, inputType, domEvent, isComposing } = data;
if (!typingInputTypes.includes(inputType)) return;
this.focusObserver.flush();
const eventInfo = new EventInfo(viewDocument, "insertText");
viewDocument.fire(eventInfo, new ViewDocumentDomEventData(view, domEvent, {
text,
selection: view.createSelection(targetRanges),
isComposing
}));
if (eventInfo.stop.called) evt.stop();
});
if (!env.isAndroid) viewDocument.on("compositionend", (evt, { data, domEvent }) => {
if (!this.isEnabled) return;
if (!data) return;
viewDocument.fire("insertText", new ViewDocumentDomEventData(view, domEvent, {
text: data,
isComposing: true
}));
}, { priority: "low" });
}
/**
* @inheritDoc
*/
observe() {}
/**
* @inheritDoc
*/
stopObserving() {}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/input
*/
/**
* Handles text input coming from the keyboard or other input methods.
*/
var Input = class extends Plugin {
/**
* The queue of `insertText` command executions that are waiting for the DOM to get updated after beforeinput event.
*/
_typingQueue;
/**
* @inheritDoc
*/
static get pluginName() {
return "Input";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
const view = editor.editing.view;
const mapper = editor.editing.mapper;
const modelSelection = model.document.selection;
this._typingQueue = new TypingQueue(editor);
view.addObserver(InsertTextObserver);
const insertTextCommand = new InsertTextCommand(editor, editor.config.get("typing.undoStep") || 20);
editor.commands.add("insertText", insertTextCommand);
editor.commands.add("input", insertTextCommand);
this.listenTo(view.document, "beforeinput", () => {
this._typingQueue.flush("next beforeinput");
}, { priority: "high" });
this.listenTo(view.document, "insertText", (evt, data) => {
const { text, selection: viewSelection } = data;
if (view.document.selection.isFake && viewSelection && view.document.selection.isSimilar(viewSelection)) data.preventDefault();
if (viewSelection && Array.from(viewSelection.getRanges()).some((range) => !range.isCollapsed)) data.preventDefault();
if (!insertTextCommand.isEnabled) {
data.preventDefault();
return;
}
let modelRanges;
if (viewSelection) modelRanges = Array.from(viewSelection.getRanges()).filter((viewRange) => {
return viewRange.root.is("rootElement");
}).map((viewRange) => mapper.toModelRange(viewRange)).map((modelRange) => _tryFixingModelRange(modelRange, model.schema) || modelRange);
if (!modelRanges || !modelRanges.length) modelRanges = Array.from(modelSelection.getRanges());
let insertText = text;
if (env.isAndroid) {
const selectedText = Array.from(modelRanges[0].getItems()).reduce((rangeText, node) => {
return rangeText + (node.is("$textProxy") ? node.data : "");
}, "");
if (selectedText) {
if (selectedText.length <= insertText.length) {
if (insertText.startsWith(selectedText)) {
insertText = insertText.substring(selectedText.length);
modelRanges[0].start = modelRanges[0].start.getShiftedBy(selectedText.length);
}
} else if (selectedText.startsWith(insertText)) {
modelRanges[0].start = modelRanges[0].start.getShiftedBy(insertText.length);
insertText = "";
}
}
if (insertText.length == 0 && modelRanges[0].isCollapsed) return;
}
const commandData = {
text: insertText,
selection: model.createSelection(modelRanges)
};
this._typingQueue.push(commandData, Boolean(data.isComposing));
if (data.domEvent.defaultPrevented) this._typingQueue.flush("beforeinput default prevented");
});
if (env.isAndroid) this.listenTo(view.document, "keydown", (evt, data) => {
if (modelSelection.isCollapsed || data.keyCode != 229 || !view.document.isComposing) return;
deleteSelectionContent(model, insertTextCommand);
});
else this.listenTo(view.document, "compositionstart", () => {
if (modelSelection.isCollapsed) return;
deleteSelectionContent(model, insertTextCommand);
}, { priority: "high" });
this.listenTo(view.document, "mutations", (evt, { mutations }) => {
if (this._typingQueue.hasAffectedElements()) for (const { node } of mutations) {
const viewElement = findMappedViewAncestor(node, mapper);
const modelElement = mapper.toModelElement(viewElement);
if (this._typingQueue.isElementAffected(modelElement)) {
this._typingQueue.flush("mutations");
return;
}
}
});
this.listenTo(view.document, "compositionend", () => {
this._typingQueue.flush("before composition end");
}, { priority: "high" });
this.listenTo(view.document, "compositionend", () => {
this._typingQueue.flush("after composition end");
const mutations = [];
if (this._typingQueue.hasAffectedElements()) for (const element of this._typingQueue.flushAffectedElements()) {
const viewElement = mapper.toViewElement(element);
if (!viewElement) continue;
mutations.push({
type: "children",
node: viewElement
});
}
if (mutations.length || !env.isAndroid) view.document.fire("mutations", { mutations });
}, { priority: "lowest" });
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this._typingQueue.destroy();
}
};
/**
* The queue of `insertText` command executions that are waiting for the DOM to get updated after beforeinput event.
*/
var TypingQueue = class {
/**
* The editor instance.
*/
editor;
/**
* Debounced queue flush as a safety mechanism for cases of mutation observer not triggering.
*/
flushDebounced = debounce(() => this.flush("timeout"), 50);
/**
* The queue of `insertText` command executions that are waiting for the DOM to get updated after beforeinput event.
*/
_queue = [];
/**
* Whether there is any composition enqueued or plain typing only.
*/
_isComposing = false;
/**
* A set of model elements. The typing happened in those elements. It's used for mutations check.
*/
_affectedElements = /* @__PURE__ */ new Set();
/**
* @inheritDoc
*/
constructor(editor) {
this.editor = editor;
}
/**
* Destroys the helper object.
*/
destroy() {
this.flushDebounced.cancel();
this._affectedElements.clear();
while (this._queue.length) this.shift();
}
/**
* Returns the size of the queue.
*/
get length() {
return this._queue.length;
}
/**
* Push next insertText command data to the queue.
*/
push(commandData, isComposing) {
const commandLiveData = { text: commandData.text };
if (commandData.selection) {
commandLiveData.selectionRanges = [];
for (const range of commandData.selection.getRanges()) {
commandLiveData.selectionRanges.push(ModelLiveRange.fromRange(range));
this._affectedElements.add(range.start.parent);
}
}
this._queue.push(commandLiveData);
this._isComposing ||= isComposing;
this.flushDebounced();
}
/**
* Shift the first item from the insertText command data queue.
*/
shift() {
const commandLiveData = this._queue.shift();
const commandData = { text: commandLiveData.text };
if (commandLiveData.selectionRanges) {
const ranges = commandLiveData.selectionRanges.map((liveRange) => detachLiveRange(liveRange)).filter((range) => !!range);
if (ranges.length) commandData.selection = this.editor.model.createSelection(ranges);
}
return commandData;
}
/**
* Applies all queued insertText command executions.
*
* @param reason Used only for debugging.
*/
flush(reason) {
const editor = this.editor;
const model = editor.model;
const view = editor.editing.view;
this.flushDebounced.cancel();
if (!this._queue.length) return;
const buffer = editor.commands.get("insertText").buffer;
model.enqueueChange(buffer.batch, () => {
buffer.lock();
while (this._queue.length) {
const commandData = this.shift();
editor.execute("insertText", commandData);
}
buffer.unlock();
if (!this._isComposing) this._affectedElements.clear();
this._isComposing = false;
});
view.scrollToTheSelection();
}
/**
* Returns `true` if the given model element is related to recent typing.
*/
isElementAffected(element) {
return this._affectedElements.has(element);
}
/**
* Returns `true` if there are any affected elements in the queue.
*/
hasAffectedElements() {
return this._affectedElements.size > 0;
}
/**
* Returns an array of typing-related elements and clears the internal list.
*/
flushAffectedElements() {
const result = Array.from(this._affectedElements);
this._affectedElements.clear();
return result;
}
};
/**
* Deletes the content selected by the document selection at the start of composition.
*/
function deleteSelectionContent(model, insertTextCommand) {
if (!insertTextCommand.isEnabled) return;
const buffer = insertTextCommand.buffer;
buffer.lock();
model.enqueueChange(buffer.batch, () => {
model.deleteContent(model.document.selection);
});
buffer.unlock();
}
/**
* Detaches a ModelLiveRange and returns the static range from it.
*/
function detachLiveRange(liveRange) {
const range = liveRange.toRange();
liveRange.detach();
if (range.root.rootName == "$graveyard") return null;
return range;
}
/**
* For the given `viewNode`, finds and returns the closest ancestor of this node that has a mapping to the model.
*/
function findMappedViewAncestor(viewNode, mapper) {
let node = viewNode.is("$text") ? viewNode.parent : viewNode;
while (!mapper.toModelElement(node)) node = node.parent;
return node;
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/deletecommand
*/
/**
* The delete command. Used by the {@link module:typing/delete~Delete delete feature} to handle the <kbd>Delete</kbd> and
* <kbd>Backspace</kbd> keys.
*/
var DeleteCommand = class extends Command {
/**
* The directionality of the delete describing in what direction it should
* consume the content when the selection is collapsed.
*/
direction;
/**
* Delete's change buffer used to group subsequent changes into batches.
*/
_buffer;
/**
* Creates an instance of the command.
*
* @param direction The directionality of the delete describing in what direction it
* should consume the content when the selection is collapsed.
*/
constructor(editor, direction) {
super(editor);
this.direction = direction;
this._buffer = new TypingChangeBuffer(editor.model, editor.config.get("typing.undoStep"));
this._isEnabledBasedOnSelection = false;
}
/**
* The current change buffer.
*/
get buffer() {
return this._buffer;
}
/**
* Executes the delete command. Depending on whether the selection is collapsed or not, deletes its content
* or a piece of content in the {@link #direction defined direction}.
*
* @fires execute
* @param options The command options.
* @param options.unit See {@link module:engine/model/utils/modifyselection~modifySelection}'s options.
* @param options.sequence A number describing which subsequent delete event it is without the key being released.
* See the {@link module:engine/view/document~ViewDocument#event:delete} event data.
* @param options.selection Selection to remove. If not set, current model selection will be used.
*/
execute(options = {}) {
const model = this.editor.model;
const doc = model.document;
model.enqueueChange(this._buffer.batch, (writer) => {
this._buffer.lock();
const selection = writer.createSelection(options.selection || doc.selection);
if (!model.canEditAt(selection)) return;
const sequence = options.sequence || 1;
const doNotResetEntireContent = selection.isCollapsed;
if (selection.isCollapsed) model.modifySelection(selection, {
direction: this.direction,
unit: options.unit,
treatEmojiAsSingleUnit: true
});
if (this._shouldEntireContentBeReplacedWithParagraph(sequence)) {
this._replaceEntireContentWithParagraph(writer);
return;
}
if (this._shouldReplaceFirstBlockWithParagraph(selection, sequence)) {
this.editor.execute("paragraph", { selection });
return;
}
if (selection.isCollapsed) return;
let changeCount = 0;
selection.getFirstRange().getMinimalFlatRanges().forEach((range) => {
changeCount += count(range.getWalker({
singleCharacters: true,
ignoreElementEnd: true,
shallow: true
}));
});
model.deleteContent(selection, {
doNotResetEntireContent,
direction: this.direction
});
this._buffer.input(changeCount);
writer.setSelection(selection);
this._buffer.unlock();
});
}
/**
* If the user keeps <kbd>Backspace</kbd> or <kbd>Delete</kbd> key pressed, the content of the current
* editable will be cleared. However, this will not yet lead to resetting the remaining block to a paragraph
* (which happens e.g. when the user does <kbd>Ctrl</kbd> + <kbd>A</kbd>, <kbd>Backspace</kbd>).
*
* But, if the user pressed the key in an empty editable for the first time,
* we want to replace the entire content with a paragraph if:
*
* * the current limit element is empty,
* * the paragraph is allowed in the limit element,
* * the limit doesn't already have a paragraph inside.
*
* See https://github.com/ckeditor/ckeditor5-typing/issues/61.
*
* @param sequence A number describing which subsequent delete event it is without the key being released.
*/
_shouldEntireContentBeReplacedWithParagraph(sequence) {
if (sequence > 1) return false;
const model = this.editor.model;
const selection = model.document.selection;
const limitElement = model.schema.getLimitElement(selection);
if (!(selection.isCollapsed && selection.containsEntireContent(limitElement))) return false;
if (!model.schema.checkChild(limitElement, "paragraph")) return false;
const limitElementFirstChild = limitElement.getChild(0);
if (limitElementFirstChild && limitElementFirstChild.is("element", "paragraph")) return false;
return true;
}
/**
* The entire content is replaced with the paragraph. Selection is moved inside the paragraph.
*
* @param writer The model writer.
*/
_replaceEntireContentWithParagraph(writer) {
const model = this.editor.model;
const selection = model.document.selection;
const limitElement = model.schema.getLimitElement(selection);
const paragraph = writer.createElement("paragraph");
writer.remove(writer.createRangeIn(limitElement));
writer.insert(paragraph, limitElement);
writer.setSelection(paragraph, 0);
}
/**
* Checks if the selection is inside an empty element that is the first child of the limit element
* and should be replaced with a paragraph.
*
* @param selection The selection.
* @param sequence A number describing which subsequent delete event it is without the key being released.
*/
_shouldReplaceFirstBlockWithParagraph(selection, sequence) {
const model = this.editor.model;
if (sequence > 1 || this.direction != "backward") return false;
if (!selection.isCollapsed) return false;
const position = selection.getFirstPosition();
const limitElement = model.schema.getLimitElement(position);
const limitElementFirstChild = limitElement.getChild(0);
if (position.parent != limitElementFirstChild) return false;
if (!selection.containsEntireContent(limitElementFirstChild)) return false;
if (!model.schema.checkChild(limitElement, "paragraph")) return false;
if (limitElementFirstChild.name == "paragraph") return false;
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/deleteobserver
*/
const DELETE_CHARACTER = "character";
const DELETE_WORD = "word";
const DELETE_CODE_POINT = "codePoint";
const DELETE_SELECTION = "selection";
const DELETE_BACKWARD = "backward";
const DELETE_FORWARD = "forward";
const DELETE_EVENT_TYPES = {
deleteContent: {
unit: DELETE_SELECTION,
direction: DELETE_BACKWARD
},
deleteContentBackward: {
unit: DELETE_CODE_POINT,
direction: DELETE_BACKWARD
},
deleteWordBackward: {
unit: DELETE_WORD,
direction: DELETE_BACKWARD
},
deleteHardLineBackward: {
unit: DELETE_SELECTION,
direction: DELETE_BACKWARD
},
deleteSoftLineBackward: {
unit: DELETE_SELECTION,
direction: DELETE_BACKWARD
},
deleteContentForward: {
unit: DELETE_CHARACTER,
direction: DELETE_FORWARD
},
deleteWordForward: {
unit: DELETE_WORD,
direction: DELETE_FORWARD
},
deleteHardLineForward: {
unit: DELETE_SELECTION,
direction: DELETE_FORWARD
},
deleteSoftLineForward: {
unit: DELETE_SELECTION,
direction: DELETE_FORWARD
}
};
/**
* Delete observer introduces the {@link module:engine/view/document~ViewDocument#event:delete} event.
*
* @internal
*/
var DeleteObserver = class extends Observer {
/**
* @inheritDoc
*/
constructor(view) {
super(view);
const document = view.document;
let sequence = 0;
document.on("keydown", () => {
sequence++;
});
document.on("keyup", () => {
sequence = 0;
});
document.on("beforeinput", (evt, data) => {
if (!this.isEnabled) return;
const { targetRanges, domEvent, inputType } = data;
const deleteEventSpec = DELETE_EVENT_TYPES[inputType];
if (!deleteEventSpec) return;
const deleteData = {
direction: deleteEventSpec.direction,
unit: deleteEventSpec.unit,
sequence
};
if (deleteData.unit == DELETE_SELECTION) deleteData.selectionToRemove = view.createSelection(targetRanges[0]);
if (inputType === "deleteContentBackward") {
if (env.isAndroid) deleteData.sequence = 1;
if (shouldUseTargetRanges(targetRanges)) {
deleteData.unit = DELETE_SELECTION;
deleteData.selectionToRemove = view.createSelection(targetRanges);
}
}
const eventInfo = new BubblingEventInfo(document, "delete", targetRanges[0]);
document.fire(eventInfo, new ViewDocumentDomEventData(view, domEvent, deleteData));
if (eventInfo.stop.called) evt.stop();
});
if (env.isBlink) enableChromeWorkaround(this);
}
/**
* @inheritDoc
*/
observe() {}
/**
* @inheritDoc
*/
stopObserving() {}
};
/**
* Enables workaround for the issue https://github.com/ckeditor/ckeditor5/issues/11904.
*/
function enableChromeWorkaround(observer) {
const view = observer.view;
const document = view.document;
let pressedKeyCode = null;
let beforeInputReceived = false;
document.on("keydown", (evt, { keyCode }) => {
pressedKeyCode = keyCode;
beforeInputReceived = false;
});
document.on("keyup", (evt, { keyCode, domEvent }) => {
const selection = document.selection;
const shouldFireDeleteEvent = observer.isEnabled && keyCode == pressedKeyCode && isDeleteKeyCode(keyCode) && !selection.isCollapsed && !beforeInputReceived;
pressedKeyCode = null;
if (shouldFireDeleteEvent) {
const eventInfo = new BubblingEventInfo(document, "delete", selection.getFirstRange());
const deleteData = {
unit: DELETE_SELECTION,
direction: getDeleteDirection(keyCode),
selectionToRemove: selection
};
document.fire(eventInfo, new ViewDocumentDomEventData(view, domEvent, deleteData));
}
});
document.on("beforeinput", (evt, { inputType }) => {
const deleteEventSpec = DELETE_EVENT_TYPES[inputType];
if (isDeleteKeyCode(pressedKeyCode) && deleteEventSpec && deleteEventSpec.direction == getDeleteDirection(pressedKeyCode)) beforeInputReceived = true;
}, { priority: "high" });
document.on("beforeinput", (evt, { inputType, data }) => {
if (pressedKeyCode == keyCodes.delete && inputType == "insertText" && data == "") evt.stop();
}, { priority: "high" });
function isDeleteKeyCode(keyCode) {
return keyCode == keyCodes.backspace || keyCode == keyCodes.delete;
}
function getDeleteDirection(keyCode) {
return keyCode == keyCodes.backspace ? DELETE_BACKWARD : DELETE_FORWARD;
}
}
/**
* Verifies whether the given target ranges cover more than a single character and should be used instead of a single code-point deletion.
*/
function shouldUseTargetRanges(targetRanges) {
if (targetRanges.length != 1 || targetRanges[0].isCollapsed) return false;
const walker = targetRanges[0].getWalker({
direction: "backward",
singleCharacters: true,
ignoreElementEnd: true
});
let count = 0;
for (const { nextPosition, item } of walker) {
if (nextPosition.parent.is("$text")) {
const data = nextPosition.parent.data;
const offset = nextPosition.offset;
if (isInsideSurrogatePair(data, offset) || isInsideCombinedSymbol(data, offset) || isInsideEmojiSequence(data, offset)) continue;
count++;
} else if (item.is("containerElement") || item.is("emptyElement")) count++;
if (count > 1) return true;
}
return false;
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/delete
*/
/**
* The delete and backspace feature. Handles keys such as <kbd>Delete</kbd> and <kbd>Backspace</kbd>, other
* keystrokes and user actions that result in deleting content in the editor.
*/
var Delete = class extends Plugin {
/**
* Whether pressing backspace should trigger undo action
*/
_undoOnBackspace;
/**
* @inheritDoc
*/
static get pluginName() {
return "Delete";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
const modelDocument = editor.model.document;
view.addObserver(DeleteObserver);
this._undoOnBackspace = false;
const deleteForwardCommand = new DeleteCommand(editor, "forward");
editor.commands.add("deleteForward", deleteForwardCommand);
editor.commands.add("forwardDelete", deleteForwardCommand);
editor.commands.add("delete", new DeleteCommand(editor, "backward"));
this.listenTo(viewDocument, "delete", (evt, data) => {
if (!viewDocument.isComposing) data.preventDefault();
const { direction, sequence, selectionToRemove, unit } = data;
const commandName = direction === "forward" ? "deleteForward" : "delete";
const commandData = { sequence };
if (unit == "selection") {
const modelRanges = Array.from(selectionToRemove.getRanges()).map((viewRange) => editor.editing.mapper.toModelRange(viewRange)).map((modelRange) => _tryFixingModelRange(modelRange, editor.model.schema) || modelRange);
commandData.selection = editor.model.createSelection(modelRanges);
} else commandData.unit = unit;
editor.execute(commandName, commandData);
view.scrollToTheSelection();
}, { priority: "low" });
this.listenTo(viewDocument, "keydown", (evt, data) => {
if (viewDocument.isComposing || data.keyCode != keyCodes.backspace || !modelDocument.selection.isCollapsed) return;
const ancestorLimit = editor.model.schema.getLimitElement(modelDocument.selection);
const limitStartPosition = editor.model.createPositionAt(ancestorLimit, 0);
if (limitStartPosition.isTouching(modelDocument.selection.getFirstPosition())) {
data.preventDefault();
const modelRange = editor.model.schema.getNearestSelectionRange(limitStartPosition, "forward");
if (!modelRange) return;
const viewSelection = view.createSelection(editor.editing.mapper.toViewRange(modelRange));
const targetRange = viewSelection.getFirstRange();
const eventInfo = new BubblingEventInfo(document, "delete", targetRange);
const deleteData = {
unit: "selection",
direction: "backward",
selectionToRemove: viewSelection
};
viewDocument.fire(eventInfo, new ViewDocumentDomEventData(view, data.domEvent, deleteData));
}
});
if (this.editor.plugins.has("UndoEditing")) {
this.listenTo(viewDocument, "delete", (evt, data) => {
if (this._undoOnBackspace && data.direction == "backward" && data.sequence == 1 && data.unit == "codePoint") {
this._undoOnBackspace = false;
editor.execute("undo");
data.preventDefault();
evt.stop();
}
}, { context: "$capture" });
this.listenTo(modelDocument, "change", () => {
this._undoOnBackspace = false;
});
}
}
/**
* If the next user action after calling this method is pressing backspace, it would undo the last change.
*
* Requires {@link module:undo/undoediting~UndoEditing} plugin. If not loaded, does nothing.
*/
requestUndoOnBackspace() {
if (this.editor.plugins.has("UndoEditing")) this._undoOnBackspace = true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/typing
*/
/**
* The typing feature. It handles typing.
*
* This is a "glue" plugin which loads the {@link module:typing/input~Input} and {@link module:typing/delete~Delete}
* plugins.
*/
var Typing = class extends Plugin {
static get requires() {
return [Input, Delete];
}
/**
* @inheritDoc
*/
static get pluginName() {
return "Typing";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Returns the last text line from the given range.
*
* "The last text line" is understood as text (from one or more text nodes) which is limited either by a parent block
* or by inline elements (e.g. `<softBreak>`).
*
* ```ts
* const rangeToCheck = model.createRange(
* model.createPositionAt( paragraph, 0 ),
* model.createPositionAt( paragraph, 'end' )
* );
*
* const { text, range } = getLastTextLine( rangeToCheck, model );
* ```
*
* For model below, the returned `text` will be "Foo bar baz" and `range` will be set on whole `<paragraph>` content:
*
* ```xml
* <paragraph>Foo bar baz<paragraph>
* ```
*
* However, in below case, `text` will be set to "baz" and `range` will be set only on "baz".
*
* ```xml
* <paragraph>Foo<softBreak></softBreak>bar<softBreak></softBreak>baz<paragraph>
* ```
*/
function getLastTextLine(range, model) {
let start = range.start;
return {
text: Array.from(range.getWalker({ ignoreElementEnd: false })).reduce((rangeText, { item }) => {
if (!(item.is("$text") || item.is("$textProxy"))) {
start = model.createPositionAfter(item);
return "";
}
return rangeText + item.data;
}, ""),
range: model.createRange(start, range.end)
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module typing/textwatcher
*/
const TextWatcherBase = /* #__PURE__ */ ObservableMixin();
/**
* The text watcher feature.
*
* Fires the {@link module:typing/textwatcher~TextWatcher#event:matched:data `matched:data`},
* {@link module:typing/textwatcher~TextWatcher#event:matched:selection `matched:selection`} and
* {@link module:typing/textwatcher~TextWatcher#event:unmatched `unmatched`} events on typing or selection changes.
*/
var TextWatcher = class extends TextWatcherBase {
/**
* The editor's model.
*/
model;
/**
* The function used to match the text.
*
* The test callback can return 3 values:
*
* * `false` if there is no match,
* * `true` if there is a match,
* * an object if there is a match and we want to pass some additional information to the {@link #event:matched:data} event.
*/
testCallback;
/**
* Whether there is a match currently.
*/
_hasMatch;
/**
* Creates a text watcher instance.
*
* @param testCallback See {@link module:typing/textwatcher~TextWatcher#testCallback}.
*/
constructor(model, testCallback) {
super();
this.model = model;
this.testCallback = testCallback;
this._hasMatch = false;
this.set("isEnabled", true);
this.on("change:isEnabled", () => {
if (this.isEnabled) this._startListening();
else {
this.stopListening(model.document.selection);
this.stopListening(model.document);
}
});
this._startListening();
}
/**
* Flag indicating whether there is a match currently.
*/
get hasMatch() {
return this._hasMatch;
}
/**
* Starts listening to the editor for typing and selection events.
*/
_startListening() {
const document = this.model.document;
this.listenTo(document.selection, "change:range", (evt, { directChange }) => {
if (!directChange) return;
if (!document.selection.isCollapsed) {
if (this.hasMatch) {
this.fire("unmatched");
this._hasMatch = false;
}
return;
}
this._evaluateTextBeforeSelection("selection");
});
this.listenTo(document, "change:data", (evt, batch) => {
if (batch.isUndo || !batch.isLocal) return;
this._evaluateTextBeforeSelection("data", { batch });
});
}
/**
* Checks the editor content for matched text.
*
* @fires matched:data
* @fires matched:selection
* @fires unmatched
*
* @param suffix A suffix used for generating the event name.
* @param data Data object for event.
*/
_evaluateTextBeforeSelection(suffix, data = {}) {
const model = this.model;
const selection = model.document.selection;
const { text, range } = getLastTextLine(model.createRange(model.createPositionAt(selection.focus.parent, 0), selection.focus), model);
const testResult = this.testCallback(text);
if (!testResult && this.hasMatch) this.fire("unmatched");
this._hasMatch = !!testResult;
if (testResult) {
const eventData = Object.assign(data, {
text,
range
});
if (typeof testResult == "object") Object.assign(eventData, testResult);
this.fire(`matched:${suffix}`, eventData);
}
}
};
/**
* @license Copyright (c) 2003-2026, 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
*/
/**
* 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>
* ```
*
*/
var TwoStepCaretMovement = class extends Plugin {
/**
* A set of attributes to handle.
*/
attributes;
/**
* The current UID of the overridden gravity, as returned by
* {@link module:engine/model/writer~ModelWriter#overrideSelectionGravity}.
*/
_overrideUid;
/**
* A flag indicating that the automatic gravity restoration should not happen upon the next
* gravity restoration.
* {@link module:engine/model/selection~ModelSelection#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 = /* @__PURE__ */ 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;
this.listenTo(view.document, "arrowKey", (evt, data) => {
if (!modelSelection.isCollapsed) return;
if (data.shiftKey || data.altKey || data.ctrlKey) return;
const arrowRightPressed = data.keyCode == keyCodes.arrowright;
const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
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);
if (isMovementHandled === true) evt.stop();
}, {
context: "$text",
priority: "highest"
});
this.listenTo(modelSelection, "change:range", (evt, data) => {
if (this._isNextGravityRestorationSkipped) {
this._isNextGravityRestorationSkipped = false;
return;
}
if (!this._isGravityOverridden) return;
if (!data.directChange && isBetweenDifferentAttributes(modelSelection.getFirstPosition(), this.attributes)) return;
this._restoreGravity();
});
this._enableClickingAfterNode();
this._enableInsertContentSelectionAttributesFixer();
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~EditingView}.
*
* @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();
if (this._isGravityOverridden) return false;
if (position.isAtStart && hasAnyAttribute(selection, attributes)) return false;
if (isBetweenDifferentAttributes(position, attributes)) {
if (eventData) preventCaretMovement(eventData);
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~EditingView}.
*
* @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();
if (this._isGravityOverridden) {
if (eventData) preventCaretMovement(eventData);
this._restoreGravity();
if (isBetweenDifferentAttributes(position, attributes, true)) clearSelectionAttributes(model, attributes);
else setSelectionAttributesFromTheNodeBefore(model, attributes, position);
return true;
} else {
if (position.isAtStart) {
if (hasAnyAttribute(selection, attributes)) {
if (eventData) preventCaretMovement(eventData);
setSelectionAttributesFromTheNodeBefore(model, attributes, position);
return true;
}
return false;
}
if (!hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes, true)) {
if (eventData) preventCaretMovement(eventData);
setSelectionAttributesFromTheNodeBefore(model, attributes, position);
return true;
}
if (isStepAfterAnyAttributeBoundary(position, attributes)) {
if (position.isAtEnd && !hasAnyAttribute(selection, attributes) && isBetweenDifferentAttributes(position, attributes)) {
if (eventData) preventCaretMovement(eventData);
setSelectionAttributesFromTheNodeBefore(model, attributes, position);
return true;
}
this._isNextGravityRestorationSkipped = true;
this._overrideGravity();
return false;
}
}
return false;
}
/**
* Starts listening to {@link module:engine/view/document~ViewDocument#event:mousedown} and
* {@link module:engine/view/document~ViewDocument#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.listenTo(document, "touchstart", () => {
clicked = false;
touched = true;
});
this.listenTo(document, "mousedown", () => {
clicked = true;
});
this.listenTo(document, "selectionChange", () => {
const attributes = this.attributes;
if (!clicked && !touched) return;
clicked = false;
touched = false;
if (!selection.isCollapsed) return;
if (!hasAnyAttribute(selection, attributes)) return;
const position = selection.getFirstPosition();
if (!isBetweenDifferentAttributes(position, attributes)) return;
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 model = this.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;
this.listenTo(view.document, "delete", (evt, data) => {
isBackspace = data.direction === "backward";
}, { priority: "high" });
this.listenTo(model, "deleteContent", () => {
if (!isBackspace) return;
const position = selection.getFirstPosition();
shouldPreserveAttributes = hasAnyAttribute(selection, this.attributes) && !isStepAfterAnyAttributeBoundary(position, this.attributes);
}, { priority: "high" });
this.listenTo(model, "deleteContent", () => {
if (!isBackspace) return;
isBackspace = false;
if (shouldPreserveAttributes) return;
editor.model.enqueueChange(() => {
const position = selection.getFirstPosition();
if (hasAnyAttribute(selection, this.attributes) && isBetweenDifferentAttributes(position, this.attributes)) {
if (position.isAtStart || isBetweenDifferentAttributes(posit