Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/content/docs/features/collaboration/comments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
141 changes: 141 additions & 0 deletions packages/core/src/comments/CommentEditorSubmitExtension.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CommentEditorSubmitExtension>[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<void>((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();
});
});
56 changes: 56 additions & 0 deletions packages/core/src/comments/CommentEditorSubmitExtension.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}>) => {
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 } : {}),
},
};
},
);
8 changes: 8 additions & 0 deletions packages/core/src/comments/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const CommentsExtension = createExtension(
threadStore,
resolveUsers,
confirmBeforeDiscard = true,
submitOnEnter = true,
},
}: ExtensionOptions<{
/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -383,6 +390,7 @@ export const CommentsExtension = createExtension(
},
commentEditorSchema,
confirmBeforeDiscard,
submitOnEnter,
} as const;
},
);
1 change: 1 addition & 0 deletions packages/core/src/comments/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./CommentEditorSubmitExtension.js";
export * from "./extension.js";
export * from "./mark.js";
export * from "./threadstore/DefaultThreadStoreAuth.js";
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/editor/BlockNoteExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export interface Extension<State = any, Key extends string = string> {

/**
* 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
Expand All @@ -77,7 +79,10 @@ export interface Extension<State = any, Key extends string = string> {
*/
readonly keyboardShortcuts?: Record<
string,
(ctx: { editor: BlockNoteEditor<any, any, any> }) => boolean
(ctx: {
editor: BlockNoteEditor<any, any, any>;
event: KeyboardEvent;
}) => boolean
>;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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("preserves 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.
Expand Down
Loading
Loading