@ckeditor/ckeditor5-engine
Version:
The editing engine of CKEditor 5 – the best browser-based rich text editor.
298 lines (297 loc) • 16.7 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module engine/view/observer/selectionobserver
*/
import { Observer } from './observer.js';
import { MutationObserver } from './mutationobserver.js';
import { FocusObserver } from './focusobserver.js';
import { env } from '@ckeditor/ckeditor5-utils';
import { debounce } from 'es-toolkit/compat';
/**
* Selection observer class observes selection changes in the document. If a selection changes on the document this
* observer checks if the DOM selection is different from the {@link module:engine/view/document~ViewDocument#selection view selection}.
* The selection observer fires {@link module:engine/view/document~ViewDocument#event:selectionChange} event only if
* a selection change was the only change in the document and the DOM selection is different from the view selection.
*
* This observer also manages the {@link module:engine/view/document~ViewDocument#isSelecting} property of the view document.
*
* Note that this observer is attached by the {@link module:engine/view/view~EditingView} and is available by default.
*/
export class SelectionObserver extends Observer {
/**
* Instance of the mutation observer. Selection observer calls
* {@link module:engine/view/observer/mutationobserver~MutationObserver#flush} to ensure that the mutations will be handled
* before the {@link module:engine/view/document~ViewDocument#event:selectionChange} event is fired.
*/
mutationObserver;
/**
* Instance of the focus observer. Focus observer calls
* {@link module:engine/view/observer/focusobserver~FocusObserver#flush} to mark the latest focus change as complete.
*/
focusObserver;
/**
* Reference to the view {@link module:engine/view/documentselection~ViewDocumentSelection} object used to compare
* new selection with it.
*/
selection;
/**
* Reference to the {@link module:engine/view/view~EditingView#domConverter}.
*/
domConverter;
/**
* A set of documents which have added `selectionchange` listener to avoid adding a listener twice to the same
* document.
*/
_documents = new WeakSet();
/**
* Fires debounced event `selectionChangeDone`. It uses `es-toolkit#debounce` method to delay function call.
*/
_fireSelectionChangeDoneDebounced;
/**
* When called, starts clearing the {@link #_loopbackCounter} counter in time intervals. When the number of selection
* changes exceeds a certain limit within the interval of time, the observer will not fire `selectionChange` but warn about
* possible infinite selection loop.
*/
_clearInfiniteLoopInterval;
/**
* Unlocks the `isSelecting` state of the view document in case the selection observer did not record this fact
* correctly (for whatever reason). It is a safeguard (paranoid check), that returns document to the normal state
* after a certain period of time (debounced, postponed by each selectionchange event).
*/
_documentIsSelectingInactivityTimeoutDebounced;
/**
* Private property to check if the code does not enter infinite loop.
*/
_loopbackCounter = 0;
/**
* A set of DOM documents that have a pending selection change.
* Pending selection change is recorded while selection change event is detected on non focused editable.
*/
_pendingSelectionChange = new Set();
constructor(view) {
super(view);
this.mutationObserver = view.getObserver(MutationObserver);
this.focusObserver = view.getObserver(FocusObserver);
this.selection = this.document.selection;
this.domConverter = view.domConverter;
this._fireSelectionChangeDoneDebounced = debounce(data => {
this.document.fire('selectionChangeDone', data);
}, 200);
this._clearInfiniteLoopInterval = setInterval(() => this._clearInfiniteLoop(), 1000);
this._documentIsSelectingInactivityTimeoutDebounced = debounce(() => (this.document.isSelecting = false), 5000);
this.view.document.on('change:isFocused', (evt, name, isFocused) => {
if (isFocused && this._pendingSelectionChange.size) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'Flush pending selection change'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
// Iterate over a copy of set because it is modified in selection change handler.
for (const domDocument of Array.from(this._pendingSelectionChange)) {
this._handleSelectionChange(domDocument);
}
this._pendingSelectionChange.clear();
}
});
}
/**
* @inheritDoc
*/
observe(domElement) {
const domDocument = domElement.ownerDocument;
const startDocumentIsSelecting = () => {
this.document.isSelecting = true;
// Let's activate the safety timeout each time the document enters the "is selecting" state.
this._documentIsSelectingInactivityTimeoutDebounced();
};
const endDocumentIsSelecting = () => {
if (!this.document.isSelecting) {
return;
}
// Make sure that model selection is up-to-date at the end of selecting process.
// Sometimes `selectionchange` events could arrive after the `mouseup` event and that selection could be already outdated.
this._handleSelectionChange(domDocument);
this.document.isSelecting = false;
// The safety timeout can be canceled when the document leaves the "is selecting" state.
this._documentIsSelectingInactivityTimeoutDebounced.cancel();
};
// The document has the "is selecting" state while the user keeps making (extending) the selection
// (e.g. by holding the mouse button and moving the cursor). The state resets when they either released
// the mouse button or interrupted the process by pressing or releasing any key.
this.listenTo(domElement, 'selectstart', startDocumentIsSelecting, { priority: 'highest' });
this.listenTo(domElement, 'keydown', endDocumentIsSelecting, { priority: 'highest', useCapture: true });
this.listenTo(domElement, 'keyup', endDocumentIsSelecting, { priority: 'highest', useCapture: true });
// Add document-wide listeners only once. This method could be called for multiple editing roots.
if (this._documents.has(domDocument)) {
return;
}
// This listener is using capture mode to make sure that selection is upcasted before any other
// handler would like to check it and update (for example table multi cell selection).
this.listenTo(domDocument, 'mouseup', endDocumentIsSelecting, { priority: 'highest', useCapture: true });
this.listenTo(domDocument, 'selectionchange', () => {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // _debouncedLine();
// @if CK_DEBUG_TYPING // const domSelection = domDocument.defaultView!.getSelection();
// @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'selectionchange'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'DOM Selection:',
// @if CK_DEBUG_TYPING // { node: domSelection!.anchorNode, offset: domSelection!.anchorOffset },
// @if CK_DEBUG_TYPING // { node: domSelection!.focusNode, offset: domSelection!.focusOffset }
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
// The Renderer is disabled while composing on non-android browsers, so we can't update the view selection
// because the DOM and view tree drifted apart. Position mapping could fail because of it.
if (this.document.isComposing && !env.isAndroid) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'Selection change ignored (isComposing)'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // console.groupEnd();
// @if CK_DEBUG_TYPING // }
return;
}
this._handleSelectionChange(domDocument);
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
// @if CK_DEBUG_TYPING // }
// Defer the safety timeout when the selection changes (e.g. the user keeps extending the selection
// using their mouse).
this._documentIsSelectingInactivityTimeoutDebounced();
});
// Update the model ViewDocumentSelection just after the Renderer and the SelectionObserver are locked.
// We do this synchronously (without waiting for the `selectionchange` DOM event) as browser updates
// the DOM selection (but not visually) to span the text that is under composition and could be replaced.
this.listenTo(this.view.document, 'compositionstart', () => {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // const domSelection = domDocument.defaultView!.getSelection();
// @if CK_DEBUG_TYPING // console.group( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'update selection on compositionstart'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'DOM Selection:',
// @if CK_DEBUG_TYPING // { node: domSelection!.anchorNode, offset: domSelection!.anchorOffset },
// @if CK_DEBUG_TYPING // { node: domSelection!.focusNode, offset: domSelection!.focusOffset }
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
this._handleSelectionChange(domDocument);
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.groupEnd();
// @if CK_DEBUG_TYPING // }
}, { priority: 'lowest' });
this._documents.add(domDocument);
}
/**
* @inheritDoc
*/
stopObserving(domElement) {
this.stopListening(domElement);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
clearInterval(this._clearInfiniteLoopInterval);
this._fireSelectionChangeDoneDebounced.cancel();
this._documentIsSelectingInactivityTimeoutDebounced.cancel();
}
/* istanbul ignore next -- @preserve */
_reportInfiniteLoop() {
// @if CK_DEBUG // throw new Error(
// @if CK_DEBUG // 'Selection change observer detected an infinite rendering loop.\n\n' +
// @if CK_DEBUG // '⚠️⚠️ Report this error on https://github.com/ckeditor/ckeditor5/issues/11658.'
// @if CK_DEBUG // );
}
/**
* Selection change listener. {@link module:engine/view/observer/mutationobserver~MutationObserver#flush Flush} mutations, check if
* a selection changes and fires {@link module:engine/view/document~ViewDocument#event:selectionChange} event on every change
* and {@link module:engine/view/document~ViewDocument#event:selectionChangeDone} when a selection stop changing.
*
* @param domDocument DOM document.
*/
_handleSelectionChange(domDocument) {
if (!this.isEnabled) {
return;
}
const domSelection = domDocument.defaultView.getSelection();
if (this.checkShouldIgnoreEventFromTarget(domSelection.anchorNode)) {
return;
}
// Ensure the mutation event will be before selection event on all browsers.
this.mutationObserver.flush();
const newViewSelection = this.domConverter.domSelectionToView(domSelection);
// Do not convert selection change if the new view selection has no ranges in it.
//
// It means that the DOM selection is in some way incorrect. Ranges that were in the DOM selection could not be
// converted to the view. This happens when the DOM selection was moved outside of the editable element.
if (newViewSelection.rangeCount == 0) {
this.view.hasDomSelection = false;
return;
}
this.view.hasDomSelection = true;
// Mark the latest focus change as complete (we got new selection after the focus so the selection is in the focused element).
this.focusObserver.flush();
// Ignore selection change as the editable is not focused. Note that in read-only mode, we have to update
// the model selection as there won't be any focus change to flush the pending selection changes.
if (!this.view.document.isFocused && !this.view.document.isReadOnly) {
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'Ignore selection change while editable is not focused'
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
this._pendingSelectionChange.add(domDocument);
return;
}
this._pendingSelectionChange.delete(domDocument);
if (this.selection.isEqual(newViewSelection) && this.domConverter.isDomSelectionCorrect(domSelection)) {
return;
}
// Ensure we are not in the infinite loop (#400).
// This counter is reset each second. 60 selection changes in 1 second is enough high number
// to be very difficult (impossible) to achieve using just keyboard keys (during normal editor use).
if (++this._loopbackCounter > 60) {
// Selection change observer detected an infinite rendering loop.
// Most probably you try to put the selection in the position which is not allowed
// by the browser and browser fixes it automatically what causes `selectionchange` event on
// which a loopback through a model tries to re-render the wrong selection and again.
this._reportInfiniteLoop();
return;
}
if (this.selection.isSimilar(newViewSelection)) {
// If selection was equal and we are at this point of algorithm, it means that it was incorrect.
// Just re-render it, no need to fire any events, etc.
this.view.forceRender();
}
else {
const data = {
oldSelection: this.selection,
newSelection: newViewSelection,
domSelection
};
// @if CK_DEBUG_TYPING // if ( ( window as any ).logCKETyping ) {
// @if CK_DEBUG_TYPING // console.info( ..._buildLogMessage( this, 'SelectionObserver',
// @if CK_DEBUG_TYPING // 'Fire selection change:',
// @if CK_DEBUG_TYPING // newViewSelection.getFirstRange()
// @if CK_DEBUG_TYPING // ) );
// @if CK_DEBUG_TYPING // }
// Prepare data for new selection and fire appropriate events.
this.document.fire('selectionChange', data);
// Call `#_fireSelectionChangeDoneDebounced` every time when `selectionChange` event is fired.
// This function is debounced what means that `selectionChangeDone` event will be fired only when
// defined int the function time will elapse since the last time the function was called.
// So `selectionChangeDone` will be fired when selection will stop changing.
this._fireSelectionChangeDoneDebounced(data);
}
}
/**
* Clears `SelectionObserver` internal properties connected with preventing infinite loop.
*/
_clearInfiniteLoop() {
this._loopbackCounter = 0;
}
}