test(editor-ext): cover recreateTransform invariant, table move/selection, unique-id
recreateTransform: apply(diff)==target round-trip across text/mark/structural edits and complexSteps/wordDiffs options. moveRow/moveColumn drive real PM tables (reorder preserves content, self-move/no-table -> false, CellSelection on select). getSelectionRangeInColumn: single/multi-column + colspan + range guard. addUniqueIdsToDoc: only configured types, nested targets, idempotency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { tableNodes } from "@tiptap/pm/tables";
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
import { getSelectionRangeInColumn } from "./get-selection-range-in-column";
|
||||
|
||||
/**
|
||||
* getSelectionRangeInColumn computes the rectangular column range (the set of
|
||||
* column indexes, plus anchor/head cell positions) that a drag-reorder or
|
||||
* column-select operation should act on, accounting for merged (colspan) cells.
|
||||
* It keys off the table found from the current selection, so we drive it with a
|
||||
* real EditorState whose selection sits inside the table.
|
||||
*/
|
||||
|
||||
// Real ProseMirror table schema (same primitives the editor uses) so TableMap /
|
||||
// cellsInRect behave exactly as in production.
|
||||
const tNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "inline*",
|
||||
cellAttributes: {},
|
||||
});
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
||||
text: { group: "inline" },
|
||||
...tNodes,
|
||||
},
|
||||
marks: {},
|
||||
});
|
||||
const cell = (txt: string, attrs?: Record<string, unknown>): PMNode =>
|
||||
schema.nodes.table_cell.createChecked(attrs ?? null, schema.text(txt));
|
||||
const row = (...cells: PMNode[]): PMNode =>
|
||||
schema.nodes.table_row.createChecked(null, cells);
|
||||
const table = (...rows: PMNode[]): PMNode =>
|
||||
schema.nodes.table.createChecked(null, rows);
|
||||
const doc = (...content: PMNode[]): PMNode =>
|
||||
schema.nodes.doc.createChecked(null, content);
|
||||
|
||||
// Build a transaction whose selection is inside the table (the function locates
|
||||
// the table via `tr.selection.$from`).
|
||||
const trFor = (d: PMNode) =>
|
||||
EditorState.create({ doc: d, selection: Selection.atStart(d) }).tr;
|
||||
|
||||
// A 2-row x 3-col grid; each column is identifiable by its top-row letter.
|
||||
const grid3x2 = () =>
|
||||
doc(
|
||||
table(
|
||||
row(cell("a"), cell("b"), cell("c")),
|
||||
row(cell("d"), cell("e"), cell("f")),
|
||||
),
|
||||
);
|
||||
|
||||
describe("getSelectionRangeInColumn", () => {
|
||||
it("returns a single-column range for a single index", () => {
|
||||
// Asking for column 1 yields exactly indexes [1].
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 1);
|
||||
expect(range).toBeTruthy();
|
||||
expect(range!.indexes).toEqual([1]);
|
||||
});
|
||||
|
||||
it("anchor/head resolve to the top and bottom cells OF the requested column", () => {
|
||||
// $head must point at the column's first (top) cell and $anchor at its last
|
||||
// (bottom) cell — pinning that the returned positions belong to column 1,
|
||||
// not some other column.
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 1)!;
|
||||
expect(tr.doc.nodeAt(range.$head.pos)?.textContent).toBe("b"); // top of col 1
|
||||
expect(tr.doc.nodeAt(range.$anchor.pos)?.textContent).toBe("e"); // bottom of col 1
|
||||
});
|
||||
|
||||
it("returns the inclusive span of columns for a multi-column request", () => {
|
||||
// A 0..2 request must enumerate every covered column, in order.
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 0, 2);
|
||||
expect(range!.indexes).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns a two-column span for an adjacent pair", () => {
|
||||
const tr = trFor(grid3x2());
|
||||
const range = getSelectionRangeInColumn(tr, 1, 2);
|
||||
expect(range!.indexes).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("expands the range to cover a horizontally merged (colspan) cell", () => {
|
||||
// Row 0 col 0 spans 2 columns. Requesting just column 0 must pull column 1
|
||||
// into the range because they are merged together in the top row.
|
||||
const d = doc(
|
||||
table(
|
||||
row(cell("ab", { colspan: 2 }), cell("c")),
|
||||
row(cell("d"), cell("e"), cell("f")),
|
||||
),
|
||||
);
|
||||
const tr = trFor(d);
|
||||
const range = getSelectionRangeInColumn(tr, 0);
|
||||
expect(range!.indexes).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("throws when the requested column is entirely out of range", () => {
|
||||
// No cells exist at column 5 of a 3-wide table, so the function cannot pick
|
||||
// an anchor cell and dereferences undefined — pin this as the current
|
||||
// (caller-guarded) contract so a silent behavior change is caught.
|
||||
const tr = trFor(grid3x2());
|
||||
expect(() => getSelectionRangeInColumn(tr, 5)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { tableNodes, CellSelection } from "@tiptap/pm/tables";
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
import { moveColumn } from "./move-column";
|
||||
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
|
||||
import { findTable } from "./query";
|
||||
|
||||
/**
|
||||
* moveColumn reorders whole columns of a real ProseMirror table by mutating a
|
||||
* Transaction (transpose -> move row -> transpose back -> replace). The invariant
|
||||
* is that after the call each column appears at its new position with every
|
||||
* cell's content preserved and nothing dropped or duplicated.
|
||||
*/
|
||||
|
||||
const tNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "inline*",
|
||||
cellAttributes: {},
|
||||
});
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
||||
text: { group: "inline" },
|
||||
...tNodes,
|
||||
},
|
||||
marks: {},
|
||||
});
|
||||
const cell = (txt: string): PMNode =>
|
||||
schema.nodes.table_cell.createChecked(null, schema.text(txt));
|
||||
const row = (...cells: PMNode[]): PMNode =>
|
||||
schema.nodes.table_row.createChecked(null, cells);
|
||||
const table = (...rows: PMNode[]): PMNode =>
|
||||
schema.nodes.table.createChecked(null, rows);
|
||||
const doc = (...content: PMNode[]): PMNode =>
|
||||
schema.nodes.doc.createChecked(null, content);
|
||||
|
||||
const grid = (tr: any): string[][] => {
|
||||
const t = findTable(tr.doc.resolve(tr.selection.from))!;
|
||||
return convertTableNodeToArrayOfRows(t.node).map((r) =>
|
||||
r.map((c) => (c ? c.textContent : "")),
|
||||
);
|
||||
};
|
||||
|
||||
// 2-row x 3-col table; column k is (rowX-col-k). Columns: 0=(a,d) 1=(b,e) 2=(c,f).
|
||||
const grid3x2 = () =>
|
||||
doc(
|
||||
table(
|
||||
row(cell("a"), cell("b"), cell("c")),
|
||||
row(cell("d"), cell("e"), cell("f")),
|
||||
),
|
||||
);
|
||||
|
||||
const stateFor = (d: PMNode) =>
|
||||
EditorState.create({ doc: d, selection: Selection.atStart(d) });
|
||||
|
||||
describe("moveColumn", () => {
|
||||
it("moves the first column to the last index, preserving column content", () => {
|
||||
// origin 0 -> target 2 sends column (a,d) to the right: cols become 1,2,0.
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["b", "c", "a"],
|
||||
["e", "f", "d"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a later column to the first index", () => {
|
||||
// origin 2 -> target 0 pulls column (c,f) to the front: cols become 2,0,1.
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 2,
|
||||
targetIndex: 0,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["c", "a", "b"],
|
||||
["f", "d", "e"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("never drops or duplicates cells when reordering columns", () => {
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
moveColumn({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(grid(tr).flat().sort()).toEqual(
|
||||
["a", "b", "c", "d", "e", "f"].sort(),
|
||||
);
|
||||
expect(grid(tr)[0].length).toBe(3);
|
||||
});
|
||||
|
||||
it("returns false (no-op) when target equals origin", () => {
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const before = grid(tr);
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(grid(tr)).toEqual(before);
|
||||
});
|
||||
|
||||
it("returns false when pos is not inside a table", () => {
|
||||
const d = doc(
|
||||
schema.nodes.paragraph.createChecked(null, schema.text("plain")),
|
||||
);
|
||||
const state = stateFor(d);
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("installs a CellSelection on the moved column when select is true", () => {
|
||||
const state = stateFor(grid3x2());
|
||||
const tr = state.tr;
|
||||
const ok = moveColumn({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: true,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(tr.selection instanceof CellSelection).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { tableNodes, CellSelection } from "@tiptap/pm/tables";
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
import { moveRow } from "./move-row";
|
||||
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
|
||||
import { findTable } from "./query";
|
||||
|
||||
/**
|
||||
* moveRow reorders whole rows of a real ProseMirror table by mutating a
|
||||
* Transaction: it locates the table, computes origin/target row ranges, rebuilds
|
||||
* the table with rows reordered, and replaces it in the doc. The invariant is
|
||||
* that after the call the table's rows appear in the new order with every cell's
|
||||
* content preserved, and no rows are dropped or duplicated.
|
||||
*/
|
||||
|
||||
const tNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "inline*",
|
||||
cellAttributes: {},
|
||||
});
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] },
|
||||
text: { group: "inline" },
|
||||
...tNodes,
|
||||
},
|
||||
marks: {},
|
||||
});
|
||||
const cell = (txt: string): PMNode =>
|
||||
schema.nodes.table_cell.createChecked(null, schema.text(txt));
|
||||
const row = (...cells: PMNode[]): PMNode =>
|
||||
schema.nodes.table_row.createChecked(null, cells);
|
||||
const table = (...rows: PMNode[]): PMNode =>
|
||||
schema.nodes.table.createChecked(null, rows);
|
||||
const doc = (...content: PMNode[]): PMNode =>
|
||||
schema.nodes.doc.createChecked(null, content);
|
||||
|
||||
// Read the table's content as a grid of cell texts (rows x cols) from whatever
|
||||
// table currently lives in `tr.doc`.
|
||||
const grid = (tr: any): string[][] => {
|
||||
const t = findTable(tr.doc.resolve(tr.selection.from))!;
|
||||
return convertTableNodeToArrayOfRows(t.node).map((r) =>
|
||||
r.map((c) => (c ? c.textContent : "")),
|
||||
);
|
||||
};
|
||||
|
||||
// 3-row x 2-col table; each row identifiable by its cells.
|
||||
const grid2x3 = () =>
|
||||
doc(
|
||||
table(
|
||||
row(cell("r0a"), cell("r0b")),
|
||||
row(cell("r1a"), cell("r1b")),
|
||||
row(cell("r2a"), cell("r2b")),
|
||||
),
|
||||
);
|
||||
|
||||
const stateFor = (d: PMNode) =>
|
||||
EditorState.create({ doc: d, selection: Selection.atStart(d) });
|
||||
|
||||
describe("moveRow", () => {
|
||||
it("moves the first row down to the last index, preserving content", () => {
|
||||
// origin 0 -> target 2 makes row 0 land after the other rows: [r1, r2, r0].
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["r1a", "r1b"],
|
||||
["r2a", "r2b"],
|
||||
["r0a", "r0b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a lower row up to an earlier index", () => {
|
||||
// origin 2 -> target 0 lifts the last row above the rest: [r2, r0, r1].
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 2,
|
||||
targetIndex: 0,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(grid(tr)).toEqual([
|
||||
["r2a", "r2b"],
|
||||
["r0a", "r0b"],
|
||||
["r1a", "r1b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("never drops or duplicates rows when reordering", () => {
|
||||
// The full multiset of cell texts is invariant under any valid move.
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
moveRow({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 2,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
const flat = grid(tr).flat().sort();
|
||||
expect(flat).toEqual(
|
||||
["r0a", "r0b", "r1a", "r1b", "r2a", "r2b"].sort(),
|
||||
);
|
||||
expect(grid(tr).length).toBe(3);
|
||||
});
|
||||
|
||||
it("returns false (no-op) when target equals origin", () => {
|
||||
// Moving a row onto itself is rejected and leaves the table unchanged.
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const before = grid(tr);
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 1,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(grid(tr)).toEqual(before);
|
||||
});
|
||||
|
||||
it("returns false when pos is not inside a table", () => {
|
||||
// Without a table at `pos`, the function bails out instead of throwing.
|
||||
const d = doc(
|
||||
schema.nodes.paragraph.createChecked(null, schema.text("plain")),
|
||||
);
|
||||
const state = stateFor(d);
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 1,
|
||||
select: false,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("installs a CellSelection on the moved row when select is true", () => {
|
||||
// With select:true the moved row at the target index is selected.
|
||||
const state = stateFor(grid2x3());
|
||||
const tr = state.tr;
|
||||
const ok = moveRow({
|
||||
tr,
|
||||
originIndex: 0,
|
||||
targetIndex: 2,
|
||||
select: true,
|
||||
pos: state.selection.from,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(tr.selection instanceof CellSelection).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user