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
102 changes: 102 additions & 0 deletions packages/core/src/blocks/Table/TableExtension.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GapCursor } from "@tiptap/pm/gapcursor";
import { TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import {
Expand All @@ -10,6 +11,7 @@ import {
} from "vite-plus/test";

import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { TableHandlesExtension } from "../../extensions/TableHandles/TableHandles.js";
import type { PartialBlock } from "../defaultBlocks.js";

/**
Expand Down Expand Up @@ -127,3 +129,103 @@ describe("Table Enter keyboard shortcut", () => {
expect(editor.document).toStrictEqual(before);
});
});

describe("TableHandlesExtension.getCellSelection", () => {
let editor: BlockNoteEditor;
const div = document.createElement("div");

beforeAll(() => {
editor = BlockNoteEditor.create();
editor.mount(div);
});

afterAll(() => {
editor._tiptapEditor.destroy();
editor = undefined as any;
});

function getCellSelection() {
return editor.getExtension(TableHandlesExtension)!.getCellSelection();
}

it("returns undefined for a gap cursor left of a leading table without crashing", () => {
// A table as the first block in the document, matching the crash repro
// where clicking in the left margin places a gap cursor at doc start.
editor.replaceBlocks(editor.document, [
{
type: "table",
content: {
type: "tableContent",
rows: [{ cells: ["Cell 1", "Cell 2"] }],
},
},
]);

editor.transact((tr) => tr.setSelection(new GapCursor(tr.doc.resolve(0))));

expect(() => getCellSelection()).not.toThrow();
expect(getCellSelection()).toBeUndefined();
});

it("returns undefined for a gap cursor next to a nested block without crashing", () => {
// A table as the first block, followed by a nested block. Clicking the
// nested block's left margin places a gap cursor that is not inside a cell.
editor.replaceBlocks(editor.document, [
{
type: "table",
content: {
type: "tableContent",
rows: [{ cells: ["Cell 1", "Cell 2"] }],
},
},
{
type: "paragraph",
content: "Parent",
children: [{ type: "paragraph", content: "Nested" }],
},
]);

let nestedPos = -1;
editor.prosemirrorView.state.doc.descendants((node, pos) => {
if (nestedPos === -1 && node.isText && node.text === "Nested") {
nestedPos = pos;
}
return true;
});
editor.transact((tr) =>
tr.setSelection(new GapCursor(tr.doc.resolve(nestedPos - 1))),
);

expect(() => getCellSelection()).not.toThrow();
expect(getCellSelection()).toBeUndefined();
});

it("returns the cell indices for a text selection inside a cell", () => {
editor.replaceBlocks(editor.document, [
{
type: "table",
content: {
type: "tableContent",
rows: [
{ cells: ["Cell 1", "Cell 2"] },
{ cells: ["Cell 3", "Cell 4"] },
],
},
},
]);

let cellPos = -1;
editor.prosemirrorView.state.doc.descendants((node, pos) => {
if (cellPos === -1 && node.isText && node.text === "Cell 4") {
cellPos = pos;
}
return true;
});
editor.transact((tr) =>
tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)),
);

expect(getCellSelection()?.from).toEqual({ row: 1, col: 1 });
expect(getCellSelection()?.to).toEqual({ row: 1, col: 1 });
});
});
6 changes: 6 additions & 0 deletions packages/core/src/blocks/defaultBlockTypeGuards.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Node } from "prosemirror-model";
import { CellSelection } from "prosemirror-tables";
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { BlockConfig, PropSchema, PropSpec } from "../schema/index.js";
Expand Down Expand Up @@ -159,3 +160,8 @@ export function isTableCellSelection(
): selection is CellSelection {
return selection instanceof CellSelection;
}

export function isTableCellNode(node: Node): boolean {
const tableRole = node.type.spec.tableRole;
return tableRole === "cell" || tableRole === "header_cell";
}
30 changes: 21 additions & 9 deletions packages/core/src/extensions/TableHandles/TableHandles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../api/nodeUtil.js";
import {
editorHasBlockWithType,
isTableCellNode,
isTableCellSelection,
} from "../../blocks/defaultBlockTypeGuards.js";
import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js";
Expand Down Expand Up @@ -1106,15 +1107,26 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
// When the selection is a normal text selection
// Assumes we are within a tableParagraph
// And find the from and to cells by resolving the positions
$fromCell = tr.doc.resolve(
selection.$from.pos - selection.$from.parentOffset - 1,
);
$toCell = tr.doc.resolve(
selection.$to.pos - selection.$to.parentOffset - 1,
);

// Opt-out when the selection is not pointing into cells
if ($fromCell.pos === 0 || $toCell.pos === 0) {
const fromCellPos =
selection.$from.pos - selection.$from.parentOffset - 1;
const toCellPos = selection.$to.pos - selection.$to.parentOffset - 1;

// Opt-out when the selection is not pointing into cells. This happens when the selection
// is at the start of the table's `blockContainer` node and therefore just before the
// actual `table` node.
if (fromCellPos < 0 || toCellPos < 0) {
return undefined;
}

$fromCell = tr.doc.resolve(fromCellPos);
$toCell = tr.doc.resolve(toCellPos);

// Opt-out when the selection is not actually pointing into table
// cells (e.g. a gap cursor next to a nested block).
if (
!isTableCellNode($fromCell.parent) ||
!isTableCellNode($toCell.parent)
) {
return undefined;
}
}
Expand Down
Loading