Skip to content

Commit 5adfe7e

Browse files
fix(core): bind Mod-a to select all the document
BlockNote had no `Mod-a` binding, so select-all was left to the browser's native `contenteditable` handling and ProseMirror had to rebuild a document selection from the DOM selection it produced. That fails when a block puts non-editable content first, which check list items do: the checkbox div sits ahead of the `<p>` holding the block's content. So in a document starting with a check list item, ProseMirror could not map the DOM selection to a valid position and dropped it, leaving the caret in place - Backspace then only edited that one block instead of clearing the document. Now `Mod-a` sets an `AllSelection` itself, which selects every block type reliably and deletes down to a single empty paragraph. Also stops `getNearestBlockPos` warning for the positions at the very start and end of the doc, which is where an `AllSelection` ends.
1 parent ea5d803 commit 5adfe7e

4 files changed

Lines changed: 142 additions & 6 deletions

File tree

packages/core/src/api/getBlockInfoFromPos.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,31 @@ export function getNearestBlockPos(doc: Node, pos: number) {
126126
node = $pos.node(depth);
127127
}
128128

129+
// The doc's boundary positions (0 and `doc.content.size`) sit around the
130+
// `blockGroup` holding the top-level blocks, so they're outside every block.
131+
// Unlike the positions handled below, they're expected rather than
132+
// exceptional, as they're where an `AllSelection` starts & ends.
133+
const atDocStart = pos <= 0;
134+
if (atDocStart || pos >= doc.content.size) {
135+
// Position 1 is just before the `blockGroup`'s first child, and
136+
// `doc.content.size - 1` just after its last.
137+
const $insideBlockGroup = doc.resolve(
138+
atDocStart ? 1 : doc.content.size - 1,
139+
);
140+
const boundaryNode = atDocStart
141+
? $insideBlockGroup.nodeAfter
142+
: $insideBlockGroup.nodeBefore;
143+
144+
if (boundaryNode?.type.isInGroup("bnBlock")) {
145+
return {
146+
posBeforeNode: atDocStart
147+
? $insideBlockGroup.pos
148+
: $insideBlockGroup.pos - boundaryNode.nodeSize,
149+
node: boundaryNode,
150+
};
151+
}
152+
}
153+
129154
// If the position doesn't lie within a block node, we instead find the
130155
// position of the next closest one. If the position is beyond the last block,
131156
// we return the position of the last block. While running `doc.descendants`

packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,37 @@ function createEditor(
8181
return editor;
8282
}
8383

84+
/** Creates a mounted editor with the cursor at the end of the first block. */
85+
function createEditorWithBlocks(
86+
blocks: ((typeof schema)["PartialBlock"] & { id: string })[],
87+
) {
88+
const editor = BlockNoteEditor.create({ schema, initialContent: blocks });
89+
editor.mount(document.createElement("div"));
90+
editor.setTextCursorPosition(blocks[0].id, "end");
91+
return editor;
92+
}
93+
8494
/**
85-
* Simulates a keyboard shortcut by dispatching a keydown event through the
86-
* editor's `handleKeyDown` props, which is how ProseMirror invokes the
87-
* keymap plugins created by `addKeyboardShortcuts`.
95+
* Simulates a keyboard shortcut (e.g. "Enter", "Mod-a") via ProseMirror's
96+
* `handleKeyDown` prop, and returns whether it was handled. Can't go via
97+
* TipTap's `keyboardShortcut` command, which replays only the shortcut's steps
98+
* - so it drops shortcuts that just move the selection.
8899
*/
89100
function pressKeys(editor: BlockNoteEditor<any, any, any>, keys: string) {
90-
editor._tiptapEditor.commands.keyboardShortcut(keys);
101+
const lastSeparatorIndex = keys.lastIndexOf("-");
102+
const modifiers = keys.slice(0, lastSeparatorIndex);
103+
const event = new KeyboardEvent("keydown", {
104+
key: keys.slice(lastSeparatorIndex + 1),
105+
// `Mod` is Cmd on macOS and Ctrl elsewhere - tests run in jsdom, which
106+
// isn't macOS.
107+
ctrlKey: modifiers.includes("Mod") || modifiers.includes("Ctrl"),
108+
shiftKey: modifiers.includes("Shift"),
109+
cancelable: true,
110+
});
111+
112+
const view = editor._tiptapEditor.view;
113+
114+
return view.someProp("handleKeyDown", (f) => f(view, event)) ?? false;
91115
}
92116

93117
function countHardBreaks(editor: BlockNoteEditor<any, any, any>) {
@@ -202,3 +226,74 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => {
202226
editor._tiptapEditor.destroy();
203227
});
204228
});
229+
230+
describe("KeyboardShortcutsExtension select all", () => {
231+
// Select-all used to have no keybinding, so it fell through to the browser.
232+
// ProseMirror couldn't map the resulting DOM selection onto a document
233+
// starting with a check list item (which renders its checkbox before its
234+
// content), so it stayed unselected and Backspace only edited one block.
235+
const checkListItemFirst = [
236+
{ id: "block-0", type: "checkListItem", content: "Check 1" },
237+
{ id: "block-1", type: "checkListItem", content: "Check 2" },
238+
{ id: "block-2", type: "paragraph", content: "Hello world" },
239+
] as const;
240+
241+
it("selects the whole document on Mod-a", () => {
242+
const editor = createEditorWithBlocks([...checkListItemFirst]);
243+
244+
expect(pressKeys(editor, "Mod-a")).toBe(true);
245+
246+
const { selection, doc } = editor._tiptapEditor.state;
247+
expect(selection.from).toBe(0);
248+
expect(selection.to).toBe(doc.content.size);
249+
250+
editor._tiptapEditor.destroy();
251+
});
252+
253+
it.each([
254+
["starting with check list items", [...checkListItemFirst]],
255+
[
256+
"of only check list items",
257+
[
258+
{ id: "block-0", type: "checkListItem", content: "Check 1" },
259+
{ id: "block-1", type: "checkListItem", content: "Check 2" },
260+
] as const,
261+
],
262+
[
263+
"of paragraphs",
264+
[
265+
{ id: "block-0", type: "paragraph", content: "Hello" },
266+
{ id: "block-1", type: "paragraph", content: "World" },
267+
] as const,
268+
],
269+
])("clears a document %s on Mod-a + Backspace", (_, blocks) => {
270+
const editor = createEditorWithBlocks([...blocks]);
271+
272+
pressKeys(editor, "Mod-a");
273+
pressKeys(editor, "Backspace");
274+
275+
// The schema refills the emptied doc with a single default block.
276+
expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]);
277+
expect(editor.document[0].content).toEqual([]);
278+
279+
editor._tiptapEditor.destroy();
280+
});
281+
282+
// A whole-document selection's endpoints lie outside any block, which
283+
// `getNearestBlockPos` still has to resolve.
284+
it("returns every block from getSelection while everything is selected", () => {
285+
const editor = createEditorWithBlocks([
286+
{ id: "block-0", type: "checkListItem", content: "Check 1" },
287+
{ id: "block-1", type: "paragraph", content: "Hello world" },
288+
]);
289+
290+
pressKeys(editor, "Mod-a");
291+
292+
expect(editor.getSelection()?.blocks.map((block) => block.type)).toEqual([
293+
"checkListItem",
294+
"paragraph",
295+
]);
296+
297+
editor._tiptapEditor.destroy();
298+
});
299+
});

packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,11 @@ export const KeyboardShortcutsExtension = Extension.create<{
953953
return {
954954
Backspace: handleBackspace,
955955
Delete: handleDelete,
956+
// Taken over from TipTap's `Keymap` extension, which BlockNote doesn't
957+
// load. Without it, ProseMirror has to derive the selection from the
958+
// browser's, which fails for blocks that render non-editable content
959+
// first - like a check list item's checkbox.
960+
"Mod-a": () => this.editor.commands.selectAll(),
956961
Enter: () => handleEnter(),
957962
"Shift-Enter": () => handleEnter(true),
958963
// Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the

packages/math-block/src/block/createReactMathBlockSpec.test.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,25 @@ describe("Math block source popup keyboard handling", () => {
264264
expect(isPopupOpen("math")).toBe(false);
265265

266266
// Single-character keys are only blocked when no Ctrl/Cmd is held, so
267-
// shortcuts pass through - keeping copy/select-all/find working.
267+
// shortcuts pass through - keeping copy/find working.
268268
// (Cut/paste also pass through; that's a known limitation.)
269269
expect(pressKey("c", { ctrlKey: true })).toBe(false);
270-
expect(pressKey("a", { ctrlKey: true })).toBe(false);
271270
expect(pressKey("f", { ctrlKey: true })).toBe(false);
272271
expect(pressKey("v", { metaKey: true })).toBe(false);
273272
});
274273

274+
it("defers select-all to the editor while the popup is closed", () => {
275+
expect(isPopupOpen("math")).toBe(false);
276+
277+
// Not swallowed by the block either, but reported as handled since the
278+
// editor binds it - and it selects the whole doc, not just this block.
279+
expect(pressKey("a", { ctrlKey: true })).toBe(true);
280+
281+
const { selection, doc } = editor._tiptapEditor.state;
282+
expect(selection.from).toBe(0);
283+
expect(selection.to).toBe(doc.content.size);
284+
});
285+
275286
it("defers deletion keys to the default while the popup is open", async () => {
276287
pressKey("Enter");
277288
await flush();

0 commit comments

Comments
 (0)