From b1cdc1d7ef3e95d7f61b7578216de5e6fe778032 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 23 Sep 2026 10:43:42 +0200 Subject: [PATCH 1/2] Added editor option to disable gap cursor --- packages/core/src/editor/BlockNoteEditor.ts | 7 + .../managers/ExtensionManager/extensions.ts | 4 +- .../NodeSelectionKeyboard.test.ts | 123 ++++++++++++++++++ .../NodeSelectionKeyboard.ts | 42 +++++- 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index bc30d58092..92e66c6b39 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -125,6 +125,13 @@ export interface BlockNoteEditorOptions< */ dropCursor?: DropCursorOptions; + /** + * Whether to enable the gap cursor around blocks without editable content. + * Defaults to true. When false, arrow up/down moves directly from a selected + * block to the adjacent block, staying put at the document boundaries. + */ + gapCursor?: boolean; + /** * The content that should be in the editor when it's created, represented as an array of {@link PartialBlock} objects. * diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..a0ccd7ab85 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -58,7 +58,7 @@ export function getDefaultTiptapExtensions( extensions.Editable, extensions.FocusEvents, extensions.Tabindex, - Gapcursor, + ...(options.gapCursor !== false ? [Gapcursor] : []), UniqueID.configure({ // everything from bnBlock group (nodes that represent a BlockNote block should have an id) @@ -166,7 +166,7 @@ export function getDefaultExtensions( : {}), }), LinkToolbarExtension(options), - NodeSelectionKeyboardExtension(), + NodeSelectionKeyboardExtension(options), PlaceholderExtension(options), ShowSelectionExtension(options), SideMenuExtension(options), diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts new file mode 100644 index 0000000000..f11eb2721d --- /dev/null +++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts @@ -0,0 +1,123 @@ +import { GapCursor } from "@tiptap/pm/gapcursor"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; + +const editors: BlockNoteEditor[] = []; + +afterEach(() => { + for (const editor of editors) { + editor._tiptapEditor.destroy(); + } + editors.length = 0; +}); + +function createEditor(gapCursor?: boolean, trailingBlock = true) { + const editor = BlockNoteEditor.create({ + gapCursor, + trailingBlock, + initialContent: [ + { id: "first", type: "image" }, + { id: "second", type: "file" }, + { id: "text", type: "paragraph", content: "hello" }, + { id: "last", type: "image" }, + ], + }); + editor.mount(document.createElement("div")); + editors.push(editor); + return editor; +} + +function pressKey(editor: BlockNoteEditor, key: "ArrowUp" | "ArrowDown") { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { + key, + keyCode: key === "ArrowUp" ? 38 : 40, + bubbles: true, + cancelable: true, + }); + // Dispatch through the DOM so ProseMirror's built-in keyboard handling runs + // after plugin handlers, just as it does for a real key press. + view.dom.dispatchEvent(event); + return event.defaultPrevented; +} + +describe("gap cursor configuration", () => { + it.each([undefined, true])( + "preserves gap navigation when gapCursor is %s", + (gapCursor) => { + const editor = createEditor(gapCursor); + editor.setTextCursorPosition("first"); + pressKey(editor, "ArrowDown"); + expect(editor.prosemirrorState.selection).toBeInstanceOf(GapCursor); + }, + ); + + it.each([ + ["first", "ArrowDown", "second"], + ["second", "ArrowUp", "first"], + ] as const)("moves directly from %s with %s", (from, key, to) => { + const editor = createEditor(false); + const doc = editor.prosemirrorState.doc; + editor.setTextCursorPosition(from); + expect(pressKey(editor, key)).toBe(true); + expect(editor.prosemirrorState.selection).toBeInstanceOf(NodeSelection); + expect(editor.getTextCursorPosition().block.id).toBe(to); + expect(editor.prosemirrorState.doc.eq(doc)).toBe(true); + expect( + editor.prosemirrorView.dom.querySelector(".ProseMirror-gapcursor"), + ).toBeNull(); + }); + + it.each([ + ["second", "ArrowDown", "start"], + ["last", "ArrowUp", "end"], + ] as const)("moves from %s into adjacent text", (from, key, placement) => { + const editor = createEditor(false); + editor.setTextCursorPosition("text", placement); + const expected = editor.prosemirrorState.selection; + editor.setTextCursorPosition(from); + pressKey(editor, key); + expect(editor.prosemirrorState.selection).toBeInstanceOf(TextSelection); + expect(editor.prosemirrorState.selection.eq(expected)).toBe(true); + }); + + it.each([true, false])( + "stays at document boundaries with trailingBlock=%s", + (trailingBlock) => { + const editor = createEditor(false, trailingBlock); + const doc = editor.prosemirrorState.doc; + for (const [id, key] of [ + ["first", "ArrowUp"], + ["last", "ArrowDown"], + ] as const) { + editor.setTextCursorPosition(id); + const selection = editor.prosemirrorState.selection; + // Repeated presses must neither move the selection nor add a block. + expect(pressKey(editor, key)).toBe(true); + expect(pressKey(editor, key)).toBe(true); + expect(editor.prosemirrorState.selection.eq(selection)).toBe(true); + expect(editor.prosemirrorState.doc.eq(doc)).toBe(true); + } + }, + ); + + it("navigates into and out of nested blocks", () => { + const editor = createEditor(false); + editor.updateBlock("first", { children: [{ id: "child", type: "image" }] }); + editor.setTextCursorPosition("first"); + const doc = editor.prosemirrorState.doc; + for (const [key, id] of [ + ["ArrowDown", "child"], + ["ArrowDown", "second"], + ["ArrowUp", "child"], + ["ArrowUp", "first"], + ] as const) { + expect(pressKey(editor, key)).toBe(true); + expect(editor.getTextCursorPosition().block.id).toBe(id); + expect(editor.prosemirrorState.selection).toBeInstanceOf(NodeSelection); + } + expect(editor.prosemirrorState.doc.eq(doc)).toBe(true); + }); +}); diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts index 357780f44a..f84c26a8ba 100644 --- a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts +++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts @@ -1,5 +1,14 @@ -import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; -import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { + NodeSelection, + Plugin, + PluginKey, + Selection, + TextSelection, +} from "prosemirror-state"; +import { + createExtension, + type ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey("node-selection-keyboard"); // By default, typing with a node selection active will cause ProseMirror to @@ -17,7 +26,7 @@ const PLUGIN_KEY = new PluginKey("node-selection-keyboard"); // keystrokes, this brings us most of the way to Notion's UX without much added // complexity. export const NodeSelectionKeyboardExtension = createExtension( - () => + ({ options = {} }: ExtensionOptions<{ gapCursor?: boolean } | undefined>) => ({ key: "nodeSelectionKeyboard", prosemirrorPlugins: [ @@ -26,11 +35,36 @@ export const NodeSelectionKeyboardExtension = createExtension( props: { handleKeyDown: (view, event) => { // Checks for node selection - if ("node" in view.state.selection) { + const { selection } = view.state; + if (selection instanceof NodeSelection) { // Checks if key press uses ctrl/meta modifier if (event.ctrlKey || event.metaKey) { return false; } + // Explicitly cancels arrow up/down presses when a whole block + // is selected at the start/end of the document and the gap + // cursor is disabled. + if ( + options.gapCursor === false && + !selection.node.isInline && + !event.shiftKey && + !event.altKey && + (event.key === "ArrowUp" || event.key === "ArrowDown") + ) { + const direction = event.key === "ArrowUp" ? -1 : 1; + const nextSelection = Selection.findFrom( + direction === -1 ? selection.$from : selection.$to, + direction, + ); + if (nextSelection) { + // Let ProseMirror's built-in arrow handling move the selection. + return false; + } + // Consume the key at document boundaries so the browser + // cannot move the selection into the trailing block widget. + event.preventDefault(); + return true; + } // Checks if key press is alphanumeric if (event.key.length === 1) { event.preventDefault(); From 64ac37e084cd0f2fbdb2efc73bfb18747bd98c19 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 25 Sep 2026 15:30:42 +0200 Subject: [PATCH 2/2] Removed gap cursor config, arrow down in last block creates empty paragraph below if it has no inline content --- packages/core/src/editor/BlockNoteEditor.ts | 7 - .../managers/ExtensionManager/extensions.ts | 4 +- .../NodeSelectionKeyboard.test.ts | 225 ++++++++++++------ .../NodeSelectionKeyboard.ts | 41 ++-- 4 files changed, 170 insertions(+), 107 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 92e66c6b39..bc30d58092 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -125,13 +125,6 @@ export interface BlockNoteEditorOptions< */ dropCursor?: DropCursorOptions; - /** - * Whether to enable the gap cursor around blocks without editable content. - * Defaults to true. When false, arrow up/down moves directly from a selected - * block to the adjacent block, staying put at the document boundaries. - */ - gapCursor?: boolean; - /** * The content that should be in the editor when it's created, represented as an array of {@link PartialBlock} objects. * diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index a0ccd7ab85..853cca2493 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -58,7 +58,7 @@ export function getDefaultTiptapExtensions( extensions.Editable, extensions.FocusEvents, extensions.Tabindex, - ...(options.gapCursor !== false ? [Gapcursor] : []), + Gapcursor, UniqueID.configure({ // everything from bnBlock group (nodes that represent a BlockNote block should have an id) @@ -166,7 +166,7 @@ export function getDefaultExtensions( : {}), }), LinkToolbarExtension(options), - NodeSelectionKeyboardExtension(options), + NodeSelectionKeyboardExtension(), PlaceholderExtension(options), ShowSelectionExtension(options), SideMenuExtension(options), diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts index f11eb2721d..104dffbd0b 100644 --- a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts +++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts @@ -2,6 +2,7 @@ import { GapCursor } from "@tiptap/pm/gapcursor"; import { NodeSelection, TextSelection } from "prosemirror-state"; import { afterEach, describe, expect, it } from "vite-plus/test"; +import type { PartialBlock } from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; const editors: BlockNoteEditor[] = []; @@ -13,111 +14,177 @@ afterEach(() => { editors.length = 0; }); -function createEditor(gapCursor?: boolean, trailingBlock = true) { - const editor = BlockNoteEditor.create({ - gapCursor, - trailingBlock, - initialContent: [ - { id: "first", type: "image" }, - { id: "second", type: "file" }, - { id: "text", type: "paragraph", content: "hello" }, - { id: "last", type: "image" }, - ], - }); +function createEditor(initialContent: PartialBlock[], trailingBlock = true) { + const editor = BlockNoteEditor.create({ initialContent, trailingBlock }); editor.mount(document.createElement("div")); editors.push(editor); return editor; } -function pressKey(editor: BlockNoteEditor, key: "ArrowUp" | "ArrowDown") { - const view = editor.prosemirrorView; +function pressKey( + editor: BlockNoteEditor, + key: "ArrowUp" | "ArrowDown" | "ArrowRight", + modifiers: KeyboardEventInit = {}, +) { const event = new KeyboardEvent("keydown", { key, - keyCode: key === "ArrowUp" ? 38 : 40, + keyCode: { ArrowUp: 38, ArrowDown: 40, ArrowRight: 39 }[key], bubbles: true, cancelable: true, + ...modifiers, }); - // Dispatch through the DOM so ProseMirror's built-in keyboard handling runs - // after plugin handlers, just as it does for a real key press. - view.dom.dispatchEvent(event); + // Include ProseMirror's built-in handling as well as the plugin handlers. + editor.prosemirrorView.dom.dispatchEvent(event); return event.defaultPrevented; } -describe("gap cursor configuration", () => { - it.each([undefined, true])( - "preserves gap navigation when gapCursor is %s", - (gapCursor) => { - const editor = createEditor(gapCursor); +describe("ArrowDown after a selected contentless block", () => { + it.each(["image", "file", "video", "audio"] as const)( + "creates and selects a paragraph after a final %s", + (type) => { + const editor = createEditor([{ id: "last", type }]); + editor.setTextCursorPosition("last"); + const original = editor.document[0]; + + expect(pressKey(editor, "ArrowDown")).toBe(true); + + expect(editor.document).toHaveLength(2); + expect(editor.document[0]).toEqual(original); + expect(editor.document[1]).toMatchObject({ + type: "paragraph", + content: [], + }); + expect(editor.getTextCursorPosition().block.id).toBe( + editor.document[1].id, + ); + expect(editor.prosemirrorState.selection).toBeInstanceOf(TextSelection); + expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(0); + expect(editor.prosemirrorState.selection.empty).toBe(true); + + pressKey(editor, "ArrowDown"); + expect(editor.document).toHaveLength(2); + }, + ); + + it("works with the trailing block decoration disabled", () => { + const editor = createEditor([{ id: "last", type: "image" }], false); + editor.setTextCursorPosition("last"); + pressKey(editor, "ArrowDown"); + expect(editor.document).toHaveLength(2); + expect(editor.getTextCursorPosition().block.type).toBe("paragraph"); + }); + + it("inserts after a final nested block at the same nesting level", () => { + const editor = createEditor([ + { + id: "parent", + type: "paragraph", + content: "parent", + children: [{ id: "last", type: "image" }], + }, + ]); + editor.setTextCursorPosition("last"); + pressKey(editor, "ArrowDown"); + expect(editor.document).toHaveLength(1); + const children = editor.document[0].children; + expect(children).toHaveLength(2); + expect(children[1]).toMatchObject({ type: "paragraph", content: [] }); + expect(editor.getTextCursorPosition().block.id).toBe(children[1].id); + }); + + it.each(["image", "paragraph"] as const)( + "does not insert when a %s follows", + (type) => { + const editor = createEditor([ + { id: "first", type: "image" }, + { id: "next", type }, + ]); editor.setTextCursorPosition("first"); + const doc = editor.document; pressKey(editor, "ArrowDown"); - expect(editor.prosemirrorState.selection).toBeInstanceOf(GapCursor); + expect(editor.document).toEqual(doc); }, ); - it.each([ - ["first", "ArrowDown", "second"], - ["second", "ArrowUp", "first"], - ] as const)("moves directly from %s with %s", (from, key, to) => { - const editor = createEditor(false); - const doc = editor.prosemirrorState.doc; - editor.setTextCursorPosition(from); - expect(pressKey(editor, key)).toBe(true); - expect(editor.prosemirrorState.selection).toBeInstanceOf(NodeSelection); - expect(editor.getTextCursorPosition().block.id).toBe(to); - expect(editor.prosemirrorState.doc.eq(doc)).toBe(true); - expect( - editor.prosemirrorView.dom.querySelector(".ProseMirror-gapcursor"), - ).toBeNull(); + it("does not insert when the selected block has children", () => { + const editor = createEditor([ + { + id: "parent", + type: "image", + children: [{ id: "child", type: "image" }], + }, + ]); + editor.setTextCursorPosition("parent"); + const doc = editor.document; + pressKey(editor, "ArrowDown"); + expect(editor.document).toEqual(doc); }); - it.each([ - ["second", "ArrowDown", "start"], - ["last", "ArrowUp", "end"], - ] as const)("moves from %s into adjacent text", (from, key, placement) => { - const editor = createEditor(false); - editor.setTextCursorPosition("text", placement); - const expected = editor.prosemirrorState.selection; - editor.setTextCursorPosition(from); - pressKey(editor, key); - expect(editor.prosemirrorState.selection).toBeInstanceOf(TextSelection); - expect(editor.prosemirrorState.selection.eq(expected)).toBe(true); + it.each(["", "hello"])( + "does not append after a text cursor in a paragraph containing %j", + (content) => { + const editor = createEditor([{ id: "last", type: "paragraph", content }]); + editor.setTextCursorPosition("last", "end"); + const doc = editor.document; + pressKey(editor, "ArrowDown"); + expect(editor.document).toEqual(doc); + }, + ); + + it("does not append after a node-selected block with inline content", () => { + const editor = createEditor([ + { id: "last", type: "paragraph", content: "hello" }, + ]); + editor.setTextCursorPosition("last"); + editor.transact((tr) => + tr.setSelection( + NodeSelection.create(tr.doc, tr.selection.$from.before()), + ), + ); + const doc = editor.document; + pressKey(editor, "ArrowDown"); + expect(editor.document).toEqual(doc); }); - it.each([true, false])( - "stays at document boundaries with trailingBlock=%s", - (trailingBlock) => { - const editor = createEditor(false, trailingBlock); - const doc = editor.prosemirrorState.doc; - for (const [id, key] of [ - ["first", "ArrowUp"], - ["last", "ArrowDown"], - ] as const) { - editor.setTextCursorPosition(id); - const selection = editor.prosemirrorState.selection; - // Repeated presses must neither move the selection nor add a block. - expect(pressKey(editor, key)).toBe(true); - expect(pressKey(editor, key)).toBe(true); - expect(editor.prosemirrorState.selection.eq(selection)).toBe(true); - expect(editor.prosemirrorState.doc.eq(doc)).toBe(true); + it.each([ + { shiftKey: true }, + { altKey: true }, + { ctrlKey: true }, + { metaKey: true }, + { isComposing: true }, + ])( + "does not insert for modified or composing key presses: %j", + (modifiers) => { + const editor = createEditor([{ id: "last", type: "image" }]); + editor.setTextCursorPosition("last"); + const doc = editor.document; + pressKey(editor, "ArrowDown", modifiers); + expect(editor.document).toEqual(doc); + }, + ); + + it.each(["ArrowUp", "ArrowRight"] as const)( + "does not insert a paragraph for %s", + (key) => { + const editor = createEditor([{ id: "last", type: "image" }]); + editor.setTextCursorPosition("last"); + const doc = editor.document; + pressKey(editor, key); + if (key === "ArrowUp") { + expect(editor.prosemirrorState.selection).toBeInstanceOf(GapCursor); } + expect(editor.document).toEqual(doc); }, ); - it("navigates into and out of nested blocks", () => { - const editor = createEditor(false); - editor.updateBlock("first", { children: [{ id: "child", type: "image" }] }); + it("preserves gap cursors between contentless blocks", () => { + const editor = createEditor([ + { id: "first", type: "image" }, + { id: "last", type: "file" }, + ]); editor.setTextCursorPosition("first"); - const doc = editor.prosemirrorState.doc; - for (const [key, id] of [ - ["ArrowDown", "child"], - ["ArrowDown", "second"], - ["ArrowUp", "child"], - ["ArrowUp", "first"], - ] as const) { - expect(pressKey(editor, key)).toBe(true); - expect(editor.getTextCursorPosition().block.id).toBe(id); - expect(editor.prosemirrorState.selection).toBeInstanceOf(NodeSelection); - } - expect(editor.prosemirrorState.doc.eq(doc)).toBe(true); + pressKey(editor, "ArrowDown"); + expect(editor.prosemirrorState.selection).toBeInstanceOf(GapCursor); + expect(editor.document).toHaveLength(2); }); }); diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts index f84c26a8ba..d0f2b26796 100644 --- a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts +++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts @@ -26,7 +26,7 @@ const PLUGIN_KEY = new PluginKey("node-selection-keyboard"); // keystrokes, this brings us most of the way to Notion's UX without much added // complexity. export const NodeSelectionKeyboardExtension = createExtension( - ({ options = {} }: ExtensionOptions<{ gapCursor?: boolean } | undefined>) => + ({ editor }: ExtensionOptions) => ({ key: "nodeSelectionKeyboard", prosemirrorPlugins: [ @@ -41,29 +41,32 @@ export const NodeSelectionKeyboardExtension = createExtension( if (event.ctrlKey || event.metaKey) { return false; } - // Explicitly cancels arrow up/down presses when a whole block - // is selected at the start/end of the document and the gap - // cursor is disabled. + // Make room to type below a contentless block at the document + // end, while preserving gap cursors everywhere else. if ( - options.gapCursor === false && - !selection.node.isInline && + event.key === "ArrowDown" && !event.shiftKey && !event.altKey && - (event.key === "ArrowUp" || event.key === "ArrowDown") + !event.isComposing && + !selection.node.isInline && + selection.eq(Selection.atEnd(view.state.doc)) ) { - const direction = event.key === "ArrowUp" ? -1 : 1; - const nextSelection = Selection.findFrom( - direction === -1 ? selection.$from : selection.$to, - direction, - ); - if (nextSelection) { - // Let ProseMirror's built-in arrow handling move the selection. - return false; + const { block } = editor.getTextCursorPosition(); + if ( + editor.schema.blockSchema[block.type].content === "none" + ) { + editor.transact((tr) => { + const [paragraph] = editor.insertBlocks( + [{ type: "paragraph" }], + block, + "after", + ); + editor.setTextCursorPosition(paragraph, "start"); + tr.scrollIntoView(); + }); + event.preventDefault(); + return true; } - // Consume the key at document boundaries so the browser - // cannot move the selection into the trailing block widget. - event.preventDefault(); - return true; } // Checks if key press is alphanumeric if (event.key.length === 1) {