Skip to content
File

Blob: tests/client/components/editor/extensions/table/collaboration.test.ts

typescript315 lines
1import { describe, expect, it, vi } from "vitest";
2import { getSchema } from "@tiptap/core";
3import type { Editor } from "@tiptap/react";
4import { TableKit } from "@tiptap/extension-table";
5import { StarterKit } from "@tiptap/starter-kit";
6import { EditorState, TextSelection } from "@tiptap/pm/state";
7import { addColumnBefore, addRowBefore, CellSelection, TableMap } from "@tiptap/pm/tables";
8import {
9 buildColumnMenuSections,
10 buildRowMenuSections,
11} from "@/client/components/editor/controllers/table-menu-actions";
12import { applyTableHandlesState, createOpenMenuState } from "@/client/components/editor/extensions/table/state";
13import { deriveCanonicalColumnWidths, snapshotTableWidths } from "@/client/components/editor/extensions/table/widths";
14 
15const schema = getSchema([
16 StarterKit.configure({ undoRedo: false }),
17 TableKit.configure({
18 table: {
19 resizable: true,
20 renderWrapper: true,
21 },
22 }),
23]);
24 
25describe("table collaboration helpers", () => {
26 it("derives canonical widths from partial shared colwidth state", () => {
27 const doc = schema.nodeFromJSON({
28 type: "doc",
29 content: [
30 {
31 type: "table",
32 content: [
33 {
34 type: "tableRow",
35 content: [
36 {
37 type: "tableHeader",
38 attrs: { colspan: 2, rowspan: 1, colwidth: [120, 0] },
39 content: [{ type: "paragraph" }],
40 },
41 {
42 type: "tableHeader",
43 attrs: { colspan: 1, rowspan: 1, colwidth: [200] },
44 content: [{ type: "paragraph" }],
45 },
46 ],
47 },
48 {
49 type: "tableRow",
50 content: [
51 {
52 type: "tableCell",
53 attrs: { colspan: 1, rowspan: 1, colwidth: null },
54 content: [{ type: "paragraph" }],
55 },
56 {
57 type: "tableCell",
58 attrs: { colspan: 1, rowspan: 1, colwidth: null },
59 content: [{ type: "paragraph" }],
60 },
61 {
62 type: "tableCell",
63 attrs: { colspan: 1, rowspan: 1, colwidth: null },
64 content: [{ type: "paragraph" }],
65 },
66 ],
67 },
68 ],
69 },
70 ],
71 });
72 
73 const table = doc.firstChild;
74 expect(table).not.toBeNull();
75 expect(snapshotTableWidths(table!)).toEqual({ hasSome: true, hasAll: false });
76 expect(deriveCanonicalColumnWidths(doc, table!, 0)).toEqual([120, 120, 200]);
77 });
78 
79 it("remaps an open row menu target after a row is inserted above it", () => {
80 const doc = createThreeRowTableDoc();
81 const table = doc.firstChild;
82 expect(table).not.toBeNull();
83 
84 const openMenu = createOpenMenuState("row", 2, 0, table!);
85 expect(openMenu?.kind).toBe("row");
86 
87 const tr = insertRowBefore(doc, 0);
88 const nextState = applyTableHandlesState(tr, { openMenu, isTyping: false });
89 
90 expect(nextState.openMenu?.kind).toBe("row");
91 if (nextState.openMenu?.kind === "row") {
92 expect(nextState.openMenu.index).toBe(3);
93 }
94 });
95 
96 it("remaps an open column menu target after a column is inserted before it", () => {
97 const doc = createThreeRowTableDoc();
98 const table = doc.firstChild;
99 expect(table).not.toBeNull();
100 
101 const openMenu = createOpenMenuState("col", 1, 0, table!);
102 expect(openMenu?.kind).toBe("col");
103 
104 const tr = insertColumnBefore(doc, 0);
105 const nextState = applyTableHandlesState(tr, { openMenu, isTyping: false });
106 
107 expect(nextState.openMenu?.kind).toBe("col");
108 if (nextState.openMenu?.kind === "col") {
109 expect(nextState.openMenu.index).toBe(2);
110 }
111 });
112 
113 it("normalizes shared widths after inserting a column into an explicitly sized table", () => {
114 const doc = createExplicitWidthTableDoc();
115 const nextTr = insertColumnBefore(doc, 1);
116 const nextDoc = nextTr.doc;
117 const nextTable = nextDoc.firstChild;
118 
119 expect(nextTable).not.toBeNull();
120 expect(snapshotTableWidths(nextTable!)).toEqual({ hasSome: true, hasAll: false });
121 expect(deriveCanonicalColumnWidths(nextDoc, nextTable!, 0)).toEqual([120, 120, 240]);
122 });
123 
124 it("offers explicit row selection from the row menu without restoring trigger focus", () => {
125 const doc = createThreeRowTableDoc();
126 const table = doc.firstChild;
127 expect(table).not.toBeNull();
128 
129 const openMenu = createOpenMenuState("row", 1, 0, table!);
130 expect(openMenu?.kind).toBe("row");
131 
132 const state = EditorState.create({ schema, doc });
133 let dispatchedSelection: EditorState["selection"] | null = null;
134 const focus = vi.fn();
135 const onDone = vi.fn();
136 const editor = {
137 state,
138 view: {
139 dispatch: vi.fn((tr: typeof state.tr) => {
140 dispatchedSelection = tr.selection;
141 }),
142 focus,
143 },
144 };
145 
146 const action = buildRowMenuSections({
147 editor: editor as unknown as Editor,
148 openMenu: openMenu!,
149 onDone,
150 })
151 .flat()
152 .find((item) => item.key === "row-select");
153 
154 expect(action).toBeDefined();
155 action?.onSelect();
156 
157 expect(dispatchedSelection).toBeInstanceOf(CellSelection);
158 expect(focus).toHaveBeenCalledOnce();
159 expect(onDone).toHaveBeenCalledWith(false);
160 });
161 
162 it("offers explicit column selection from the column menu without restoring trigger focus", () => {
163 const doc = createThreeRowTableDoc();
164 const table = doc.firstChild;
165 expect(table).not.toBeNull();
166 
167 const openMenu = createOpenMenuState("col", 1, 0, table!);
168 expect(openMenu?.kind).toBe("col");
169 
170 const state = EditorState.create({ schema, doc });
171 let dispatchedSelection: EditorState["selection"] | null = null;
172 const focus = vi.fn();
173 const onDone = vi.fn();
174 const editor = {
175 state,
176 view: {
177 dispatch: vi.fn((tr: typeof state.tr) => {
178 dispatchedSelection = tr.selection;
179 }),
180 focus,
181 },
182 };
183 
184 const action = buildColumnMenuSections({
185 editor: editor as unknown as Editor,
186 openMenu: openMenu!,
187 onDone,
188 })
189 .flat()
190 .find((item) => item.key === "col-select");
191 
192 expect(action).toBeDefined();
193 action?.onSelect();
194 
195 expect(dispatchedSelection).toBeInstanceOf(CellSelection);
196 expect(focus).toHaveBeenCalledOnce();
197 expect(onDone).toHaveBeenCalledWith(false);
198 });
199});
200 
201function createThreeRowTableDoc() {
202 return schema.nodeFromJSON({
203 type: "doc",
204 content: [
205 {
206 type: "table",
207 content: [
208 createRow("tableHeader", "tableHeader"),
209 createRow("tableCell", "tableCell"),
210 createRow("tableCell", "tableCell"),
211 ],
212 },
213 ],
214 });
215}
216 
217function createExplicitWidthTableDoc() {
218 return schema.nodeFromJSON({
219 type: "doc",
220 content: [
221 {
222 type: "table",
223 content: [
224 {
225 type: "tableRow",
226 content: [
227 {
228 type: "tableHeader",
229 attrs: { colspan: 1, rowspan: 1, colwidth: [120] },
230 content: [{ type: "paragraph" }],
231 },
232 {
233 type: "tableHeader",
234 attrs: { colspan: 1, rowspan: 1, colwidth: [240] },
235 content: [{ type: "paragraph" }],
236 },
237 ],
238 },
239 {
240 type: "tableRow",
241 content: [
242 {
243 type: "tableCell",
244 attrs: { colspan: 1, rowspan: 1, colwidth: [120] },
245 content: [{ type: "paragraph" }],
246 },
247 {
248 type: "tableCell",
249 attrs: { colspan: 1, rowspan: 1, colwidth: [240] },
250 content: [{ type: "paragraph" }],
251 },
252 ],
253 },
254 ],
255 },
256 ],
257 });
258}
259 
260function createRow(leftType: "tableHeader" | "tableCell", rightType: "tableHeader" | "tableCell") {
261 return {
262 type: "tableRow",
263 content: [
264 {
265 type: leftType,
266 attrs: { colspan: 1, rowspan: 1, colwidth: null },
267 content: [{ type: "paragraph" }],
268 },
269 {
270 type: rightType,
271 attrs: { colspan: 1, rowspan: 1, colwidth: null },
272 content: [{ type: "paragraph" }],
273 },
274 ],
275 };
276}
277 
278function insertRowBefore(doc: ReturnType<typeof createThreeRowTableDoc>, rowIndex: number) {
279 const table = doc.firstChild!;
280 const map = TableMap.get(table);
281 const cellPos = 1 + map.positionAt(rowIndex, 0, table);
282 const state = EditorState.create({
283 schema,
284 doc,
285 selection: TextSelection.near(doc.resolve(cellPos + 1)),
286 });
287 
288 let nextTr = null;
289 addRowBefore(state, (tr) => {
290 nextTr = tr;
291 });
292 
293 expect(nextTr).not.toBeNull();
294 return nextTr!;
295}
296 
297function insertColumnBefore(doc: ReturnType<typeof createThreeRowTableDoc>, colIndex: number) {
298 const table = doc.firstChild!;
299 const map = TableMap.get(table);
300 const cellPos = 1 + map.positionAt(0, colIndex, table);
301 const state = EditorState.create({
302 schema,
303 doc,
304 selection: TextSelection.near(doc.resolve(cellPos + 1)),
305 });
306 
307 let nextTr = null;
308 addColumnBefore(state, (tr) => {
309 nextTr = tr;
310 });
311 
312 expect(nextTr).not.toBeNull();
313 return nextTr!;
314}