diff --git a/packages/richtext-lexical/src/features/toolbars/shared/ToolbarButton/index.tsx b/packages/richtext-lexical/src/features/toolbars/shared/ToolbarButton/index.tsx index 91ca2ad90..c596e5e41 100644 --- a/packages/richtext-lexical/src/features/toolbars/shared/ToolbarButton/index.tsx +++ b/packages/richtext-lexical/src/features/toolbars/shared/ToolbarButton/index.tsx @@ -3,12 +3,13 @@ import type { LexicalEditor } from 'lexical' import { mergeRegister } from '@lexical/utils' import { $addUpdateTag, $getSelection } from 'lexical' -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import type { ToolbarGroupItem } from '../../types.js' import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js' import './index.scss' +import { useRunDeprioritized } from '../../../../utilities/useRunDeprioritized.js' const baseClass = 'toolbar-popup__button' @@ -21,91 +22,84 @@ export const ToolbarButton = ({ editor: LexicalEditor item: ToolbarGroupItem }) => { - const [enabled, setEnabled] = useState(true) - const [active, setActive] = useState(false) - const [className, setClassName] = useState(baseClass) + const [_state, setState] = useState({ active: false, enabled: true }) + const deferredState = useDeferredValue(_state) + const editorConfigContext = useEditorConfigContext() + const className = useMemo(() => { + return [ + baseClass, + !deferredState.enabled ? 'disabled' : '', + deferredState.active ? 'active' : '', + item.key ? `${baseClass}-${item.key}` : '', + ] + .filter(Boolean) + .join(' ') + }, [deferredState, item.key]) const updateStates = useCallback(() => { editor.getEditorState().read(() => { const selection = $getSelection() if (!selection) { return } - if (item.isActive) { - const isActive = item.isActive({ editor, editorConfigContext, selection }) - if (active !== isActive) { - setActive(isActive) + const newActive = item.isActive + ? item.isActive({ editor, editorConfigContext, selection }) + : false + + const newEnabled = item.isEnabled + ? item.isEnabled({ editor, editorConfigContext, selection }) + : true + + setState((prev) => { + if (prev.active === newActive && prev.enabled === newEnabled) { + return prev } - } - if (item.isEnabled) { - const isEnabled = item.isEnabled({ editor, editorConfigContext, selection }) - if (enabled !== isEnabled) { - setEnabled(isEnabled) - } - } + return { active: newActive, enabled: newEnabled } + }) }) - }, [active, editor, editorConfigContext, enabled, item]) + }, [editor, editorConfigContext, item]) + + const runDeprioritized = useRunDeprioritized() useEffect(() => { - updateStates() - }, [updateStates]) + const listener = () => runDeprioritized(updateStates) + + const cleanup = mergeRegister(editor.registerUpdateListener(listener)) + document.addEventListener('mouseup', listener) - useEffect(() => { - document.addEventListener('mouseup', updateStates) return () => { - document.removeEventListener('mouseup', updateStates) + cleanup() + document.removeEventListener('mouseup', listener) } - }, [updateStates]) + }, [editor, runDeprioritized, updateStates]) - useEffect(() => { - return mergeRegister( - editor.registerUpdateListener(() => { - updateStates() - }), - ) - }, [editor, updateStates]) + const handleClick = useCallback(() => { + if (!_state.enabled) { + return + } - useEffect(() => { - setClassName( - [ - baseClass, - enabled === false ? 'disabled' : '', - active ? 'active' : '', - item?.key ? `${baseClass}-` + item.key : '', - ] - .filter(Boolean) - .join(' '), - ) - }, [enabled, active, className, item.key]) + editor.focus(() => { + editor.update(() => { + $addUpdateTag('toolbar') + }) + // We need to wrap the onSelect in the callback, so the editor is properly focused before the onSelect is called. + item.onSelect?.({ + editor, + isActive: _state.active, + }) + }) + }, [editor, item, _state]) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + // This fixes a bug where you are unable to click the button if you are in a NESTED editor (editor in blocks field in editor). + // Thus only happens if you click on the SVG of the button. Clicking on the outside works. Related issue: https://github.com/payloadcms/payload/issues/4025 + // TODO: Find out why exactly it happens and why e.preventDefault() on the mouseDown fixes it. Write that down here, or potentially fix a root cause, if there is any. + e.preventDefault() + }, []) return ( - ) diff --git a/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/DropDown.tsx b/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/DropDown.tsx index d831546e9..bfa2c34ea 100644 --- a/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/DropDown.tsx +++ b/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/DropDown.tsx @@ -31,20 +31,16 @@ export function DropDownItem({ item: ToolbarGroupItem tooltip?: string }): React.ReactNode { - const [className, setClassName] = useState(baseClass) - - useEffect(() => { - setClassName( - [ - baseClass, - enabled === false ? 'disabled' : '', - active ? 'active' : '', - item?.key ? `${baseClass}-${item.key}` : '', - ] - .filter(Boolean) - .join(' '), - ) - }, [enabled, active, className, item.key]) + const className = useMemo(() => { + return [ + baseClass, + enabled === false ? 'disabled' : '', + active ? 'active' : '', + item?.key ? `${baseClass}-${item.key}` : '', + ] + .filter(Boolean) + .join(' ') + }, [enabled, active, item.key]) const ref = useRef(null) diff --git a/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/index.tsx b/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/index.tsx index 344fd4dba..c2cf95bfd 100644 --- a/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/index.tsx +++ b/packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/index.tsx @@ -1,5 +1,5 @@ 'use client' -import React, { useCallback, useEffect } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo } from 'react' const baseClass = 'toolbar-popup__dropdown' @@ -12,8 +12,9 @@ import { $getSelection } from 'lexical' import type { ToolbarDropdownGroup, ToolbarGroupItem } from '../../types.js' import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js' -import { DropDown, DropDownItem } from './DropDown.js' +import { useRunDeprioritized } from '../../../../utilities/useRunDeprioritized.js' import './index.scss' +import { DropDown, DropDownItem } from './DropDown.js' const ToolbarItem = ({ active, @@ -78,6 +79,8 @@ const ToolbarItem = ({ ) } +const MemoToolbarItem = React.memo(ToolbarItem) + export const ToolbarDropdown = ({ anchorElem, classNames, @@ -103,12 +106,22 @@ export const ToolbarDropdown = ({ maxActiveItems?: number onActiveChange?: ({ activeItems }: { activeItems: ToolbarGroupItem[] }) => void }) => { - const [activeItemKeys, setActiveItemKeys] = React.useState([]) - const [enabledItemKeys, setEnabledItemKeys] = React.useState([]) - const [enabledGroup, setEnabledGroup] = React.useState(true) + const [toolbarState, setToolbarState] = React.useState<{ + activeItemKeys: string[] + enabledGroup: boolean + enabledItemKeys: string[] + }>({ + activeItemKeys: [], + enabledGroup: true, + enabledItemKeys: [], + }) + const deferredToolbarState = useDeferredValue(toolbarState) + const editorConfigContext = useEditorConfigContext() const { items, key: groupKey } = group + const runDeprioritized = useRunDeprioritized() + const updateStates = useCallback(() => { editor.getEditorState().read(() => { const selection = $getSelection() @@ -137,11 +150,14 @@ export const ToolbarDropdown = ({ _enabledItemKeys.push(item.key) } } - if (group.isEnabled) { - setEnabledGroup(group.isEnabled({ editor, editorConfigContext, selection })) - } - setActiveItemKeys(_activeItemKeys) - setEnabledItemKeys(_enabledItemKeys) + + setToolbarState({ + activeItemKeys: _activeItemKeys, + enabledGroup: group.isEnabled + ? group.isEnabled({ editor, editorConfigContext, selection }) + : true, + enabledItemKeys: _enabledItemKeys, + }) if (onActiveChange) { onActiveChange({ activeItems: _activeItems }) @@ -149,17 +165,28 @@ export const ToolbarDropdown = ({ }) }, [editor, editorConfigContext, group, items, maxActiveItems, onActiveChange]) - useEffect(() => { - updateStates() - }, [updateStates]) - useEffect(() => { return mergeRegister( - editor.registerUpdateListener(() => { - updateStates() + editor.registerUpdateListener(async () => { + await runDeprioritized(updateStates) }), ) - }, [editor, updateStates]) + }, [editor, runDeprioritized, updateStates]) + + const renderedItems = useMemo(() => { + return items?.length + ? items.map((item) => ( + + )) + : null + }, [items, deferredToolbarState, anchorElem, editor]) return ( - {items.length - ? items.map((item) => { - return ( - - ) - }) - : null} + {renderedItems} ) } diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx index c73892076..71da30774 100644 --- a/packages/richtext-lexical/src/field/Field.tsx +++ b/packages/richtext-lexical/src/field/Field.tsx @@ -12,7 +12,7 @@ import { useField, } from '@payloadcms/ui' import { mergeFieldStyles } from '@payloadcms/ui/shared' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { ErrorBoundary } from 'react-error-boundary' import type { SanitizedClientEditorConfig } from '../lexical/config/types.js' @@ -24,6 +24,7 @@ import './index.scss' import type { LexicalRichTextFieldProps } from '../types.js' import { LexicalProvider } from '../lexical/LexicalProvider.js' +import { useRunDeprioritized } from '../utilities/useRunDeprioritized.js' const baseClass = 'rich-text-lexical' @@ -116,35 +117,22 @@ const RichTextComponent: React.FC< const pathWithEditDepth = `${path}.${editDepth}` - const dispatchFieldUpdateTask = useRef(undefined) + const runDeprioritized = useRunDeprioritized() // defaults to 500 ms timeout const handleChange = useCallback( (editorState: EditorState) => { - const updateFieldValue = (editorState: EditorState) => { + // Capture `editorState` in the closure so we can safely run later. + const updateFieldValue = () => { const newState = editorState.toJSON() prevValueRef.current = newState setValue(newState) } - if (typeof window.requestIdleCallback === 'function') { - // Cancel earlier scheduled value updates, - // so that a CPU-limited event loop isn't flooded with n callbacks for n keystrokes into the rich text field, - // but that there's only ever the latest one state update - // dispatch task, to be executed with the next idle time, - // or the deadline of 500ms. - if (typeof window.cancelIdleCallback === 'function' && dispatchFieldUpdateTask.current) { - cancelIdleCallback(dispatchFieldUpdateTask.current) - } - // Schedule the state update to happen the next time the browser has sufficient resources, - // or the latest after 500ms. - dispatchFieldUpdateTask.current = requestIdleCallback(() => updateFieldValue(editorState), { - timeout: 500, - }) - } else { - updateFieldValue(editorState) - } + // Queue the update for the browser’s idle time (or Safari shim) + // and let the hook handle debouncing/cancellation. + void runDeprioritized(updateFieldValue) }, - [setValue], + [setValue, runDeprioritized], // `runDeprioritized` is stable (useCallback inside hook) ) const styles = useMemo(() => mergeFieldStyles(field), [field]) diff --git a/packages/richtext-lexical/src/utilities/useRunDeprioritized.ts b/packages/richtext-lexical/src/utilities/useRunDeprioritized.ts new file mode 100644 index 000000000..39158e47d --- /dev/null +++ b/packages/richtext-lexical/src/utilities/useRunDeprioritized.ts @@ -0,0 +1,77 @@ +'use client' +import { useCallback, useRef } from 'react' + +/** + * Simple hook that lets you run any callback once the main thread is idle + * (via `requestIdleCallback`) or when that API is missing (Safari) - after the + * next animation frame (`interactionResponse`). + * + * This will help you to avoid blocking the main thread with heavy work. + * + * The latest invocation wins: if a new run is queued before the previous one + * executes, the previous task is cancelled. + * + * Usage: + * ```ts + * const runDeprioritized = useRunDeprioritized(); + * + * const onEditorChange = (state: EditorState) => { + * runDeprioritized(() => { + * // heavy work here … + * }); + * }; + * ``` + * + * @param timeout Optional timeout (ms) for `requestIdleCallback`; defaults to 500 ms. + * @returns A `runDeprioritized(fn)` helper. + */ + +export function useRunDeprioritized(timeout = 500) { + const idleHandleRef = useRef(undefined) + + /** + * Schedule `fn` and resolve when it has executed. + */ + const runDeprioritized = useCallback( + (fn: () => void): Promise => { + return new Promise((resolve) => { + const exec = () => { + fn() + resolve() + } + + if ('requestIdleCallback' in window) { + // Cancel any previously queued task so only the latest runs. + if ('cancelIdleCallback' in window && idleHandleRef.current !== undefined) { + // Cancel earlier scheduled value updates, + // so that a CPU-limited event loop isn't flooded with n callbacks for n keystrokes into the rich text field, + // but that there's only ever the latest one state update + // dispatch task, to be executed with the next idle time, + // or the deadline of 500ms. + cancelIdleCallback(idleHandleRef.current) + } + // Schedule the state update to happen the next time the browser has sufficient resources, + // or the latest after 500ms. + idleHandleRef.current = requestIdleCallback(exec, { timeout }) + } else { + // Safari fallback: rAF + setTimeout shim. + void interactionResponse().then(exec) + } + }) + }, + [timeout], + ) + + return runDeprioritized +} + +function interactionResponse(): Promise { + // Taken from https://github.com/vercel-labs/await-interaction-response/tree/main/packages/await-interaction-response/src + + return new Promise((resolve) => { + setTimeout(resolve, 100) // Fallback for the case where the animation frame never fires. + requestAnimationFrame(() => { + setTimeout(resolve, 0) + }) + }) +} diff --git a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts index 3f1bf9385..c3bce87cb 100644 --- a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts @@ -767,6 +767,7 @@ describe('lexicalMain', () => { // make text bold await boldButton.click() + await wait(300) // Save drawer await docDrawer.locator('button').getByText('Save').first().click()