From c96e9e4cb65029448886e31b246aaf2bc7af80a3 Mon Sep 17 00:00:00 2001 From: Habib Ur Rehman Date: Sat, 12 Sep 2026 18:13:48 +0000 Subject: [PATCH] fix: allow selecting tables alongside other blocks prosemirror-tables' normalizeSelection clamped any TextSelection that left a table when $to.parentOffset === 0, and cell-drag handling refused to escape the table. That blocked mouse selection of a table plus neighbouring blocks and made native Ctrl/Cmd+A unreliable. Keep cell selection inside tables, but preserve TextSelections that span a table and other blocks. Add a Notion-style Mod-a keymap (first press selects the current block, second selects the document) that uses table-aware ranges so select-all works in documents that contain tables. --- .../blockManipulation/selections/selection.ts | 125 +++++--- .../core/src/blocks/Table/TableExtension.ts | 4 +- .../tableCrossBlockSelection.browser.test.ts | 125 ++++++++ .../Table/tableCrossBlockSelection.test.ts | 269 ++++++++++++++++++ .../tableEditingWithCrossBlockSelection.ts | 240 ++++++++++++++++ .../KeyboardShortcutsExtension.test.ts | 105 +++++++ .../KeyboardShortcutsExtension.ts | 39 +++ .../block/createReactMathBlockSpec.test.tsx | 7 +- 8 files changed, 878 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/blocks/Table/tableCrossBlockSelection.browser.test.ts create mode 100644 packages/core/src/blocks/Table/tableCrossBlockSelection.test.ts create mode 100644 packages/core/src/blocks/Table/tableEditingWithCrossBlockSelection.ts diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index d6229a3f0a..b2515e9096 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -1,5 +1,10 @@ -import { TextSelection, type Transaction } from "prosemirror-state"; -import { TableMap } from "prosemirror-tables"; +import type { Node } from "prosemirror-model"; +import { + Selection as PMSelection, + TextSelection, + type Transaction, +} from "prosemirror-state"; +import { cellAround, TableMap } from "prosemirror-tables"; import { Block } from "../../../blocks/defaultBlocks.js"; import { Selection } from "../../../editor/selectionTypes.js"; import { @@ -9,7 +14,11 @@ import { StyleSchema, } from "../../../schema/index.js"; import { expandPMRangeToWords } from "../../../util/expandToWords.js"; -import { getBlockInfo, getNearestBlockPos } from "../../getBlockInfoFromPos.js"; +import { + type BlockInfo, + getBlockInfo, + getNearestBlockPos, +} from "../../getBlockInfoFromPos.js"; import { nodeToBlock, prosemirrorSliceToSlicedBlocks, @@ -132,6 +141,79 @@ export function getSelection< }; } +/** + * Positions of the first and last selectable text inside a table block. + * Matches the endpoints `setSelection` has always used for table anchors/heads. + */ +export function getTableContentRange( + doc: Node, + tableContent: { node: Node; beforePos: number }, +): { from: number; to: number } { + const tableMap = TableMap.get(tableContent.node); + const firstCellPos = + tableContent.beforePos + tableMap.positionAt(0, 0, tableContent.node) + 1; + const lastCellPos = + tableContent.beforePos + + tableMap.positionAt( + tableMap.height - 1, + tableMap.width - 1, + tableContent.node, + ) + + 1; + const lastCellNodeSize = doc.resolve(lastCellPos).nodeAfter!.nodeSize; + return { + from: firstCellPos + 2, + to: lastCellPos + lastCellNodeSize - 2, + }; +} + +/** + * Selectable content range of the current block, if it has any. Tables use the + * first/last cell text positions so a `TextSelection` can cover the whole + * table; inline/plain blocks use the content node's interior. + */ +export function getBlockContentRange( + doc: Node, + blockInfo: BlockInfo, +): { from: number; to: number } | undefined { + if (!blockInfo.isBlockContainer) { + return undefined; + } + + if (blockInfo.blockContent.node.type.spec.tableRole === "table") { + return getTableContentRange(doc, blockInfo.blockContent); + } + + if ( + blockInfo.blockContent.node.isTextblock || + blockInfo.blockContent.node.inlineContent + ) { + return { + from: blockInfo.blockContent.beforePos + 1, + to: blockInfo.blockContent.afterPos - 1, + }; + } + + return undefined; +} + +/** + * Whole-document `TextSelection`. Endpoints that fall inside a table are + * expanded to the table node's boundaries so Backspace/Delete can remove the + * isolating table instead of only emptying its cells. + */ +export function getWholeDocTextSelection(doc: Node): TextSelection { + const atStart = PMSelection.atStart(doc); + const atEnd = PMSelection.atEnd(doc); + const startCell = cellAround(atStart.$from); + const endCell = cellAround(atEnd.$to); + + const from = startCell ? startCell.start(-1) - 1 : atStart.from; + const to = endCell ? endCell.start(-1) + endCell.node(-1).nodeSize : atEnd.to; + + return TextSelection.create(doc, from, to); +} + export function setSelection( tr: Transaction, startBlock: BlockIdentifier, @@ -183,35 +265,14 @@ export function setSelection( ); } - let startPos: number; - let endPos: number; - - if (anchorBlockConfig.content === "table") { - const tableMap = TableMap.get(anchorBlockInfo.blockContent.node); - const firstCellPos = - anchorBlockInfo.blockContent.beforePos + - tableMap.positionAt(0, 0, anchorBlockInfo.blockContent.node) + - 1; - startPos = firstCellPos + 2; - } else { - startPos = anchorBlockInfo.blockContent.beforePos + 1; - } - - if (headBlockConfig.content === "table") { - const tableMap = TableMap.get(headBlockInfo.blockContent.node); - const lastCellPos = - headBlockInfo.blockContent.beforePos + - tableMap.positionAt( - tableMap.height - 1, - tableMap.width - 1, - headBlockInfo.blockContent.node, - ) + - 1; - const lastCellNodeSize = tr.doc.resolve(lastCellPos).nodeAfter!.nodeSize; - endPos = lastCellPos + lastCellNodeSize - 2; - } else { - endPos = headBlockInfo.blockContent.afterPos - 1; - } + const startPos = + anchorBlockConfig.content === "table" + ? getTableContentRange(tr.doc, anchorBlockInfo.blockContent).from + : anchorBlockInfo.blockContent.beforePos + 1; + const endPos = + headBlockConfig.content === "table" + ? getTableContentRange(tr.doc, headBlockInfo.blockContent).to + : headBlockInfo.blockContent.afterPos - 1; // TODO: We should polish up the `MultipleNodeSelection` and use that instead. // Right now it's missing a few things like a jsonID and styling to show diff --git a/packages/core/src/blocks/Table/TableExtension.ts b/packages/core/src/blocks/Table/TableExtension.ts index 70cea2ee9f..1b714adce3 100644 --- a/packages/core/src/blocks/Table/TableExtension.ts +++ b/packages/core/src/blocks/Table/TableExtension.ts @@ -7,8 +7,8 @@ import { moveCellForward, nextCell, selectionCell, - tableEditing, } from "prosemirror-tables"; +import { tableEditingWithCrossBlockSelection } from "./tableEditingWithCrossBlockSelection.js"; export const RESIZE_MIN_WIDTH = 35; export const EMPTY_CELL_WIDTH = 120; @@ -27,7 +27,7 @@ export const TableExtension = Extension.create({ // but is wrapped in a `blockContent` HTML element. View: null, }), - tableEditing(), + tableEditingWithCrossBlockSelection(), ]; }, diff --git a/packages/core/src/blocks/Table/tableCrossBlockSelection.browser.test.ts b/packages/core/src/blocks/Table/tableCrossBlockSelection.browser.test.ts new file mode 100644 index 0000000000..cc48cb7782 --- /dev/null +++ b/packages/core/src/blocks/Table/tableCrossBlockSelection.browser.test.ts @@ -0,0 +1,125 @@ +import { TextSelection } from "prosemirror-state"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; + +// Mouse-drag selection across a table and neighbouring blocks needs a real +// layout (getBoundingClientRect / posAtCoords). The matching node tests in +// `tableCrossBlockSelection.test.ts` cover programmatic selection and Mod-a. + +describe("table + neighbouring block mouse selection", () => { + let editor: BlockNoteEditor; + let mountPoint: HTMLElement; + + beforeEach(() => { + mountPoint = document.createElement("div"); + document.body.appendChild(mountPoint); + + editor = BlockNoteEditor.create({ + initialContent: [ + { id: "paragraph-before", type: "paragraph", content: "Before table" }, + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["Cell 1", "Cell 2"] }, + { cells: ["Cell 3", "Cell 4"] }, + ], + }, + }, + { id: "paragraph-after", type: "paragraph", content: "After table" }, + ], + }); + editor.mount(mountPoint); + }); + + afterEach(() => { + editor.unmount(); + editor._tiptapEditor.destroy(); + mountPoint.remove(); + }); + + function queryText(text: string) { + const walker = document.createTreeWalker(mountPoint, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.textContent === text) { + return node; + } + } + throw new Error(`Text node "${text}" not found`); + } + + function clientPoint(text: string, atEnd = false) { + const node = queryText(text); + const range = document.createRange(); + range.setStart(node, atEnd ? (node.textContent?.length ?? 0) : 0); + range.setEnd(node, atEnd ? (node.textContent?.length ?? 0) : 0); + const rect = range.getBoundingClientRect(); + return { + clientX: rect.left + Math.min(2, rect.width / 2), + clientY: rect.top + rect.height / 2, + }; + } + + function dragSelect(fromText: string, toText: string) { + const from = clientPoint(fromText); + const to = clientPoint(toText, true); + const view = editor.prosemirrorView; + + view.dom.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: from.clientX, + clientY: from.clientY, + }), + ); + view.dom.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: to.clientX, + clientY: to.clientY, + }), + ); + view.dom.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + clientX: to.clientX, + clientY: to.clientY, + }), + ); + } + + it("selects a paragraph, the table, and the next paragraph by dragging", () => { + dragSelect("Before table", "After table"); + + expect(editor.getSelection()?.blocks.map((block) => block.type)).toEqual([ + "paragraph", + "table", + "paragraph", + ]); + expect(editor.prosemirrorView.state.selection).toBeInstanceOf( + TextSelection, + ); + }); + + it("selects the table together with the following paragraph when dragging out of a cell", () => { + dragSelect("Cell 1", "After table"); + + expect(editor.getSelection()?.blocks.map((block) => block.type)).toEqual([ + "table", + "paragraph", + ]); + }); +}); diff --git a/packages/core/src/blocks/Table/tableCrossBlockSelection.test.ts b/packages/core/src/blocks/Table/tableCrossBlockSelection.test.ts new file mode 100644 index 0000000000..bfe1599f9e --- /dev/null +++ b/packages/core/src/blocks/Table/tableCrossBlockSelection.test.ts @@ -0,0 +1,269 @@ +import { TextSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { getWholeDocTextSelection } from "../../api/blockManipulation/selections/selection.js"; +import { getBlockInfo } from "../../api/getBlockInfoFromPos.js"; +import { getNodeById } from "../../api/nodeUtil.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { PartialBlock } from "../defaultBlocks.js"; + +/** + * @vitest-environment jsdom + */ + +// A document that contains a table must still allow selecting that table +// alongside neighbouring blocks, and Mod-a must select the whole document +// (not get clamped into a cell). + +const tableAndParagraphs: PartialBlock[] = [ + { id: "paragraph-before", type: "paragraph", content: "Before table" }, + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Cell 1", "Cell 2"] }, { cells: ["Cell 3", "Cell 4"] }], + }, + }, + { id: "paragraph-after", type: "paragraph", content: "After table" }, +]; + +function createEditor(initialContent: PartialBlock[] = tableAndParagraphs) { + const editor = BlockNoteEditor.create({ initialContent }); + editor.mount(document.createElement("div")); + return editor; +} + +function posOfText( + editor: BlockNoteEditor, + text: string, + atEnd = false, +): number { + let pos = -1; + editor.prosemirrorView.state.doc.descendants((node, nodePos) => { + if (pos === -1 && node.isText && node.text === text) { + pos = atEnd ? nodePos + node.nodeSize : nodePos; + } + return true; + }); + if (pos === -1) { + throw new Error(`Text "${text}" not found`); + } + return pos; +} + +function pressSelectAll(editor: BlockNoteEditor) { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { + key: "a", + code: "KeyA", + ctrlKey: true, + }); + view.someProp("handleKeyDown", (handler) => handler(view, event)); +} + +function pressBackspace(editor: BlockNoteEditor) { + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { + key: "Backspace", + code: "Backspace", + }); + view.someProp("handleKeyDown", (handler) => handler(view, event)); +} + +function selectedBlockTypes(editor: BlockNoteEditor) { + return editor.getSelection()?.blocks.map((block) => block.type); +} + +describe("table + neighbouring block selection", () => { + let editor: BlockNoteEditor; + + afterEach(() => { + editor?._tiptapEditor.destroy(); + }); + + it("keeps a TextSelection that starts in a paragraph and ends in a table", () => { + editor = createEditor(); + + editor.setSelection("paragraph-before", "table-0"); + + expect(selectedBlockTypes(editor)).toEqual(["paragraph", "table"]); + expect(editor.prosemirrorView.state.selection).toBeInstanceOf( + TextSelection, + ); + }); + + it("keeps a TextSelection that starts in a table and ends in a paragraph", () => { + editor = createEditor(); + + editor.setSelection("table-0", "paragraph-after"); + + expect(selectedBlockTypes(editor)).toEqual(["table", "paragraph"]); + expect(editor.prosemirrorView.state.selection).toBeInstanceOf( + TextSelection, + ); + }); + + it("keeps a TextSelection spanning a paragraph, a table, and the next paragraph", () => { + editor = createEditor(); + + editor.setSelection("paragraph-before", "paragraph-after"); + + expect(selectedBlockTypes(editor)).toEqual([ + "paragraph", + "table", + "paragraph", + ]); + }); + + // `tableEditing`'s `normalizeSelection` used to treat any TextSelection + // with `$to.parentOffset === 0` and one endpoint in a cell as an accidental + // intra-table span, and clamp it back to a single cell. That is exactly the + // shape of a mouse selection that has just crossed from a table into the + // following paragraph (or of select-all when the doc starts with a table + // and ends on an empty block). + it("does not clamp a TextSelection that leaves a table at parentOffset 0", () => { + editor = createEditor(); + + const from = posOfText(editor, "Cell 1"); + const to = posOfText(editor, "After table"); + + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, from, to)), + ); + + expect(selectedBlockTypes(editor)).toEqual(["table", "paragraph"]); + expect(editor.prosemirrorView.state.selection.from).toBe(from); + expect(editor.prosemirrorView.state.selection.to).toBe(to); + }); + + it("does not clamp a TextSelection from a leading table to an empty trailing paragraph", () => { + editor = createEditor([ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Only"] }], + }, + }, + { id: "empty", type: "paragraph", content: "" }, + ]); + + const from = posOfText(editor, "Only"); + const emptyBlock = getBlockInfo( + getNodeById("empty", editor.prosemirrorView.state.doc)!, + ); + if (!emptyBlock.isBlockContainer) { + throw new Error("empty paragraph is not a block container"); + } + const to = emptyBlock.blockContent.beforePos + 1; + + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, from, to)), + ); + + expect(selectedBlockTypes(editor)).toEqual(["table", "paragraph"]); + expect(editor.prosemirrorView.state.selection.from).toBe(from); + expect(editor.prosemirrorView.state.selection.to).toBe(to); + }); +}); + +describe("Mod-a with tables", () => { + let editor: BlockNoteEditor; + + afterEach(() => { + editor?._tiptapEditor.destroy(); + }); + + function expectWholeDocSelected() { + const { selection, doc } = editor.prosemirrorView.state; + const wholeDoc = getWholeDocTextSelection(doc); + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(wholeDoc.from); + expect(selection.to).toBe(wholeDoc.to); + } + + it("selects the table on the first Mod-a and the whole document on the second", () => { + editor = createEditor(); + editor.setTextCursorPosition("table-0", "start"); + + pressSelectAll(editor); + + expect(selectedBlockTypes(editor)).toEqual(["table"]); + + pressSelectAll(editor); + + expectWholeDocSelected(); + expect(selectedBlockTypes(editor)).toEqual([ + "paragraph", + "table", + "paragraph", + ]); + }); + + it("selects the current paragraph, then the whole document including the table", () => { + editor = createEditor(); + editor.setTextCursorPosition("paragraph-before", "end"); + + pressSelectAll(editor); + + const before = getBlockInfo( + getNodeById("paragraph-before", editor.prosemirrorView.state.doc)!, + ); + if (!before.isBlockContainer) { + throw new Error("paragraph-before is not a block container"); + } + expect(editor.prosemirrorView.state.selection.from).toBe( + before.blockContent.beforePos + 1, + ); + expect(editor.prosemirrorView.state.selection.to).toBe( + before.blockContent.afterPos - 1, + ); + + pressSelectAll(editor); + + expectWholeDocSelected(); + expect(selectedBlockTypes(editor)).toEqual([ + "paragraph", + "table", + "paragraph", + ]); + }); + + it("clears a document that contains a table on Mod-a + Backspace", () => { + editor = createEditor(); + editor.setTextCursorPosition("paragraph-after", "end"); + + pressSelectAll(editor); + pressSelectAll(editor); + pressBackspace(editor); + + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + }); + + it("clears a document that starts with a table on Mod-a + Backspace", () => { + editor = createEditor([ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Only"] }], + }, + }, + { id: "paragraph-after", type: "paragraph", content: "After" }, + ]); + editor.setTextCursorPosition("table-0", "start"); + + pressSelectAll(editor); + pressSelectAll(editor); + pressBackspace(editor); + + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + }); +}); diff --git a/packages/core/src/blocks/Table/tableEditingWithCrossBlockSelection.ts b/packages/core/src/blocks/Table/tableEditingWithCrossBlockSelection.ts new file mode 100644 index 0000000000..812ff4114b --- /dev/null +++ b/packages/core/src/blocks/Table/tableEditingWithCrossBlockSelection.ts @@ -0,0 +1,240 @@ +import type { Node, ResolvedPos } from "prosemirror-model"; +import { + NodeSelection, + Plugin, + TextSelection, + type EditorState, + type Transaction, +} from "prosemirror-state"; +import { + CellSelection, + TableMap, + cellAround, + fixTables, + inSameTable, + tableEditing, + tableEditingKey, +} from "prosemirror-tables"; +import type { EditorView } from "prosemirror-view"; + +import { getTableContentRange } from "../../api/blockManipulation/selections/selection.js"; + +/** + * `tableEditing` plugin that still does cell selection, copy/paste, and table + * fixing, but does **not** trap selections that leave the table. + * + * Upstream `normalizeSelection` treats any `TextSelection` with one endpoint + * in a cell and `$to.parentOffset === 0` as an accidental intra-table span + * and clamps it back to a single cell. That also matches: + * - mouse-selecting from a table into the next paragraph + * - select-all in a document that starts with a table and ends on an empty + * block + * + * `handleMouseDown` similarly refuses to update once the pointer leaves the + * table (`inSameTable`). Both are why tables could not be selected alongside + * other blocks. + */ +export function tableEditingWithCrossBlockSelection(options?: { + allowTableNodeSelection?: boolean; +}) { + const allowTableNodeSelection = options?.allowTableNodeSelection ?? false; + const stock = tableEditing({ allowTableNodeSelection }); + const stockMouseDown = stock.spec.props?.handleDOMEvents?.mousedown as + | ((view: EditorView, event: MouseEvent) => boolean | void) + | undefined; + + return new Plugin({ + key: tableEditingKey, + state: stock.spec.state, + props: { + decorations: stock.spec.props?.decorations, + handleTripleClick: stock.spec.props?.handleTripleClick, + handleKeyDown: stock.spec.props?.handleKeyDown, + handlePaste: stock.spec.props?.handlePaste, + handleDOMEvents: { + mousedown: handleMouseDownAllowingTableEscape(stockMouseDown), + }, + createSelectionBetween(view) { + // Only freeze the selection while a cell-drag is still inside the + // table. Once it has escaped (we converted it to a TextSelection), + // let ProseMirror map the DOM selection as usual. + if (tableEditingKey.getState(view.state) == null) { + return null; + } + if (view.state.selection instanceof CellSelection) { + return view.state.selection; + } + return null; + }, + }, + appendTransaction(_, oldState, state) { + return normalizeSelectionAllowingCrossBlock( + state, + fixTables(state, oldState), + allowTableNodeSelection, + ); + }, + }); +} + +function handleMouseDownAllowingTableEscape( + stockMouseDown: + | ((view: EditorView, event: MouseEvent) => boolean | void) + | undefined, +) { + return function onMouseDown(view: EditorView, event: MouseEvent) { + stockMouseDown?.(view, event); + + if (event.button !== 0 || event.ctrlKey || event.metaKey) { + return; + } + + function move(rawEvent: Event) { + const mouseEvent = rawEvent as MouseEvent; + if (mouseEvent.buttons !== 1) { + return; + } + + const dragAnchor = tableEditingKey.getState(view.state); + if (dragAnchor == null) { + return; + } + + const mousePos = view.posAtCoords({ + left: mouseEvent.clientX, + top: mouseEvent.clientY, + }); + if (!mousePos) { + return; + } + + const $mouse = view.state.doc.resolve( + mousePos.inside >= 0 ? mousePos.inside : mousePos.pos, + ); + const $mouseCell = cellAround($mouse); + const $anchorCell = view.state.doc.resolve(dragAnchor); + + if ($mouseCell && inSameTable($anchorCell, $mouseCell)) { + return; + } + + const next = textSelectionLeavingTable( + view.state.doc, + $anchorCell, + mousePos.pos, + ); + if (!next || next.eq(view.state.selection)) { + return; + } + + view.dispatch(view.state.tr.setSelection(next)); + } + + function stop() { + view.root.removeEventListener("mousemove", move); + view.root.removeEventListener("mouseup", stop); + view.root.removeEventListener("dragstart", stop); + } + + view.root.addEventListener("mousemove", move); + view.root.addEventListener("mouseup", stop); + view.root.addEventListener("dragstart", stop); + }; +} + +function textSelectionLeavingTable( + doc: Node, + $anchorCell: ResolvedPos, + outsidePos: number, +): TextSelection | undefined { + const tableNode = $anchorCell.node(-1); + if (tableNode.type.spec.tableRole !== "table") { + return undefined; + } + + const tableStart = $anchorCell.start(-1); + const tableRange = getTableContentRange(doc, { + node: tableNode, + beforePos: tableStart - 1, + }); + const clampedOutside = Math.max(0, Math.min(outsidePos, doc.content.size)); + + if (clampedOutside < tableStart) { + return TextSelection.create(doc, clampedOutside, tableRange.to); + } + return TextSelection.create(doc, tableRange.from, clampedOutside); +} + +/** + * Copy of prosemirror-tables' `normalizeSelection`, except + * `isTextSelectionAcrossCells` only fires when **both** endpoints are in + * cells of the same table. See the file-level comment. + */ +function normalizeSelectionAllowingCrossBlock( + state: EditorState, + tr: Transaction | undefined, + allowTableNodeSelection: boolean, +): Transaction | undefined { + const sel = (tr || state).selection; + const doc = (tr || state).doc; + let normalize: NodeSelection | TextSelection | CellSelection | undefined; + let role: string | undefined; + + if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) { + if (role === "cell" || role === "header_cell") { + normalize = CellSelection.create(doc, sel.from); + } else if (role === "row") { + const $cell = doc.resolve(sel.from + 1); + normalize = CellSelection.rowSelection($cell, $cell); + } else if (!allowTableNodeSelection) { + const map = TableMap.get(sel.node); + const start = sel.from + 1; + const lastCell = start + map.map[map.width * map.height - 1]; + normalize = CellSelection.create(doc, start + 1, lastCell); + } + } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) { + normalize = TextSelection.create(doc, sel.from); + } else if ( + sel instanceof TextSelection && + isTextSelectionAcrossCellsInSameTable(sel) + ) { + normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end()); + } + + if (normalize) { + (tr || (tr = state.tr)).setSelection(normalize); + } + return tr; +} + +function isCellBoundarySelection({ $from, $to }: TextSelection) { + if ($from.pos === $to.pos || $from.pos < $to.pos - 6) { + return false; + } + let afterFrom = $from.pos; + let depth = $from.depth; + for (; depth >= 0; depth--, afterFrom++) { + if ($from.after(depth + 1) < $from.end(depth)) { + break; + } + } + let beforeTo = $to.pos; + for (let d = $to.depth; d >= 0; d--, beforeTo--) { + if ($to.before(d + 1) > $to.start(d)) { + break; + } + } + return ( + afterFrom === beforeTo && + /row|table/.test($from.node(depth).type.spec.tableRole ?? "") + ); +} + +function isTextSelectionAcrossCellsInSameTable({ $from, $to }: TextSelection) { + const $fromCell = cellAround($from); + const $toCell = cellAround($to); + if (!$fromCell || !$toCell || !inSameTable($fromCell, $toCell)) { + return false; + } + return $fromCell.pos !== $toCell.pos && $to.parentOffset === 0; +} diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index 2f1e601a35..36f2519609 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -1,5 +1,8 @@ +import { Selection, TextSelection } from "prosemirror-state"; import { describe, expect, it } from "vite-plus/test"; +import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js"; +import { getNodeById } from "../../../api/nodeUtil.js"; import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; @@ -110,6 +113,108 @@ function getTextContent(editor: BlockNoteEditor) { return text; } +describe("KeyboardShortcutsExtension Mod-a (select all)", () => { + // BlockNote disables TipTap's core extensions, so it has no default `Mod-a` + // binding and select-all used to rely on the browser's native behaviour. + // The first Mod-a selects the current block; the second expands to the + // whole document. Tables are covered in `tableCrossBlockSelection.test.ts`. + function createSelectAllEditor( + blocks: { type: "paragraph" | "checkListItem"; content: string }[], + ) { + const editor = BlockNoteEditor.create({ + schema, + initialContent: blocks.map((block, index) => ({ + id: `block-${index}`, + ...block, + })), + }); + editor.mount(document.createElement("div")); + return editor; + } + + function pressSelectAll(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "a", + code: "KeyA", + ctrlKey: true, + }); + view.someProp("handleKeyDown", (handler) => handler(view, event)); + } + + function pressBackspace(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "Backspace", + code: "Backspace", + }); + view.someProp("handleKeyDown", (handler) => handler(view, event)); + } + + function expectWholeDocSelected(editor: BlockNoteEditor) { + const { selection, doc } = editor._tiptapEditor.state; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(Selection.atStart(doc).from); + expect(selection.to).toBe(Selection.atEnd(doc).to); + } + + function expectBlockContentSelected( + editor: BlockNoteEditor, + blockId: string, + ) { + const { selection, doc } = editor._tiptapEditor.state; + const blockInfo = getBlockInfo(getNodeById(blockId, doc)!); + if (!blockInfo.isBlockContainer) { + throw new Error(`Block ${blockId} is not a block container`); + } + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(blockInfo.blockContent.beforePos + 1); + expect(selection.to).toBe(blockInfo.blockContent.afterPos - 1); + } + + it("escalates the selection and clears a paragraph-first document", () => { + const editor = createSelectAllEditor([ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ]); + editor.setTextCursorPosition("block-0", "end"); + + pressSelectAll(editor); + expectBlockContentSelected(editor, "block-0"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + + editor._tiptapEditor.destroy(); + }); + + it("escalates the selection and clears a check-list-first document", () => { + const editor = createSelectAllEditor([ + { type: "checkListItem", content: "First" }, + { type: "paragraph", content: "Second" }, + ]); + editor.setTextCursorPosition("block-1", "end"); + + pressSelectAll(editor); + expectBlockContentSelected(editor, "block-1"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + + editor._tiptapEditor.destroy(); + }); +}); + describe("KeyboardShortcutsExtension hardBreakShortcut", () => { it("inserts a hard break on Shift-Enter by default", () => { const editor = createEditor("paragraph"); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..8ba5e81f24 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -17,6 +17,10 @@ import { import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { + getBlockContentRange, + getWholeDocTextSelection, +} from "../../../api/blockManipulation/selections/selection.js"; import { getBlockInfoFromResolvedPos, getBlockInfoFromSelection, @@ -997,6 +1001,41 @@ export const KeyboardShortcutsExtension = Extension.create<{ "Mod-z": () => this.options.editor.undo(), "Mod-y": () => this.options.editor.redo(), "Shift-Mod-z": () => this.options.editor.redo(), + "Mod-a": () => { + const view = this.editor.view; + const { doc, selection, tr } = view.state; + + // Follows Notion: the first Mod-a selects the current block's + // content, and any subsequent Mod-a expands to the whole document. + // TextSelection (not AllSelection) so from/to stay inside blocks — + // getBlock etc. keep working. + // + // Table ranges use the first/last cell text positions; a naive + // beforePos+1/afterPos-1 on the table node is not a valid text + // range. Whole-doc selection uses TextSelection.create so it + // survives tableEditing's normalizeSelection. + const blockInfo = getBlockInfoFromSelection(view.state); + const blockContentRange = getBlockContentRange(doc, blockInfo); + + const selectWholeDoc = + blockContentRange === undefined || + selection.from < blockContentRange.from || + selection.to > blockContentRange.to || + (selection.from === blockContentRange.from && + selection.to === blockContentRange.to); + + const nextSelection = selectWholeDoc + ? getWholeDocTextSelection(doc) + : TextSelection.create( + doc, + blockContentRange.from, + blockContentRange.to, + ); + + view.dispatch(tr.setSelection(nextSelection)); + + return true; + }, }; }, }); diff --git a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx index d2d2e31796..d36127bc6b 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx @@ -264,12 +264,15 @@ 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); + // Ctrl/Cmd-a is the exception: select-all is handled explicitly by + // the global keymap (see KeyboardShortcutsExtension), not deferred to the + // browser, so it reports as handled rather than passing through. + expect(pressKey("a", { ctrlKey: true })).toBe(true); }); it("defers deletion keys to the default while the popup is open", async () => {