Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
134 changes: 134 additions & 0 deletions packages/core/src/blocks/Table/TableExtension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";

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

/**
* @vitest-environment jsdom
*/

/**
* Simulate a keyboard shortcut by invoking the view's handleKeyDown prop,
* which is how ProseMirror routes keymap-based handlers like Enter.
*/
function pressEnter(editor: BlockNoteEditor) {
const view = editor.prosemirrorView;
const event = new KeyboardEvent("keydown", { key: "Enter" });
view.someProp("handleKeyDown", (f) => f(view, event));
}

const testDocument: PartialBlock[] = [
{
id: "table-0",
type: "table",
content: {
type: "tableContent",
rows: [
{ cells: ["Cell 1", "Cell 2", "Cell 3"] },
{ cells: ["Cell 4", "Cell 5", "Cell 6"] },
{ cells: ["Cell 7", "Cell 8", "Cell 9"] },
],
},
},
];

describe("Table Enter keyboard shortcut", () => {
let editor: BlockNoteEditor;
const div = document.createElement("div");

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

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

beforeEach(() => {
editor.replaceBlocks(editor.document, testDocument);
});

/**
* Returns the document position just inside the cell containing `cellText`.
*/
function posInCell(cellText: string): number {
const view = editor.prosemirrorView;
let pos = -1;
view.state.doc.descendants((node, nodePos) => {
if (pos === -1 && node.isText && node.text === cellText) {
pos = nodePos;
}
return true;
});
if (pos === -1) {
throw new Error(`Cell with text "${cellText}" not found`);
}
return pos;
}

function setCursorInCell(cellText: string, offset = 1) {
const pos = posInCell(cellText);
editor.transact((tr) =>
tr.setSelection(TextSelection.create(tr.doc, pos + offset)),
);
}

it("moves the selection to the cell below", () => {
setCursorInCell("Cell 5");

pressEnter(editor);

const parentText =
editor.prosemirrorView.state.selection.$head.parent.textContent;
expect(parentText).toBe("Cell 8");
});

it("does not crash and is a no-op on the last row", () => {
setCursorInCell("Cell 8");

const before = editor.document;
expect(() => pressEnter(editor)).not.toThrow();
// The table structure must be left intact (Enter is a no-op here).
expect(editor.document).toStrictEqual(before);
});

it("does not crash with a (non-empty) text selection in the last row", () => {
const start = posInCell("Cell 8");
editor.transact((tr) =>
tr.setSelection(TextSelection.create(tr.doc, start, start + 4)),
);

const before = editor.document;
expect(() => pressEnter(editor)).not.toThrow();
expect(editor.document).toStrictEqual(before);
});

it("does not crash with a multi-cell selection", () => {
const view = editor.prosemirrorView;
const cellPositions: number[] = [];
view.state.doc.descendants((node, pos) => {
if (node.type.name === "tableCell" || node.type.name === "tableHeader") {
cellPositions.push(pos);
}
return true;
});

editor.transact((tr) =>
tr.setSelection(
CellSelection.create(
tr.doc,
cellPositions[0],
cellPositions[1],
) as any,
),
);

const before = editor.document;
expect(() => pressEnter(editor)).not.toThrow();
expect(editor.document).toStrictEqual(before);
});
});
27 changes: 13 additions & 14 deletions packages/core/src/blocks/Table/TableExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,19 @@ export const TableExtension = Extension.create({
return {
// Moves the selection to the cell below.
Enter: () => {
if (
this.editor.state.selection.$head.parent.type.name !==
"tableParagraph"
) {
// We use `isInTable` rather than checking whether the cursor's parent
// is a `tableParagraph`, since for cell selections that span multiple
// cells the selection head resolves to a `tableRow` instead.
if (!isInTable(this.editor.state)) {
return false;
}

return this.editor.commands.command(({ state, dispatch }) => {
if (!isInTable(state)) {
return false;
}

const $cell = selectionCell(state);
const $nextCell = nextCell($cell, "vert", 1);

if (!$nextCell) {
return false;
}
const $nextCell = $cell ? nextCell($cell, "vert", 1) : null;

if (dispatch) {
// Moves the selection to the cell below, if there is one.
if ($nextCell && dispatch) {
dispatch(
state.tr
.setSelection(
Expand All @@ -64,6 +57,12 @@ export const TableExtension = Extension.create({
);
}

// Always consume the Enter key while inside a table, even when there
// is no cell below (e.g. the last row) or the selection spans
// multiple cells. Otherwise it falls through to the default
// `splitBlock` handler, which tries to split the table cell and
// throws `Cannot join tableCell onto blockContainer`, crashing the
// editor. On the last row, Enter simply becomes a no-op.
return true;
});
},
Expand Down
Loading