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
125 changes: 93 additions & 32 deletions packages/core/src/api/blockManipulation/selections/selection.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/blocks/Table/TableExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,7 +27,7 @@ export const TableExtension = Extension.create({
// but is wrapped in a `blockContent` HTML element.
View: null,
}),
tableEditing(),
tableEditingWithCrossBlockSelection(),
];
},

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
Loading