@ckeditor/ckeditor5-typing
Version:
Typing feature for CKEditor 5.
1,044 lines (1,038 loc) โข 110 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/dist/index.js';
import { env, EventInfo, count, isInsideSurrogatePair, isInsideCombinedSymbol, isInsideEmojiSequence, keyCodes, ObservableMixin } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { Observer, FocusObserver, ViewDocumentDomEventData, _tryFixingModelRange, ModelLiveRange, BubblingEventInfo, MouseObserver, TouchObserver } from '@ckeditor/ckeditor5-engine/dist/index.js';
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
*/ /**
* @module typing/utils/changebuffer
*/ /**
* 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 );
* ```
*/ class TypingChangeBuffer {
/**
* 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;
// The function to be called in order to notify the buffer about batches which appeared in the document.
// The callback will check whether it is a new batch and in that case the buffer will be flushed.
//
// The reason why the buffer needs to be flushed whenever a new batch appears is that the changes added afterwards
// should be added to a new batch. For instance, when the user types, then inserts an image, and then types again,
// the characters typed after inserting the image should be added to a different batch than the characters typed before.
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;
}
}
}
/**
* The insert text command. Used by the {@link module:typing/input~Input input feature} to handle typing.
*/ class InsertTextCommand 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);
// Since this command may execute on different selectable than selection, it should be checked directly in execute block.
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);
}
// Stop executing if selectable is in non-editable place.
if (!model.canEditAt(selection)) {
return;
}
const resultRange = options.resultRange;
model.enqueueChange(this._buffer.batch, (writer)=>{
this._buffer.lock();
// Store selection attributes before deleting old content to preserve formatting and link.
// This unifies the behavior between ModelDocumentSelection and Selection provided as input option.
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);
});
}
}
// @if CK_DEBUG_TYPING // const { _buildLogMessage } = require( '@ckeditor/ckeditor5-engine/src/dev-utils/utils.js' );
const TYPING_INPUT_TYPES = [
// For collapsed range:
// - This one is a regular typing (all browsers, all systems).
// - This one is used by Chrome when typing accented letter โ 2nd step when the user selects the accent (Mac).
// For non-collapsed range:
// - This one is used by Chrome when typing accented letter โ when the selection box first appears (Mac).
// - This one is used by Safari when accepting spell check suggestions from the context menu (Mac).
'insertText',
// This one is used by Safari when typing accented letter (Mac).
// This one is used by Safari when accepting spell check suggestions from the autocorrection pop-up (Mac).
'insertReplacementText'
];
const TYPING_INPUT_TYPES_ANDROID = [
...TYPING_INPUT_TYPES,
'insertCompositionText'
];
/**
* Text insertion observer introduces the {@link module:engine/view/document~ViewDocument#event:insertText} event.
*/ class InsertTextObserver 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);
// On Android composition events should immediately be applied to the model. Rendering is not disabled.
// On non-Android the model is updated only on composition end.
// On Android we can't rely on composition start/end to update model.
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;
}
// Mark the latest focus change as complete (we are typing in editable after the focus
// so the selection is in the focused element).
this.focusObserver.flush();
const eventInfo = new EventInfo(viewDocument, 'insertText');
viewDocument.fire(eventInfo, new ViewDocumentDomEventData(view, domEvent, {
text,
selection: view.createSelection(targetRanges),
isComposing
}));
// Stop the beforeinput event if `delete` event was stopped.
// https://github.com/ckeditor/ckeditor5/issues/753
if (eventInfo.stop.called) {
evt.stop();
}
});
// On Android composition events are immediately applied to the model.
// On non-Android the model is updated only on composition end.
// On Android we can't rely on composition start/end to update model.
if (!env.isAndroid) {
// Note: The priority must be lower than the CompositionObserver handler to call it after the renderer is unblocked.
// This is important for view to DOM position mapping.
// This causes the effect of first remove composed DOM and then reapply it after model modification.
viewDocument.on('compositionend', (evt, { data, domEvent })=>{
if (!this.isEnabled) {
return;
}
// In case of aborted composition.
if (!data) {
return;
}
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'InsertTextObserver',
// @if CK_DEBUG_TYPING // `%cFire insertText event, %c${ JSON.stringify( data ) }`,
// @if CK_DEBUG_TYPING // 'font-weight: bold',
// @if CK_DEBUG_TYPING // 'color: blue'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
// How do we know where to insert the composed text?
// 1. The SelectionObserver is blocked and the view is not updated with the composition changes.
// 2. The last moment before it's locked is the `compositionstart` event.
// 3. The `SelectionObserver` is listening for `compositionstart` event and immediately converts
// the selection. Handle this at the low priority so after the rendering is blocked.
viewDocument.fire('insertText', new ViewDocumentDomEventData(view, domEvent, {
text: data,
isComposing: true
}));
}, {
priority: 'low'
});
}
}
/**
* @inheritDoc
*/ observe() {}
/**
* @inheritDoc
*/ stopObserving() {}
}
// @if CK_DEBUG_TYPING // const { _debouncedLine, _buildLogMessage } = require( '@ckeditor/ckeditor5-engine/src/dev-utils/utils.js' );
/**
* Handles text input coming from the keyboard or other input methods.
*/ class Input 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);
// TODO The above default configuration value should be defined using editor.config.define() once it's fixed.
const insertTextCommand = new InsertTextCommand(editor, editor.config.get('typing.undoStep') || 20);
// Register `insertText` command and add `input` command as an alias for backward compatibility.
editor.commands.add('insertText', insertTextCommand);
editor.commands.add('input', insertTextCommand);
this.listenTo(view.document, 'beforeinput', ()=>{
// Flush queue on the next beforeinput event because it could happen
// that the mutation observer does not notice the DOM change in time.
this._typingQueue.flush('next beforeinput');
}, {
priority: 'high'
});
this.listenTo(view.document, 'insertText', (evt, data)=>{
const { text, selection: viewSelection } = data;
// In case of a synthetic event, make sure that selection is not fake.
if (view.document.selection.isFake && viewSelection && view.document.selection.isSimilar(viewSelection)) {
data.preventDefault();
}
// In case of typing on a non-collapsed range, we have to handle it ourselves as a browser
// could modify the DOM unpredictably.
// Noticed cases:
// * <pre><code>[foo</code></pre><p>]bar</p>
// * <p>[foo</p><pre>]<code>bar</code></pre>
// * <p>[foo</p><blockquote><p>]bar</p></blockquote>
//
// Especially tricky case is when a code block follows a paragraph as code block on the view side
// is rendered as a <code> element inside a <pre> element, but only the <code> element is mapped to the model.
// While mapping view position <pre>]<code> to model, the model position results before the <codeBlock> element,
// and this triggers selection fixer to cover only text in the previous paragraph.
//
// This is safe for composition as those events are not cancellable
// and the preventDefault() and defaultPrevented are not affected.
if (viewSelection && Array.from(viewSelection.getRanges()).some((range)=>!range.isCollapsed)) {
data.preventDefault();
}
if (!insertTextCommand.isEnabled) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // '%cInsertText command is disabled - prevent DOM change.',
// @if CK_DEBUG_TYPING // 'font-style: italic'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
data.preventDefault();
return;
}
let modelRanges;
// If view selection was specified, translate it to model selection.
if (viewSelection) {
modelRanges = Array.from(viewSelection.getRanges()).filter((viewRange)=>{
// On Windows 11 with the US International keyboard, events are batched.
// In other words, the first backtick press does not send an `insertText` event,
// and only the second one sends it double (batched).
// This causes a race condition if during the first backtick insert the element is removed from the tree,
// and the second event flushes changes, and it's original targetRanges,
// which were initially good, now refer to a removed element.
// This is not reproducible on Mac/Linux as they enter composition mode.
// See more: https://github.com/ckeditor/ckeditor5/issues/18926.
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;
// Typing in English on Android is firing composition events for the whole typed word.
// We need to check the target range text to only apply the difference.
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)) {
// TODO this should be mapped as delete?
modelRanges[0].start = modelRanges[0].start.getShiftedBy(insertText.length);
insertText = '';
}
}
}
if (insertText.length == 0 && modelRanges[0].isCollapsed) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // '%cIgnore insertion of an empty data to the collapsed range.',
// @if CK_DEBUG_TYPING // 'font-style: italic'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
return;
}
}
// Note: the TypingQueue stores live-ranges internally as RTC could change the model while waiting for mutations.
const commandData = {
text: insertText,
selection: model.createSelection(modelRanges)
};
// This is a beforeinput event, so we need to wait until the browser updates the DOM,
// and we could apply changes to the model and verify if the DOM is valid.
// The browser applies changes to the DOM not immediately on beforeinput event.
// We just wait for mutation observer to notice changes or as a fallback a timeout.
//
// Previously we were cancelling the non-composition events, but it caused issues especially in Safari.
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // `%cQueue insertText:%c "${ commandData.text }"%c ` +
// @if CK_DEBUG_TYPING // `[${ commandData.selection.getFirstPosition().path }]-` +
// @if CK_DEBUG_TYPING // `[${ commandData.selection.getLastPosition().path }]` +
// @if CK_DEBUG_TYPING // ` queue size: ${ this._typingQueue.length + 1 }`,
// @if CK_DEBUG_TYPING // 'font-weight: bold',
// @if CK_DEBUG_TYPING // 'color: blue',
// @if CK_DEBUG_TYPING // ''
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
this._typingQueue.push(commandData, Boolean(data.isComposing));
if (data.domEvent.defaultPrevented) {
this._typingQueue.flush('beforeinput default prevented');
}
});
// Delete selected content on composition start.
if (env.isAndroid) {
// On Android with English keyboard, the composition starts just by putting caret
// at the word end or by selecting a table column. This is not a real composition started.
// Trigger delete content on first composition key pressed.
this.listenTo(view.document, 'keydown', (evt, data)=>{
if (modelSelection.isCollapsed || data.keyCode != 229 || !view.document.isComposing) {
return;
}
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // const firstPositionPath = modelSelection.getFirstPosition()!.path;
// @if CK_DEBUG_TYPING // const lastPositionPath = modelSelection.getLastPosition()!.path;
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // '%cKeyDown 229%c -> model.deleteContent() ' +
// @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`,
// @if CK_DEBUG_TYPING // 'font-weight: bold',
// @if CK_DEBUG_TYPING // ''
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
deleteSelectionContent(model, insertTextCommand);
});
} else {
// Note: The priority must precede the CompositionObserver handler to call it before
// the renderer is blocked, because we want to render this change.
this.listenTo(view.document, 'compositionstart', ()=>{
if (modelSelection.isCollapsed) {
return;
}
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // const firstPositionPath = modelSelection.getFirstPosition()!.path;
// @if CK_DEBUG_TYPING // const lastPositionPath = modelSelection.getLastPosition()!.path;
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // '%cComposition start%c -> model.deleteContent() ' +
// @if CK_DEBUG_TYPING // `[${ firstPositionPath }]-[${ lastPositionPath }]`,
// @if CK_DEBUG_TYPING // 'font-weight: bold',
// @if CK_DEBUG_TYPING // '',
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
deleteSelectionContent(model, insertTextCommand);
}, {
priority: 'high'
});
}
// Apply changes to the model as they are applied to the DOM by the browser.
// On beforeinput event, the DOM is not yet modified. We wait for detected mutations to apply model changes.
this.listenTo(view.document, 'mutations', (evt, { mutations })=>{
// Check if mutations are relevant for queued changes.
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;
}
}
}
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // '%cMutations not related to the composition.',
// @if CK_DEBUG_TYPING // 'font-style: italic'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
});
// Make sure that all changes are applied to the model before the end of composition.
this.listenTo(view.document, 'compositionend', ()=>{
this._typingQueue.flush('before composition end');
}, {
priority: 'high'
});
// Trigger mutations check after the composition completes to fix all DOM changes that got ignored during composition.
// On Android, the Renderer is not disabled while composing. While updating DOM nodes, we ignore some changes
// that are not that important (like NBSP vs. plain space character) and could break the composition flow.
// After composition is completed, we trigger additional `mutations` event for elements affected by the composition
// so the Renderer can adjust the DOM to the expected structure without breaking the composition.
this.listenTo(view.document, 'compositionend', ()=>{
// There could be new item queued on the composition end, so flush it.
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
});
}
}
// Fire composition mutations, if any.
//
// For non-Android:
// After the composition end, we need to verify if there are no left-overs.
// Listening at the lowest priority, so after the `InsertTextObserver` added above (all composed text
// should already be applied to the model, view, and DOM).
// On non-Android the `Renderer` is blocked while the user is composing, but the `MutationObserver` still collects
// mutated nodes and fires `mutations` events.
// Those events are recorded by the `Renderer` but not applied to the DOM while composing.
// We need to trigger those checks (and fixes) once again but this time without specifying the exact mutations
// since they are already recorded by the `Renderer`.
// It in most cases just clears the internal record of mutated text nodes
// since all changes should already be applied to the DOM.
// This is especially needed when a user cancels composition, so we can clear nodes marked to sync.
if (mutations.length || !env.isAndroid) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // '%cFire post-composition mutation fixes.',
// @if CK_DEBUG_TYPING // 'font-weight: bold'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
view.document.fire('mutations', {
mutations
});
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
// @if CK_DEBUG_TYPING // }
}
}, {
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.
*/ class TypingQueue {
/**
* 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 = 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));
// Keep reference to the model element for later mutation checks.
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;
}
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // `%cFlush insertText queue on ${ reason }.`,
// @if CK_DEBUG_TYPING // 'font-weight: bold'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
const insertTextCommand = editor.commands.get('insertText');
const buffer = insertTextCommand.buffer;
model.enqueueChange(buffer.batch, ()=>{
buffer.lock();
while(this._queue.length){
const commandData = this.shift();
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // `%cExecute queued insertText:%c "${ commandData.text }"%c ` +
// @if CK_DEBUG_TYPING // `[${ commandData.selection.getFirstPosition().path }]-` +
// @if CK_DEBUG_TYPING // `[${ commandData.selection.getLastPosition().path }]`,
// @if CK_DEBUG_TYPING // 'font-weight: bold',
// @if CK_DEBUG_TYPING // 'color: blue',
// @if CK_DEBUG_TYPING // ''
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
editor.execute('insertText', commandData);
}
buffer.unlock();
if (!this._isComposing) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'Input',
// @if CK_DEBUG_TYPING // 'Clear affected elements set'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
this._affectedElements.clear();
}
this._isComposing = false;
});
view.scrollToTheSelection();
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
// @if CK_DEBUG_TYPING // }
}
/**
* 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) {
// By relying on the state of the input command we allow disabling the entire input easily
// by just disabling the input command. We couldโve used here the delete command but that
// would mean requiring the delete feature which would block loading one without the other.
// We could also check the editor.isReadOnly property, but that wouldn't allow to block
// the input without blocking other features.
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;
}
// @if CK_DEBUG_TYPING // const { _buildLogMessage } = require( '@ckeditor/ckeditor5-engine/src/dev-utils/utils.js' );
/**
* The delete command. Used by the {@link module:typing/delete~Delete delete feature} to handle the <kbd>Delete</kbd> and
* <kbd>Backspace</kbd> keys.
*/ class DeleteCommand 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'));
// Since this command may execute on different selectable than selection, it should be checked directly in execute block.
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);
// Don't execute command when selection is in non-editable place.
if (!model.canEditAt(selection)) {
return;
}
const sequence = options.sequence || 1;
// Do not replace the whole selected content if selection was collapsed.
// This prevents such situation:
//
// <h1></h1><p>[]</p> --> <h1>[</h1><p>]</p> --> <p></p>
// starting content --> after `modifySelection` --> after `deleteContent`.
const doNotResetEntireContent = selection.isCollapsed;
// Try to extend the selection in the specified direction.
if (selection.isCollapsed) {
model.modifySelection(selection, {
direction: this.direction,
unit: options.unit,
treatEmojiAsSingleUnit: true
});
}
// Check if deleting in an empty editor. See #61.
if (this._shouldEntireContentBeReplacedWithParagraph(sequence)) {
this._replaceEntireContentWithParagraph(writer);
return;
}
// Check if deleting in the first empty block.
// See https://github.com/ckeditor/ckeditor5/issues/8137.
if (this._shouldReplaceFirstBlockWithParagraph(selection, sequence)) {
this.editor.execute('paragraph', {
selection
});
return;
}
// If selection is still collapsed, then there's nothing to delete.
if (selection.isCollapsed) {
return;
}
let changeCount = 0;
selection.getFirstRange().getMinimalFlatRanges().forEach((range)=>{
changeCount += count(range.getWalker({
singleCharacters: true,
ignoreElementEnd: true,
shallow: true
}));
});
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.log( ..._buildLogMessage( this, 'DeleteCommand',
// @if CK_DEBUG_TYPING // 'Delete content',
// @if CK_DEBUG_TYPING // `[${ selection.getFirstPosition()!.path }]-[${ selection.getLastPosition()!.path }]`,
// @if CK_DEBUG_TYPING // options
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
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) {
// Does nothing if user pressed and held the "Backspace" or "Delete" key.
if (sequence > 1) {
return false;
}
const model = this.editor.model;
const doc = model.document;
const selection = doc.selection;
const limitElement = model.schema.getLimitElement(selection);
// If a collapsed selection contains the whole content it means that the content is empty
// (from the user perspective).
const limitElementIsEmpty = selection.isCollapsed && selection.containsEntireContent(limitElement);
if (!limitElementIsEmpty) {
return false;
}
if (!model.schema.checkChild(limitElement, 'paragraph')) {
return false;
}
const limitElementFirstChild = limitElement.getChild(0);
// Does nothing if the limit element already contains only a paragraph.
// We ignore the case when paragraph might have some inline elements (<p><inlineWidget>[]</inlineWidget></p>)
// because we don't support such cases yet and it's unclear whether inlineWidget shouldn't be a limit itself.
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 doc = model.document;
const selection = doc.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;
// Does nothing if user pressed and held the "Backspace" key or it was a "Delete" button.
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);
// Only elements that are direct children of the limit element can be replaced.
// Unwrapping from a block quote should be handled in a dedicated feature.
if (position.parent != limitElementFirstChild) {
return false;
}
// A block should be replaced only if it was empty.
if (!selection.containsEntireContent(limitElementFirstChild)) {
return false;
}
// Replace with a paragraph only if it's allowed there.
if (!model.schema.checkChild(limitElement, 'paragraph')) {
return false;
}
// Does nothing if the limit element already contains only a paragraph.
if (limitElementFirstChild.name == 'paragraph') {
return false;
}
return true;
}
}
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 = {
// --------------------------------------- Backward delete types -----------------------------------------------------
// This happens in Safari on Mac when some content is selected and Ctrl + K is pressed.
deleteContent: {
unit: DELETE_SELECTION,
// According to the Input Events Level 2 spec, this delete type has no direction
// but to keep things simple, let's default to backward.
direction: DELETE_BACKWARD
},
// Chrome and Safari on Mac: Backspace or Ctrl + H
deleteContentBackward: {
// This kind of deletions must be done on the code point-level instead of target range provided by the DOM beforeinput event.
// Take for instance "๐จโ๐ฉโ๐งโ๐ง", it equals:
//
// * [ "๐จ", "ZERO WIDTH JOINER", "๐ฉ", "ZERO WIDTH JOINER", "๐ง", "ZERO WIDTH JOINER", "๐ง" ]
// * or simply "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F467}"
//
// The range provided by the browser would cause the entire multi-byte grapheme to disappear while the user
// intention when deleting backwards ("๐จโ๐ฉโ๐งโ๐ง[]", then backspace) is gradual "decomposition" (first to "๐จโ๐ฉโ๐งโ[]",
// then to "๐จโ๐ฉโ[]", etc.).
//
// * "๐จโ๐ฉโ๐งโ๐ง[]" + backward delete (by code point) -> results in "๐จโ๐ฉโ๐ง[]", removed the last "๐ง" ๐
// * "๐จโ๐ฉโ๐งโ๐ง[]" + backward delete (by character) -> results in "[]", removed the whole grapheme ๐
//
// Deleting by code-point is simply a better UX. See "deleteContentForward" to learn more.
unit: DELETE_CODE_POINT,
direction: DELETE_BACKWARD
},
// On Mac: Option + Backspace.
// On iOS: Hold the backspace for a while and the whole words will start to disappear.
deleteWordBackward: {
unit: DELETE_WORD,
direction: DELETE_BACKWARD
},
// Safari on Mac: Cmd + Backspace
deleteHardLineBackward: {
unit: DELETE_SELECTION,
direction: DELETE_BACKWARD
},
// Chrome on Mac: Cmd + Backspace.
deleteSoftLineBackward: {
unit: DELETE_SELECTION,
direction: DELETE_BACKWARD
},
// --------------------------------------- Forward delete types -----------------------------------------------------
// Chrome on Mac: Fn + Backspace or Ctrl + D
// Safari on Mac: Ctrl + K or Ctrl + D
deleteContentForward: {
// Unlike backward delete, this delete must be performed by character instead of by code point, which
// provides the best UX for working with accented letters.
// Take, for example "bฬ" ("\u0062\u0302", or [ "LATIN SMALL LETTER B", "COMBINING CIRCUMFLEX ACCENT" ]):
//
// * "bฬ[]" + backward delete (by code point) -> results in "b[]", removed the combining mark ๐
// * "[]bฬ" + forward delete (by code point) -> results in "[]^", a bare combining mark does that not make sense when alone ๐
// * "[]bฬ" + forward delete (by character) -> results in "[]", removed both "b" and the combining mark ๐
//
// See: "deleteContentBackward" to learn more.
unit: DELETE_CHARACTER,
direction: DELETE_FORWARD
},
// On Mac: Fn + Option + Backspace.
deleteWordForward: {
unit: DELETE_WORD,
direction: DELETE_FORWARD
},
// Chrome on Mac: Ctrl