UNPKG

@ckeditor/ckeditor5-typing

Version:

Typing feature for CKEditor 5.

1,044 lines (1,038 loc) โ€ข 110 kB
/** * @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