diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 561f77b158..51c54be196 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -562,6 +562,13 @@ export class BlockNoteEditor< }; this.pmSchema.cached.blockNoteEditor = this; + this._tiptapEditor.on("mount", () => { + this.headless = false; + }); + this._tiptapEditor.on("unmount", () => { + this.headless = true; + }); + // Initialize managers this._blockManager = new BlockManager(this as any); @@ -758,9 +765,7 @@ export class BlockNoteEditor< return this.prosemirrorView?.hasFocus() || false; } - public get headless() { - return !this._tiptapEditor.isInitialized; - } + public headless = true; /** * Focus on the editor @@ -1296,7 +1301,7 @@ export class BlockNoteEditor< editor: BlockNoteEditor; }) => void, ) { - this._eventManager.onMount(callback); + return this._eventManager.onMount(callback); } /** @@ -1312,7 +1317,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..c21fc326ad 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts @@ -1,5 +1,6 @@ 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. @@ -10,6 +11,7 @@ export function useGridSuggestionMenuKeyboardNavigation( columns: number, onItemClick?: (item: Item) => void, ) { + const editorDOMElement = useEditorDOMElement(editor); const [selectedIndex, setSelectedIndex] = useState(0); const isGrid = columns !== undefined && columns > 1; @@ -66,17 +68,20 @@ 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..856765a056 --- /dev/null +++ b/packages/react/src/hooks/useEditorDomElement.ts @@ -0,0 +1,19 @@ +import { BlockNoteEditor } from "@blocknote/core"; + +import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; +import { useEditorState } from "./useEditorState.js"; + +// Returns the editor's DOM element reactively. +export function useEditorDOMElement(editor?: BlockNoteEditor) { + const editorContext = useBlockNoteContext(); + if (!editor) { + editor = editorContext?.editor; + } + + return useEditorState({ + editor, + selector: (ctx) => ctx.editor?.domElement, + equalityFn: (a, b) => a === b, + on: "mount", + }); +} diff --git a/packages/react/src/hooks/useEditorState.ts b/packages/react/src/hooks/useEditorState.ts index c739d2f6b3..6b63c8c4c1 100644 --- a/packages/react/src/hooks/useEditorState.ts +++ b/packages/react/src/hooks/useEditorState.ts @@ -46,7 +46,7 @@ export type UseEditorStateOptions< * The event to subscribe to. * @default "all" */ - on?: "all" | "selection" | "change"; + on?: "all" | "mount" | "selection" | "change"; }; /** @@ -117,7 +117,7 @@ class EditorStateManager< */ watch( nextEditor: BlockNoteEditor | null, - on: "all" | "selection" | "change", + on: "all" | "mount" | "selection" | "change", ): undefined | (() => void) { this.editor = nextEditor as TEditor; @@ -135,14 +135,21 @@ class EditorStateManager< const currentTiptapEditor = this.editor._tiptapEditor; const EVENT_TYPES = { - all: "transaction", - selection: "selectionUpdate", - change: "update", + all: ["transaction", "create", "mount", "unmount"], + // Listen for "create" as "mount" may fire before the hook is run. + mount: ["create", "mount", "unmount"], + selection: ["selectionUpdate"], + change: ["update"], } as const; - currentTiptapEditor.on(EVENT_TYPES[on], fn); + for (const eventType of EVENT_TYPES[on]) { + currentTiptapEditor.on(eventType, fn); + } + return () => { - currentTiptapEditor.off(EVENT_TYPES[on], fn); + for (const eventType of EVENT_TYPES[on]) { + currentTiptapEditor.off(eventType, fn); + } }; } 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";