UNPKG

@ckeditor/ckeditor5-utils

Version:

Miscellaneous utilities used by CKEditor 5.

331 lines (330 loc) • 14.9 kB
/** * @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 */ /* global setTimeout, clearTimeout */ /** * @module utils/focustracker */ import DomEmitterMixin from './dom/emittermixin.js'; import ObservableMixin from './observablemixin.js'; import CKEditorError from './ckeditorerror.js'; import { isElement as _isElement } from 'es-toolkit/compat'; /** * Allows observing a group of DOM `Element`s or {@link module:ui/view~View view instances} whether at least one of them (or their child) * is focused. * * Used by the {@link module:core/editor/editor~Editor} in order to track whether the focus is still within the application, * or were used outside of its UI. * * **Note** `focus` and `blur` listeners use event capturing, so it is only needed to register wrapper `Element` * which contain other `focusable` elements. But note that this wrapper element has to be focusable too * (have e.g. `tabindex="-1"`). * * Check out the {@glink framework/deep-dive/ui/focus-tracking "Deep dive into focus tracking"} guide to learn more. */ export default class FocusTracker extends /* #__PURE__ */ DomEmitterMixin(/* #__PURE__ */ ObservableMixin()) { /** * List of registered DOM elements. * * @internal */ _elements = new Set(); /** * List of views with external focus trackers that contribute to the state of this focus tracker. * * @internal */ _externalViews = new Set(); /** * Asynchronous blur event timeout. */ _blurTimeout = null; // @if CK_DEBUG_FOCUSTRACKER // public _label?: string; constructor() { super(); this.set('isFocused', false); this.set('focusedElement', null); // @if CK_DEBUG_FOCUSTRACKER // FocusTracker._instances.push( this ); } /** * List of registered DOM elements. * * **Note**: The list does do not include elements from {@link #externalViews}. */ get elements() { return Array.from(this._elements.values()); } /** * List of external focusable views that contribute to the state of this focus tracker. See {@link #add} to learn more. */ get externalViews() { return Array.from(this._externalViews.values()); } /** * Starts tracking a specified DOM element or a {@link module:ui/view~View} instance. * * * If a DOM element is passed, the focus tracker listens to the `focus` and `blur` events on this element. * Tracked elements are listed in {@link #elements}. * * If a {@link module:ui/view~View} instance is passed that has a `FocusTracker` instance ({@link ~ViewWithFocusTracker}), * the external focus tracker's state ({@link #isFocused}, {@link #focusedElement}) starts contributing to the current tracker instance. * This allows for increasing the "reach" of a focus tracker instance, by connecting two or more focus trackers together when DOM * elements they track are located in different subtrees in DOM. External focus trackers are listed in {@link #externalViews}. * * If a {@link module:ui/view~View} instance is passed that has no `FocusTracker` (**not** a {@link ~ViewWithFocusTracker}), * its {@link module:ui/view~View#element} is used to track focus like any other DOM element. */ add(elementOrView) { if (isElement(elementOrView)) { this._addElement(elementOrView); } else { if (isViewWithFocusTracker(elementOrView)) { this._addView(elementOrView); } else { if (!elementOrView.element) { /** * The {@link module:ui/view~View} added to the {@link module:utils/focustracker~FocusTracker} does not have an * {@link module:ui/view~View#element}. Make sure the view is {@link module:ui/view~View#render} before adding * it to the focus tracker. * * @error focustracker-add-view-missing-element */ throw new CKEditorError('focustracker-add-view-missing-element', { focusTracker: this, view: elementOrView }); } this._addElement(elementOrView.element); } } } /** * Stops tracking focus in the specified DOM element or a {@link module:ui/view~View view instance}. See {@link #add} to learn more. */ remove(elementOrView) { if (isElement(elementOrView)) { this._removeElement(elementOrView); } else { if (isViewWithFocusTracker(elementOrView)) { this._removeView(elementOrView); } else { // Assuming that if the view was successfully added, it must have come with an existing #element. this._removeElement(elementOrView.element); } } } /** * Adds a DOM element to the focus tracker and starts listening to the `focus` and `blur` events on it. */ _addElement(element) { if (this._elements.has(element)) { /** * This element is already tracked by {@link module:utils/focustracker~FocusTracker}. * * @error focustracker-add-element-already-exist */ throw new CKEditorError('focustracker-add-element-already-exist', this); } this.listenTo(element, 'focus', () => { // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": Focus with useCapture on DOM element` ); const externalFocusedViewInSubtree = this.externalViews.find(view => isExternalViewSubtreeFocused(element, view)); if (externalFocusedViewInSubtree) { this._focus(externalFocusedViewInSubtree.element); } else { this._focus(element); } }, { useCapture: true }); this.listenTo(element, 'blur', () => { // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": Blur with useCapture on DOM element` ); this._blur(); }, { useCapture: true }); this._elements.add(element); } /** * Removes a DOM element from the focus tracker. */ _removeElement(element) { if (this._elements.has(element)) { this.stopListening(element); this._elements.delete(element); } if (element === this.focusedElement) { this._blur(); } } /** * Adds an external {@link module:ui/view~View view instance} to this focus tracker and makes it contribute to this focus tracker's * state either by its `View#element` or by its `View#focusTracker` instance. */ _addView(view) { if (view.element) { this._addElement(view.element); } this.listenTo(view.focusTracker, 'change:focusedElement', () => { // @if CK_DEBUG_FOCUSTRACKER // console.log( // @if CK_DEBUG_FOCUSTRACKER // `"${ getName( this ) }": Related "${ getName( view.focusTracker ) }"#focusedElement = `, // @if CK_DEBUG_FOCUSTRACKER // view.focusTracker.focusedElement // @if CK_DEBUG_FOCUSTRACKER // ); if (view.focusTracker.focusedElement) { if (view.element) { this._focus(view.element); } } else { this._blur(); } }); this._externalViews.add(view); } /** * Removes an external {@link module:ui/view~View view instance} from this focus tracker. */ _removeView(view) { if (view.element) { this._removeElement(view.element); } this.stopListening(view.focusTracker); this._externalViews.delete(view); } /** * Destroys the focus tracker by: * - Disabling all event listeners attached to tracked elements or external views. * - Removing all tracked elements and views that were previously added. */ destroy() { this.stopListening(); this._elements.clear(); this._externalViews.clear(); this.isFocused = false; this.focusedElement = null; } /** * Stores currently focused element as {@link #focusedElement} and sets {@link #isFocused} `true`. */ _focus(element) { // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": _focus() on element`, element ); this._clearBlurTimeout(); this.focusedElement = element; this.isFocused = true; } /** * Clears currently {@link #focusedElement} and sets {@link #isFocused} `false`. * * This method uses `setTimeout()` to change order of `blur` and `focus` events calls, ensuring that moving focus between * two elements within a single focus tracker's scope, will not cause `[ blurA, focusB ]` sequence but just `[ focusB ]`. * The former would cause a momentary change of `#isFocused` to `false` which is not desired because any logic listening to * a focus tracker state would experience UI flashes and glitches as the user focus travels across the UI. */ _blur() { const isAnyElementFocused = this.elements.find(element => element.contains(document.activeElement)); // Avoid blurs originating from external FTs when the focus still remains in one of the #elements. if (isAnyElementFocused) { return; } const isAnyExternalViewFocused = this.externalViews.find(view => { // Do not consider external views's focus trackers as focused if there's a blur timeout pending. return view.focusTracker.isFocused && !view.focusTracker._blurTimeout; }); // Avoid unnecessary DOM blurs coming from #elements when the focus still remains in one of #externalViews. if (isAnyExternalViewFocused) { return; } this._clearBlurTimeout(); this._blurTimeout = setTimeout(() => { // @if CK_DEBUG_FOCUSTRACKER // console.log( `"${ getName( this ) }": Blur.` ); this.focusedElement = null; this.isFocused = false; }, 0); } /** * Clears the asynchronous blur event timeout on demand. See {@link #_blur} to learn more. */ _clearBlurTimeout() { clearTimeout(this._blurTimeout); this._blurTimeout = null; } } /** * Checks whether a view is an instance of {@link ~ViewWithFocusTracker}. */ export function isViewWithFocusTracker(view) { return 'focusTracker' in view && view.focusTracker instanceof FocusTracker; } function isElement(value) { return _isElement(value); } function isExternalViewSubtreeFocused(subTreeRoot, view) { if (isFocusedView(subTreeRoot, view)) { return true; } return !!view.focusTracker.externalViews.find(view => isFocusedView(subTreeRoot, view)); } function isFocusedView(subTreeRoot, view) { // Note: You cannot depend on externalView.focusTracker.focusedElement because blurs are asynchronous and the value may // be outdated when moving focus between two elements. Using document.activeElement instead. return !!view.element && view.element.contains(document.activeElement) && subTreeRoot.contains(view.element); } // @if CK_DEBUG_FOCUSTRACKER // declare global { // @if CK_DEBUG_FOCUSTRACKER // interface Window { // @if CK_DEBUG_FOCUSTRACKER // logFocusTrackers: Function; // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // function getName( focusTracker: FocusTracker ): string { // @if CK_DEBUG_FOCUSTRACKER // return focusTracker._label || 'Unknown'; // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // function logState( // @if CK_DEBUG_FOCUSTRACKER // focusTracker: FocusTracker, // @if CK_DEBUG_FOCUSTRACKER // keysToLog: Array<string> = [ 'isFocused', 'focusedElement' ] // @if CK_DEBUG_FOCUSTRACKER // ): string { // @if CK_DEBUG_FOCUSTRACKER // keysToLog.forEach( key => { console.log( `${ key }=`, focusTracker[ key ] ) } ); // @if CK_DEBUG_FOCUSTRACKER // console.log( 'elements', focusTracker.elements ); // @if CK_DEBUG_FOCUSTRACKER // console.log( 'externalViews', focusTracker.externalViews ); // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // window.logFocusTrackers = ( // @if CK_DEBUG_FOCUSTRACKER // filter = () => true, // @if CK_DEBUG_FOCUSTRACKER // keysToLog: Array<string> // @if CK_DEBUG_FOCUSTRACKER // ): void => { // @if CK_DEBUG_FOCUSTRACKER // console.group( 'FocusTrackers' ); // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // for ( const focusTracker of FocusTracker._instances ) { // @if CK_DEBUG_FOCUSTRACKER // if ( filter( focusTracker ) ) { // @if CK_DEBUG_FOCUSTRACKER // console.group( `"${ getName( focusTracker ) }"` ); // @if CK_DEBUG_FOCUSTRACKER // logState( focusTracker, keysToLog ); // @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); // @if CK_DEBUG_FOCUSTRACKER // }; // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // window.logFocusTrackerTree = ( // @if CK_DEBUG_FOCUSTRACKER // rootFocusTracker: FocusTracker, // @if CK_DEBUG_FOCUSTRACKER // filter = () => true, // @if CK_DEBUG_FOCUSTRACKER // keysToLog: Array<string> // @if CK_DEBUG_FOCUSTRACKER // ): void => { // @if CK_DEBUG_FOCUSTRACKER // console.group( 'FocusTrackers tree' ); // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // logBranch( rootFocusTracker, filter ); // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // function logBranch( focusTracker, filter ) { // @if CK_DEBUG_FOCUSTRACKER // console.group( `"${ getName( focusTracker ) }"` ); // @if CK_DEBUG_FOCUSTRACKER // logState( focusTracker, keysToLog ); // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // for ( const externalView of focusTracker.externalViews ) { // @if CK_DEBUG_FOCUSTRACKER // if ( filter( externalView.focusTracker ) ) { // @if CK_DEBUG_FOCUSTRACKER // logBranch( externalView.focusTracker, filter ); // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); // @if CK_DEBUG_FOCUSTRACKER // } // @if CK_DEBUG_FOCUSTRACKER // // @if CK_DEBUG_FOCUSTRACKER // console.groupEnd(); // @if CK_DEBUG_FOCUSTRACKER // };