Skip to content

Commit 64ac37e

Browse files
committed
Removed gap cursor config, arrow down in last block creates empty paragraph below if it has no inline content
1 parent b1cdc1d commit 64ac37e

4 files changed

Lines changed: 170 additions & 107 deletions

File tree

‎packages/core/src/editor/BlockNoteEditor.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,6 @@ export interface BlockNoteEditorOptions<
125125
*/
126126
dropCursor?: DropCursorOptions;
127127

128-
/**
129-
* Whether to enable the gap cursor around blocks without editable content.
130-
* Defaults to true. When false, arrow up/down moves directly from a selected
131-
* block to the adjacent block, staying put at the document boundaries.
132-
*/
133-
gapCursor?: boolean;
134-
135128
/**
136129
* The content that should be in the editor when it's created, represented as an array of {@link PartialBlock} objects.
137130
*

‎packages/core/src/editor/managers/ExtensionManager/extensions.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function getDefaultTiptapExtensions(
5858
extensions.Editable,
5959
extensions.FocusEvents,
6060
extensions.Tabindex,
61-
...(options.gapCursor !== false ? [Gapcursor] : []),
61+
Gapcursor,
6262

6363
UniqueID.configure({
6464
// everything from bnBlock group (nodes that represent a BlockNote block should have an id)
@@ -166,7 +166,7 @@ export function getDefaultExtensions(
166166
: {}),
167167
}),
168168
LinkToolbarExtension(options),
169-
NodeSelectionKeyboardExtension(options),
169+
NodeSelectionKeyboardExtension(),
170170
PlaceholderExtension(options),
171171
ShowSelectionExtension(options),
172172
SideMenuExtension(options),

‎packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.test.ts‎

Lines changed: 146 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { GapCursor } from "@tiptap/pm/gapcursor";
22
import { NodeSelection, TextSelection } from "prosemirror-state";
33
import { afterEach, describe, expect, it } from "vite-plus/test";
44

5+
import type { PartialBlock } from "../../blocks/defaultBlocks.js";
56
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
67

78
const editors: BlockNoteEditor[] = [];
@@ -13,111 +14,177 @@ afterEach(() => {
1314
editors.length = 0;
1415
});
1516

16-
function createEditor(gapCursor?: boolean, trailingBlock = true) {
17-
const editor = BlockNoteEditor.create({
18-
gapCursor,
19-
trailingBlock,
20-
initialContent: [
21-
{ id: "first", type: "image" },
22-
{ id: "second", type: "file" },
23-
{ id: "text", type: "paragraph", content: "hello" },
24-
{ id: "last", type: "image" },
25-
],
26-
});
17+
function createEditor(initialContent: PartialBlock[], trailingBlock = true) {
18+
const editor = BlockNoteEditor.create({ initialContent, trailingBlock });
2719
editor.mount(document.createElement("div"));
2820
editors.push(editor);
2921
return editor;
3022
}
3123

32-
function pressKey(editor: BlockNoteEditor, key: "ArrowUp" | "ArrowDown") {
33-
const view = editor.prosemirrorView;
24+
function pressKey(
25+
editor: BlockNoteEditor,
26+
key: "ArrowUp" | "ArrowDown" | "ArrowRight",
27+
modifiers: KeyboardEventInit = {},
28+
) {
3429
const event = new KeyboardEvent("keydown", {
3530
key,
36-
keyCode: key === "ArrowUp" ? 38 : 40,
31+
keyCode: { ArrowUp: 38, ArrowDown: 40, ArrowRight: 39 }[key],
3732
bubbles: true,
3833
cancelable: true,
34+
...modifiers,
3935
});
40-
// Dispatch through the DOM so ProseMirror's built-in keyboard handling runs
41-
// after plugin handlers, just as it does for a real key press.
42-
view.dom.dispatchEvent(event);
36+
// Include ProseMirror's built-in handling as well as the plugin handlers.
37+
editor.prosemirrorView.dom.dispatchEvent(event);
4338
return event.defaultPrevented;
4439
}
4540

46-
describe("gap cursor configuration", () => {
47-
it.each([undefined, true])(
48-
"preserves gap navigation when gapCursor is %s",
49-
(gapCursor) => {
50-
const editor = createEditor(gapCursor);
41+
describe("ArrowDown after a selected contentless block", () => {
42+
it.each(["image", "file", "video", "audio"] as const)(
43+
"creates and selects a paragraph after a final %s",
44+
(type) => {
45+
const editor = createEditor([{ id: "last", type }]);
46+
editor.setTextCursorPosition("last");
47+
const original = editor.document[0];
48+
49+
expect(pressKey(editor, "ArrowDown")).toBe(true);
50+
51+
expect(editor.document).toHaveLength(2);
52+
expect(editor.document[0]).toEqual(original);
53+
expect(editor.document[1]).toMatchObject({
54+
type: "paragraph",
55+
content: [],
56+
});
57+
expect(editor.getTextCursorPosition().block.id).toBe(
58+
editor.document[1].id,
59+
);
60+
expect(editor.prosemirrorState.selection).toBeInstanceOf(TextSelection);
61+
expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(0);
62+
expect(editor.prosemirrorState.selection.empty).toBe(true);
63+
64+
pressKey(editor, "ArrowDown");
65+
expect(editor.document).toHaveLength(2);
66+
},
67+
);
68+
69+
it("works with the trailing block decoration disabled", () => {
70+
const editor = createEditor([{ id: "last", type: "image" }], false);
71+
editor.setTextCursorPosition("last");
72+
pressKey(editor, "ArrowDown");
73+
expect(editor.document).toHaveLength(2);
74+
expect(editor.getTextCursorPosition().block.type).toBe("paragraph");
75+
});
76+
77+
it("inserts after a final nested block at the same nesting level", () => {
78+
const editor = createEditor([
79+
{
80+
id: "parent",
81+
type: "paragraph",
82+
content: "parent",
83+
children: [{ id: "last", type: "image" }],
84+
},
85+
]);
86+
editor.setTextCursorPosition("last");
87+
pressKey(editor, "ArrowDown");
88+
expect(editor.document).toHaveLength(1);
89+
const children = editor.document[0].children;
90+
expect(children).toHaveLength(2);
91+
expect(children[1]).toMatchObject({ type: "paragraph", content: [] });
92+
expect(editor.getTextCursorPosition().block.id).toBe(children[1].id);
93+
});
94+
95+
it.each(["image", "paragraph"] as const)(
96+
"does not insert when a %s follows",
97+
(type) => {
98+
const editor = createEditor([
99+
{ id: "first", type: "image" },
100+
{ id: "next", type },
101+
]);
51102
editor.setTextCursorPosition("first");
103+
const doc = editor.document;
52104
pressKey(editor, "ArrowDown");
53-
expect(editor.prosemirrorState.selection).toBeInstanceOf(GapCursor);
105+
expect(editor.document).toEqual(doc);
54106
},
55107
);
56108

57-
it.each([
58-
["first", "ArrowDown", "second"],
59-
["second", "ArrowUp", "first"],
60-
] as const)("moves directly from %s with %s", (from, key, to) => {
61-
const editor = createEditor(false);
62-
const doc = editor.prosemirrorState.doc;
63-
editor.setTextCursorPosition(from);
64-
expect(pressKey(editor, key)).toBe(true);
65-
expect(editor.prosemirrorState.selection).toBeInstanceOf(NodeSelection);
66-
expect(editor.getTextCursorPosition().block.id).toBe(to);
67-
expect(editor.prosemirrorState.doc.eq(doc)).toBe(true);
68-
expect(
69-
editor.prosemirrorView.dom.querySelector(".ProseMirror-gapcursor"),
70-
).toBeNull();
109+
it("does not insert when the selected block has children", () => {
110+
const editor = createEditor([
111+
{
112+
id: "parent",
113+
type: "image",
114+
children: [{ id: "child", type: "image" }],
115+
},
116+
]);
117+
editor.setTextCursorPosition("parent");
118+
const doc = editor.document;
119+
pressKey(editor, "ArrowDown");
120+
expect(editor.document).toEqual(doc);
71121
});
72122

73-
it.each([
74-
["second", "ArrowDown", "start"],
75-
["last", "ArrowUp", "end"],
76-
] as const)("moves from %s into adjacent text", (from, key, placement) => {
77-
const editor = createEditor(false);
78-
editor.setTextCursorPosition("text", placement);
79-
const expected = editor.prosemirrorState.selection;
80-
editor.setTextCursorPosition(from);
81-
pressKey(editor, key);
82-
expect(editor.prosemirrorState.selection).toBeInstanceOf(TextSelection);
83-
expect(editor.prosemirrorState.selection.eq(expected)).toBe(true);
123+
it.each(["", "hello"])(
124+
"does not append after a text cursor in a paragraph containing %j",
125+
(content) => {
126+
const editor = createEditor([{ id: "last", type: "paragraph", content }]);
127+
editor.setTextCursorPosition("last", "end");
128+
const doc = editor.document;
129+
pressKey(editor, "ArrowDown");
130+
expect(editor.document).toEqual(doc);
131+
},
132+
);
133+
134+
it("does not append after a node-selected block with inline content", () => {
135+
const editor = createEditor([
136+
{ id: "last", type: "paragraph", content: "hello" },
137+
]);
138+
editor.setTextCursorPosition("last");
139+
editor.transact((tr) =>
140+
tr.setSelection(
141+
NodeSelection.create(tr.doc, tr.selection.$from.before()),
142+
),
143+
);
144+
const doc = editor.document;
145+
pressKey(editor, "ArrowDown");
146+
expect(editor.document).toEqual(doc);
84147
});
85148

86-
it.each([true, false])(
87-
"stays at document boundaries with trailingBlock=%s",
88-
(trailingBlock) => {
89-
const editor = createEditor(false, trailingBlock);
90-
const doc = editor.prosemirrorState.doc;
91-
for (const [id, key] of [
92-
["first", "ArrowUp"],
93-
["last", "ArrowDown"],
94-
] as const) {
95-
editor.setTextCursorPosition(id);
96-
const selection = editor.prosemirrorState.selection;
97-
// Repeated presses must neither move the selection nor add a block.
98-
expect(pressKey(editor, key)).toBe(true);
99-
expect(pressKey(editor, key)).toBe(true);
100-
expect(editor.prosemirrorState.selection.eq(selection)).toBe(true);
101-
expect(editor.prosemirrorState.doc.eq(doc)).toBe(true);
149+
it.each([
150+
{ shiftKey: true },
151+
{ altKey: true },
152+
{ ctrlKey: true },
153+
{ metaKey: true },
154+
{ isComposing: true },
155+
])(
156+
"does not insert for modified or composing key presses: %j",
157+
(modifiers) => {
158+
const editor = createEditor([{ id: "last", type: "image" }]);
159+
editor.setTextCursorPosition("last");
160+
const doc = editor.document;
161+
pressKey(editor, "ArrowDown", modifiers);
162+
expect(editor.document).toEqual(doc);
163+
},
164+
);
165+
166+
it.each(["ArrowUp", "ArrowRight"] as const)(
167+
"does not insert a paragraph for %s",
168+
(key) => {
169+
const editor = createEditor([{ id: "last", type: "image" }]);
170+
editor.setTextCursorPosition("last");
171+
const doc = editor.document;
172+
pressKey(editor, key);
173+
if (key === "ArrowUp") {
174+
expect(editor.prosemirrorState.selection).toBeInstanceOf(GapCursor);
102175
}
176+
expect(editor.document).toEqual(doc);
103177
},
104178
);
105179

106-
it("navigates into and out of nested blocks", () => {
107-
const editor = createEditor(false);
108-
editor.updateBlock("first", { children: [{ id: "child", type: "image" }] });
180+
it("preserves gap cursors between contentless blocks", () => {
181+
const editor = createEditor([
182+
{ id: "first", type: "image" },
183+
{ id: "last", type: "file" },
184+
]);
109185
editor.setTextCursorPosition("first");
110-
const doc = editor.prosemirrorState.doc;
111-
for (const [key, id] of [
112-
["ArrowDown", "child"],
113-
["ArrowDown", "second"],
114-
["ArrowUp", "child"],
115-
["ArrowUp", "first"],
116-
] as const) {
117-
expect(pressKey(editor, key)).toBe(true);
118-
expect(editor.getTextCursorPosition().block.id).toBe(id);
119-
expect(editor.prosemirrorState.selection).toBeInstanceOf(NodeSelection);
120-
}
121-
expect(editor.prosemirrorState.doc.eq(doc)).toBe(true);
186+
pressKey(editor, "ArrowDown");
187+
expect(editor.prosemirrorState.selection).toBeInstanceOf(GapCursor);
188+
expect(editor.document).toHaveLength(2);
122189
});
123190
});

‎packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const PLUGIN_KEY = new PluginKey("node-selection-keyboard");
2626
// keystrokes, this brings us most of the way to Notion's UX without much added
2727
// complexity.
2828
export const NodeSelectionKeyboardExtension = createExtension(
29-
({ options = {} }: ExtensionOptions<{ gapCursor?: boolean } | undefined>) =>
29+
({ editor }: ExtensionOptions) =>
3030
({
3131
key: "nodeSelectionKeyboard",
3232
prosemirrorPlugins: [
@@ -41,29 +41,32 @@ export const NodeSelectionKeyboardExtension = createExtension(
4141
if (event.ctrlKey || event.metaKey) {
4242
return false;
4343
}
44-
// Explicitly cancels arrow up/down presses when a whole block
45-
// is selected at the start/end of the document and the gap
46-
// cursor is disabled.
44+
// Make room to type below a contentless block at the document
45+
// end, while preserving gap cursors everywhere else.
4746
if (
48-
options.gapCursor === false &&
49-
!selection.node.isInline &&
47+
event.key === "ArrowDown" &&
5048
!event.shiftKey &&
5149
!event.altKey &&
52-
(event.key === "ArrowUp" || event.key === "ArrowDown")
50+
!event.isComposing &&
51+
!selection.node.isInline &&
52+
selection.eq(Selection.atEnd(view.state.doc))
5353
) {
54-
const direction = event.key === "ArrowUp" ? -1 : 1;
55-
const nextSelection = Selection.findFrom(
56-
direction === -1 ? selection.$from : selection.$to,
57-
direction,
58-
);
59-
if (nextSelection) {
60-
// Let ProseMirror's built-in arrow handling move the selection.
61-
return false;
54+
const { block } = editor.getTextCursorPosition();
55+
if (
56+
editor.schema.blockSchema[block.type].content === "none"
57+
) {
58+
editor.transact((tr) => {
59+
const [paragraph] = editor.insertBlocks(
60+
[{ type: "paragraph" }],
61+
block,
62+
"after",
63+
);
64+
editor.setTextCursorPosition(paragraph, "start");
65+
tr.scrollIntoView();
66+
});
67+
event.preventDefault();
68+
return true;
6269
}
63-
// Consume the key at document boundaries so the browser
64-
// cannot move the selection into the trailing block widget.
65-
event.preventDefault();
66-
return true;
6770
}
6871
// Checks if key press is alphanumeric
6972
if (event.key.length === 1) {

0 commit comments

Comments
 (0)