quasar-ui-danx 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/dist/danx.es.js +16119 -10641
  3. package/dist/danx.es.js.map +1 -1
  4. package/dist/danx.umd.js +202 -123
  5. package/dist/danx.umd.js.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/package.json +8 -1
  8. package/src/components/Utility/Buttons/ActionButton.vue +15 -5
  9. package/src/components/Utility/Code/CodeViewer.vue +41 -16
  10. package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
  11. package/src/components/Utility/Code/CodeViewerFooter.vue +3 -1
  12. package/src/components/Utility/Code/LanguageBadge.vue +278 -5
  13. package/src/components/Utility/Code/MarkdownContent.vue +31 -163
  14. package/src/components/Utility/Code/index.ts +3 -0
  15. package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
  16. package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
  17. package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
  18. package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
  19. package/src/components/Utility/Markdown/MarkdownEditor.vue +233 -0
  20. package/src/components/Utility/Markdown/MarkdownEditorContent.vue +296 -0
  21. package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
  22. package/src/components/Utility/Markdown/TablePopover.vue +420 -0
  23. package/src/components/Utility/Markdown/index.ts +11 -0
  24. package/src/components/Utility/Markdown/types.ts +27 -0
  25. package/src/components/Utility/Widgets/LabelPillWidget.vue +20 -0
  26. package/src/components/Utility/index.ts +1 -0
  27. package/src/composables/index.ts +1 -0
  28. package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
  29. package/src/composables/markdown/features/useBlockquotes.ts +248 -0
  30. package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
  31. package/src/composables/markdown/features/useCodeBlocks.spec.ts +805 -0
  32. package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
  33. package/src/composables/markdown/features/useContextMenu.ts +444 -0
  34. package/src/composables/markdown/features/useFocusTracking.ts +116 -0
  35. package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
  36. package/src/composables/markdown/features/useHeadings.ts +290 -0
  37. package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
  38. package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
  39. package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
  40. package/src/composables/markdown/features/useLinks.spec.ts +388 -0
  41. package/src/composables/markdown/features/useLinks.ts +374 -0
  42. package/src/composables/markdown/features/useLists.spec.ts +834 -0
  43. package/src/composables/markdown/features/useLists.ts +747 -0
  44. package/src/composables/markdown/features/usePopoverManager.ts +181 -0
  45. package/src/composables/markdown/features/useTables.spec.ts +1601 -0
  46. package/src/composables/markdown/features/useTables.ts +1107 -0
  47. package/src/composables/markdown/index.ts +16 -0
  48. package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
  49. package/src/composables/markdown/useMarkdownEditor.ts +1077 -0
  50. package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
  51. package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
  52. package/src/composables/markdown/useMarkdownSelection.ts +219 -0
  53. package/src/composables/markdown/useMarkdownSync.ts +549 -0
  54. package/src/composables/useCodeFormat.ts +17 -10
  55. package/src/composables/useCodeViewerEditor.spec.ts +655 -0
  56. package/src/composables/useCodeViewerEditor.ts +174 -20
  57. package/src/helpers/formats/highlightCSS.ts +236 -0
  58. package/src/helpers/formats/highlightHTML.ts +483 -0
  59. package/src/helpers/formats/highlightJavaScript.ts +346 -0
  60. package/src/helpers/formats/highlightSyntax.ts +15 -4
  61. package/src/helpers/formats/index.ts +3 -0
  62. package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
  63. package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
  64. package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +425 -0
  65. package/src/helpers/formats/markdown/index.ts +7 -0
  66. package/src/helpers/formats/markdown/linePatterns.spec.ts +498 -0
  67. package/src/helpers/formats/markdown/linePatterns.ts +172 -0
  68. package/src/styles/danx.scss +3 -3
  69. package/src/styles/index.scss +5 -5
  70. package/src/styles/themes/danx/code.scss +257 -1
  71. package/src/styles/themes/danx/index.scss +10 -10
  72. package/src/styles/themes/danx/markdown.scss +59 -0
  73. package/src/test/helpers/editorTestUtils.spec.ts +296 -0
  74. package/src/test/helpers/editorTestUtils.ts +253 -0
  75. package/src/test/helpers/index.ts +1 -0
  76. package/src/test/highlighters.test.ts +153 -0
  77. package/src/test/setup.test.ts +12 -0
  78. package/src/test/setup.ts +12 -0
  79. package/src/types/widgets.d.ts +2 -2
  80. package/vite.config.js +5 -1
  81. package/vitest.config.ts +19 -0
@@ -0,0 +1,1601 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { useTables } from "./useTables";
3
+ import { createTestEditor, TestEditorResult } from "../../../test/helpers/editorTestUtils";
4
+
5
+ describe("useTables", () => {
6
+ let editor: TestEditorResult;
7
+ let onContentChange: ReturnType<typeof vi.fn>;
8
+
9
+ beforeEach(() => {
10
+ onContentChange = vi.fn();
11
+ });
12
+
13
+ afterEach(() => {
14
+ if (editor) {
15
+ editor.destroy();
16
+ }
17
+ });
18
+
19
+ function createTables() {
20
+ return useTables({
21
+ contentRef: editor.contentRef,
22
+ onContentChange
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Helper to create a table HTML structure
28
+ */
29
+ function createTableHtml(rows: number, cols: number, headerContent?: string[], bodyContent?: string[][]): string {
30
+ let html = "<table><thead><tr>";
31
+ for (let c = 0; c < cols; c++) {
32
+ const content = headerContent?.[c] || `Header ${c + 1}`;
33
+ html += `<th>${content}</th>`;
34
+ }
35
+ html += "</tr></thead>";
36
+
37
+ if (rows > 1) {
38
+ html += "<tbody>";
39
+ for (let r = 1; r < rows; r++) {
40
+ html += "<tr>";
41
+ for (let c = 0; c < cols; c++) {
42
+ const content = bodyContent?.[r - 1]?.[c] || `Cell ${r}-${c + 1}`;
43
+ html += `<td>${content}</td>`;
44
+ }
45
+ html += "</tr>";
46
+ }
47
+ html += "</tbody>";
48
+ }
49
+ html += "</table>";
50
+ return html;
51
+ }
52
+
53
+ /**
54
+ * Helper to set cursor in a table cell
55
+ */
56
+ function setCursorInCell(cell: HTMLTableCellElement, offset: number = 0): void {
57
+ const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT);
58
+ const textNode = walker.nextNode() as Text | null;
59
+
60
+ if (textNode) {
61
+ editor.setCursor(textNode, Math.min(offset, textNode.textContent?.length || 0));
62
+ } else {
63
+ // If no text node, set cursor in the cell itself
64
+ const range = document.createRange();
65
+ if (cell.firstChild) {
66
+ range.setStartBefore(cell.firstChild);
67
+ } else {
68
+ range.setStart(cell, 0);
69
+ }
70
+ range.collapse(true);
71
+ const sel = window.getSelection();
72
+ sel?.removeAllRanges();
73
+ sel?.addRange(range);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Helper to get a cell at specific position
79
+ */
80
+ function getCell(table: HTMLTableElement, rowIndex: number, colIndex: number): HTMLTableCellElement | null {
81
+ const rows: HTMLTableRowElement[] = [];
82
+ if (table.tHead) {
83
+ rows.push(...Array.from(table.tHead.rows));
84
+ }
85
+ for (const tbody of Array.from(table.tBodies)) {
86
+ rows.push(...Array.from(tbody.rows));
87
+ }
88
+ if (rowIndex < 0 || rowIndex >= rows.length) return null;
89
+ const row = rows[rowIndex];
90
+ if (colIndex < 0 || colIndex >= row.cells.length) return null;
91
+ return row.cells[colIndex];
92
+ }
93
+
94
+ describe("isInTable", () => {
95
+ it("returns true when cursor is in a table cell", () => {
96
+ editor = createTestEditor(createTableHtml(2, 2));
97
+ const tables = createTables();
98
+ const cell = editor.container.querySelector("th") as HTMLTableCellElement;
99
+ setCursorInCell(cell);
100
+
101
+ expect(tables.isInTable()).toBe(true);
102
+ });
103
+
104
+ it("returns true when cursor is in tbody cell", () => {
105
+ editor = createTestEditor(createTableHtml(3, 2));
106
+ const tables = createTables();
107
+ const cell = editor.container.querySelector("td") as HTMLTableCellElement;
108
+ setCursorInCell(cell);
109
+
110
+ expect(tables.isInTable()).toBe(true);
111
+ });
112
+
113
+ it("returns false when cursor is in a paragraph", () => {
114
+ editor = createTestEditor("<p>Hello world</p>");
115
+ const tables = createTables();
116
+ editor.setCursorInBlock(0, 5);
117
+
118
+ expect(tables.isInTable()).toBe(false);
119
+ });
120
+
121
+ it("returns false when cursor is outside table but in same container", () => {
122
+ editor = createTestEditor(`<p>Before table</p>${createTableHtml(2, 2)}`);
123
+ const tables = createTables();
124
+ editor.setCursorInBlock(0, 5);
125
+
126
+ expect(tables.isInTable()).toBe(false);
127
+ });
128
+
129
+ it("returns false when contentRef is null", () => {
130
+ editor = createTestEditor(createTableHtml(2, 2));
131
+ const { isInTable } = useTables({
132
+ contentRef: { value: null },
133
+ onContentChange
134
+ });
135
+
136
+ expect(isInTable()).toBe(false);
137
+ });
138
+
139
+ it("returns false when no selection exists", () => {
140
+ editor = createTestEditor(createTableHtml(2, 2));
141
+ const tables = createTables();
142
+ window.getSelection()?.removeAllRanges();
143
+
144
+ expect(tables.isInTable()).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("isInTableCell", () => {
149
+ it("returns true when cursor is in TH", () => {
150
+ editor = createTestEditor(createTableHtml(2, 2));
151
+ const tables = createTables();
152
+ const th = editor.container.querySelector("th") as HTMLTableCellElement;
153
+ setCursorInCell(th);
154
+
155
+ expect(tables.isInTableCell()).toBe(true);
156
+ });
157
+
158
+ it("returns true when cursor is in TD", () => {
159
+ editor = createTestEditor(createTableHtml(2, 2));
160
+ const tables = createTables();
161
+ const td = editor.container.querySelector("td") as HTMLTableCellElement;
162
+ setCursorInCell(td);
163
+
164
+ expect(tables.isInTableCell()).toBe(true);
165
+ });
166
+
167
+ it("returns false when cursor is in paragraph", () => {
168
+ editor = createTestEditor("<p>Hello world</p>");
169
+ const tables = createTables();
170
+ editor.setCursorInBlock(0, 5);
171
+
172
+ expect(tables.isInTableCell()).toBe(false);
173
+ });
174
+
175
+ it("returns false when contentRef is null", () => {
176
+ editor = createTestEditor(createTableHtml(2, 2));
177
+ const { isInTableCell } = useTables({
178
+ contentRef: { value: null },
179
+ onContentChange
180
+ });
181
+
182
+ expect(isInTableCell()).toBe(false);
183
+ });
184
+ });
185
+
186
+ describe("getCurrentTable", () => {
187
+ it("returns table element when cursor is in table", () => {
188
+ editor = createTestEditor(createTableHtml(2, 2));
189
+ const tables = createTables();
190
+ const th = editor.container.querySelector("th") as HTMLTableCellElement;
191
+ setCursorInCell(th);
192
+
193
+ const result = tables.getCurrentTable();
194
+ expect(result).toBeInstanceOf(HTMLTableElement);
195
+ expect(result).toBe(editor.container.querySelector("table"));
196
+ });
197
+
198
+ it("returns null when cursor is outside table", () => {
199
+ editor = createTestEditor("<p>Not in table</p>");
200
+ const tables = createTables();
201
+ editor.setCursorInBlock(0, 0);
202
+
203
+ expect(tables.getCurrentTable()).toBeNull();
204
+ });
205
+
206
+ it("returns null when contentRef is null", () => {
207
+ editor = createTestEditor(createTableHtml(2, 2));
208
+ const { getCurrentTable } = useTables({
209
+ contentRef: { value: null },
210
+ onContentChange
211
+ });
212
+
213
+ expect(getCurrentTable()).toBeNull();
214
+ });
215
+ });
216
+
217
+ describe("getCurrentCell", () => {
218
+ it("returns TH element when cursor is in header", () => {
219
+ editor = createTestEditor(createTableHtml(2, 2));
220
+ const tables = createTables();
221
+ const th = editor.container.querySelector("th") as HTMLTableCellElement;
222
+ setCursorInCell(th);
223
+
224
+ const result = tables.getCurrentCell();
225
+ expect(result).toBeInstanceOf(HTMLTableCellElement);
226
+ expect(result?.tagName).toBe("TH");
227
+ });
228
+
229
+ it("returns TD element when cursor is in body cell", () => {
230
+ editor = createTestEditor(createTableHtml(2, 2));
231
+ const tables = createTables();
232
+ const td = editor.container.querySelector("td") as HTMLTableCellElement;
233
+ setCursorInCell(td);
234
+
235
+ const result = tables.getCurrentCell();
236
+ expect(result).toBeInstanceOf(HTMLTableCellElement);
237
+ expect(result?.tagName).toBe("TD");
238
+ });
239
+
240
+ it("returns null when cursor is outside table", () => {
241
+ editor = createTestEditor("<p>Not in table</p>");
242
+ const tables = createTables();
243
+ editor.setCursorInBlock(0, 0);
244
+
245
+ expect(tables.getCurrentCell()).toBeNull();
246
+ });
247
+
248
+ it("returns null when contentRef is null", () => {
249
+ editor = createTestEditor(createTableHtml(2, 2));
250
+ const { getCurrentCell } = useTables({
251
+ contentRef: { value: null },
252
+ onContentChange
253
+ });
254
+
255
+ expect(getCurrentCell()).toBeNull();
256
+ });
257
+ });
258
+
259
+ describe("createTable", () => {
260
+ it("creates table with correct number of rows and columns", () => {
261
+ editor = createTestEditor("<p>Insert here</p>");
262
+ const tables = createTables();
263
+ editor.setCursorInBlock(0, 5);
264
+
265
+ tables.createTable(3, 4);
266
+
267
+ const table = editor.container.querySelector("table");
268
+ expect(table).not.toBeNull();
269
+
270
+ // Check header row (1 row in thead)
271
+ const thead = table?.querySelector("thead");
272
+ expect(thead?.querySelectorAll("th").length).toBe(4);
273
+
274
+ // Check body rows (2 rows in tbody)
275
+ const tbody = table?.querySelector("tbody");
276
+ expect(tbody?.querySelectorAll("tr").length).toBe(2);
277
+ expect(tbody?.querySelectorAll("td").length).toBe(8);
278
+ });
279
+
280
+ it("creates table with thead and th cells for header row", () => {
281
+ editor = createTestEditor("<p>Insert here</p>");
282
+ const tables = createTables();
283
+ editor.setCursorInBlock(0, 0);
284
+
285
+ tables.createTable(2, 2);
286
+
287
+ const table = editor.container.querySelector("table");
288
+ expect(table?.querySelector("thead")).not.toBeNull();
289
+ expect(table?.querySelectorAll("th").length).toBe(2);
290
+ });
291
+
292
+ it("creates table with tbody and td cells for body rows", () => {
293
+ editor = createTestEditor("<p>Insert here</p>");
294
+ const tables = createTables();
295
+ editor.setCursorInBlock(0, 0);
296
+
297
+ tables.createTable(3, 2);
298
+
299
+ const table = editor.container.querySelector("table");
300
+ expect(table?.querySelector("tbody")).not.toBeNull();
301
+ expect(table?.querySelectorAll("td").length).toBe(4);
302
+ });
303
+
304
+ it("creates cells with BR placeholder for focusability", () => {
305
+ editor = createTestEditor("<p>Insert here</p>");
306
+ const tables = createTables();
307
+ editor.setCursorInBlock(0, 0);
308
+
309
+ tables.createTable(2, 2);
310
+
311
+ const cells = editor.container.querySelectorAll("th, td");
312
+ cells.forEach(cell => {
313
+ expect(cell.querySelector("br")).not.toBeNull();
314
+ });
315
+ });
316
+
317
+ it("calls onContentChange after creation", () => {
318
+ editor = createTestEditor("<p>Insert here</p>");
319
+ const tables = createTables();
320
+ editor.setCursorInBlock(0, 0);
321
+
322
+ tables.createTable(2, 2);
323
+
324
+ expect(onContentChange).toHaveBeenCalled();
325
+ });
326
+
327
+ it("does nothing with invalid dimensions", () => {
328
+ editor = createTestEditor("<p>Insert here</p>");
329
+ const tables = createTables();
330
+ editor.setCursorInBlock(0, 0);
331
+
332
+ tables.createTable(0, 2);
333
+ expect(editor.container.querySelector("table")).toBeNull();
334
+
335
+ tables.createTable(2, 0);
336
+ expect(editor.container.querySelector("table")).toBeNull();
337
+
338
+ tables.createTable(-1, 2);
339
+ expect(editor.container.querySelector("table")).toBeNull();
340
+ });
341
+
342
+ it("creates table with only header row when rows=1", () => {
343
+ editor = createTestEditor("<p>Insert here</p>");
344
+ const tables = createTables();
345
+ editor.setCursorInBlock(0, 0);
346
+
347
+ tables.createTable(1, 3);
348
+
349
+ const table = editor.container.querySelector("table");
350
+ expect(table?.querySelector("thead")).not.toBeNull();
351
+ expect(table?.querySelector("tbody")).toBeNull();
352
+ expect(table?.querySelectorAll("th").length).toBe(3);
353
+ });
354
+
355
+ it("does nothing when contentRef is null", () => {
356
+ editor = createTestEditor("<p>Test</p>");
357
+ const { createTable } = useTables({
358
+ contentRef: { value: null },
359
+ onContentChange
360
+ });
361
+
362
+ createTable(2, 2);
363
+
364
+ expect(editor.container.querySelector("table")).toBeNull();
365
+ expect(onContentChange).not.toHaveBeenCalled();
366
+ });
367
+ });
368
+
369
+ describe("navigateToNextCell", () => {
370
+ it("moves to next cell in same row", () => {
371
+ editor = createTestEditor(createTableHtml(2, 3));
372
+ const tables = createTables();
373
+ const table = editor.container.querySelector("table") as HTMLTableElement;
374
+ const firstCell = getCell(table, 0, 0)!;
375
+ setCursorInCell(firstCell);
376
+
377
+ const result = tables.navigateToNextCell();
378
+
379
+ expect(result).toBe(true);
380
+ const currentCell = tables.getCurrentCell();
381
+ expect(currentCell).toBe(getCell(table, 0, 1));
382
+ });
383
+
384
+ it("moves to first cell of next row at end of row", () => {
385
+ editor = createTestEditor(createTableHtml(2, 3));
386
+ const tables = createTables();
387
+ const table = editor.container.querySelector("table") as HTMLTableElement;
388
+ const lastCellInFirstRow = getCell(table, 0, 2)!;
389
+ setCursorInCell(lastCellInFirstRow);
390
+
391
+ const result = tables.navigateToNextCell();
392
+
393
+ expect(result).toBe(true);
394
+ const currentCell = tables.getCurrentCell();
395
+ expect(currentCell).toBe(getCell(table, 1, 0));
396
+ });
397
+
398
+ it("returns false at end of table", () => {
399
+ editor = createTestEditor(createTableHtml(2, 2));
400
+ const tables = createTables();
401
+ const table = editor.container.querySelector("table") as HTMLTableElement;
402
+ const lastCell = getCell(table, 1, 1)!;
403
+ setCursorInCell(lastCell);
404
+
405
+ const result = tables.navigateToNextCell();
406
+
407
+ expect(result).toBe(false);
408
+ });
409
+
410
+ it("returns false when not in table", () => {
411
+ editor = createTestEditor("<p>Not in table</p>");
412
+ const tables = createTables();
413
+ editor.setCursorInBlock(0, 0);
414
+
415
+ expect(tables.navigateToNextCell()).toBe(false);
416
+ });
417
+ });
418
+
419
+ describe("navigateToPreviousCell", () => {
420
+ it("moves to previous cell in same row", () => {
421
+ editor = createTestEditor(createTableHtml(2, 3));
422
+ const tables = createTables();
423
+ const table = editor.container.querySelector("table") as HTMLTableElement;
424
+ const secondCell = getCell(table, 0, 1)!;
425
+ setCursorInCell(secondCell);
426
+
427
+ const result = tables.navigateToPreviousCell();
428
+
429
+ expect(result).toBe(true);
430
+ const currentCell = tables.getCurrentCell();
431
+ expect(currentCell).toBe(getCell(table, 0, 0));
432
+ });
433
+
434
+ it("moves to last cell of previous row at start of row", () => {
435
+ editor = createTestEditor(createTableHtml(2, 3));
436
+ const tables = createTables();
437
+ const table = editor.container.querySelector("table") as HTMLTableElement;
438
+ const firstCellInSecondRow = getCell(table, 1, 0)!;
439
+ setCursorInCell(firstCellInSecondRow);
440
+
441
+ const result = tables.navigateToPreviousCell();
442
+
443
+ expect(result).toBe(true);
444
+ const currentCell = tables.getCurrentCell();
445
+ expect(currentCell).toBe(getCell(table, 0, 2));
446
+ });
447
+
448
+ it("returns false at start of table", () => {
449
+ editor = createTestEditor(createTableHtml(2, 2));
450
+ const tables = createTables();
451
+ const table = editor.container.querySelector("table") as HTMLTableElement;
452
+ const firstCell = getCell(table, 0, 0)!;
453
+ setCursorInCell(firstCell);
454
+
455
+ const result = tables.navigateToPreviousCell();
456
+
457
+ expect(result).toBe(false);
458
+ });
459
+
460
+ it("returns false when not in table", () => {
461
+ editor = createTestEditor("<p>Not in table</p>");
462
+ const tables = createTables();
463
+ editor.setCursorInBlock(0, 0);
464
+
465
+ expect(tables.navigateToPreviousCell()).toBe(false);
466
+ });
467
+ });
468
+
469
+ describe("navigateToCellBelow", () => {
470
+ it("moves to cell directly below", () => {
471
+ editor = createTestEditor(createTableHtml(3, 2));
472
+ const tables = createTables();
473
+ const table = editor.container.querySelector("table") as HTMLTableElement;
474
+ const headerCell = getCell(table, 0, 0)!;
475
+ setCursorInCell(headerCell);
476
+
477
+ const result = tables.navigateToCellBelow();
478
+
479
+ expect(result).toBe(true);
480
+ const currentCell = tables.getCurrentCell();
481
+ expect(currentCell).toBe(getCell(table, 1, 0));
482
+ });
483
+
484
+ it("maintains column position when moving down", () => {
485
+ editor = createTestEditor(createTableHtml(3, 3));
486
+ const tables = createTables();
487
+ const table = editor.container.querySelector("table") as HTMLTableElement;
488
+ const cell = getCell(table, 0, 1)!;
489
+ setCursorInCell(cell);
490
+
491
+ tables.navigateToCellBelow();
492
+
493
+ const currentCell = tables.getCurrentCell();
494
+ expect(currentCell).toBe(getCell(table, 1, 1));
495
+ });
496
+
497
+ it("returns false at bottom of table", () => {
498
+ editor = createTestEditor(createTableHtml(2, 2));
499
+ const tables = createTables();
500
+ const table = editor.container.querySelector("table") as HTMLTableElement;
501
+ const bottomCell = getCell(table, 1, 0)!;
502
+ setCursorInCell(bottomCell);
503
+
504
+ const result = tables.navigateToCellBelow();
505
+
506
+ expect(result).toBe(false);
507
+ });
508
+
509
+ it("returns false when not in table", () => {
510
+ editor = createTestEditor("<p>Not in table</p>");
511
+ const tables = createTables();
512
+ editor.setCursorInBlock(0, 0);
513
+
514
+ expect(tables.navigateToCellBelow()).toBe(false);
515
+ });
516
+ });
517
+
518
+ describe("navigateToCellAbove", () => {
519
+ it("moves to cell directly above", () => {
520
+ editor = createTestEditor(createTableHtml(3, 2));
521
+ const tables = createTables();
522
+ const table = editor.container.querySelector("table") as HTMLTableElement;
523
+ const bodyCell = getCell(table, 1, 0)!;
524
+ setCursorInCell(bodyCell);
525
+
526
+ const result = tables.navigateToCellAbove();
527
+
528
+ expect(result).toBe(true);
529
+ const currentCell = tables.getCurrentCell();
530
+ expect(currentCell).toBe(getCell(table, 0, 0));
531
+ });
532
+
533
+ it("maintains column position when moving up", () => {
534
+ editor = createTestEditor(createTableHtml(3, 3));
535
+ const tables = createTables();
536
+ const table = editor.container.querySelector("table") as HTMLTableElement;
537
+ const cell = getCell(table, 2, 1)!;
538
+ setCursorInCell(cell);
539
+
540
+ tables.navigateToCellAbove();
541
+
542
+ const currentCell = tables.getCurrentCell();
543
+ expect(currentCell).toBe(getCell(table, 1, 1));
544
+ });
545
+
546
+ it("returns false at top of table", () => {
547
+ editor = createTestEditor(createTableHtml(2, 2));
548
+ const tables = createTables();
549
+ const table = editor.container.querySelector("table") as HTMLTableElement;
550
+ const topCell = getCell(table, 0, 0)!;
551
+ setCursorInCell(topCell);
552
+
553
+ const result = tables.navigateToCellAbove();
554
+
555
+ expect(result).toBe(false);
556
+ });
557
+
558
+ it("returns false when not in table", () => {
559
+ editor = createTestEditor("<p>Not in table</p>");
560
+ const tables = createTables();
561
+ editor.setCursorInBlock(0, 0);
562
+
563
+ expect(tables.navigateToCellAbove()).toBe(false);
564
+ });
565
+ });
566
+
567
+ describe("cursor position preservation", () => {
568
+ /**
569
+ * Helper to get cursor offset within a cell
570
+ */
571
+ function getCursorOffsetInCell(cell: HTMLTableCellElement): number {
572
+ const selection = window.getSelection();
573
+ if (!selection || !selection.rangeCount) return -1;
574
+
575
+ const range = selection.getRangeAt(0);
576
+ if (!cell.contains(range.startContainer)) return -1;
577
+
578
+ const preCaretRange = document.createRange();
579
+ preCaretRange.selectNodeContents(cell);
580
+ preCaretRange.setEnd(range.startContainer, range.startOffset);
581
+ return preCaretRange.toString().length;
582
+ }
583
+
584
+ describe("navigateToCellBelow with cursor offset", () => {
585
+ it("preserves cursor position when moving down", () => {
586
+ // Create table with specific content
587
+ editor = createTestEditor(
588
+ "<table><thead><tr><th>Hello</th></tr></thead><tbody><tr><td>World</td></tr></tbody></table>"
589
+ );
590
+ const tables = createTables();
591
+ const table = editor.container.querySelector("table") as HTMLTableElement;
592
+ const headerCell = getCell(table, 0, 0)!;
593
+
594
+ // Place cursor at position 3 in "Hello" (after "Hel")
595
+ setCursorInCell(headerCell, 3);
596
+
597
+ // Verify cursor is at position 3
598
+ expect(getCursorOffsetInCell(headerCell)).toBe(3);
599
+
600
+ // Navigate down
601
+ tables.navigateToCellBelow();
602
+
603
+ // Verify cursor is at position 3 in second row ("Wor|ld")
604
+ const targetCell = getCell(table, 1, 0)!;
605
+ expect(tables.getCurrentCell()).toBe(targetCell);
606
+ expect(getCursorOffsetInCell(targetCell)).toBe(3);
607
+ });
608
+
609
+ it("clamps cursor position when target cell is shorter", () => {
610
+ // Cell 1 has "Hello" (5 chars), Cell 2 has "Hi" (2 chars)
611
+ editor = createTestEditor(
612
+ "<table><thead><tr><th>Hello</th></tr></thead><tbody><tr><td>Hi</td></tr></tbody></table>"
613
+ );
614
+ const tables = createTables();
615
+ const table = editor.container.querySelector("table") as HTMLTableElement;
616
+ const headerCell = getCell(table, 0, 0)!;
617
+
618
+ // Place cursor at position 4 in "Hello"
619
+ setCursorInCell(headerCell, 4);
620
+ expect(getCursorOffsetInCell(headerCell)).toBe(4);
621
+
622
+ // Navigate down
623
+ tables.navigateToCellBelow();
624
+
625
+ // Cursor should be clamped to position 2 (end of "Hi")
626
+ const targetCell = getCell(table, 1, 0)!;
627
+ expect(tables.getCurrentCell()).toBe(targetCell);
628
+ expect(getCursorOffsetInCell(targetCell)).toBe(2);
629
+ });
630
+
631
+ it("places cursor at start for empty target cell", () => {
632
+ // Cell 1 has "Hello", Cell 2 is empty (just BR placeholder)
633
+ editor = createTestEditor(
634
+ "<table><thead><tr><th>Hello</th></tr></thead><tbody><tr><td><br></td></tr></tbody></table>"
635
+ );
636
+ const tables = createTables();
637
+ const table = editor.container.querySelector("table") as HTMLTableElement;
638
+ const headerCell = getCell(table, 0, 0)!;
639
+
640
+ // Place cursor at position 3 in "Hello"
641
+ setCursorInCell(headerCell, 3);
642
+
643
+ // Navigate down
644
+ tables.navigateToCellBelow();
645
+
646
+ // Cursor should be at position 0 in empty cell
647
+ const targetCell = getCell(table, 1, 0)!;
648
+ expect(tables.getCurrentCell()).toBe(targetCell);
649
+ // Empty cell has no text, cursor should be at 0
650
+ expect(getCursorOffsetInCell(targetCell)).toBe(0);
651
+ });
652
+
653
+ it("handles cursor at end of cell when moving down", () => {
654
+ editor = createTestEditor(
655
+ "<table><thead><tr><th>ABC</th></tr></thead><tbody><tr><td>DEFGH</td></tr></tbody></table>"
656
+ );
657
+ const tables = createTables();
658
+ const table = editor.container.querySelector("table") as HTMLTableElement;
659
+ const headerCell = getCell(table, 0, 0)!;
660
+
661
+ // Place cursor at end of "ABC" (position 3)
662
+ setCursorInCell(headerCell, 3);
663
+
664
+ // Navigate down
665
+ tables.navigateToCellBelow();
666
+
667
+ // Cursor should be at position 3 in "DEFGH"
668
+ const targetCell = getCell(table, 1, 0)!;
669
+ expect(getCursorOffsetInCell(targetCell)).toBe(3);
670
+ });
671
+ });
672
+
673
+ describe("navigateToCellAbove with cursor offset", () => {
674
+ it("preserves cursor position when moving up", () => {
675
+ editor = createTestEditor(
676
+ "<table><thead><tr><th>Hello</th></tr></thead><tbody><tr><td>World</td></tr></tbody></table>"
677
+ );
678
+ const tables = createTables();
679
+ const table = editor.container.querySelector("table") as HTMLTableElement;
680
+ const bodyCell = getCell(table, 1, 0)!;
681
+
682
+ // Place cursor at position 2 in "World" (after "Wo")
683
+ setCursorInCell(bodyCell, 2);
684
+ expect(getCursorOffsetInCell(bodyCell)).toBe(2);
685
+
686
+ // Navigate up
687
+ tables.navigateToCellAbove();
688
+
689
+ // Verify cursor is at position 2 in header row ("He|llo")
690
+ const targetCell = getCell(table, 0, 0)!;
691
+ expect(tables.getCurrentCell()).toBe(targetCell);
692
+ expect(getCursorOffsetInCell(targetCell)).toBe(2);
693
+ });
694
+
695
+ it("clamps cursor position when target cell is shorter", () => {
696
+ // Cell 1 (header) has "Hi" (2 chars), Cell 2 has "Hello" (5 chars)
697
+ editor = createTestEditor(
698
+ "<table><thead><tr><th>Hi</th></tr></thead><tbody><tr><td>Hello</td></tr></tbody></table>"
699
+ );
700
+ const tables = createTables();
701
+ const table = editor.container.querySelector("table") as HTMLTableElement;
702
+ const bodyCell = getCell(table, 1, 0)!;
703
+
704
+ // Place cursor at position 4 in "Hello"
705
+ setCursorInCell(bodyCell, 4);
706
+ expect(getCursorOffsetInCell(bodyCell)).toBe(4);
707
+
708
+ // Navigate up
709
+ tables.navigateToCellAbove();
710
+
711
+ // Cursor should be clamped to position 2 (end of "Hi")
712
+ const targetCell = getCell(table, 0, 0)!;
713
+ expect(tables.getCurrentCell()).toBe(targetCell);
714
+ expect(getCursorOffsetInCell(targetCell)).toBe(2);
715
+ });
716
+
717
+ it("places cursor at start for empty target cell", () => {
718
+ // Header cell is empty, body cell has content
719
+ editor = createTestEditor(
720
+ "<table><thead><tr><th><br></th></tr></thead><tbody><tr><td>Hello</td></tr></tbody></table>"
721
+ );
722
+ const tables = createTables();
723
+ const table = editor.container.querySelector("table") as HTMLTableElement;
724
+ const bodyCell = getCell(table, 1, 0)!;
725
+
726
+ // Place cursor at position 3 in "Hello"
727
+ setCursorInCell(bodyCell, 3);
728
+
729
+ // Navigate up
730
+ tables.navigateToCellAbove();
731
+
732
+ // Cursor should be at position 0 in empty header cell
733
+ const targetCell = getCell(table, 0, 0)!;
734
+ expect(tables.getCurrentCell()).toBe(targetCell);
735
+ expect(getCursorOffsetInCell(targetCell)).toBe(0);
736
+ });
737
+
738
+ it("handles multi-row navigation preserving offset", () => {
739
+ // 3-row table to test navigating through multiple rows
740
+ editor = createTestEditor(
741
+ "<table><thead><tr><th>Row1</th></tr></thead><tbody><tr><td>Row2</td></tr><tr><td>Row3</td></tr></tbody></table>"
742
+ );
743
+ const tables = createTables();
744
+ const table = editor.container.querySelector("table") as HTMLTableElement;
745
+ const lastRowCell = getCell(table, 2, 0)!;
746
+
747
+ // Place cursor at position 2 in "Row3"
748
+ setCursorInCell(lastRowCell, 2);
749
+
750
+ // Navigate up twice
751
+ tables.navigateToCellAbove();
752
+ let currentCell = tables.getCurrentCell();
753
+ expect(currentCell).toBe(getCell(table, 1, 0));
754
+ expect(getCursorOffsetInCell(currentCell!)).toBe(2);
755
+
756
+ tables.navigateToCellAbove();
757
+ currentCell = tables.getCurrentCell();
758
+ expect(currentCell).toBe(getCell(table, 0, 0));
759
+ expect(getCursorOffsetInCell(currentCell!)).toBe(2);
760
+ });
761
+ });
762
+
763
+ describe("cursor offset edge cases", () => {
764
+ it("handles cursor at position 0", () => {
765
+ editor = createTestEditor(
766
+ "<table><thead><tr><th>Hello</th></tr></thead><tbody><tr><td>World</td></tr></tbody></table>"
767
+ );
768
+ const tables = createTables();
769
+ const table = editor.container.querySelector("table") as HTMLTableElement;
770
+ const headerCell = getCell(table, 0, 0)!;
771
+
772
+ // Place cursor at position 0
773
+ setCursorInCell(headerCell, 0);
774
+ expect(getCursorOffsetInCell(headerCell)).toBe(0);
775
+
776
+ // Navigate down
777
+ tables.navigateToCellBelow();
778
+
779
+ // Cursor should be at position 0
780
+ const targetCell = getCell(table, 1, 0)!;
781
+ expect(getCursorOffsetInCell(targetCell)).toBe(0);
782
+ });
783
+
784
+ it("handles cells with inline formatting", () => {
785
+ // Cell with bold text
786
+ editor = createTestEditor(
787
+ "<table><thead><tr><th><strong>Bold</strong></th></tr></thead><tbody><tr><td>Plain</td></tr></tbody></table>"
788
+ );
789
+ const tables = createTables();
790
+ const table = editor.container.querySelector("table") as HTMLTableElement;
791
+ const headerCell = getCell(table, 0, 0)!;
792
+
793
+ // Place cursor at position 2 in "Bold" (inside strong tag)
794
+ const strongText = headerCell.querySelector("strong")!.firstChild!;
795
+ editor.setCursor(strongText, 2);
796
+ expect(getCursorOffsetInCell(headerCell)).toBe(2);
797
+
798
+ // Navigate down
799
+ tables.navigateToCellBelow();
800
+
801
+ // Cursor should be at position 2 in "Plain"
802
+ const targetCell = getCell(table, 1, 0)!;
803
+ expect(getCursorOffsetInCell(targetCell)).toBe(2);
804
+ });
805
+
806
+ it("handles mixed content cells", () => {
807
+ // Cell with text + bold + text
808
+ editor = createTestEditor(
809
+ "<table><thead><tr><th>A<strong>B</strong>C</th></tr></thead><tbody><tr><td>DEFGH</td></tr></tbody></table>"
810
+ );
811
+ const tables = createTables();
812
+ const table = editor.container.querySelector("table") as HTMLTableElement;
813
+ const headerCell = getCell(table, 0, 0)!;
814
+
815
+ // Total text is "ABC", place cursor at position 2 (after "AB")
816
+ // Need to find the right text node - the one after strong
817
+ const walker = document.createTreeWalker(headerCell, NodeFilter.SHOW_TEXT);
818
+ walker.nextNode(); // "A"
819
+ walker.nextNode(); // "B" inside strong
820
+ const lastTextNode = walker.nextNode() as Text; // "C"
821
+
822
+ // Set cursor at start of "C" which is offset 2 in total text
823
+ editor.setCursor(lastTextNode, 0);
824
+ expect(getCursorOffsetInCell(headerCell)).toBe(2);
825
+
826
+ // Navigate down
827
+ tables.navigateToCellBelow();
828
+
829
+ // Cursor should be at position 2 in "DEFGH"
830
+ const targetCell = getCell(table, 1, 0)!;
831
+ expect(getCursorOffsetInCell(targetCell)).toBe(2);
832
+ });
833
+ });
834
+ });
835
+
836
+ describe("insertRowAbove", () => {
837
+ it("inserts a new row above the current row", () => {
838
+ editor = createTestEditor(createTableHtml(2, 2, ["H1", "H2"], [["C1", "C2"]]));
839
+ const tables = createTables();
840
+ const table = editor.container.querySelector("table") as HTMLTableElement;
841
+ const bodyCell = getCell(table, 1, 0)!;
842
+ setCursorInCell(bodyCell);
843
+
844
+ tables.insertRowAbove();
845
+
846
+ // Should now have 3 rows total
847
+ const allRows = table.querySelectorAll("tr");
848
+ expect(allRows.length).toBe(3);
849
+ expect(onContentChange).toHaveBeenCalled();
850
+ });
851
+
852
+ it("creates row with correct number of cells", () => {
853
+ editor = createTestEditor(createTableHtml(2, 3));
854
+ const tables = createTables();
855
+ const table = editor.container.querySelector("table") as HTMLTableElement;
856
+ const bodyCell = getCell(table, 1, 0)!;
857
+ setCursorInCell(bodyCell);
858
+
859
+ tables.insertRowAbove();
860
+
861
+ // New row should have 3 cells
862
+ const tbody = table.querySelector("tbody");
863
+ const newRow = tbody?.querySelectorAll("tr")[0];
864
+ expect(newRow?.cells.length).toBe(3);
865
+ });
866
+
867
+ it("does nothing when not in table", () => {
868
+ editor = createTestEditor("<p>Not in table</p>");
869
+ const tables = createTables();
870
+ editor.setCursorInBlock(0, 0);
871
+
872
+ tables.insertRowAbove();
873
+
874
+ expect(onContentChange).not.toHaveBeenCalled();
875
+ });
876
+
877
+ it("does nothing when contentRef is null", () => {
878
+ editor = createTestEditor(createTableHtml(2, 2));
879
+ const { insertRowAbove } = useTables({
880
+ contentRef: { value: null },
881
+ onContentChange
882
+ });
883
+
884
+ insertRowAbove();
885
+
886
+ expect(onContentChange).not.toHaveBeenCalled();
887
+ });
888
+ });
889
+
890
+ describe("insertRowBelow", () => {
891
+ it("inserts a new row below the current row", () => {
892
+ editor = createTestEditor(createTableHtml(2, 2));
893
+ const tables = createTables();
894
+ const table = editor.container.querySelector("table") as HTMLTableElement;
895
+ const bodyCell = getCell(table, 1, 0)!;
896
+ setCursorInCell(bodyCell);
897
+
898
+ tables.insertRowBelow();
899
+
900
+ // Should now have 3 rows total
901
+ const allRows = table.querySelectorAll("tr");
902
+ expect(allRows.length).toBe(3);
903
+ expect(onContentChange).toHaveBeenCalled();
904
+ });
905
+
906
+ it("inserts row into tbody when cursor is in header", () => {
907
+ editor = createTestEditor(createTableHtml(2, 2));
908
+ const tables = createTables();
909
+ const table = editor.container.querySelector("table") as HTMLTableElement;
910
+ const headerCell = getCell(table, 0, 0)!;
911
+ setCursorInCell(headerCell);
912
+
913
+ tables.insertRowBelow();
914
+
915
+ // New row should be in tbody
916
+ const tbody = table.querySelector("tbody");
917
+ expect(tbody?.querySelectorAll("tr").length).toBe(2);
918
+ });
919
+
920
+ it("creates row with TD cells, not TH", () => {
921
+ editor = createTestEditor(createTableHtml(2, 2));
922
+ const tables = createTables();
923
+ const table = editor.container.querySelector("table") as HTMLTableElement;
924
+ const headerCell = getCell(table, 0, 0)!;
925
+ setCursorInCell(headerCell);
926
+
927
+ tables.insertRowBelow();
928
+
929
+ const tbody = table.querySelector("tbody");
930
+ const firstBodyRow = tbody?.querySelector("tr");
931
+ expect(firstBodyRow?.querySelectorAll("td").length).toBe(2);
932
+ expect(firstBodyRow?.querySelectorAll("th").length).toBe(0);
933
+ });
934
+
935
+ it("does nothing when not in table", () => {
936
+ editor = createTestEditor("<p>Not in table</p>");
937
+ const tables = createTables();
938
+ editor.setCursorInBlock(0, 0);
939
+
940
+ tables.insertRowBelow();
941
+
942
+ expect(onContentChange).not.toHaveBeenCalled();
943
+ });
944
+ });
945
+
946
+ describe("deleteCurrentRow", () => {
947
+ it("removes the current row", () => {
948
+ editor = createTestEditor(createTableHtml(3, 2));
949
+ const tables = createTables();
950
+ const table = editor.container.querySelector("table") as HTMLTableElement;
951
+ const middleRowCell = getCell(table, 1, 0)!;
952
+ setCursorInCell(middleRowCell);
953
+
954
+ tables.deleteCurrentRow();
955
+
956
+ const allRows = table.querySelectorAll("tr");
957
+ expect(allRows.length).toBe(2);
958
+ expect(onContentChange).toHaveBeenCalled();
959
+ });
960
+
961
+ it("deletes entire table when last row is deleted", () => {
962
+ editor = createTestEditor(createTableHtml(1, 2));
963
+ const tables = createTables();
964
+ const table = editor.container.querySelector("table") as HTMLTableElement;
965
+ const cell = getCell(table, 0, 0)!;
966
+ setCursorInCell(cell);
967
+
968
+ tables.deleteCurrentRow();
969
+
970
+ expect(editor.container.querySelector("table")).toBeNull();
971
+ });
972
+
973
+ it("focuses adjacent row after deletion", () => {
974
+ editor = createTestEditor(createTableHtml(3, 2));
975
+ const tables = createTables();
976
+ const table = editor.container.querySelector("table") as HTMLTableElement;
977
+ const middleRowCell = getCell(table, 1, 0)!;
978
+ setCursorInCell(middleRowCell);
979
+
980
+ tables.deleteCurrentRow();
981
+
982
+ // Should still be in a table cell
983
+ expect(tables.isInTableCell()).toBe(true);
984
+ });
985
+
986
+ it("does nothing when not in table", () => {
987
+ editor = createTestEditor("<p>Not in table</p>");
988
+ const tables = createTables();
989
+ editor.setCursorInBlock(0, 0);
990
+
991
+ tables.deleteCurrentRow();
992
+
993
+ expect(onContentChange).not.toHaveBeenCalled();
994
+ });
995
+ });
996
+
997
+ describe("insertColumnLeft", () => {
998
+ it("inserts a column to the left", () => {
999
+ editor = createTestEditor(createTableHtml(2, 2));
1000
+ const tables = createTables();
1001
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1002
+ const cell = getCell(table, 0, 1)!;
1003
+ setCursorInCell(cell);
1004
+
1005
+ tables.insertColumnLeft();
1006
+
1007
+ // Should now have 3 columns
1008
+ expect(table.querySelector("thead tr")?.cells.length).toBe(3);
1009
+ expect(onContentChange).toHaveBeenCalled();
1010
+ });
1011
+
1012
+ it("inserts TH in header row, TD in body rows", () => {
1013
+ editor = createTestEditor(createTableHtml(2, 2));
1014
+ const tables = createTables();
1015
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1016
+ const cell = getCell(table, 0, 0)!;
1017
+ setCursorInCell(cell);
1018
+
1019
+ tables.insertColumnLeft();
1020
+
1021
+ const headerRow = table.querySelector("thead tr");
1022
+ expect(headerRow?.cells[0].tagName).toBe("TH");
1023
+
1024
+ const bodyRow = table.querySelector("tbody tr");
1025
+ expect(bodyRow?.cells[0].tagName).toBe("TD");
1026
+ });
1027
+
1028
+ it("does nothing when not in table", () => {
1029
+ editor = createTestEditor("<p>Not in table</p>");
1030
+ const tables = createTables();
1031
+ editor.setCursorInBlock(0, 0);
1032
+
1033
+ tables.insertColumnLeft();
1034
+
1035
+ expect(onContentChange).not.toHaveBeenCalled();
1036
+ });
1037
+ });
1038
+
1039
+ describe("insertColumnRight", () => {
1040
+ it("inserts a column to the right", () => {
1041
+ editor = createTestEditor(createTableHtml(2, 2));
1042
+ const tables = createTables();
1043
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1044
+ const cell = getCell(table, 0, 0)!;
1045
+ setCursorInCell(cell);
1046
+
1047
+ tables.insertColumnRight();
1048
+
1049
+ // Should now have 3 columns
1050
+ expect(table.querySelector("thead tr")?.cells.length).toBe(3);
1051
+ expect(onContentChange).toHaveBeenCalled();
1052
+ });
1053
+
1054
+ it("inserts column after current position", () => {
1055
+ editor = createTestEditor(createTableHtml(2, 2, ["A", "B"], [["1", "2"]]));
1056
+ const tables = createTables();
1057
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1058
+ const cell = getCell(table, 0, 0)!;
1059
+ setCursorInCell(cell);
1060
+
1061
+ tables.insertColumnRight();
1062
+
1063
+ const headerRow = table.querySelector("thead tr");
1064
+ expect(headerRow?.cells[0].textContent).toBe("A");
1065
+ expect(headerRow?.cells[2].textContent).toBe("B");
1066
+ });
1067
+
1068
+ it("does nothing when not in table", () => {
1069
+ editor = createTestEditor("<p>Not in table</p>");
1070
+ const tables = createTables();
1071
+ editor.setCursorInBlock(0, 0);
1072
+
1073
+ tables.insertColumnRight();
1074
+
1075
+ expect(onContentChange).not.toHaveBeenCalled();
1076
+ });
1077
+ });
1078
+
1079
+ describe("deleteCurrentColumn", () => {
1080
+ it("removes the current column", () => {
1081
+ editor = createTestEditor(createTableHtml(2, 3));
1082
+ const tables = createTables();
1083
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1084
+ const cell = getCell(table, 0, 1)!;
1085
+ setCursorInCell(cell);
1086
+
1087
+ tables.deleteCurrentColumn();
1088
+
1089
+ expect(table.querySelector("thead tr")?.cells.length).toBe(2);
1090
+ expect(onContentChange).toHaveBeenCalled();
1091
+ });
1092
+
1093
+ it("deletes entire table when last column is deleted", () => {
1094
+ editor = createTestEditor(createTableHtml(2, 1));
1095
+ const tables = createTables();
1096
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1097
+ const cell = getCell(table, 0, 0)!;
1098
+ setCursorInCell(cell);
1099
+
1100
+ tables.deleteCurrentColumn();
1101
+
1102
+ expect(editor.container.querySelector("table")).toBeNull();
1103
+ });
1104
+
1105
+ it("removes column from all rows", () => {
1106
+ editor = createTestEditor(createTableHtml(3, 3));
1107
+ const tables = createTables();
1108
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1109
+ const cell = getCell(table, 0, 1)!;
1110
+ setCursorInCell(cell);
1111
+
1112
+ tables.deleteCurrentColumn();
1113
+
1114
+ // All rows should have 2 cells now
1115
+ const allRows = table.querySelectorAll("tr");
1116
+ allRows.forEach(row => {
1117
+ expect(row.cells.length).toBe(2);
1118
+ });
1119
+ });
1120
+
1121
+ it("does nothing when not in table", () => {
1122
+ editor = createTestEditor("<p>Not in table</p>");
1123
+ const tables = createTables();
1124
+ editor.setCursorInBlock(0, 0);
1125
+
1126
+ tables.deleteCurrentColumn();
1127
+
1128
+ expect(onContentChange).not.toHaveBeenCalled();
1129
+ });
1130
+ });
1131
+
1132
+ describe("deleteTable", () => {
1133
+ it("removes entire table", () => {
1134
+ editor = createTestEditor(createTableHtml(2, 2));
1135
+ const tables = createTables();
1136
+ const cell = editor.container.querySelector("th") as HTMLTableCellElement;
1137
+ setCursorInCell(cell);
1138
+
1139
+ tables.deleteTable();
1140
+
1141
+ expect(editor.container.querySelector("table")).toBeNull();
1142
+ expect(onContentChange).toHaveBeenCalled();
1143
+ });
1144
+
1145
+ it("creates paragraph if content area becomes empty", () => {
1146
+ editor = createTestEditor(createTableHtml(2, 2));
1147
+ const tables = createTables();
1148
+ const cell = editor.container.querySelector("th") as HTMLTableCellElement;
1149
+ setCursorInCell(cell);
1150
+
1151
+ tables.deleteTable();
1152
+
1153
+ expect(editor.container.querySelector("p")).not.toBeNull();
1154
+ });
1155
+
1156
+ it("focuses next sibling after deletion", () => {
1157
+ editor = createTestEditor(`${createTableHtml(2, 2)}<p>After table</p>`);
1158
+ const tables = createTables();
1159
+ const cell = editor.container.querySelector("th") as HTMLTableCellElement;
1160
+ setCursorInCell(cell);
1161
+
1162
+ tables.deleteTable();
1163
+
1164
+ expect(editor.container.querySelector("table")).toBeNull();
1165
+ expect(editor.container.querySelector("p")?.textContent).toBe("After table");
1166
+ });
1167
+
1168
+ it("does nothing when not in table", () => {
1169
+ editor = createTestEditor("<p>Not in table</p>");
1170
+ const tables = createTables();
1171
+ editor.setCursorInBlock(0, 0);
1172
+
1173
+ tables.deleteTable();
1174
+
1175
+ expect(onContentChange).not.toHaveBeenCalled();
1176
+ });
1177
+
1178
+ it("does nothing when contentRef is null", () => {
1179
+ editor = createTestEditor(createTableHtml(2, 2));
1180
+ const { deleteTable } = useTables({
1181
+ contentRef: { value: null },
1182
+ onContentChange
1183
+ });
1184
+
1185
+ deleteTable();
1186
+
1187
+ expect(editor.container.querySelector("table")).not.toBeNull();
1188
+ expect(onContentChange).not.toHaveBeenCalled();
1189
+ });
1190
+ });
1191
+
1192
+ describe("setColumnAlignmentLeft", () => {
1193
+ it("sets column alignment to left", () => {
1194
+ editor = createTestEditor(createTableHtml(2, 2));
1195
+ const tables = createTables();
1196
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1197
+ const cell = getCell(table, 0, 0)!;
1198
+ cell.style.textAlign = "center";
1199
+ setCursorInCell(cell);
1200
+
1201
+ tables.setColumnAlignmentLeft();
1202
+
1203
+ // Left alignment removes the style property
1204
+ expect(cell.style.textAlign).toBe("");
1205
+ expect(onContentChange).toHaveBeenCalled();
1206
+ });
1207
+
1208
+ it("does nothing when not in table", () => {
1209
+ editor = createTestEditor("<p>Not in table</p>");
1210
+ const tables = createTables();
1211
+ editor.setCursorInBlock(0, 0);
1212
+
1213
+ tables.setColumnAlignmentLeft();
1214
+
1215
+ expect(onContentChange).not.toHaveBeenCalled();
1216
+ });
1217
+ });
1218
+
1219
+ describe("setColumnAlignmentCenter", () => {
1220
+ it("sets column alignment to center", () => {
1221
+ editor = createTestEditor(createTableHtml(2, 2));
1222
+ const tables = createTables();
1223
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1224
+ const cell = getCell(table, 0, 0)!;
1225
+ setCursorInCell(cell);
1226
+
1227
+ tables.setColumnAlignmentCenter();
1228
+
1229
+ expect(cell.style.textAlign).toBe("center");
1230
+ expect(onContentChange).toHaveBeenCalled();
1231
+ });
1232
+
1233
+ it("applies alignment to all cells in column", () => {
1234
+ editor = createTestEditor(createTableHtml(3, 2));
1235
+ const tables = createTables();
1236
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1237
+ const cell = getCell(table, 0, 0)!;
1238
+ setCursorInCell(cell);
1239
+
1240
+ tables.setColumnAlignmentCenter();
1241
+
1242
+ // All cells in first column should be centered
1243
+ expect(getCell(table, 0, 0)?.style.textAlign).toBe("center");
1244
+ expect(getCell(table, 1, 0)?.style.textAlign).toBe("center");
1245
+ expect(getCell(table, 2, 0)?.style.textAlign).toBe("center");
1246
+ });
1247
+
1248
+ it("does nothing when not in table", () => {
1249
+ editor = createTestEditor("<p>Not in table</p>");
1250
+ const tables = createTables();
1251
+ editor.setCursorInBlock(0, 0);
1252
+
1253
+ tables.setColumnAlignmentCenter();
1254
+
1255
+ expect(onContentChange).not.toHaveBeenCalled();
1256
+ });
1257
+ });
1258
+
1259
+ describe("setColumnAlignmentRight", () => {
1260
+ it("sets column alignment to right", () => {
1261
+ editor = createTestEditor(createTableHtml(2, 2));
1262
+ const tables = createTables();
1263
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1264
+ const cell = getCell(table, 0, 0)!;
1265
+ setCursorInCell(cell);
1266
+
1267
+ tables.setColumnAlignmentRight();
1268
+
1269
+ expect(cell.style.textAlign).toBe("right");
1270
+ expect(onContentChange).toHaveBeenCalled();
1271
+ });
1272
+
1273
+ it("does nothing when not in table", () => {
1274
+ editor = createTestEditor("<p>Not in table</p>");
1275
+ const tables = createTables();
1276
+ editor.setCursorInBlock(0, 0);
1277
+
1278
+ tables.setColumnAlignmentRight();
1279
+
1280
+ expect(onContentChange).not.toHaveBeenCalled();
1281
+ });
1282
+ });
1283
+
1284
+ describe("handleTableTab", () => {
1285
+ it("navigates to next cell on Tab", () => {
1286
+ editor = createTestEditor(createTableHtml(2, 3));
1287
+ const tables = createTables();
1288
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1289
+ const firstCell = getCell(table, 0, 0)!;
1290
+ setCursorInCell(firstCell);
1291
+
1292
+ const result = tables.handleTableTab(false);
1293
+
1294
+ expect(result).toBe(true);
1295
+ expect(tables.getCurrentCell()).toBe(getCell(table, 0, 1));
1296
+ });
1297
+
1298
+ it("navigates to previous cell on Shift+Tab", () => {
1299
+ editor = createTestEditor(createTableHtml(2, 3));
1300
+ const tables = createTables();
1301
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1302
+ const secondCell = getCell(table, 0, 1)!;
1303
+ setCursorInCell(secondCell);
1304
+
1305
+ const result = tables.handleTableTab(true);
1306
+
1307
+ expect(result).toBe(true);
1308
+ expect(tables.getCurrentCell()).toBe(getCell(table, 0, 0));
1309
+ });
1310
+
1311
+ it("creates new row when Tab at end of table", () => {
1312
+ editor = createTestEditor(createTableHtml(2, 2));
1313
+ const tables = createTables();
1314
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1315
+ const lastCell = getCell(table, 1, 1)!;
1316
+ setCursorInCell(lastCell);
1317
+
1318
+ const result = tables.handleTableTab(false);
1319
+
1320
+ expect(result).toBe(true);
1321
+ // Should have 3 rows now
1322
+ expect(table.querySelectorAll("tr").length).toBe(3);
1323
+ });
1324
+
1325
+ it("returns false on Shift+Tab at start of table", () => {
1326
+ editor = createTestEditor(createTableHtml(2, 2));
1327
+ const tables = createTables();
1328
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1329
+ const firstCell = getCell(table, 0, 0)!;
1330
+ setCursorInCell(firstCell);
1331
+
1332
+ const result = tables.handleTableTab(true);
1333
+
1334
+ expect(result).toBe(false);
1335
+ });
1336
+
1337
+ it("returns false when not in table cell", () => {
1338
+ editor = createTestEditor("<p>Not in table</p>");
1339
+ const tables = createTables();
1340
+ editor.setCursorInBlock(0, 0);
1341
+
1342
+ expect(tables.handleTableTab(false)).toBe(false);
1343
+ expect(tables.handleTableTab(true)).toBe(false);
1344
+ });
1345
+ });
1346
+
1347
+ describe("handleTableEnter", () => {
1348
+ it("moves to cell below", () => {
1349
+ editor = createTestEditor(createTableHtml(3, 2));
1350
+ const tables = createTables();
1351
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1352
+ const headerCell = getCell(table, 0, 0)!;
1353
+ setCursorInCell(headerCell);
1354
+
1355
+ const result = tables.handleTableEnter();
1356
+
1357
+ expect(result).toBe(true);
1358
+ expect(tables.getCurrentCell()).toBe(getCell(table, 1, 0));
1359
+ });
1360
+
1361
+ it("creates new row when at bottom of table", () => {
1362
+ editor = createTestEditor(createTableHtml(2, 2));
1363
+ const tables = createTables();
1364
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1365
+ const bottomCell = getCell(table, 1, 0)!;
1366
+ setCursorInCell(bottomCell);
1367
+
1368
+ const result = tables.handleTableEnter();
1369
+
1370
+ expect(result).toBe(true);
1371
+ // Should have 3 rows now
1372
+ expect(table.querySelectorAll("tr").length).toBe(3);
1373
+ });
1374
+
1375
+ it("returns false when not in table cell", () => {
1376
+ editor = createTestEditor("<p>Not in table</p>");
1377
+ const tables = createTables();
1378
+ editor.setCursorInBlock(0, 0);
1379
+
1380
+ expect(tables.handleTableEnter()).toBe(false);
1381
+ });
1382
+
1383
+ it("maintains column position when moving down", () => {
1384
+ editor = createTestEditor(createTableHtml(3, 3));
1385
+ const tables = createTables();
1386
+ const table = editor.container.querySelector("table") as HTMLTableElement;
1387
+ const cell = getCell(table, 0, 1)!;
1388
+ setCursorInCell(cell);
1389
+
1390
+ tables.handleTableEnter();
1391
+
1392
+ const currentCell = tables.getCurrentCell();
1393
+ expect(currentCell).toBe(getCell(table, 1, 1));
1394
+ });
1395
+ });
1396
+
1397
+ describe("insertTable", () => {
1398
+ // Mock getBoundingClientRect for Range since jsdom doesn't implement it
1399
+ let originalGetBoundingClientRect: typeof Range.prototype.getBoundingClientRect;
1400
+
1401
+ beforeEach(() => {
1402
+ originalGetBoundingClientRect = Range.prototype.getBoundingClientRect;
1403
+ Range.prototype.getBoundingClientRect = vi.fn(() => ({
1404
+ x: 100,
1405
+ y: 100,
1406
+ width: 0,
1407
+ height: 0,
1408
+ top: 100,
1409
+ right: 100,
1410
+ bottom: 100,
1411
+ left: 100,
1412
+ toJSON: () => ({})
1413
+ }));
1414
+ });
1415
+
1416
+ afterEach(() => {
1417
+ Range.prototype.getBoundingClientRect = originalGetBoundingClientRect;
1418
+ });
1419
+
1420
+ it("creates default 3x3 table when no popover callback provided", () => {
1421
+ editor = createTestEditor("<p>Insert here</p>");
1422
+ const tables = createTables();
1423
+ editor.setCursorInBlock(0, 0);
1424
+
1425
+ tables.insertTable();
1426
+
1427
+ const table = editor.container.querySelector("table");
1428
+ expect(table).not.toBeNull();
1429
+ expect(table?.querySelector("thead")?.querySelectorAll("th").length).toBe(3);
1430
+ // 2 body rows
1431
+ expect(table?.querySelector("tbody")?.querySelectorAll("tr").length).toBe(2);
1432
+ });
1433
+
1434
+ it("calls onShowTablePopover when provided", () => {
1435
+ editor = createTestEditor("<p>Insert here</p>");
1436
+ const onShowTablePopover = vi.fn();
1437
+ const tables = useTables({
1438
+ contentRef: editor.contentRef,
1439
+ onContentChange,
1440
+ onShowTablePopover
1441
+ });
1442
+ editor.setCursorInBlock(0, 0);
1443
+
1444
+ tables.insertTable();
1445
+
1446
+ expect(onShowTablePopover).toHaveBeenCalled();
1447
+ const options = onShowTablePopover.mock.calls[0][0];
1448
+ expect(options.position).toBeDefined();
1449
+ expect(typeof options.onSubmit).toBe("function");
1450
+ expect(typeof options.onCancel).toBe("function");
1451
+ });
1452
+
1453
+ it("creates table with specified dimensions when popover submits", () => {
1454
+ editor = createTestEditor("<p>Insert here</p>");
1455
+ let capturedOptions: any = null;
1456
+ const onShowTablePopover = vi.fn((opts) => {
1457
+ capturedOptions = opts;
1458
+ });
1459
+ const tables = useTables({
1460
+ contentRef: editor.contentRef,
1461
+ onContentChange,
1462
+ onShowTablePopover
1463
+ });
1464
+ editor.setCursorInBlock(0, 0);
1465
+
1466
+ tables.insertTable();
1467
+ // Simulate popover submission
1468
+ capturedOptions.onSubmit(4, 5);
1469
+
1470
+ const table = editor.container.querySelector("table");
1471
+ expect(table?.querySelector("thead")?.querySelectorAll("th").length).toBe(5);
1472
+ // 3 body rows (4 total - 1 header)
1473
+ expect(table?.querySelector("tbody")?.querySelectorAll("tr").length).toBe(3);
1474
+ });
1475
+
1476
+ it("does nothing when contentRef is null", () => {
1477
+ editor = createTestEditor("<p>Test</p>");
1478
+ const { insertTable } = useTables({
1479
+ contentRef: { value: null },
1480
+ onContentChange
1481
+ });
1482
+
1483
+ insertTable();
1484
+
1485
+ expect(editor.container.querySelector("table")).toBeNull();
1486
+ });
1487
+ });
1488
+
1489
+ describe("edge cases", () => {
1490
+ it("handles table without tbody", () => {
1491
+ editor = createTestEditor("<table><thead><tr><th>Header</th></tr></thead></table>");
1492
+ const tables = createTables();
1493
+ const th = editor.container.querySelector("th") as HTMLTableCellElement;
1494
+ setCursorInCell(th);
1495
+
1496
+ expect(tables.isInTable()).toBe(true);
1497
+ expect(tables.isInTableCell()).toBe(true);
1498
+ });
1499
+
1500
+ it("handles table without thead", () => {
1501
+ editor = createTestEditor("<table><tbody><tr><td>Cell</td></tr></tbody></table>");
1502
+ const tables = createTables();
1503
+ const td = editor.container.querySelector("td") as HTMLTableCellElement;
1504
+ setCursorInCell(td);
1505
+
1506
+ expect(tables.isInTable()).toBe(true);
1507
+ expect(tables.getCurrentTable()).not.toBeNull();
1508
+ });
1509
+
1510
+ it("handles empty cells", () => {
1511
+ editor = createTestEditor("<table><thead><tr><th><br></th></tr></thead></table>");
1512
+ const tables = createTables();
1513
+ const th = editor.container.querySelector("th") as HTMLTableCellElement;
1514
+
1515
+ // Set cursor in empty cell
1516
+ const range = document.createRange();
1517
+ range.setStart(th, 0);
1518
+ range.collapse(true);
1519
+ const sel = window.getSelection();
1520
+ sel?.removeAllRanges();
1521
+ sel?.addRange(range);
1522
+
1523
+ expect(tables.isInTableCell()).toBe(true);
1524
+ });
1525
+
1526
+ it("handles nested content in cells", () => {
1527
+ editor = createTestEditor("<table><thead><tr><th><strong>Bold header</strong></th></tr></thead></table>");
1528
+ const tables = createTables();
1529
+ const strong = editor.container.querySelector("strong") as HTMLElement;
1530
+ editor.setCursor(strong.firstChild!, 2);
1531
+
1532
+ expect(tables.isInTable()).toBe(true);
1533
+ expect(tables.isInTableCell()).toBe(true);
1534
+ expect(tables.getCurrentCell()?.tagName).toBe("TH");
1535
+ });
1536
+
1537
+ it("handles multiple tables in document", () => {
1538
+ editor = createTestEditor(`
1539
+ ${createTableHtml(2, 2)}
1540
+ <p>Between tables</p>
1541
+ ${createTableHtml(3, 3)}
1542
+ `);
1543
+ const tables = createTables();
1544
+
1545
+ // Focus in second table
1546
+ const secondTable = editor.container.querySelectorAll("table")[1];
1547
+ const cell = secondTable.querySelector("th") as HTMLTableCellElement;
1548
+ setCursorInCell(cell);
1549
+
1550
+ expect(tables.getCurrentTable()).toBe(secondTable);
1551
+ });
1552
+ });
1553
+
1554
+ describe("return type", () => {
1555
+ beforeEach(() => {
1556
+ editor = createTestEditor("<p>test</p>");
1557
+ });
1558
+
1559
+ it("returns all expected functions", () => {
1560
+ const tables = createTables();
1561
+
1562
+ // Creation
1563
+ expect(typeof tables.insertTable).toBe("function");
1564
+ expect(typeof tables.createTable).toBe("function");
1565
+
1566
+ // Detection
1567
+ expect(typeof tables.isInTable).toBe("function");
1568
+ expect(typeof tables.isInTableCell).toBe("function");
1569
+ expect(typeof tables.getCurrentTable).toBe("function");
1570
+ expect(typeof tables.getCurrentCell).toBe("function");
1571
+
1572
+ // Navigation
1573
+ expect(typeof tables.navigateToNextCell).toBe("function");
1574
+ expect(typeof tables.navigateToPreviousCell).toBe("function");
1575
+ expect(typeof tables.navigateToCellBelow).toBe("function");
1576
+ expect(typeof tables.navigateToCellAbove).toBe("function");
1577
+
1578
+ // Row operations
1579
+ expect(typeof tables.insertRowAbove).toBe("function");
1580
+ expect(typeof tables.insertRowBelow).toBe("function");
1581
+ expect(typeof tables.deleteCurrentRow).toBe("function");
1582
+
1583
+ // Column operations
1584
+ expect(typeof tables.insertColumnLeft).toBe("function");
1585
+ expect(typeof tables.insertColumnRight).toBe("function");
1586
+ expect(typeof tables.deleteCurrentColumn).toBe("function");
1587
+
1588
+ // Table operations
1589
+ expect(typeof tables.deleteTable).toBe("function");
1590
+
1591
+ // Alignment
1592
+ expect(typeof tables.setColumnAlignmentLeft).toBe("function");
1593
+ expect(typeof tables.setColumnAlignmentCenter).toBe("function");
1594
+ expect(typeof tables.setColumnAlignmentRight).toBe("function");
1595
+
1596
+ // Key handlers
1597
+ expect(typeof tables.handleTableTab).toBe("function");
1598
+ expect(typeof tables.handleTableEnter).toBe("function");
1599
+ });
1600
+ });
1601
+ });