diff --git a/packages/lexical-link/src/ClickableLinkExtension.ts b/packages/lexical-link/src/ClickableLinkExtension.ts index 5c6378bd4f8..9c952edbe08 100644 --- a/packages/lexical-link/src/ClickableLinkExtension.ts +++ b/packages/lexical-link/src/ClickableLinkExtension.ts @@ -112,14 +112,14 @@ export function registerClickableLink( } }; - return editor.registerRootListener((rootElement, prevRootElement) => { - if (prevRootElement !== null) { - prevRootElement.removeEventListener('click', onClick); - prevRootElement.removeEventListener('mouseup', onMouseUp); - } - if (rootElement !== null) { + return editor.registerRootListener((rootElement) => { + if (rootElement) { rootElement.addEventListener('click', onClick, eventOptions); rootElement.addEventListener('mouseup', onMouseUp, eventOptions); + return () => { + rootElement.removeEventListener('click', onClick); + rootElement.removeEventListener('mouseup', onMouseUp); + }; } }); } diff --git a/packages/lexical-list/src/checkList.ts b/packages/lexical-list/src/checkList.ts index cf8e6b832c7..a7a54aed0a3 100644 --- a/packages/lexical-list/src/checkList.ts +++ b/packages/lexical-list/src/checkList.ts @@ -164,7 +164,7 @@ export function registerCheckList( COMMAND_PRIORITY_LOW, ), - editor.registerRootListener((rootElement, prevElement) => { + editor.registerRootListener((rootElement) => { if (rootElement !== null) { rootElement.addEventListener('click', configHandleClick); // Use capture so we run before other listeners that might move focus. @@ -185,31 +185,30 @@ export function registerCheckList( capture: true, passive: false, }); - } - - if (prevElement !== null) { - prevElement.removeEventListener('click', configHandleClick); - prevElement.removeEventListener( - 'pointerdown', - configHandleSelectDefaults, - { - capture: true, - }, - ); - prevElement.removeEventListener( - 'mousedown', - configHandleSelectDefaults, - { - capture: true, - }, - ); - prevElement.removeEventListener( - 'touchstart', - configHandleSelectDefaults, - { - capture: true, - }, - ); + return () => { + rootElement.removeEventListener('click', configHandleClick); + rootElement.removeEventListener( + 'pointerdown', + configHandleSelectDefaults, + { + capture: true, + }, + ); + rootElement.removeEventListener( + 'mousedown', + configHandleSelectDefaults, + { + capture: true, + }, + ); + rootElement.removeEventListener( + 'touchstart', + configHandleSelectDefaults, + { + capture: true, + }, + ); + }; } }), ); diff --git a/packages/lexical-playground/src/nodes/ImageComponent.tsx b/packages/lexical-playground/src/nodes/ImageComponent.tsx index 005fc77e583..4c516abb1df 100644 --- a/packages/lexical-playground/src/nodes/ImageComponent.tsx +++ b/packages/lexical-playground/src/nodes/ImageComponent.tsx @@ -237,8 +237,6 @@ function BrokenImage(): JSX.Element { ); } -function noop() {} - export default function ImageComponent({ src, altText, @@ -397,7 +395,6 @@ export default function ImageComponent({ ); }, [editor]); useEffect(() => { - let rootCleanup = noop; return mergeRegister( editor.registerCommand( CLICK_COMMAND, @@ -416,15 +413,12 @@ export default function ImageComponent({ COMMAND_PRIORITY_LOW, ), editor.registerRootListener((rootElement) => { - rootCleanup(); - rootCleanup = noop; if (rootElement) { rootElement.addEventListener('contextmenu', onRightClick); - rootCleanup = () => + return () => rootElement.removeEventListener('contextmenu', onRightClick); } }), - () => rootCleanup(), ); }, [editor, $onEnter, $onEscape, onClick, onRightClick]); diff --git a/packages/lexical-playground/src/nodes/StickyComponent.tsx b/packages/lexical-playground/src/nodes/StickyComponent.tsx index 0f735cee40b..43d75238ffd 100644 --- a/packages/lexical-playground/src/nodes/StickyComponent.tsx +++ b/packages/lexical-playground/src/nodes/StickyComponent.tsx @@ -100,16 +100,12 @@ export default function StickyComponent({ } }); - const removeRootListener = editor.registerRootListener( - (nextRootElem, prevRootElem) => { - if (prevRootElem !== null) { - resizeObserver.unobserve(prevRootElem); - } - if (nextRootElem !== null) { - resizeObserver.observe(nextRootElem); - } - }, - ); + const removeRootListener = editor.registerRootListener((nextRootElem) => { + if (nextRootElem !== null) { + resizeObserver.observe(nextRootElem); + return () => resizeObserver.unobserve(nextRootElem); + } + }); const handleWindowResize = () => { const rootElement = editor.getRootElement(); diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx index ee5f7e5c04d..448b42f99db 100644 --- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx @@ -869,12 +869,11 @@ function TableCellActionMenuContainer({ COMMAND_PRIORITY_CRITICAL, ), editor.registerRootListener((rootElement, prevRootElement) => { - if (prevRootElement) { - prevRootElement.removeEventListener('pointerup', delayedCallback); - } if (rootElement) { rootElement.addEventListener('pointerup', delayedCallback); delayedCallback(); + return () => + rootElement.removeEventListener('pointerup', delayedCallback); } }), () => clearTimeout(timeoutId), diff --git a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx index 7bd80026036..0818ef23031 100644 --- a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx +++ b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx @@ -176,14 +176,16 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { capture: true, }); - const removeRootListener = editor.registerRootListener( - (rootElement, prevRootElement) => { - prevRootElement?.removeEventListener('pointermove', onPointerMove); - prevRootElement?.removeEventListener('pointerdown', onPointerDown); - rootElement?.addEventListener('pointermove', onPointerMove); - rootElement?.addEventListener('pointerdown', onPointerDown); - }, - ); + const removeRootListener = editor.registerRootListener((rootElement) => { + if (rootElement) { + rootElement.addEventListener('pointermove', onPointerMove); + rootElement.addEventListener('pointerdown', onPointerDown); + return () => { + rootElement.removeEventListener('pointermove', onPointerMove); + rootElement.removeEventListener('pointerdown', onPointerDown); + }; + } + }); return () => { removeRootListener(); diff --git a/packages/lexical-playground/src/plugins/TableHoverActionsV2Plugin/index.tsx b/packages/lexical-playground/src/plugins/TableHoverActionsV2Plugin/index.tsx index 0c5a97063d7..769e079f5c6 100644 --- a/packages/lexical-playground/src/plugins/TableHoverActionsV2Plugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableHoverActionsV2Plugin/index.tsx @@ -199,9 +199,6 @@ function TableHoverActionsV2({ const dragHandleRef = useRef(null); const hoveredLeftCellRef = useRef(null); const hoveredTopCellRef = useRef(null); - const handleMouseLeaveRef = useRef<((event: MouseEvent) => void) | null>( - null, - ); const dropIndicatorCleanupRef = useRef void>>([]); const [hoveredTable, setHoveredTable] = useState( null, @@ -358,17 +355,12 @@ function TableHoverActionsV2({ setIsVisible(false); setIsLeftVisible(false); }; - handleMouseLeaveRef.current = handleMouseLeave; - - return editor.registerRootListener((rootElement, prevRootElement) => { - if (prevRootElement && handleMouseLeaveRef.current) { - prevRootElement.removeEventListener( - 'mouseleave', - handleMouseLeaveRef.current, - ); - } - if (rootElement && handleMouseLeaveRef.current) { - rootElement.addEventListener('mouseleave', handleMouseLeaveRef.current); + + return editor.registerRootListener((rootElement) => { + if (rootElement) { + rootElement.addEventListener('mouseleave', handleMouseLeave); + return () => + rootElement.removeEventListener('mouseleave', handleMouseLeave); } }); }, [editor]); diff --git a/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx b/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx index 8957f790501..65914a9b72a 100644 --- a/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx @@ -277,21 +277,16 @@ ${steps.map(formatStep).join(`\n`)} } }; - return editor.registerRootListener( - ( - rootElement: null | HTMLElement, - prevRootElement: null | HTMLElement, - ) => { - if (prevRootElement !== null) { - prevRootElement.removeEventListener('keydown', onKeyDown); - prevRootElement.removeEventListener('keyup', onKeyUp); - } - if (rootElement !== null) { - rootElement.addEventListener('keydown', onKeyDown); - rootElement.addEventListener('keyup', onKeyUp); - } - }, - ); + return editor.registerRootListener((rootElement) => { + if (rootElement) { + rootElement.addEventListener('keydown', onKeyDown); + rootElement.addEventListener('keyup', onKeyUp); + return () => { + rootElement.removeEventListener('keydown', onKeyDown); + rootElement.removeEventListener('keyup', onKeyUp); + }; + } + }); }, [editor, isRecording, pushStep]); useLayoutEffect(() => { diff --git a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx index 8266f4118a8..9cac0f2b5b7 100644 --- a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx +++ b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx @@ -456,7 +456,7 @@ function useDraggableBlockMenu( } return mergeRegister( - editor.registerRootListener((rootElement, prevRootElement) => { + editor.registerRootListener((rootElement) => { function onBlur(event: FocusEvent) { const relatedTarget = event.relatedTarget; if ( @@ -483,10 +483,7 @@ function useDraggableBlockMenu( if (rootElement) { rootElement.addEventListener('blur', onBlur, true); - } - - if (prevRootElement) { - prevRootElement.removeEventListener('blur', onBlur, true); + return () => rootElement.removeEventListener('blur', onBlur, true); } }), // Intercept BLUR_COMMAND if focus is on the menu (fallback in case event propagation wasn't stopped) diff --git a/packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx b/packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx index 7f275d196d3..de0c4a51af9 100644 --- a/packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx @@ -248,12 +248,11 @@ const NodeContextMenuPlugin = forwardRef< setIsOpen(true); } - return editor.registerRootListener((rootElement, prevRootElement) => { - if (prevRootElement !== null) { - prevRootElement.removeEventListener('contextmenu', onContextMenu); - } + return editor.registerRootListener((rootElement) => { if (rootElement !== null) { rootElement.addEventListener('contextmenu', onContextMenu); + return () => + rootElement.removeEventListener('contextmenu', onContextMenu); } }); }, [items, itemClassName, separatorClassName, refs, editor]); diff --git a/packages/lexical-react/src/LexicalNodeEventPlugin.ts b/packages/lexical-react/src/LexicalNodeEventPlugin.ts index 6defe39c01a..2fe41360009 100644 --- a/packages/lexical-react/src/LexicalNodeEventPlugin.ts +++ b/packages/lexical-react/src/LexicalNodeEventPlugin.ts @@ -56,13 +56,11 @@ export function NodeEventPlugin({ }); }; - return editor.registerRootListener((rootElement, prevRootElement) => { + return editor.registerRootListener((rootElement) => { if (rootElement) { rootElement.addEventListener(eventType, onEvent, isCaptured); - } - - if (prevRootElement) { - prevRootElement.removeEventListener(eventType, onEvent, isCaptured); + return () => + rootElement.removeEventListener(eventType, onEvent, isCaptured); } }); // We intentionally don't respect changes to eventType. diff --git a/packages/lexical-website/docs/concepts/dom-events.md b/packages/lexical-website/docs/concepts/dom-events.md index 31455c7c308..d0aa4284c4c 100644 --- a/packages/lexical-website/docs/concepts/dom-events.md +++ b/packages/lexical-website/docs/concepts/dom-events.md @@ -15,12 +15,12 @@ function myListener(event) { alert('Nice!'); } -const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => { +const removeRootListener = editor.registerRootListener((rootElement) => { // add the listener to the current root element rootElement.addEventListener('click', myListener); // remove the listener from the old root element - make sure the ref to myListener // is stable so the removal works and you avoid a memory leak. - prevRootElement.removeEventListener('click', myListener); + return () => rootElement.removeEventListener('click', myListener); }); // teardown the listener - return this from your useEffect callback if you're using React. diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 651d8a2fe8d..091801988f6 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -109,7 +109,7 @@ type DecoratorListener = (decorator: { type RootListener = ( rootElement: null | HTMLElement, prevRootElement: null | HTMLElement, -) => void; +) => void | (() => void); type TextContentListener = (text: string) => void; type ErrorHandler = (error: Error) => void; export type MutationListener = ( @@ -123,13 +123,13 @@ export type MutationListener = ( export type MutationListenerOptions = { skipInitialization?: boolean; }; -export type EditableListener = (editable: boolean) => void; +export type EditableListener = (editable: boolean) => void | (() => void); type Listeners = { - decorator: Set, + decorator: Map void)>, mutation: MutationListeners, - textcontent: Set, - root: Set, - update: Set, + textcontent: Map void)>, + root: Map void)>, + update: Map void)>, }; export type CommandListener

= (payload: P, editor: LexicalEditor) => boolean; // $FlowFixMe[unclear-type] diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 685f9364344..41cde629e57 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -17,7 +17,7 @@ import type { import invariant from 'shared/invariant'; -import {$getRoot, $getSelection, TextNode} from '.'; +import {$getRoot, $getSelection, mergeRegister, TextNode} from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; import {cloneEditorState, createEmptyEditorState} from './LexicalEditorState'; import { @@ -327,10 +327,15 @@ export type DecoratorListener = ( decorator: Record, ) => void; +/** + * A listener that is called when {@link LexicalEditor.setRootElement} changes the + * element that the editor is attached to. If this callback returns a function, + * that function will be called before the next value update or unregister. + */ export type RootListener = ( rootElement: null | HTMLElement, prevRootElement: null | HTMLElement, -) => void; +) => void | (() => void); export type TextContentListener = (text: string) => void; @@ -345,7 +350,12 @@ export type MutationListener = ( export type CommandListener

= (payload: P, editor: LexicalEditor) => boolean; -export type EditableListener = (editable: boolean) => void; +/** + * A listener that is called when {@link LexicalEditor.setEditable} changes the + * editable state of the editor. If this callback returns a function, + * that function will be called before the next value update or unregister. + */ +export type EditableListener = (editable: boolean) => void | (() => void); export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4; @@ -388,23 +398,29 @@ type Commands = Map< Array>> >; +export type ListenerMap = Map void)>; + export interface Listeners { // eslint-disable-next-line @typescript-eslint/no-explicit-any - decorator: Set>; + decorator: ListenerMap>; mutation: MutationListeners; - editable: Set; - root: Set; - textcontent: Set; - update: Set; + editable: ListenerMap; + root: ListenerMap; + textcontent: ListenerMap; + update: ListenerMap; } -export type SetListeners = { - [K in keyof Listeners as Listeners[K] extends Set< +export type MapListeners = { + [K in keyof Listeners as Listeners[K] extends Map< // eslint-disable-next-line @typescript-eslint/no-explicit-any - (...args: any[]) => void + (...args: any[]) => void | undefined | (() => void), + undefined | (() => void) > ? K - : never]: Listeners[K] extends Set<(...args: infer Args) => void> + : never]: Listeners[K] extends Map< + (...args: infer Args) => void | undefined | (() => void), + undefined | (() => void) + > ? Args : never; }; @@ -667,6 +683,35 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { return editor; } +function triggerListener< + T extends (...args_: Args) => void | undefined | (() => void), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Args extends any[], +>(listenerMap: ListenerMap, listener: T, args: Args) { + const unregister = listenerMap.get(listener); + if (unregister) { + unregister(); + } + listenerMap.set(listener, listener(...args) || undefined); +} + +function unregisterListener(listenerMap: ListenerMap, listener: T): void { + const unregister = listenerMap.get(listener); + listenerMap.delete(listener); + if (unregister) { + unregister(); + } +} + +function registerListener( + listenerMap: ListenerMap, + listener: T, + unregister?: undefined | (() => void), +): () => void { + listenerMap.set(listener, unregister); + return unregisterListener.bind(null, listenerMap, listener); +} + export class LexicalEditor { /** @internal */ declare ['constructor']: KlassConstructor; @@ -763,12 +808,12 @@ export class LexicalEditor { this._updating = false; // Listeners this._listeners = { - decorator: new Set(), - editable: new Set(), + decorator: new Map(), + editable: new Map(), mutation: new Map(), - root: new Set(), - textcontent: new Set(), - update: new Set(), + root: new Map(), + textcontent: new Map(), + update: new Map(), }; // Commands this._commands = new Map(); @@ -815,25 +860,20 @@ export class LexicalEditor { * @returns a teardown function that can be used to cleanup the listener. */ registerUpdateListener(listener: UpdateListener): () => void { - const listenerSetOrMap = this._listeners.update; - listenerSetOrMap.add(listener); - return () => { - listenerSetOrMap.delete(listener); - }; + return registerListener(this._listeners.update, listener); } /** * Registers a listener for for when the editor changes between editable and non-editable states. * Will trigger the provided callback each time the editor transitions between these states until the * teardown function is called. * + * If the listener returns a function, that function will be called before the next transition or + * teardown. + * * @returns a teardown function that can be used to cleanup the listener. */ registerEditableListener(listener: EditableListener): () => void { - const listenerSetOrMap = this._listeners.editable; - listenerSetOrMap.add(listener); - return () => { - listenerSetOrMap.delete(listener); - }; + return registerListener(this._listeners.editable, listener); } /** * Registers a listener for when the editor's decorator object changes. The decorator object contains @@ -845,11 +885,7 @@ export class LexicalEditor { * @returns a teardown function that can be used to cleanup the listener. */ registerDecoratorListener(listener: DecoratorListener): () => void { - const listenerSetOrMap = this._listeners.decorator; - listenerSetOrMap.add(listener); - return () => { - listenerSetOrMap.delete(listener); - }; + return registerListener(this._listeners.decorator, listener); } /** * Registers a listener for when Lexical commits an update to the DOM and the text content of @@ -862,11 +898,7 @@ export class LexicalEditor { * @returns a teardown function that can be used to cleanup the listener. */ registerTextContentListener(listener: TextContentListener): () => void { - const listenerSetOrMap = this._listeners.textcontent; - listenerSetOrMap.add(listener); - return () => { - listenerSetOrMap.delete(listener); - }; + return registerListener(this._listeners.textcontent, listener); } /** * Registers a listener for when the editor's root DOM element (the content editable @@ -877,16 +909,21 @@ export class LexicalEditor { * Will trigger the provided callback each time the editor transitions between these states until the * teardown function is called. * + * If the listener returns a function, that function will be called before the next transition or + * teardown. + * * @returns a teardown function that can be used to cleanup the listener. */ registerRootListener(listener: RootListener): () => void { - const listenerSetOrMap = this._listeners.root; - listener(this._rootElement, null); - listenerSetOrMap.add(listener); - return () => { - listener(null, this._rootElement); - listenerSetOrMap.delete(listener); - }; + const listenerMap = this._listeners.root; + return mergeRegister( + registerListener( + listenerMap, + listener, + listener(this._rootElement, null) || undefined, + ), + () => triggerListener(listenerMap, listener, [null, this._rootElement]), + ); } /** * Registers a listener that will trigger anytime the provided command diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 7901b9c3cea..0520aace0b4 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -23,10 +23,10 @@ import { EditorUpdateOptions, LexicalCommand, LexicalEditor, + MapListeners, MutatedNodes, RegisteredNodes, resetEditor, - SetListeners, Transform, } from './LexicalEditor'; import { @@ -748,21 +748,31 @@ function triggerMutationListeners( } } -export function triggerListeners( +export function triggerListeners( type: T, editor: LexicalEditor, isCurrentlyEnqueuingUpdates: boolean, - ...payload: SetListeners[T] + ...payload: MapListeners[T] ): void { const previouslyUpdating = editor._updating; editor._updating = isCurrentlyEnqueuingUpdates; try { - const listeners = Array.from( - editor._listeners[type] as Set<(...args: SetListeners[T]) => void>, - ); - for (let i = 0; i < listeners.length; i++) { - listeners[i].apply(null, payload); + const listenerMap = editor._listeners[type] as Map< + (...args: MapListeners[T]) => void | undefined | (() => void), + void | undefined | (() => void) + >; + const listeners = Array.from(listenerMap); + for (const [listener, unregister] of listeners) { + if (unregister) { + unregister(); + } + const nextUnregister = listener(...payload); + if (listenerMap.has(listener)) { + listenerMap.set(listener, nextUnregister); + } else if (nextUnregister) { + nextUnregister(); + } } } finally { editor._updating = previouslyUpdating; diff --git a/packages/lexical/src/__tests__/unit/LexicalEditorListener.test.ts b/packages/lexical/src/__tests__/unit/LexicalEditorListener.test.ts new file mode 100644 index 00000000000..7b0c25800e1 --- /dev/null +++ b/packages/lexical/src/__tests__/unit/LexicalEditorListener.test.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {buildEditorFromExtensions} from '@lexical/extension'; +import {describe, expect, test, vi} from 'vitest'; + +describe('LexicalEditor listeners', () => { + describe('registerRootListener', () => { + test('can return a function that is called when unregistered', () => { + const editor = buildEditorFromExtensions({name: '@test'}); + const rootListenerCallback = vi.fn(); + const rootListener = vi + .fn() + .mockImplementation((nextRoot, prevRoot) => rootListenerCallback); + const unregister = editor.registerRootListener(rootListener); + expect(editor._listeners.root.has(rootListener)).toBe(true); + expect(rootListener).toHaveBeenCalledTimes(1); + expect(rootListener).toHaveBeenLastCalledWith(null, null); + expect(rootListenerCallback).toHaveBeenCalledTimes(0); + unregister(); + expect(rootListener).toHaveBeenCalledTimes(2); + expect(rootListener).toHaveBeenLastCalledWith(null, null); + // Called once to unregister the original return value and then again after the finalization (null, …) call + expect(rootListenerCallback).toHaveBeenCalledTimes(2); + expect(editor._listeners.root.has(rootListener)).toBe(false); + }); + test('updates the function on each call', () => { + const editor = buildEditorFromExtensions({name: '@test'}); + const rootListenerCallback = vi.fn(); + const rootListener = vi + .fn() + .mockImplementationOnce((nextRoot, prevRoot) => rootListenerCallback); + const unregister = editor.registerRootListener(rootListener); + expect(rootListener).toHaveBeenCalledTimes(1); + expect(rootListener).toHaveBeenLastCalledWith(null, null); + expect(rootListenerCallback).toHaveBeenCalledTimes(0); + unregister(); + expect(rootListener).toHaveBeenCalledTimes(2); + expect(rootListener).toHaveBeenLastCalledWith(null, null); + // Only the first call returns the function + expect(rootListenerCallback).toHaveBeenCalledTimes(1); + }); + test('works when the root element changes too', () => { + const editor = buildEditorFromExtensions({name: '@test'}); + const rootListenerCallback = vi.fn(); + const rootListener = vi + .fn() + .mockImplementation((nextRoot, prevRoot) => + rootListenerCallback.bind(null, nextRoot, prevRoot), + ); + const initialRoot = document.createElement('div'); + const nextRoot = document.createElement('div'); + editor.setRootElement(initialRoot); + const unregister = editor.registerRootListener(rootListener); + expect(rootListenerCallback).toHaveBeenCalledTimes(0); + editor.setRootElement(nextRoot); + editor.setRootElement(null); + editor.setRootElement(initialRoot); + // We haven't unregistered the call to initialRoot yet + expect(rootListenerCallback.mock.calls).toEqual([ + [initialRoot, null], + [nextRoot, initialRoot], + [null, nextRoot], + ]); + unregister(); + expect(rootListenerCallback.mock.calls).toEqual([ + [initialRoot, null], + [nextRoot, initialRoot], + [null, nextRoot], + [initialRoot, null], + [null, initialRoot], + ]); + }); + }); + + describe('registerEditableListener', () => { + test('can return a function that is called when unregistered', () => { + const editor = buildEditorFromExtensions({name: '@test'}); + const editableListenerCallback = vi.fn(); + const editableListener = vi + .fn() + .mockImplementation(() => editableListenerCallback); + const unregister = editor.registerEditableListener(editableListener); + expect(editor._listeners.editable.has(editableListener)).toBe(true); + // Not called immediately on registration + expect(editableListener).toHaveBeenCalledTimes(0); + expect(editableListenerCallback).toHaveBeenCalledTimes(0); + editor.setEditable(false); + // Called on first change + expect(editableListener).toHaveBeenCalledTimes(1); + expect(editableListenerCallback).toHaveBeenCalledTimes(0); + unregister(); + // Called on unregister + expect(editableListener).toHaveBeenCalledTimes(1); + expect(editableListenerCallback).toHaveBeenCalledTimes(1); + expect(editor._listeners.editable.has(editableListener)).toBe(false); + }); + test('updates the function on each call', () => { + const editor = buildEditorFromExtensions({name: '@test'}); + const editableListenerCallback = vi.fn(); + const editableListener = vi + .fn() + .mockImplementationOnce(() => editableListenerCallback); + const unregister = editor.registerEditableListener(editableListener); + // Not called immediately + expect(editableListener).toHaveBeenCalledTimes(0); + expect(editableListenerCallback).toHaveBeenCalledTimes(0); + editor.setEditable(false); + expect(editableListener).toHaveBeenCalledTimes(1); + expect(editableListener).toHaveBeenLastCalledWith(false); + expect(editableListenerCallback).toHaveBeenCalledTimes(0); + editor.setEditable(true); + expect(editableListener).toHaveBeenCalledTimes(2); + expect(editableListener).toHaveBeenLastCalledWith(true); + // Only the first call returns the function + expect(editableListenerCallback).toHaveBeenCalledTimes(1); + unregister(); + // Only the first call returns the function + expect(editableListenerCallback).toHaveBeenCalledTimes(1); + }); + test('works when editable state changes', () => { + const editor = buildEditorFromExtensions({name: '@test'}); + const editableListenerCallback = vi.fn(); + const editableListener = vi + .fn() + .mockImplementation((editable) => + editableListenerCallback.bind(null, editable), + ); + const unregister = editor.registerEditableListener(editableListener); + // Not called on registration + expect(editableListener).toHaveBeenCalledTimes(0); + expect(editableListenerCallback).toHaveBeenCalledTimes(0); + editor.setEditable(false); + expect(editableListener).toHaveBeenCalledTimes(1); + expect(editableListenerCallback).toHaveBeenCalledTimes(0); + editor.setEditable(true); + expect(editableListener).toHaveBeenCalledTimes(2); + // Previous callback is called when state changes + expect(editableListenerCallback.mock.calls).toEqual([[false]]); + editor.setEditable(false); + expect(editableListenerCallback.mock.calls).toEqual([[false], [true]]); + unregister(); + // Final callback is called on unregister + expect(editableListenerCallback.mock.calls).toEqual([ + [false], + [true], + [false], + ]); + }); + }); +});