From f747a8d77f26de191ae290eebeea0f34bdd0df29 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 21 Sep 2026 18:50:07 +0200 Subject: [PATCH 1/9] Made enter save comment in comment editor --- .../react/src/components/Comments/Comment.tsx | 28 +++++----- .../src/components/Comments/CommentEditor.tsx | 35 +++++++++++++ .../components/Comments/FloatingComposer.tsx | 1 + .../react/src/components/Comments/Thread.tsx | 1 + .../src/end-to-end/comments/comments.test.tsx | 52 +++++++++++++++++++ 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index d4a6df40d6..42f93f7591 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -4,7 +4,7 @@ import { Dictionary, mergeCSSClasses } from "@blocknote/core"; import { CommentsExtension } from "@blocknote/core/comments"; import type { CommentData, ThreadData } from "@blocknote/core/comments"; import { ThreadStore } from "@blocknote/core/comments"; -import { MouseEvent, ReactNode, memo, useCallback, useState } from "react"; +import { ReactNode, memo, useCallback, useState } from "react"; import { RiArrowGoBackFill, RiCheckFill, @@ -34,7 +34,7 @@ type CommentEditorActionsProps = { isEditing: boolean; threadStore: ThreadStore; onReactionSelect: (emoji: string) => Promise; - onEditSubmit: (event: MouseEvent) => Promise; + onEditSubmit: () => Promise; onEditCancel: () => void; onEmojiPickerOpenChange: (open: boolean) => void; Components: Components; @@ -176,20 +176,17 @@ export const Comment = ({ setEditing(false); }, [commentEditor, comment.body]); - const onEditSubmit = useCallback( - async (_event: MouseEvent) => { - await threadStore.updateComment({ - commentId: comment.id, - comment: { - body: commentEditor.document, - }, - threadId: thread.id, - }); + const onEditSubmit = useCallback(async () => { + await threadStore.updateComment({ + commentId: comment.id, + comment: { + body: commentEditor.document, + }, + threadId: thread.id, + }); - setEditing(false); - }, - [comment, thread.id, commentEditor, threadStore], - ); + setEditing(false); + }, [comment, thread.id, commentEditor, threadStore]); const onDelete = useCallback(async () => { await threadStore.deleteComment({ @@ -348,6 +345,7 @@ export const Comment = ({ autoFocus={isEditing} editor={commentEditor} editable={isEditing} + onSubmit={onEditSubmit} actions={ comment.reactions.length > 0 || isEditing ? ({ isFocused, isEmpty }) => ( diff --git a/packages/react/src/components/Comments/CommentEditor.tsx b/packages/react/src/components/Comments/CommentEditor.tsx index f8eaa05d46..858d051dc0 100644 --- a/packages/react/src/components/Comments/CommentEditor.tsx +++ b/packages/react/src/components/Comments/CommentEditor.tsx @@ -1,6 +1,7 @@ import { BlockNoteEditor } from "@blocknote/core"; import { ReactNode, useCallback, useEffect, useState } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useEditorState } from "../../hooks/useEditorState.js"; /** @@ -18,7 +19,10 @@ export const CommentEditor = (props: { editable: boolean; actions?: (args: { isFocused: boolean; isEmpty: boolean }) => ReactNode; editor: BlockNoteEditor; + onSubmit?: () => void | Promise; }) => { + const { editor, editable, onSubmit } = props; + const editorElement = useEditorDOMElement(editor); const [isFocused, setIsFocused] = useState(false); const isEmpty = useEditorState({ editor: props.editor, @@ -27,6 +31,37 @@ export const CommentEditor = (props: { const components = useComponentsContext()!; + useEffect(() => { + if (!editorElement || !editable || !onSubmit) { + return; + } + + function onKeyDown(event: KeyboardEvent) { + if ( + event.key !== "Enter" || + event.shiftKey || + event.ctrlKey || + event.metaKey || + event.altKey || + event.isComposing + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!editor.isEmpty && !event.repeat) { + onSubmit?.(); + } + } + + editorElement.addEventListener("keydown", onKeyDown, true); + return () => { + editorElement.removeEventListener("keydown", onKeyDown, true); + }; + }, [editorElement, editor, editable, onSubmit]); + const onFocus = useCallback(() => { setIsFocused(true); }, []); diff --git a/packages/react/src/components/Comments/FloatingComposer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx index 827ddb69b6..25084142c3 100644 --- a/packages/react/src/components/Comments/FloatingComposer.tsx +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -93,6 +93,7 @@ export function FloatingComposer< autoFocus={true} editable={true} editor={newCommentEditor} + onSubmit={onSave} actions={({ isFocused, isEmpty }) => ( ( { }); describe("Check Comments functionality", () => { + test("Enter submits comments, replies and edits; Shift+Enter inserts a line break", async () => { + await focusOnEditor(); + await userEvent.keyboard("hello"); + await doubleClickElement(page.getByText("hello").element()); + await userEvent.click(await waitForSelector('[data-test="addcomment"]')); + const composer = await waitForSelector( + '.bn-comment-editor [contenteditable="true"]', + ); + + // Empty comments cannot be submitted, and Enter must not add a block. + await userEvent.keyboard("{Enter}"); + expect(composer.querySelectorAll(".bn-block-content")).toHaveLength(1); + await expectSelectorCount(".bn-thread", 1); + + await userEvent.keyboard("first line{Shift>}{Enter}{/Shift}second line"); + expect(composer.querySelectorAll(".bn-block-content")).toHaveLength(1); + expect(composer.querySelector("br")).not.toBeNull(); + + // Enter used to confirm IME composition must not submit the comment. + composer.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + isComposing: true, + bubbles: true, + cancelable: true, + }), + ); + await expectSelectorCount(".bn-thread", 1); + + await userEvent.keyboard("{Enter}"); + await expectSelectorCount(".bn-thread", 0); + await userEvent.click(await waitForSelector("span.bn-thread-mark")); + await expectSelectorCount(".bn-thread-comment", 1); + expect(document.querySelector(".bn-thread-comment")?.textContent).toContain( + "second line", + ); + + await userEvent.click( + await waitForSelector('.bn-thread-composer [contenteditable="true"]'), + ); + await userEvent.keyboard("reply{Enter}"); + await expectSelectorCount(".bn-thread-comment", 2); + await moveMouseOverElement(await waitForSelector(".bn-thread-comment")); + await userEvent.click(await waitForSelector('[data-test="moreActions"]')); + await userEvent.click(page.getByRole("menuitem", { name: "Edit comment" })); + await userEvent.keyboard("{End} edited{Enter}"); + await expectSelectorCount('.bn-thread-comment [contenteditable="true"]', 0); + expect(document.querySelector(".bn-thread-comment")?.textContent).toContain( + "edited", + ); + }); + test("Should be able to add reactions", async () => { await focusOnEditor(); From d72e449f27e16ce27b8f3d505eaa986fc245024b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 22 Sep 2026 13:37:47 +0000 Subject: [PATCH 2/9] refactor(comments): handle configurable submission in an extension --- .../CommentEditorSubmitExtension.test.ts | 141 ++++++++++++++++++ .../comments/CommentEditorSubmitExtension.ts | 56 +++++++ packages/core/src/comments/extension.ts | 8 + packages/core/src/comments/index.ts | 1 + .../core/src/editor/BlockNoteExtension.ts | 7 +- .../ExtensionManager/ExtensionManager.test.ts | 89 ++++++++++- .../editor/managers/ExtensionManager/index.ts | 43 ++++-- .../react/src/components/Comments/Comment.tsx | 36 +++-- .../src/components/Comments/CommentEditor.tsx | 35 ----- .../components/Comments/FloatingComposer.tsx | 48 ++---- .../Comments/FloatingComposerController.tsx | 21 ++- .../Comments/FloatingThreadController.tsx | 20 ++- .../react/src/components/Comments/Thread.tsx | 35 +++-- 13 files changed, 425 insertions(+), 115 deletions(-) create mode 100644 packages/core/src/comments/CommentEditorSubmitExtension.test.ts create mode 100644 packages/core/src/comments/CommentEditorSubmitExtension.ts diff --git a/packages/core/src/comments/CommentEditorSubmitExtension.test.ts b/packages/core/src/comments/CommentEditorSubmitExtension.test.ts new file mode 100644 index 0000000000..506104fa45 --- /dev/null +++ b/packages/core/src/comments/CommentEditorSubmitExtension.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { CommentEditorSubmitExtension } from "./CommentEditorSubmitExtension.js"; + +const editors: BlockNoteEditor[] = []; + +function createEditor( + options: Parameters[0], + content = "A comment", +) { + const editor = BlockNoteEditor.create({ + trailingBlock: false, + initialContent: [{ type: "paragraph", content }], + extensions: [CommentEditorSubmitExtension(options)], + }); + editor.mount(document.createElement("div")); + editors.push(editor); + return editor; +} + +function pressEnter(editor: BlockNoteEditor, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { + key: "Enter", + keyCode: 13, + bubbles: true, + cancelable: true, + ...options, + }); + editor.prosemirrorView.dom.dispatchEvent(event); + return event; +} + +afterEach(() => { + for (const editor of editors) { + editor.unmount(); + } + editors.length = 0; +}); + +describe("CommentEditorSubmitExtension", () => { + it("passes the editor to the callback provided at initialization", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit }); + pressEnter(editor); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith(editor); + }); + + it("shares the pending guard between keyboard and button submission", async () => { + let finish = () => {}; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const onSubmit = vi.fn(() => pending); + const editor = createEditor({ onSubmit }); + const submit = editor.getExtension(CommentEditorSubmitExtension)!.submit; + pressEnter(editor); + await submit(); + pressEnter(editor, { ctrlKey: true }); + expect(onSubmit).toHaveBeenCalledOnce(); + finish(); + await pending; + await submit(); + expect(onSubmit).toHaveBeenCalledTimes(2); + }); + + it("takes precedence over code block Enter behavior", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit }); + editor.updateBlock(editor.document[0], { type: "codeBlock" }); + pressEnter(editor); + expect(onSubmit).toHaveBeenCalledOnce(); + expect(editor.prosemirrorState.doc.textContent).toBe("A comment"); + }); + + it("submits on Enter by default without inserting a block", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit }); + expect(pressEnter(editor).defaultPrevented).toBe(true); + expect(onSubmit).toHaveBeenCalledOnce(); + expect(editor.document).toHaveLength(1); + }); + + it("allows normal Enter when disabled, but still submits on Mod-Enter", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit, submitOnEnter: false }); + pressEnter(editor); + expect(onSubmit).not.toHaveBeenCalled(); + expect(editor.document).toHaveLength(2); + pressEnter(editor, { ctrlKey: true }); + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + it("preserves Shift-Enter line breaks", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit }); + pressEnter(editor, { shiftKey: true }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(editor.document).toHaveLength(1); + let lineBreaks = 0; + editor.prosemirrorState.doc.descendants((node) => { + if (node.type.name === "hardBreak") { + lineBreaks++; + } + }); + expect(lineBreaks).toBe(1); + }); + + it("consumes submission shortcuts on empty comments", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit }, ""); + pressEnter(editor); + pressEnter(editor, { ctrlKey: true }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(editor.document).toHaveLength(1); + }); + + it("ignores composition and held keys", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit }); + pressEnter(editor, { isComposing: true }); + pressEnter(editor, { keyCode: 229 }); + const documentBeforeRepeat = editor.document; + expect(pressEnter(editor, { repeat: true }).defaultPrevented).toBe(true); + expect(editor.document).toEqual(documentBeforeRepeat); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("does not submit read-only editors", () => { + const onSubmit = vi.fn(); + const editor = createEditor({ onSubmit }); + editor.isEditable = false; + pressEnter(editor); + expect(onSubmit).not.toHaveBeenCalled(); + editor.isEditable = true; + pressEnter(editor); + expect(onSubmit).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/src/comments/CommentEditorSubmitExtension.ts b/packages/core/src/comments/CommentEditorSubmitExtension.ts new file mode 100644 index 0000000000..f464ce434b --- /dev/null +++ b/packages/core/src/comments/CommentEditorSubmitExtension.ts @@ -0,0 +1,56 @@ +import { + createExtension, + type ExtensionOptions, +} from "../editor/BlockNoteExtension.js"; + +/** Keyboard submission for a comment editor. */ +export const CommentEditorSubmitExtension = createExtension( + ({ + editor, + options: { submitOnEnter = true, onSubmit }, + }: ExtensionOptions<{ + /** Submit on plain Enter. Mod-Enter always submits. @default true */ + submitOnEnter?: boolean; + onSubmit: (editor: ExtensionOptions["editor"]) => void | Promise; + }>) => { + let submitting = false; + + async function submit() { + if (!editor.isEditable || editor.isEmpty || submitting) { + return; + } + submitting = true; + try { + await onSubmit(editor); + } finally { + submitting = false; + } + } + + function handleSubmit({ event }: { event: KeyboardEvent }) { + const view = editor.prosemirrorView; + if ( + !view.editable || + view.composing || + event.isComposing || + event.keyCode === 229 + ) { + return false; + } + if (!event.repeat) { + void submit(); + } + return true; + } + + return { + key: "commentEditorSubmit", + submit, + runsBefore: ["default"], + keyboardShortcuts: { + "Mod-Enter": handleSubmit, + ...(submitOnEnter ? { Enter: handleSubmit } : {}), + }, + }; + }, +); diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index 8b20fc8757..5658081552 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -64,6 +64,7 @@ export const CommentsExtension = createExtension( threadStore, resolveUsers, confirmBeforeDiscard = true, + submitOnEnter = true, }, }: ExtensionOptions<{ /** @@ -93,6 +94,12 @@ export const CommentsExtension = createExtension( * @default true */ confirmBeforeDiscard?: boolean; + /** + * Submit comments, replies, and edits on Enter. Shift-Enter inserts a line + * break, and Mod-Enter always submits, regardless of this setting. + * @default true + */ + submitOnEnter?: boolean; }>) => { if (!resolveUsers) { throw new Error( @@ -383,6 +390,7 @@ export const CommentsExtension = createExtension( }, commentEditorSchema, confirmBeforeDiscard, + submitOnEnter, } as const; }, ); diff --git a/packages/core/src/comments/index.ts b/packages/core/src/comments/index.ts index 7cc20cfe8d..a041254f89 100644 --- a/packages/core/src/comments/index.ts +++ b/packages/core/src/comments/index.ts @@ -1,3 +1,4 @@ +export * from "./CommentEditorSubmitExtension.js"; export * from "./extension.js"; export * from "./mark.js"; export * from "./threadstore/DefaultThreadStoreAuth.js"; diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index 6fa0ab89e6..c32fe88e9f 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -55,6 +55,8 @@ export interface Extension { /** * A mapping of a keyboard shortcut to a function that will be called when the shortcut is pressed + * The callback receives the editor and the original keyboard event. Return + * true to consume the shortcut, or false to let other handlers run. * * The keys are in the format: * - Key names may be strings like `Shift-Ctrl-Enter`—a key identifier prefixed with zero or more modifiers @@ -77,7 +79,10 @@ export interface Extension { */ readonly keyboardShortcuts?: Record< string, - (ctx: { editor: BlockNoteEditor }) => boolean + (ctx: { + editor: BlockNoteEditor; + event: KeyboardEvent; + }) => boolean >; /** diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts index 4c97e8849c..b1bf6986d7 100644 --- a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { Plugin, PluginKey } from "prosemirror-state"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { createExtension } from "../../BlockNoteExtension.js"; import { BlockNoteEditor } from "../../BlockNoteEditor.js"; @@ -15,6 +15,93 @@ function createMountedEditor( return editor; } +describe("Extension keyboard shortcuts", () => { + it("validates shortcut names at initialization", () => { + expect(() => + createMountedEditor([ + createExtension({ + key: "invalid-shortcut", + keyboardShortcuts: { "InvalidModifier-k": () => true }, + }), + ]), + ).toThrow("Unrecognized modifier name: InvalidModifier"); + }); + + it("restores the outer event for fallback matching after a nested key event", () => { + const nestedEvent = new KeyboardEvent("keydown", { + key: "x", + bubbles: true, + }); + const nested = vi.fn(() => true); + const fallback = vi.fn(() => true); + const editor = createMountedEditor([ + createExtension({ + key: "nested-shortcuts", + keyboardShortcuts: { + "Shift-K": ({ editor }) => { + editor.prosemirrorView.dom.dispatchEvent(nestedEvent); + return false; + }, + K: fallback, + x: nested, + }, + }), + ]); + const outerEvent = new KeyboardEvent("keydown", { + key: "K", + keyCode: 75, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + editor.prosemirrorView.dom.dispatchEvent(outerEvent); + expect(nested).toHaveBeenCalledExactlyOnceWith({ + editor, + event: nestedEvent, + }); + expect(fallback).toHaveBeenCalledExactlyOnceWith({ + editor, + event: outerEvent, + }); + expect(outerEvent.defaultPrevented).toBe(true); + editor.unmount(); + }); + + it("forwards the original event and preserves modifier matching and fallthrough", () => { + const first = vi.fn(() => false); + const second = vi.fn(() => true); + const editor = createMountedEditor([ + createExtension({ + key: "first-shortcut", + runsBefore: ["second-shortcut"], + keyboardShortcuts: { "Ctrl-Shift-k": first }, + }), + createExtension({ + key: "second-shortcut", + keyboardShortcuts: { "Ctrl-Shift-k": second }, + }), + ]); + const event = new KeyboardEvent("keydown", { + key: "K", + keyCode: 75, + ctrlKey: true, + shiftKey: true, + repeat: true, + bubbles: true, + cancelable: true, + }); + editor.prosemirrorView.dom.dispatchEvent(event); + expect(first).toHaveBeenCalledExactlyOnceWith({ editor, event }); + expect(second).toHaveBeenCalledExactlyOnceWith({ editor, event }); + expect(event.defaultPrevented).toBe(true); + editor.prosemirrorView.dom.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", bubbles: true }), + ); + expect(first).toHaveBeenCalledOnce(); + editor.unmount(); + }); +}); + /** * Returns the index of the plugin identified by `key` within the editor's * ProseMirror plugin list. A lower index means it runs/applies earlier. diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 5cf6e74c1c..fb991e4eaa 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -6,7 +6,7 @@ import { AnyExtension as AnyTiptapExtension, Extension as TiptapExtension, } from "@tiptap/core"; -import { keymap } from "@tiptap/pm/keymap"; +import { keydownHandler } from "@tiptap/pm/keymap"; import { Plugin, TextSelection } from "prosemirror-state"; import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { setTextCursorPosition } from "../../../api/blockManipulation/selections/textCursorPosition.js"; @@ -592,16 +592,41 @@ export class ExtensionManager { } if (Object.keys(extension.keyboardShortcuts || {}).length) { - plugins.push( - keymap( - Object.fromEntries( - Object.entries(extension.keyboardShortcuts!).map(([key, value]) => [ - key, - () => value({ editor: this.editor }), - ]), - ), + let currentEvent: KeyboardEvent | undefined; + // Normalize bindings once, as keymap does. Commands don't receive the + // keyboard event, so expose it only for the duration of handleKeyDown. + const handleKeyDown = keydownHandler( + Object.fromEntries( + Object.entries(extension.keyboardShortcuts!).map(([key, value]) => [ + key, + () => { + if (!currentEvent) { + throw new Error( + "Keyboard shortcut called outside handleKeyDown", + ); + } + return value({ editor: this.editor, event: currentEvent }); + }, + ]), ), ); + plugins.push( + new Plugin({ + props: { + handleKeyDown: (view, event) => { + const previousEvent = currentEvent; + currentEvent = event; + try { + return handleKeyDown(view, event); + } finally { + // Restore the outer event when a shortcut dispatches another + // key event synchronously, including when a command throws. + currentEvent = previousEvent; + } + }, + }, + }), + ); } return { plugins, inputRules }; diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 42f93f7591..c8456e27c1 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -1,7 +1,10 @@ "use client"; import { Dictionary, mergeCSSClasses } from "@blocknote/core"; -import { CommentsExtension } from "@blocknote/core/comments"; +import { + CommentEditorSubmitExtension, + CommentsExtension, +} from "@blocknote/core/comments"; import type { CommentData, ThreadData } from "@blocknote/core/comments"; import { ThreadStore } from "@blocknote/core/comments"; import { ReactNode, memo, useCallback, useState } from "react"; @@ -148,6 +151,8 @@ export const Comment = ({ const dict = useDictionary(); + const [isEditing, setEditing] = useState(false); + const commentEditor = useCreateBlockNote({ initialContent: comment.body, trailingBlock: false, @@ -158,11 +163,23 @@ export const Comment = ({ }, }, schema: comments.commentEditorSchema || defaultCommentEditorSchema, + extensions: [ + CommentEditorSubmitExtension({ + submitOnEnter: comments.submitOnEnter, + onSubmit: async (editor) => { + await comments.threadStore.updateComment({ + commentId: comment.id, + comment: { body: editor.document }, + threadId: thread.id, + }); + setEditing(false); + }, + }), + ], }); const Components = useComponentsContext()!; - const [isEditing, setEditing] = useState(false); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const threadStore = comments.threadStore; @@ -176,17 +193,9 @@ export const Comment = ({ setEditing(false); }, [commentEditor, comment.body]); - const onEditSubmit = useCallback(async () => { - await threadStore.updateComment({ - commentId: comment.id, - comment: { - body: commentEditor.document, - }, - threadId: thread.id, - }); - - setEditing(false); - }, [comment, thread.id, commentEditor, threadStore]); + const onEditSubmit = commentEditor.getExtension( + CommentEditorSubmitExtension, + )!.submit; const onDelete = useCallback(async () => { await threadStore.deleteComment({ @@ -345,7 +354,6 @@ export const Comment = ({ autoFocus={isEditing} editor={commentEditor} editable={isEditing} - onSubmit={onEditSubmit} actions={ comment.reactions.length > 0 || isEditing ? ({ isFocused, isEmpty }) => ( diff --git a/packages/react/src/components/Comments/CommentEditor.tsx b/packages/react/src/components/Comments/CommentEditor.tsx index 858d051dc0..f8eaa05d46 100644 --- a/packages/react/src/components/Comments/CommentEditor.tsx +++ b/packages/react/src/components/Comments/CommentEditor.tsx @@ -1,7 +1,6 @@ import { BlockNoteEditor } from "@blocknote/core"; import { ReactNode, useCallback, useEffect, useState } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useEditorState } from "../../hooks/useEditorState.js"; /** @@ -19,10 +18,7 @@ export const CommentEditor = (props: { editable: boolean; actions?: (args: { isFocused: boolean; isEmpty: boolean }) => ReactNode; editor: BlockNoteEditor; - onSubmit?: () => void | Promise; }) => { - const { editor, editable, onSubmit } = props; - const editorElement = useEditorDOMElement(editor); const [isFocused, setIsFocused] = useState(false); const isEmpty = useEditorState({ editor: props.editor, @@ -31,37 +27,6 @@ export const CommentEditor = (props: { const components = useComponentsContext()!; - useEffect(() => { - if (!editorElement || !editable || !onSubmit) { - return; - } - - function onKeyDown(event: KeyboardEvent) { - if ( - event.key !== "Enter" || - event.shiftKey || - event.ctrlKey || - event.metaKey || - event.altKey || - event.isComposing - ) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - if (!editor.isEmpty && !event.repeat) { - onSubmit?.(); - } - } - - editorElement.addEventListener("keydown", onKeyDown, true); - return () => { - editorElement.removeEventListener("keydown", onKeyDown, true); - }; - }, [editorElement, editor, editable, onSubmit]); - const onFocus = useCallback(() => { setIsFocused(true); }, []); diff --git a/packages/react/src/components/Comments/FloatingComposer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx index 25084142c3..8df81c94d7 100644 --- a/packages/react/src/components/Comments/FloatingComposer.tsx +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -1,23 +1,10 @@ -import { - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - DefaultStyleSchema, - Dictionary, - InlineContentSchema, - mergeCSSClasses, - StyleSchema, -} from "@blocknote/core"; -import { CommentsExtension } from "@blocknote/core/comments"; -import { TextSelection } from "@tiptap/pm/state"; -import { memo, useCallback } from "react"; +import { BlockNoteEditor, Dictionary, mergeCSSClasses } from "@blocknote/core"; +import { CommentEditorSubmitExtension } from "@blocknote/core/comments"; +import { memo } from "react"; import { Components, useComponentsContext, } from "../../editor/ComponentsContext.js"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { useExtension } from "../../hooks/useExtension.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { CommentEditor } from "./CommentEditor.js"; @@ -53,39 +40,23 @@ const FloatingComposerActionsComponent = memo( * * It's used when the user highlights a parts of the document to create a new comment / thread. */ -export function FloatingComposer< - B extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema, ->(props: { +export function FloatingComposer(props: { /** * The (empty) editor used to compose the new comment. Created and owned by * the `FloatingComposerController`, so it can check for unsaved text before - * the composer is dismissed. + * the composer is dismissed. Includes `CommentEditorSubmitExtension` with + * the callback that creates the thread. */ newCommentEditor: BlockNoteEditor; }) { - const editor = useBlockNoteEditor(); const newCommentEditor = props.newCommentEditor; - const comments = useExtension(CommentsExtension); - const Components = useComponentsContext()!; const dict = useDictionary(); - const onSave = useCallback(async () => { - // (later) For REST API, we should implement a loading state and error state - await comments.createThread({ - initialComment: { - body: newCommentEditor.document, - }, - }); - comments.stopPendingComment(); - editor.transact((tr) => { - tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); - }); - editor.focus(); - }, [comments, newCommentEditor, editor]); + const onSave = newCommentEditor.getExtension( + CommentEditorSubmitExtension, + )!.submit; return ( @@ -93,7 +64,6 @@ export function FloatingComposer< autoFocus={true} editable={true} editor={newCommentEditor} - onSubmit={onSave} actions={({ isFocused, isEmpty }) => ( { + await comments.createThread({ + initialComment: { body: commentEditor.document }, + }); + comments.stopPendingComment(); + editor.transact((tr) => { + tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); + }); + editor.focus(); + }, + }), + ], }, [pendingComment], ); diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index a1f082d2e3..52a3533c9f 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -1,4 +1,7 @@ -import { CommentsExtension } from "@blocknote/core/comments"; +import { + CommentEditorSubmitExtension, + CommentsExtension, +} from "@blocknote/core/comments"; import { flip, offset, shift } from "@floating-ui/react"; import { ComponentProps, FC, useMemo } from "react"; @@ -57,6 +60,21 @@ export default function FloatingThreadController(props: { }, }, schema: comments.commentEditorSchema || defaultCommentEditorSchema, + extensions: [ + CommentEditorSubmitExtension({ + submitOnEnter: comments.submitOnEnter, + onSubmit: async (editor) => { + if (!selectedThread) { + return; + } + await comments.threadStore.addComment({ + comment: { body: editor.document }, + threadId: selectedThread.id, + }); + editor.removeBlocks(editor.document); + }, + }), + ], }, [selectedThread?.id], ); diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index a9f01af97f..3f02f7bfee 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -1,7 +1,10 @@ import { BlockNoteEditor, Dictionary, mergeCSSClasses } from "@blocknote/core"; -import { CommentsExtension } from "@blocknote/core/comments"; +import { + CommentEditorSubmitExtension, + CommentsExtension, +} from "@blocknote/core/comments"; import { ThreadData } from "@blocknote/core/comments"; -import { FocusEvent, memo, useCallback } from "react"; +import { FocusEvent, memo } from "react"; import { Components, @@ -90,6 +93,7 @@ export type ThreadProps = { * The editor used to compose a reply. Provided by `FloatingThreadController` * so it can check for unsaved text before discarding the floating card. When * omitted (e.g. in the sidebar), the thread creates its own. + * Must include `CommentEditorSubmitExtension` configured with the reply's save callback. */ newCommentEditor?: BlockNoteEditor; }; @@ -127,6 +131,18 @@ export const Thread = ({ }, }, schema: comments.commentEditorSchema || defaultCommentEditorSchema, + extensions: [ + CommentEditorSubmitExtension({ + submitOnEnter: comments.submitOnEnter, + onSubmit: async (editor) => { + await comments.threadStore.addComment({ + comment: { body: editor.document }, + threadId: thread.id, + }); + editor.removeBlocks(editor.document); + }, + }), + ], }); // Use the editor provided by the controller (which owns the dismiss @@ -134,17 +150,9 @@ export const Thread = ({ // our own when the thread is rendered standalone (e.g. in the sidebar). const newCommentEditor = providedNewCommentEditor ?? ownNewCommentEditor; - const onNewCommentSave = useCallback(async () => { - await comments.threadStore.addComment({ - comment: { - body: newCommentEditor.document, - }, - threadId: thread.id, - }); - - // reset editor - newCommentEditor.removeBlocks(newCommentEditor.document); - }, [comments, newCommentEditor, thread.id]); + const onNewCommentSave = newCommentEditor.getExtension( + CommentEditorSubmitExtension, + )!.submit; return ( ( Date: Tue, 22 Sep 2026 14:35:27 +0000 Subject: [PATCH 3/9] refactor(comments): simplify shortcut event forwarding and validate submit extension --- .../ExtensionManager/ExtensionManager.test.ts | 13 +------ .../editor/managers/ExtensionManager/index.ts | 37 +++++-------------- .../components/Comments/FloatingComposer.tsx | 10 ++++- .../react/src/components/Comments/Thread.tsx | 10 ++++- 4 files changed, 27 insertions(+), 43 deletions(-) diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts index b1bf6986d7..56dd4a0efb 100644 --- a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -16,18 +16,7 @@ function createMountedEditor( } describe("Extension keyboard shortcuts", () => { - it("validates shortcut names at initialization", () => { - expect(() => - createMountedEditor([ - createExtension({ - key: "invalid-shortcut", - keyboardShortcuts: { "InvalidModifier-k": () => true }, - }), - ]), - ).toThrow("Unrecognized modifier name: InvalidModifier"); - }); - - it("restores the outer event for fallback matching after a nested key event", () => { + it("preserves the outer event for fallback matching after a nested key event", () => { const nestedEvent = new KeyboardEvent("keydown", { key: "x", bubbles: true, diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index fb991e4eaa..f84da832ed 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -592,37 +592,20 @@ export class ExtensionManager { } if (Object.keys(extension.keyboardShortcuts || {}).length) { - let currentEvent: KeyboardEvent | undefined; - // Normalize bindings once, as keymap does. Commands don't receive the - // keyboard event, so expose it only for the duration of handleKeyDown. - const handleKeyDown = keydownHandler( - Object.fromEntries( - Object.entries(extension.keyboardShortcuts!).map(([key, value]) => [ - key, - () => { - if (!currentEvent) { - throw new Error( - "Keyboard shortcut called outside handleKeyDown", - ); - } - return value({ editor: this.editor, event: currentEvent }); - }, - ]), - ), - ); plugins.push( new Plugin({ props: { handleKeyDown: (view, event) => { - const previousEvent = currentEvent; - currentEvent = event; - try { - return handleKeyDown(view, event); - } finally { - // Restore the outer event when a shortcut dispatches another - // key event synchronously, including when a command throws. - currentEvent = previousEvent; - } + const bindings = Object.fromEntries( + Object.entries(extension.keyboardShortcuts!).map( + ([key, callback]) => [ + key, + () => callback({ editor: this.editor, event }), + ], + ), + ); + + return keydownHandler(bindings)(view, event); }, }, }), diff --git a/packages/react/src/components/Comments/FloatingComposer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx index 8df81c94d7..ba1b8f72f1 100644 --- a/packages/react/src/components/Comments/FloatingComposer.tsx +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -54,9 +54,15 @@ export function FloatingComposer(props: { const Components = useComponentsContext()!; const dict = useDictionary(); - const onSave = newCommentEditor.getExtension( + const submitExtension = newCommentEditor.getExtension( CommentEditorSubmitExtension, - )!.submit; + ); + if (!submitExtension) { + throw new Error( + "FloatingComposer's newCommentEditor must include CommentEditorSubmitExtension.", + ); + } + const onSave = submitExtension.submit; return ( diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index 3f02f7bfee..831a08b402 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -150,9 +150,15 @@ export const Thread = ({ // our own when the thread is rendered standalone (e.g. in the sidebar). const newCommentEditor = providedNewCommentEditor ?? ownNewCommentEditor; - const onNewCommentSave = newCommentEditor.getExtension( + const submitExtension = newCommentEditor.getExtension( CommentEditorSubmitExtension, - )!.submit; + ); + if (!submitExtension) { + throw new Error( + "Thread's newCommentEditor must include CommentEditorSubmitExtension.", + ); + } + const onNewCommentSave = submitExtension.submit; return ( Date: Tue, 22 Sep 2026 14:51:00 +0000 Subject: [PATCH 4/9] docs(comments): document keyboard submission setting --- docs/content/docs/features/collaboration/comments.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/content/docs/features/collaboration/comments.mdx b/docs/content/docs/features/collaboration/comments.mdx index b5602fc1ef..7d6930c5f2 100644 --- a/docs/content/docs/features/collaboration/comments.mdx +++ b/docs/content/docs/features/collaboration/comments.mdx @@ -175,3 +175,9 @@ The only requirement for `ThreadsSidebar` is that it should be placed somewhere **`maxCommentsBeforeCollapse`**: The maximum number of comments that can be in a thread before the replies get collapsed. Defaults to 5. See [here](https://playground.blocknotejs.org/collaboration/comments-with-sidebar?hideMenu=true) for a standalone example of the `ThreadsSidebar` component. + +## Keyboard submission + +By default, Enter submits a comment, reply, or edit. Set `submitOnEnter: false` in your `CommentsExtension` options to let Enter insert a new paragraph instead. + +With either setting, Shift+Enter inserts a line break, and Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) submits. From 7b84b32b9e7b97725444861b9d6a5c89ac7c9817 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 22 Sep 2026 14:52:15 +0000 Subject: [PATCH 5/9] fix(comments): submit replies to the latest thread --- packages/react/src/components/Comments/Thread.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index 831a08b402..4f5d67b64e 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -4,7 +4,7 @@ import { CommentsExtension, } from "@blocknote/core/comments"; import { ThreadData } from "@blocknote/core/comments"; -import { FocusEvent, memo } from "react"; +import { FocusEvent, memo, useRef } from "react"; import { Components, @@ -121,6 +121,8 @@ export const Thread = ({ const dict = useDictionary(); const comments = useExtension(CommentsExtension); + const threadIdRef = useRef(thread.id); + threadIdRef.current = thread.id; const ownNewCommentEditor = useCreateBlockNote({ trailingBlock: false, @@ -137,7 +139,7 @@ export const Thread = ({ onSubmit: async (editor) => { await comments.threadStore.addComment({ comment: { body: editor.document }, - threadId: thread.id, + threadId: threadIdRef.current, }); editor.removeBlocks(editor.document); }, From b364848e175bd0c59dc6f824b28606394fee1d37 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 22 Sep 2026 15:12:28 +0000 Subject: [PATCH 6/9] test(comments): fix more actions button selector --- tests/src/end-to-end/comments/comments.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/end-to-end/comments/comments.test.tsx b/tests/src/end-to-end/comments/comments.test.tsx index 92f35382f8..41d2661631 100644 --- a/tests/src/end-to-end/comments/comments.test.tsx +++ b/tests/src/end-to-end/comments/comments.test.tsx @@ -102,7 +102,7 @@ describe("Check Comments functionality", () => { await userEvent.keyboard("reply{Enter}"); await expectSelectorCount(".bn-thread-comment", 2); await moveMouseOverElement(await waitForSelector(".bn-thread-comment")); - await userEvent.click(await waitForSelector('[data-test="moreActions"]')); + await userEvent.click(await waitForSelector('[data-test="moreactions"]')); await userEvent.click(page.getByRole("menuitem", { name: "Edit comment" })); await userEvent.keyboard("{End} edited{Enter}"); await expectSelectorCount('.bn-thread-comment [contenteditable="true"]', 0); From f005d037f88824d7ecc5ce530035e70dc7091858 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 22 Sep 2026 15:28:24 +0000 Subject: [PATCH 7/9] test(comments): wait for comment editing and saving --- tests/src/end-to-end/comments/comments.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/src/end-to-end/comments/comments.test.tsx b/tests/src/end-to-end/comments/comments.test.tsx index 41d2661631..b5ec2a6267 100644 --- a/tests/src/end-to-end/comments/comments.test.tsx +++ b/tests/src/end-to-end/comments/comments.test.tsx @@ -104,11 +104,16 @@ describe("Check Comments functionality", () => { await moveMouseOverElement(await waitForSelector(".bn-thread-comment")); await userEvent.click(await waitForSelector('[data-test="moreactions"]')); await userEvent.click(page.getByRole("menuitem", { name: "Edit comment" })); + await userEvent.click( + await waitForSelector('.bn-thread-comment [contenteditable="true"]'), + ); await userEvent.keyboard("{End} edited{Enter}"); await expectSelectorCount('.bn-thread-comment [contenteditable="true"]', 0); - expect(document.querySelector(".bn-thread-comment")?.textContent).toContain( - "edited", - ); + await vi.waitFor(() => { + expect( + document.querySelector(".bn-thread-comment")?.textContent, + ).toContain("edited"); + }); }); test("Should be able to add reactions", async () => { From a3b6c9046cfc86697642f82412bf56b0da604da0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 22 Sep 2026 16:07:31 +0000 Subject: [PATCH 8/9] fix(comments): keep actions visible while menu is open --- packages/react/src/components/Comments/Comment.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index c8456e27c1..14f7dedd77 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -181,6 +181,7 @@ export const Comment = ({ const Components = useComponentsContext()!; const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); + const [moreActionsOpen, setMoreActionsOpen] = useState(false); const threadStore = comments.threadStore; @@ -295,7 +296,10 @@ export const Comment = ({ ))} {(canDeleteComment || canEditComment) && ( - + Date: Tue, 22 Sep 2026 16:43:00 +0000 Subject: [PATCH 9/9] fix: bind handlers only once --- .../ExtensionManager/ExtensionManager.test.ts | 11 ++++++ .../editor/managers/ExtensionManager/index.ts | 35 +++++++++++++------ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts index 56dd4a0efb..5e0edadae5 100644 --- a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -16,6 +16,17 @@ function createMountedEditor( } describe("Extension keyboard shortcuts", () => { + it("validates shortcut names at initialization", () => { + expect(() => + createMountedEditor([ + createExtension({ + key: "invalid-shortcut", + keyboardShortcuts: { "InvalidModifier-k": () => true }, + }), + ]), + ).toThrow("Unrecognized modifier name: InvalidModifier"); + }); + it("preserves the outer event for fallback matching after a nested key event", () => { const nestedEvent = new KeyboardEvent("keydown", { key: "x", diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index f84da832ed..701e7db14e 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -592,20 +592,35 @@ export class ExtensionManager { } if (Object.keys(extension.keyboardShortcuts || {}).length) { + let currentEvent: KeyboardEvent | undefined; + const handleKeyDown = keydownHandler( + Object.fromEntries( + Object.entries(extension.keyboardShortcuts!).map( + ([key, callback]) => [ + key, + () => { + if (!currentEvent) { + throw new Error( + "Keyboard shortcut called outside handleKeyDown", + ); + } + return callback({ editor: this.editor, event: currentEvent }); + }, + ], + ), + ), + ); plugins.push( new Plugin({ props: { handleKeyDown: (view, event) => { - const bindings = Object.fromEntries( - Object.entries(extension.keyboardShortcuts!).map( - ([key, callback]) => [ - key, - () => callback({ editor: this.editor, event }), - ], - ), - ); - - return keydownHandler(bindings)(view, event); + const previousEvent = currentEvent; + currentEvent = event; + try { + return handleKeyDown(view, event); + } finally { + currentEvent = previousEvent; + } }, }, }),