File
Blob: tests/client/components/editor/extensions/table/collaboration.test.ts
| 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | import { getSchema } from "@tiptap/core"; |
| 3 | import type { Editor } from "@tiptap/react"; |
| 4 | import { TableKit } from "@tiptap/extension-table"; |
| 5 | import { StarterKit } from "@tiptap/starter-kit"; |
| 6 | import { EditorState, TextSelection } from "@tiptap/pm/state"; |
| 7 | import { addColumnBefore, addRowBefore, CellSelection, TableMap } from "@tiptap/pm/tables"; |
| 8 | import { |
| 9 | buildColumnMenuSections, |
| 10 | buildRowMenuSections, |
| 11 | } from "@/client/components/editor/controllers/table-menu-actions"; |
| 12 | import { applyTableHandlesState, createOpenMenuState } from "@/client/components/editor/extensions/table/state"; |
| 13 | import { deriveCanonicalColumnWidths, snapshotTableWidths } from "@/client/components/editor/extensions/table/widths"; |
| 14 | |
| 15 | const schema = getSchema([ |
| 16 | StarterKit.configure({ undoRedo: false }), |
| 17 | TableKit.configure({ |
| 18 | table: { |
| 19 | resizable: true, |
| 20 | renderWrapper: true, |
| 21 | }, |
| 22 | }), |
| 23 | ]); |
| 24 | |
| 25 | describe("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 | |
| 201 | function 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 | |
| 217 | function 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 | |
| 260 | function 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 | |
| 278 | function 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 | |
| 297 | function 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 | } |