diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 561f77b158..85a6b9900e 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1296,7 +1296,7 @@ export class BlockNoteEditor< editor: BlockNoteEditor; }) => void, ) { - this._eventManager.onMount(callback); + return this._eventManager.onMount(callback); } /** @@ -1312,7 +1312,7 @@ export class BlockNoteEditor< editor: BlockNoteEditor; }) => void, ) { - this._eventManager.onUnmount(callback); + return this._eventManager.onUnmount(callback); } /** diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 925c6b7d12..e3a7b736b1 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -15,6 +15,7 @@ import { import { useComponentsContext } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useExtension } from "../../../hooks/useExtension.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -41,6 +42,7 @@ function checkLinkInSchema( export const CreateLinkButton = () => { const editor = useBlockNoteEditor(); + const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; const dict = useDictionary(); @@ -97,13 +99,12 @@ export const CreateLinkButton = () => { } }; - const domElement = editor.domElement; - domElement?.addEventListener("keydown", callback); + editorDOMElement?.addEventListener("keydown", callback); return () => { - domElement?.removeEventListener("keydown", callback); + editorDOMElement?.removeEventListener("keydown", callback); }; - }, [editor.domElement]); + }, [editorDOMElement]); if (state === undefined) { return null; diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index ea876c24f8..9e7fe42d07 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -4,6 +4,7 @@ import { Range } from "@tiptap/core"; import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { @@ -22,6 +23,8 @@ export const LinkToolbarController = (props: { const [toolbarOpen, setToolbarOpen] = useState(false); const [toolbarPositionFrozen, setToolbarPositionFrozen] = useState(false); + const editorDOMElement = useEditorDOMElement(); + const linkToolbar = useExtension(LinkToolbarExtension); // Because the toolbar opens with a delay when a link is hovered by the mouse @@ -98,16 +101,14 @@ export const LinkToolbarController = (props: { const destroyOnSelectionChangeHandler = editor.onSelectionChange(textCursorCallback); - const domElement = editor.domElement; - - domElement?.addEventListener("mouseover", mouseCursorCallback); + editorDOMElement?.addEventListener("mouseover", mouseCursorCallback); return () => { destroyOnChangeHandler(); destroyOnSelectionChangeHandler(); - domElement?.removeEventListener("mouseover", mouseCursorCallback); + editorDOMElement?.removeEventListener("mouseover", mouseCursorCallback); }; - }, [editor, editor.domElement, linkToolbar, link, toolbarPositionFrozen]); + }, [editor, editorDOMElement, linkToolbar, link, toolbarPositionFrozen]); const floatingUIOptions = useMemo( () => ({ diff --git a/packages/react/src/components/Popovers/PositionPopover.tsx b/packages/react/src/components/Popovers/PositionPopover.tsx index fafebd45b7..93ef837f61 100644 --- a/packages/react/src/components/Popovers/PositionPopover.tsx +++ b/packages/react/src/components/Popovers/PositionPopover.tsx @@ -2,6 +2,7 @@ import { posToDOMRect } from "@tiptap/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; import { GenericPopover, GenericPopoverReference } from "./GenericPopover.js"; @@ -15,6 +16,7 @@ export const PositionPopover = ( const { from, to } = position || {}; const editor = useBlockNoteEditor(); + const editorDOMElement = useEditorDOMElement(); const reference = useMemo(() => { if (from === undefined || to === undefined) { @@ -25,11 +27,11 @@ export const PositionPopover = ( // Use first child as the editor DOM element may itself be scrollable. // For FloatingUI to auto-update the position during scrolling, the // `contextElement` must be a descendant of the scroll container. - element: editor.domElement?.firstElementChild || undefined, + element: editorDOMElement?.firstElementChild || undefined, getBoundingClientRect: () => posToDOMRect(editor.prosemirrorView, from, to ?? from), }; - }, [editor, from, to]); + }, [editor, editorDOMElement, from, to]); return ( diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index b2af2c55e5..a0fdcb61d4 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -7,6 +7,7 @@ import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState, @@ -64,6 +65,7 @@ export function GridSuggestionMenuController< InlineContentSchema, StyleSchema >(); + const editorDOMElement = useEditorDOMElement(); const { triggerCharacter, @@ -108,7 +110,7 @@ export function GridSuggestionMenuController< // Use first child as the editor DOM element may itself be scrollable. // For FloatingUI to auto-update the position during scrolling, the // `contextElement` must be a descendant of the scroll container. - element: (editor.domElement?.firstChild || undefined) as + element: (editorDOMElement?.firstChild || undefined) as | Element | undefined, getBoundingClientRect: () => state?.referencePos || new DOMRect(), diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts index e2b27f60e3..8dfaffc6ed 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts @@ -1,15 +1,17 @@ import { BlockNoteEditor } from "@blocknote/core"; import { useEffect, useState } from "react"; +import { useEditorDOMElement } from "../../../../hooks/useEditorDomElement.js"; // Hook which handles keyboard navigation of a grid suggestion menu. Arrow keys // are used to select a menu item, enter is used to execute it. export function useGridSuggestionMenuKeyboardNavigation( - editor: BlockNoteEditor, + _editor: BlockNoteEditor, query: string, items: Item[], columns: number, onItemClick?: (item: Item) => void, ) { + const editorDOMElement = useEditorDOMElement(); const [selectedIndex, setSelectedIndex] = useState(0); const isGrid = columns !== undefined && columns > 1; @@ -66,17 +68,16 @@ export function useGridSuggestionMenuKeyboardNavigation( return false; }; - const domElement = editor.domElement; - domElement?.addEventListener("keydown", handleMenuNavigationKeys, true); + editorDOMElement?.addEventListener("keydown", handleMenuNavigationKeys, true); return () => { - domElement?.removeEventListener( + editorDOMElement?.removeEventListener( "keydown", handleMenuNavigationKeys, true, ); }; - }, [editor.domElement, items, selectedIndex, onItemClick, columns, isGrid]); + }, [editorDOMElement, items, selectedIndex, onItemClick, columns, isGrid]); // Resets index when items change useEffect(() => { diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 6efb18c845..8b4c81e6f7 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -8,6 +8,7 @@ import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { @@ -58,6 +59,7 @@ export function SuggestionMenuController< InlineContentSchema, StyleSchema >(); + const editorDOMElement = useEditorDOMElement(); const { triggerCharacter, @@ -101,7 +103,7 @@ export function SuggestionMenuController< // Use first child as the editor DOM element may itself be scrollable. // For FloatingUI to auto-update the position during scrolling, the // `contextElement` must be a descendant of the scroll container. - element: (editor.domElement?.firstChild || undefined) as + element: (editorDOMElement?.firstChild || undefined) as | Element | undefined, getBoundingClientRect: () => state?.referencePos || new DOMRect(), diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts index 6972c67f1f..8984dbb5bf 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -1,27 +1,29 @@ import { BlockNoteEditor } from "@blocknote/core"; import { useEffect } from "react"; +import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; import { useSuggestionMenuKeyboardHandler } from "./useSuggestionMenuKeyboardHandler.js"; // Hook which handles keyboard navigation of a suggestion menu. Up & down arrow // keys are used to select a menu item, enter is used to execute it. export function useSuggestionMenuKeyboardNavigation( - editor: BlockNoteEditor, + _editor: BlockNoteEditor, query: string, items: Item[], onItemClick?: (item: Item) => void, element?: HTMLElement, ) { + const editorDOMElement = useEditorDOMElement(); const { selectedIndex, setSelectedIndex, handler } = useSuggestionMenuKeyboardHandler(items, onItemClick); useEffect(() => { - const el = element || editor.domElement; + const el = element || editorDOMElement; el?.addEventListener("keydown", handler, true); return () => { el?.removeEventListener("keydown", handler, true); }; - }, [editor.domElement, items, selectedIndex, onItemClick, element, handler]); + }, [editorDOMElement, items, selectedIndex, onItemClick, element, handler]); // Resets index when items change useEffect(() => { diff --git a/packages/react/src/hooks/useEditorDomElement.ts b/packages/react/src/hooks/useEditorDomElement.ts new file mode 100644 index 0000000000..dde7cd2fce --- /dev/null +++ b/packages/react/src/hooks/useEditorDomElement.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +import { useBlockNoteEditor } from "./useBlockNoteEditor.js"; +import { useEditorState } from "./useEditorState.js"; + +/** + * Returns the editor's DOM element reactively. + * + * `editor.domElement` is gated behind TipTap's `isInitialized` flag, which is + * set in a `setTimeout(0)` after mount. A plain read of `editor.domElement` + * during the first render (or effect) will see `undefined`, and no transaction + * fires after `isInitialized` flips to notify subscribers. + * + * This hook uses `useEditorState` to pick up changes that coincide with + * transactions (e.g. remounts), and supplements it with a TipTap `create` + * event listener to handle the initial mount timing. + */ +export function useEditorDOMElement() { + const editor = useBlockNoteEditor(); + + // Handle initial mount timing. TipTap sets isInitialized synchronously + // right after emitting the "create" event, so by the time React processes + // this state update, editor.domElement is available. + const [, setInitialized] = useState(!editor.headless); + useEffect(() => { + if (!editor.headless) { + return; + } + const handler = () => setInitialized(true); + editor._tiptapEditor.on("create", handler); + return () => { + editor._tiptapEditor.off("create", handler); + }; + }, [editor]); + + // Re-evaluate editor.domElement on every render (including the one + // triggered by setInitialized above) and on every transaction. + return useEditorState({ + editor, + selector: (ctx) => ctx.editor?.domElement, + equalityFn: (a, b) => a === b, + }); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index fdefed5e49..6ed745a789 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -117,6 +117,7 @@ export * from "./hooks/useActiveStyles.js"; export * from "./hooks/useBlockNoteEditor.js"; export * from "./hooks/useCreateBlockNote.js"; export * from "./hooks/useEditorChange.js"; +export * from "./hooks/useEditorDomElement.js"; export * from "./hooks/useEditorSelectionBoundingBox.js"; export * from "./hooks/useEditorSelectionChange.js"; export * from "./hooks/useFocusWithin.js";