Skip to content
Open
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
25 changes: 25 additions & 0 deletions packages/core/src/api/getBlockInfoFromPos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ export function getNearestBlockPos(doc: Node, pos: number) {
node = $pos.node(depth);
}

// The doc's boundary positions (0 and `doc.content.size`) sit around the
// `blockGroup` holding the top-level blocks, so they're outside every block.
// Unlike the positions handled below, they're expected rather than
// exceptional, as they're where an `AllSelection` starts & ends.
const atDocStart = pos <= 0;
if (atDocStart || pos >= doc.content.size) {
// Position 1 is just before the `blockGroup`'s first child, and
// `doc.content.size - 1` just after its last.
const $insideBlockGroup = doc.resolve(
atDocStart ? 1 : doc.content.size - 1,
);
const boundaryNode = atDocStart
? $insideBlockGroup.nodeAfter
: $insideBlockGroup.nodeBefore;

if (boundaryNode?.type.isInGroup("bnBlock")) {
return {
posBeforeNode: atDocStart
? $insideBlockGroup.pos
: $insideBlockGroup.pos - boundaryNode.nodeSize,
node: boundaryNode,
};
}
}

// If the position doesn't lie within a block node, we instead find the
// position of the next closest one. If the position is beyond the last block,
// we return the position of the last block. While running `doc.descendants`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,37 @@ function createEditor(
return editor;
}

/** Creates a mounted editor with the cursor at the end of the first block. */
function createEditorWithBlocks(
blocks: ((typeof schema)["PartialBlock"] & { id: string })[],
) {
const editor = BlockNoteEditor.create({ schema, initialContent: blocks });
editor.mount(document.createElement("div"));
editor.setTextCursorPosition(blocks[0].id, "end");
return editor;
}

/**
* Simulates a keyboard shortcut by dispatching a keydown event through the
* editor's `handleKeyDown` props, which is how ProseMirror invokes the
* keymap plugins created by `addKeyboardShortcuts`.
* Simulates a keyboard shortcut (e.g. "Enter", "Mod-a") via ProseMirror's
* `handleKeyDown` prop, and returns whether it was handled. Can't go via
* TipTap's `keyboardShortcut` command, which replays only the shortcut's steps
* - so it drops shortcuts that just move the selection.
*/
function pressKeys(editor: BlockNoteEditor<any, any, any>, keys: string) {
editor._tiptapEditor.commands.keyboardShortcut(keys);
const lastSeparatorIndex = keys.lastIndexOf("-");
const modifiers = keys.slice(0, lastSeparatorIndex);
const event = new KeyboardEvent("keydown", {
key: keys.slice(lastSeparatorIndex + 1),
// `Mod` is Cmd on macOS and Ctrl elsewhere - tests run in jsdom, which
// isn't macOS.
ctrlKey: modifiers.includes("Mod") || modifiers.includes("Ctrl"),
shiftKey: modifiers.includes("Shift"),
cancelable: true,
});

const view = editor._tiptapEditor.view;

return view.someProp("handleKeyDown", (f) => f(view, event)) ?? false;
}

function countHardBreaks(editor: BlockNoteEditor<any, any, any>) {
Expand Down Expand Up @@ -202,3 +226,74 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => {
editor._tiptapEditor.destroy();
});
});

describe("KeyboardShortcutsExtension select all", () => {
// Select-all used to have no keybinding, so it fell through to the browser.
// ProseMirror couldn't map the resulting DOM selection onto a document
// starting with a check list item (which renders its checkbox before its
// content), so it stayed unselected and Backspace only edited one block.
const checkListItemFirst = [
{ id: "block-0", type: "checkListItem", content: "Check 1" },
{ id: "block-1", type: "checkListItem", content: "Check 2" },
{ id: "block-2", type: "paragraph", content: "Hello world" },
] as const;

it("selects the whole document on Mod-a", () => {
const editor = createEditorWithBlocks([...checkListItemFirst]);

expect(pressKeys(editor, "Mod-a")).toBe(true);

const { selection, doc } = editor._tiptapEditor.state;
expect(selection.from).toBe(0);
expect(selection.to).toBe(doc.content.size);

editor._tiptapEditor.destroy();
});

it.each([
["starting with check list items", [...checkListItemFirst]],
[
"of only check list items",
[
{ id: "block-0", type: "checkListItem", content: "Check 1" },
{ id: "block-1", type: "checkListItem", content: "Check 2" },
] as const,
],
[
"of paragraphs",
[
{ id: "block-0", type: "paragraph", content: "Hello" },
{ id: "block-1", type: "paragraph", content: "World" },
] as const,
],
])("clears a document %s on Mod-a + Backspace", (_, blocks) => {
const editor = createEditorWithBlocks([...blocks]);

pressKeys(editor, "Mod-a");
pressKeys(editor, "Backspace");

// The schema refills the emptied doc with a single default block.
expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]);
expect(editor.document[0].content).toEqual([]);

editor._tiptapEditor.destroy();
});

// A whole-document selection's endpoints lie outside any block, which
// `getNearestBlockPos` still has to resolve.
it("returns every block from getSelection while everything is selected", () => {
const editor = createEditorWithBlocks([
{ id: "block-0", type: "checkListItem", content: "Check 1" },
{ id: "block-1", type: "paragraph", content: "Hello world" },
]);

pressKeys(editor, "Mod-a");

expect(editor.getSelection()?.blocks.map((block) => block.type)).toEqual([
"checkListItem",
"paragraph",
]);

editor._tiptapEditor.destroy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,11 @@ export const KeyboardShortcutsExtension = Extension.create<{
return {
Backspace: handleBackspace,
Delete: handleDelete,
// Taken over from TipTap's `Keymap` extension, which BlockNote doesn't
// load. Without it, ProseMirror has to derive the selection from the
// browser's, which fails for blocks that render non-editable content
// first - like a check list item's checkbox.
"Mod-a": () => this.editor.commands.selectAll(),
Enter: () => handleEnter(),
"Shift-Enter": () => handleEnter(true),
// Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the
Expand Down
15 changes: 13 additions & 2 deletions packages/math-block/src/block/createReactMathBlockSpec.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,14 +264,25 @@ describe("Math block source popup keyboard handling", () => {
expect(isPopupOpen("math")).toBe(false);

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

it("defers select-all to the editor while the popup is closed", () => {
expect(isPopupOpen("math")).toBe(false);

// Not swallowed by the block either, but reported as handled since the
// editor binds it - and it selects the whole doc, not just this block.
expect(pressKey("a", { ctrlKey: true })).toBe(true);

const { selection, doc } = editor._tiptapEditor.state;
expect(selection.from).toBe(0);
expect(selection.to).toBe(doc.content.size);
});

it("defers deletion keys to the default while the popup is open", async () => {
pressKey("Enter");
await flush();
Expand Down