Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1296,7 +1296,7 @@ export class BlockNoteEditor<
editor: BlockNoteEditor<BSchema, ISchema, SSchema>;
}) => void,
) {
this._eventManager.onMount(callback);
return this._eventManager.onMount(callback);
}

/**
Expand All @@ -1312,7 +1312,7 @@ export class BlockNoteEditor<
editor: BlockNoteEditor<BSchema, ISchema, SSchema>;
}) => void,
) {
this._eventManager.onUnmount(callback);
return this._eventManager.onUnmount(callback);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -41,6 +42,7 @@ function checkLinkInSchema(

export const CreateLinkButton = () => {
const editor = useBlockNoteEditor<any, any, any>();
const editorDOMElement = useEditorDOMElement();
const Components = useComponentsContext()!;
const dict = useDictionary();

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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<FloatingUIOptions>(
() => ({
Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/components/Popovers/PositionPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,6 +16,7 @@ export const PositionPopover = (
const { from, to } = position || {};

const editor = useBlockNoteEditor<any, any, any>();
const editorDOMElement = useEditorDOMElement();

const reference = useMemo<GenericPopoverReference | undefined>(() => {
if (from === undefined || to === undefined) {
Expand All @@ -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 (
<GenericPopover reference={reference} {...floatingUIOptions}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +65,7 @@ export function GridSuggestionMenuController<
InlineContentSchema,
StyleSchema
>();
const editorDOMElement = useEditorDOMElement();

const {
triggerCharacter,
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Item>(
editor: BlockNoteEditor<any, any, any>,
_editor: BlockNoteEditor<any, any, any>,
query: string,
items: Item[],
columns: number,
onItemClick?: (item: Item) => void,
) {
const editorDOMElement = useEditorDOMElement();
const [selectedIndex, setSelectedIndex] = useState<number>(0);
Comment on lines +8 to 15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t ignore the explicit editor argument in this exported hook.

The implementation now binds keyboard listeners using context (useEditorDOMElement()), while Line 8 still accepts an editor argument. That can desync targets in multi-editor/custom usage and is a behavioral regression for consumers passing an explicit editor.

💡 Suggested fix
 export function useGridSuggestionMenuKeyboardNavigation<Item>(
-  _editor: BlockNoteEditor<any, any, any>,
+  editor: BlockNoteEditor<any, any, any>,
   query: string,
   items: Item[],
   columns: number,
   onItemClick?: (item: Item) => void,
 ) {
-  const editorDOMElement = useEditorDOMElement();
+  const editorDOMElement = useEditorDOMElement(editor);

And update useEditorDOMElement to accept an optional editor override:

export function useEditorDOMElement(
  editorOverride?: BlockNoteEditor<any, any, any>,
) {
  const contextEditor = useBlockNoteEditor<any, any, any>();
  const editor = editorOverride ?? contextEditor;
  // ...
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts`
around lines 8 - 15, The hook useGridSuggestionMenuKeyboardNavigation currently
accepts an explicit _editor argument but ignores it by always using
useEditorDOMElement() from context; update the implementation to pass the
explicit editor through to useEditorDOMElement (i.e., call
useEditorDOMElement(editor) or similar) and modify useEditorDOMElement to accept
an optional editorOverride parameter that falls back to useBlockNoteEditor when
not provided so keyboard listeners target the provided editor instance and
restore correct behavior for multi-editor/custom usage.


const isGrid = columns !== undefined && columns > 1;
Expand Down Expand Up @@ -66,17 +68,16 @@ export function useGridSuggestionMenuKeyboardNavigation<Item>(
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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -58,6 +59,7 @@ export function SuggestionMenuController<
InlineContentSchema,
StyleSchema
>();
const editorDOMElement = useEditorDOMElement();

const {
triggerCharacter,
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Item>(
editor: BlockNoteEditor<any, any, any>,
_editor: BlockNoteEditor<any, any, any>,
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(() => {
Expand Down
43 changes: 43 additions & 0 deletions packages/react/src/hooks/useEditorDomElement.ts
Original file line number Diff line number Diff line change
@@ -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<any, any, any>();

// 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);
};
Comment on lines +24 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Force a re-render on every create event, not just the first one.

setInitialized(true) is idempotent, so subsequent mount cycles for the same editor instance won’t trigger a render. That can leave descendants bound to stale/missing editor.domElement after remount.

💡 Suggested fix
-  const [, setInitialized] = useState(!editor.headless);
+  const [, bumpMountVersion] = useState(0);
   useEffect(() => {
     if (!editor.headless) {
       return;
     }
-    const handler = () => setInitialized(true);
+    const handler = () => bumpMountVersion((v) => v + 1);
     editor._tiptapEditor.on("create", handler);
     return () => {
       editor._tiptapEditor.off("create", handler);
     };
   }, [editor]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
};
const [, bumpMountVersion] = useState(0);
useEffect(() => {
if (!editor.headless) {
return;
}
const handler = () => bumpMountVersion((v) => v + 1);
editor._tiptapEditor.on("create", handler);
return () => {
editor._tiptapEditor.off("create", handler);
};
}, [editor]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/hooks/useEditorDomElement.ts` around lines 24 - 33, The
handler passed to editor._tiptapEditor.on("create") uses setInitialized(true)
which is idempotent and prevents re-renders after the first create; change the
effect to update state with a non-idempotent update (e.g. flip a boolean or
increment a counter) so each "create" event forces a render. Update the
useEffect in useEditorDomElement.ts around setInitialized and the
editor._tiptapEditor.on/off("create") handlers to call setInitialized(prev =>
!prev) or setInitialized(n => n + 1) instead of setInitialized(true) so
descendant consumers see a fresh editor.domElement on every create.

}, [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,
});
}
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading