quasar-ui-danx 0.4.99 → 0.5.1

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 (90) hide show
  1. package/dist/danx.es.js +17884 -12732
  2. package/dist/danx.es.js.map +1 -1
  3. package/dist/danx.umd.js +192 -118
  4. package/dist/danx.umd.js.map +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +11 -2
  7. package/scripts/publish.sh +76 -0
  8. package/src/components/Utility/Code/CodeViewer.vue +31 -14
  9. package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
  10. package/src/components/Utility/Code/CodeViewerFooter.vue +1 -1
  11. package/src/components/Utility/Code/LanguageBadge.vue +278 -5
  12. package/src/components/Utility/Code/MarkdownContent.vue +160 -6
  13. package/src/components/Utility/Code/index.ts +3 -0
  14. package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
  15. package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
  16. package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
  17. package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
  18. package/src/components/Utility/Markdown/MarkdownEditor.vue +228 -0
  19. package/src/components/Utility/Markdown/MarkdownEditorContent.vue +235 -0
  20. package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
  21. package/src/components/Utility/Markdown/TablePopover.vue +420 -0
  22. package/src/components/Utility/Markdown/index.ts +11 -0
  23. package/src/components/Utility/Markdown/types.ts +27 -0
  24. package/src/components/Utility/index.ts +1 -0
  25. package/src/composables/index.ts +1 -0
  26. package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
  27. package/src/composables/markdown/features/useBlockquotes.ts +248 -0
  28. package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
  29. package/src/composables/markdown/features/useCodeBlocks.spec.ts +779 -0
  30. package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
  31. package/src/composables/markdown/features/useContextMenu.ts +444 -0
  32. package/src/composables/markdown/features/useFocusTracking.ts +116 -0
  33. package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
  34. package/src/composables/markdown/features/useHeadings.ts +290 -0
  35. package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
  36. package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
  37. package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
  38. package/src/composables/markdown/features/useLinks.spec.ts +369 -0
  39. package/src/composables/markdown/features/useLinks.ts +374 -0
  40. package/src/composables/markdown/features/useLists.spec.ts +834 -0
  41. package/src/composables/markdown/features/useLists.ts +747 -0
  42. package/src/composables/markdown/features/usePopoverManager.ts +181 -0
  43. package/src/composables/markdown/features/useTables.spec.ts +1601 -0
  44. package/src/composables/markdown/features/useTables.ts +1107 -0
  45. package/src/composables/markdown/index.ts +16 -0
  46. package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
  47. package/src/composables/markdown/useMarkdownEditor.ts +1068 -0
  48. package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
  49. package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
  50. package/src/composables/markdown/useMarkdownSelection.ts +219 -0
  51. package/src/composables/markdown/useMarkdownSync.ts +549 -0
  52. package/src/composables/useCodeViewerEditor.spec.ts +655 -0
  53. package/src/composables/useCodeViewerEditor.ts +174 -20
  54. package/src/helpers/formats/index.ts +1 -1
  55. package/src/helpers/formats/markdown/escapeHtml.ts +15 -0
  56. package/src/helpers/formats/markdown/escapeSequences.ts +60 -0
  57. package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
  58. package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
  59. package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +412 -0
  60. package/src/helpers/formats/markdown/index.ts +92 -0
  61. package/src/helpers/formats/markdown/linePatterns.spec.ts +495 -0
  62. package/src/helpers/formats/markdown/linePatterns.ts +172 -0
  63. package/src/helpers/formats/markdown/parseInline.ts +124 -0
  64. package/src/helpers/formats/markdown/render/index.ts +92 -0
  65. package/src/helpers/formats/markdown/render/renderFootnotes.ts +30 -0
  66. package/src/helpers/formats/markdown/render/renderList.ts +69 -0
  67. package/src/helpers/formats/markdown/render/renderTable.ts +38 -0
  68. package/src/helpers/formats/markdown/state.ts +58 -0
  69. package/src/helpers/formats/markdown/tokenize/extractDefinitions.ts +39 -0
  70. package/src/helpers/formats/markdown/tokenize/index.ts +139 -0
  71. package/src/helpers/formats/markdown/tokenize/parseBlockquote.ts +34 -0
  72. package/src/helpers/formats/markdown/tokenize/parseCodeBlock.ts +85 -0
  73. package/src/helpers/formats/markdown/tokenize/parseDefinitionList.ts +88 -0
  74. package/src/helpers/formats/markdown/tokenize/parseHeading.ts +65 -0
  75. package/src/helpers/formats/markdown/tokenize/parseHorizontalRule.ts +22 -0
  76. package/src/helpers/formats/markdown/tokenize/parseList.ts +119 -0
  77. package/src/helpers/formats/markdown/tokenize/parseParagraph.ts +59 -0
  78. package/src/helpers/formats/markdown/tokenize/parseTable.ts +70 -0
  79. package/src/helpers/formats/markdown/tokenize/parseTaskList.ts +47 -0
  80. package/src/helpers/formats/markdown/tokenize/utils.ts +25 -0
  81. package/src/helpers/formats/markdown/types.ts +63 -0
  82. package/src/styles/danx.scss +1 -0
  83. package/src/styles/themes/danx/markdown.scss +96 -0
  84. package/src/test/helpers/editorTestUtils.spec.ts +296 -0
  85. package/src/test/helpers/editorTestUtils.ts +253 -0
  86. package/src/test/helpers/index.ts +1 -0
  87. package/src/test/setup.test.ts +12 -0
  88. package/src/test/setup.ts +12 -0
  89. package/vitest.config.ts +19 -0
  90. package/src/helpers/formats/renderMarkdown.ts +0 -338
@@ -0,0 +1,1107 @@
1
+ import { Ref } from "vue";
2
+
3
+ /**
4
+ * Position for table popover
5
+ */
6
+ export interface TablePopoverPosition {
7
+ x: number;
8
+ y: number;
9
+ }
10
+
11
+ /**
12
+ * Options passed to the onShowTablePopover callback
13
+ */
14
+ export interface ShowTablePopoverOptions {
15
+ /** Position in viewport where popover should appear */
16
+ position: TablePopoverPosition;
17
+ /** Callback to complete the table insertion with specified dimensions */
18
+ onSubmit: (rows: number, cols: number) => void;
19
+ /** Callback to cancel the operation */
20
+ onCancel: () => void;
21
+ }
22
+
23
+ /**
24
+ * Options for useTables composable
25
+ */
26
+ export interface UseTablesOptions {
27
+ contentRef: Ref<HTMLElement | null>;
28
+ onContentChange: () => void;
29
+ /** Callback to show the table popover UI for dimension selection */
30
+ onShowTablePopover?: (options: ShowTablePopoverOptions) => void;
31
+ }
32
+
33
+ /**
34
+ * Return type for useTables composable
35
+ */
36
+ export interface UseTablesReturn {
37
+ // Creation
38
+ /** Insert a table - shows popover if callback provided, otherwise creates default 3x3 */
39
+ insertTable: () => void;
40
+ /** Create a table with specific dimensions */
41
+ createTable: (rows: number, cols: number) => void;
42
+
43
+ // Detection
44
+ /** Check if cursor is inside a table */
45
+ isInTable: () => boolean;
46
+ /** Check if cursor is inside a table cell */
47
+ isInTableCell: () => boolean;
48
+ /** Get the current table element */
49
+ getCurrentTable: () => HTMLTableElement | null;
50
+ /** Get the current table cell element */
51
+ getCurrentCell: () => HTMLTableCellElement | null;
52
+
53
+ // Navigation
54
+ /** Navigate to the next cell (right, then next row). Returns false if at end. */
55
+ navigateToNextCell: () => boolean;
56
+ /** Navigate to the previous cell. Returns false if at start. */
57
+ navigateToPreviousCell: () => boolean;
58
+ /** Navigate to the cell directly below. Returns false if at bottom row. */
59
+ navigateToCellBelow: () => boolean;
60
+ /** Navigate to the cell directly above. Returns false if at top row. */
61
+ navigateToCellAbove: () => boolean;
62
+
63
+ // Row operations
64
+ /** Insert a new row above the current row */
65
+ insertRowAbove: () => void;
66
+ /** Insert a new row below the current row */
67
+ insertRowBelow: () => void;
68
+ /** Delete the current row */
69
+ deleteCurrentRow: () => void;
70
+
71
+ // Column operations
72
+ /** Insert a new column to the left */
73
+ insertColumnLeft: () => void;
74
+ /** Insert a new column to the right */
75
+ insertColumnRight: () => void;
76
+ /** Delete the current column */
77
+ deleteCurrentColumn: () => void;
78
+
79
+ // Table operations
80
+ /** Delete the entire table */
81
+ deleteTable: () => void;
82
+
83
+ // Alignment
84
+ /** Set column alignment to left */
85
+ setColumnAlignmentLeft: () => void;
86
+ /** Set column alignment to center */
87
+ setColumnAlignmentCenter: () => void;
88
+ /** Set column alignment to right */
89
+ setColumnAlignmentRight: () => void;
90
+
91
+ // Key handlers
92
+ /** Handle Tab key in table - returns true if handled */
93
+ handleTableTab: (shift: boolean) => boolean;
94
+ /** Handle Enter key in table - returns true if handled */
95
+ handleTableEnter: () => boolean;
96
+ }
97
+
98
+ /**
99
+ * Get the cursor position in viewport coordinates
100
+ */
101
+ function getCursorPosition(): TablePopoverPosition {
102
+ const selection = window.getSelection();
103
+ if (!selection || !selection.rangeCount) {
104
+ return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
105
+ }
106
+
107
+ const range = selection.getRangeAt(0);
108
+ const rect = range.getBoundingClientRect();
109
+
110
+ // If rect has no dimensions (collapsed cursor), use the start position
111
+ if (rect.width === 0 && rect.height === 0) {
112
+ return {
113
+ x: rect.left || window.innerWidth / 2,
114
+ y: rect.bottom || window.innerHeight / 2
115
+ };
116
+ }
117
+
118
+ // Center horizontally on the selection, position below
119
+ return {
120
+ x: rect.left + (rect.width / 2),
121
+ y: rect.bottom
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Find the table ancestor if one exists
127
+ */
128
+ function findTableAncestor(node: Node | null, contentRef: HTMLElement): HTMLTableElement | null {
129
+ if (!node) return null;
130
+
131
+ let current: Node | null = node;
132
+ while (current && current !== contentRef) {
133
+ if (current.nodeType === Node.ELEMENT_NODE && (current as Element).tagName === "TABLE") {
134
+ return current as HTMLTableElement;
135
+ }
136
+ current = current.parentNode;
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Find the table cell (TD or TH) ancestor if one exists
144
+ */
145
+ function findCellAncestor(node: Node | null, contentRef: HTMLElement): HTMLTableCellElement | null {
146
+ if (!node) return null;
147
+
148
+ let current: Node | null = node;
149
+ while (current && current !== contentRef) {
150
+ if (current.nodeType === Node.ELEMENT_NODE) {
151
+ const tag = (current as Element).tagName;
152
+ if (tag === "TD" || tag === "TH") {
153
+ return current as HTMLTableCellElement;
154
+ }
155
+ }
156
+ current = current.parentNode;
157
+ }
158
+
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Get the row and column indices of a cell
164
+ */
165
+ function getCellCoordinates(cell: HTMLTableCellElement): { row: number; col: number } {
166
+ const row = cell.parentElement as HTMLTableRowElement | null;
167
+ if (!row) return { row: -1, col: -1 };
168
+
169
+ const table = row.parentElement?.parentElement as HTMLTableElement | null
170
+ || row.parentElement as HTMLTableElement | null;
171
+ if (!table) return { row: -1, col: -1 };
172
+
173
+ // Get column index
174
+ const colIndex = Array.from(row.cells).indexOf(cell);
175
+
176
+ // Get row index (accounting for thead/tbody)
177
+ let rowIndex = 0;
178
+ const rows = getAllTableRows(table);
179
+ for (let i = 0; i < rows.length; i++) {
180
+ if (rows[i] === row) {
181
+ rowIndex = i;
182
+ break;
183
+ }
184
+ }
185
+
186
+ return { row: rowIndex, col: colIndex };
187
+ }
188
+
189
+ /**
190
+ * Get all rows from a table (including thead and tbody)
191
+ */
192
+ function getAllTableRows(table: HTMLTableElement): HTMLTableRowElement[] {
193
+ const rows: HTMLTableRowElement[] = [];
194
+
195
+ // Get rows from thead
196
+ if (table.tHead) {
197
+ rows.push(...Array.from(table.tHead.rows));
198
+ }
199
+
200
+ // Get rows from tbody(s)
201
+ for (const tbody of Array.from(table.tBodies)) {
202
+ rows.push(...Array.from(tbody.rows));
203
+ }
204
+
205
+ // Get any direct row children (tables without thead/tbody)
206
+ for (const child of Array.from(table.children)) {
207
+ if (child.tagName === "TR") {
208
+ rows.push(child as HTMLTableRowElement);
209
+ }
210
+ }
211
+
212
+ return rows;
213
+ }
214
+
215
+ /**
216
+ * Get the number of columns in a table
217
+ */
218
+ function getColumnCount(table: HTMLTableElement): number {
219
+ const rows = getAllTableRows(table);
220
+ if (rows.length === 0) return 0;
221
+ return rows[0].cells.length;
222
+ }
223
+
224
+ /**
225
+ * Get a cell at specific coordinates
226
+ */
227
+ function getCellAt(table: HTMLTableElement, rowIndex: number, colIndex: number): HTMLTableCellElement | null {
228
+ const rows = getAllTableRows(table);
229
+ if (rowIndex < 0 || rowIndex >= rows.length) return null;
230
+
231
+ const row = rows[rowIndex];
232
+ if (colIndex < 0 || colIndex >= row.cells.length) return null;
233
+
234
+ return row.cells[colIndex];
235
+ }
236
+
237
+ /**
238
+ * Get the cursor offset (character position) within a cell
239
+ * This measures the text length from the start of the cell to the cursor position
240
+ */
241
+ function getCursorOffsetInCell(cell: HTMLTableCellElement): number {
242
+ const selection = window.getSelection();
243
+ if (!selection || !selection.rangeCount) return 0;
244
+
245
+ const range = selection.getRangeAt(0);
246
+
247
+ // Create a range from cell start to cursor position
248
+ const preCaretRange = document.createRange();
249
+ preCaretRange.selectNodeContents(cell);
250
+ preCaretRange.setEnd(range.startContainer, range.startOffset);
251
+
252
+ // Get text length up to cursor
253
+ return preCaretRange.toString().length;
254
+ }
255
+
256
+ /**
257
+ * Set the cursor at a specific character offset within a cell
258
+ * If the offset exceeds the cell's text length, places cursor at the end
259
+ */
260
+ function setCursorOffsetInCell(cell: HTMLTableCellElement, targetOffset: number): void {
261
+ const selection = window.getSelection();
262
+ if (!selection) return;
263
+
264
+ const textContent = cell.textContent || "";
265
+ const maxOffset = textContent.length;
266
+ const offset = Math.min(targetOffset, maxOffset);
267
+
268
+ // Find the text node and position for this offset
269
+ let currentOffset = 0;
270
+ const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT);
271
+ let node: Text | null = null;
272
+
273
+ while ((node = walker.nextNode() as Text | null)) {
274
+ const nodeLength = node.textContent?.length || 0;
275
+ if (currentOffset + nodeLength >= offset) {
276
+ // Found the right node
277
+ const range = document.createRange();
278
+ range.setStart(node, offset - currentOffset);
279
+ range.collapse(true);
280
+ selection.removeAllRanges();
281
+ selection.addRange(range);
282
+ return;
283
+ }
284
+ currentOffset += nodeLength;
285
+ }
286
+
287
+ // Fallback: place at end of cell
288
+ const range = document.createRange();
289
+ range.selectNodeContents(cell);
290
+ range.collapse(false);
291
+ selection.removeAllRanges();
292
+ selection.addRange(range);
293
+ }
294
+
295
+ /**
296
+ * Get the first text node with actual content in an element
297
+ */
298
+ function getFirstTextNode(node: Node): Text | null {
299
+ if (node.nodeType === Node.TEXT_NODE) {
300
+ // Return the text node even if it's empty/whitespace - we can position at offset 0
301
+ return node as Text;
302
+ }
303
+ for (const child of Array.from(node.childNodes)) {
304
+ // Skip BR elements - they're placeholders for empty cells
305
+ if (child.nodeType === Node.ELEMENT_NODE && (child as Element).tagName === "BR") {
306
+ continue;
307
+ }
308
+ const found = getFirstTextNode(child);
309
+ if (found) return found;
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * Place cursor at the start of a cell without selecting text
316
+ */
317
+ function focusCell(cell: HTMLTableCellElement): void {
318
+ const selection = window.getSelection();
319
+ if (!selection) return;
320
+
321
+ const range = document.createRange();
322
+
323
+ // Find first text node
324
+ const firstTextNode = getFirstTextNode(cell);
325
+
326
+ if (firstTextNode) {
327
+ // Place cursor at start of text node
328
+ range.setStart(firstTextNode, 0);
329
+ range.collapse(true); // Collapse to start, no selection
330
+ } else {
331
+ // Empty cell or only has BR - position at start of cell contents
332
+ range.selectNodeContents(cell);
333
+ range.collapse(true); // Collapse to start, no selection
334
+ }
335
+
336
+ selection.removeAllRanges();
337
+ selection.addRange(range);
338
+
339
+ // Ensure the cell element itself has focus for keyboard events
340
+ cell.focus();
341
+ }
342
+
343
+ /**
344
+ * Select all content in a cell
345
+ */
346
+ function selectCellContent(cell: HTMLTableCellElement): void {
347
+ const selection = window.getSelection();
348
+ if (!selection) return;
349
+
350
+ const range = document.createRange();
351
+ range.selectNodeContents(cell);
352
+ selection.removeAllRanges();
353
+ selection.addRange(range);
354
+ }
355
+
356
+ /**
357
+ * Dispatch an input event to trigger content sync
358
+ */
359
+ function dispatchInputEvent(element: HTMLElement): void {
360
+ element.dispatchEvent(new InputEvent("input", { bubbles: true }));
361
+ }
362
+
363
+ /**
364
+ * Get the current selection range if valid
365
+ */
366
+ function getCurrentSelectionRange(): Range | null {
367
+ const selection = window.getSelection();
368
+ if (!selection || !selection.rangeCount) return null;
369
+ return selection.getRangeAt(0);
370
+ }
371
+
372
+ /**
373
+ * Create a table cell with initial content
374
+ */
375
+ function createCell(isHeader: boolean): HTMLTableCellElement {
376
+ const cell = document.createElement(isHeader ? "th" : "td");
377
+ // Add a BR to make empty cell focusable
378
+ cell.appendChild(document.createElement("br"));
379
+ return cell;
380
+ }
381
+
382
+ /**
383
+ * Create a table row with specified number of cells
384
+ */
385
+ function createRow(colCount: number, isHeader: boolean): HTMLTableRowElement {
386
+ const row = document.createElement("tr");
387
+ for (let i = 0; i < colCount; i++) {
388
+ row.appendChild(createCell(isHeader));
389
+ }
390
+ return row;
391
+ }
392
+
393
+ /**
394
+ * Get the alignment of a column based on the first cell
395
+ */
396
+ function getColumnAlignment(table: HTMLTableElement, colIndex: number): "left" | "center" | "right" {
397
+ const rows = getAllTableRows(table);
398
+ if (rows.length === 0) return "left";
399
+
400
+ const cell = rows[0].cells[colIndex];
401
+ if (!cell) return "left";
402
+
403
+ const textAlign = cell.style.textAlign;
404
+ if (textAlign === "center") return "center";
405
+ if (textAlign === "right") return "right";
406
+ return "left";
407
+ }
408
+
409
+ /**
410
+ * Set alignment for all cells in a column
411
+ */
412
+ function setColumnAlignment(table: HTMLTableElement, colIndex: number, alignment: "left" | "center" | "right"): void {
413
+ const rows = getAllTableRows(table);
414
+ for (const row of rows) {
415
+ const cell = row.cells[colIndex];
416
+ if (cell) {
417
+ if (alignment === "left") {
418
+ cell.style.removeProperty("text-align");
419
+ } else {
420
+ cell.style.textAlign = alignment;
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Composable for table operations in markdown editor
428
+ */
429
+ export function useTables(options: UseTablesOptions): UseTablesReturn {
430
+ const { contentRef, onContentChange, onShowTablePopover } = options;
431
+
432
+ // Store the selection range so we can restore it after popover interaction
433
+ let savedRange: Range | null = null;
434
+
435
+ /**
436
+ * Save the current selection for later restoration
437
+ */
438
+ function saveSelection(): void {
439
+ const selection = window.getSelection();
440
+ if (selection && selection.rangeCount > 0) {
441
+ savedRange = selection.getRangeAt(0).cloneRange();
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Restore the previously saved selection
447
+ */
448
+ function restoreSelection(): void {
449
+ if (savedRange) {
450
+ const selection = window.getSelection();
451
+ selection?.removeAllRanges();
452
+ selection?.addRange(savedRange);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Check if cursor is inside a table
458
+ */
459
+ function isInTable(): boolean {
460
+ return getCurrentTable() !== null;
461
+ }
462
+
463
+ /**
464
+ * Check if cursor is inside a table cell
465
+ */
466
+ function isInTableCell(): boolean {
467
+ return getCurrentCell() !== null;
468
+ }
469
+
470
+ /**
471
+ * Get the current table element
472
+ */
473
+ function getCurrentTable(): HTMLTableElement | null {
474
+ if (!contentRef.value) return null;
475
+ const range = getCurrentSelectionRange();
476
+ if (!range) return null;
477
+ return findTableAncestor(range.startContainer, contentRef.value);
478
+ }
479
+
480
+ /**
481
+ * Get the current table cell element
482
+ */
483
+ function getCurrentCell(): HTMLTableCellElement | null {
484
+ if (!contentRef.value) return null;
485
+ const range = getCurrentSelectionRange();
486
+ if (!range) return null;
487
+ return findCellAncestor(range.startContainer, contentRef.value);
488
+ }
489
+
490
+ /**
491
+ * Get both current cell and table, or null if not in a table
492
+ */
493
+ function getCurrentCellAndTable(): { cell: HTMLTableCellElement; table: HTMLTableElement } | null {
494
+ const cell = getCurrentCell();
495
+ const table = getCurrentTable();
496
+ if (!cell || !table) return null;
497
+ return { cell, table };
498
+ }
499
+
500
+ /**
501
+ * Helper to notify content changes after table modifications
502
+ */
503
+ function notifyContentChange(): void {
504
+ if (contentRef.value) {
505
+ dispatchInputEvent(contentRef.value);
506
+ onContentChange();
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Insert a table - shows popover if callback provided, otherwise creates default 3x3
512
+ */
513
+ function insertTable(): void {
514
+ if (!contentRef.value) return;
515
+
516
+ saveSelection();
517
+ const position = getCursorPosition();
518
+
519
+ if (onShowTablePopover) {
520
+ onShowTablePopover({
521
+ position,
522
+ onSubmit: (rows: number, cols: number) => {
523
+ restoreSelection();
524
+ createTable(rows, cols);
525
+ },
526
+ onCancel: () => {
527
+ restoreSelection();
528
+ contentRef.value?.focus();
529
+ }
530
+ });
531
+ } else {
532
+ // Default to 3x3 table if no popover callback
533
+ createTable(3, 3);
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Create a table with specific dimensions
539
+ */
540
+ function createTable(rows: number, cols: number): void {
541
+ if (!contentRef.value) return;
542
+ if (rows < 1 || cols < 1) return;
543
+
544
+ const range = getCurrentSelectionRange();
545
+ if (!range) return;
546
+
547
+ // Check if selection is within our content area
548
+ if (!contentRef.value.contains(range.startContainer)) return;
549
+
550
+ // Create table structure
551
+ const table = document.createElement("table");
552
+
553
+ // Create thead with first row (headers)
554
+ const thead = document.createElement("thead");
555
+ thead.appendChild(createRow(cols, true));
556
+ table.appendChild(thead);
557
+
558
+ // Create tbody with remaining rows
559
+ if (rows > 1) {
560
+ const tbody = document.createElement("tbody");
561
+ for (let i = 1; i < rows; i++) {
562
+ tbody.appendChild(createRow(cols, false));
563
+ }
564
+ table.appendChild(tbody);
565
+ }
566
+
567
+ // Insert table at cursor position
568
+ // First, find a suitable insertion point
569
+ let insertionPoint: Node | null = range.startContainer;
570
+
571
+ // Walk up to find a block-level element
572
+ while (insertionPoint && insertionPoint !== contentRef.value) {
573
+ if (insertionPoint.nodeType === Node.ELEMENT_NODE) {
574
+ const tag = (insertionPoint as Element).tagName;
575
+ if (["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6"].includes(tag)) {
576
+ break;
577
+ }
578
+ }
579
+ insertionPoint = insertionPoint.parentNode;
580
+ }
581
+
582
+ if (insertionPoint && insertionPoint !== contentRef.value) {
583
+ // Insert table after the current block
584
+ insertionPoint.parentNode?.insertBefore(table, insertionPoint.nextSibling);
585
+ } else {
586
+ // Insert at end of content
587
+ contentRef.value.appendChild(table);
588
+ }
589
+
590
+ // Focus the first cell
591
+ const firstCell = table.querySelector("th, td") as HTMLTableCellElement | null;
592
+ if (firstCell) {
593
+ focusCell(firstCell);
594
+ }
595
+
596
+ notifyContentChange();
597
+ }
598
+
599
+ /**
600
+ * Navigate to the next cell (right, then next row)
601
+ * Returns false if at the end of the table
602
+ */
603
+ function navigateToNextCell(): boolean {
604
+ const context = getCurrentCellAndTable();
605
+ if (!context) return false;
606
+
607
+ const { cell, table } = context;
608
+ const { row, col } = getCellCoordinates(cell);
609
+ const rows = getAllTableRows(table);
610
+ const colCount = getColumnCount(table);
611
+
612
+ // Try next column
613
+ if (col + 1 < colCount) {
614
+ const nextCell = getCellAt(table, row, col + 1);
615
+ if (nextCell) {
616
+ selectCellContent(nextCell);
617
+ return true;
618
+ }
619
+ }
620
+
621
+ // Try first column of next row
622
+ if (row + 1 < rows.length) {
623
+ const nextCell = getCellAt(table, row + 1, 0);
624
+ if (nextCell) {
625
+ selectCellContent(nextCell);
626
+ return true;
627
+ }
628
+ }
629
+
630
+ return false;
631
+ }
632
+
633
+ /**
634
+ * Navigate to the previous cell
635
+ * Returns false if at the start of the table
636
+ */
637
+ function navigateToPreviousCell(): boolean {
638
+ const context = getCurrentCellAndTable();
639
+ if (!context) return false;
640
+
641
+ const { cell, table } = context;
642
+ const { row, col } = getCellCoordinates(cell);
643
+ const colCount = getColumnCount(table);
644
+
645
+ // Try previous column
646
+ if (col > 0) {
647
+ const prevCell = getCellAt(table, row, col - 1);
648
+ if (prevCell) {
649
+ selectCellContent(prevCell);
650
+ return true;
651
+ }
652
+ }
653
+
654
+ // Try last column of previous row
655
+ if (row > 0) {
656
+ const prevCell = getCellAt(table, row - 1, colCount - 1);
657
+ if (prevCell) {
658
+ selectCellContent(prevCell);
659
+ return true;
660
+ }
661
+ }
662
+
663
+ return false;
664
+ }
665
+
666
+ /**
667
+ * Navigate to the cell directly below
668
+ * Returns false if at the bottom row of the table
669
+ * Preserves cursor offset position from the source cell
670
+ */
671
+ function navigateToCellBelow(): boolean {
672
+ const context = getCurrentCellAndTable();
673
+ if (!context) return false;
674
+
675
+ const { cell, table } = context;
676
+
677
+ // Get current cursor offset BEFORE navigating
678
+ const cursorOffset = getCursorOffsetInCell(cell);
679
+
680
+ const { row, col } = getCellCoordinates(cell);
681
+ const rows = getAllTableRows(table);
682
+
683
+ if (row + 1 < rows.length) {
684
+ const belowCell = getCellAt(table, row + 1, col);
685
+ if (belowCell) {
686
+ // Set cursor at same offset in target cell (clamped to cell length)
687
+ setCursorOffsetInCell(belowCell, cursorOffset);
688
+ return true;
689
+ }
690
+ }
691
+
692
+ return false;
693
+ }
694
+
695
+ /**
696
+ * Navigate to the cell directly above
697
+ * Returns false if at the top row of the table
698
+ * Preserves cursor offset position from the source cell
699
+ */
700
+ function navigateToCellAbove(): boolean {
701
+ const context = getCurrentCellAndTable();
702
+ if (!context) return false;
703
+
704
+ const { cell, table } = context;
705
+
706
+ // Get current cursor offset BEFORE navigating
707
+ const cursorOffset = getCursorOffsetInCell(cell);
708
+
709
+ const { row, col } = getCellCoordinates(cell);
710
+
711
+ if (row > 0) {
712
+ const aboveCell = getCellAt(table, row - 1, col);
713
+ if (aboveCell) {
714
+ // Set cursor at same offset in target cell (clamped to cell length)
715
+ setCursorOffsetInCell(aboveCell, cursorOffset);
716
+ return true;
717
+ }
718
+ }
719
+
720
+ return false;
721
+ }
722
+
723
+ /**
724
+ * Insert a new row above the current row
725
+ */
726
+ function insertRowAbove(): void {
727
+ const context = getCurrentCellAndTable();
728
+ if (!context) return;
729
+
730
+ const { cell } = context;
731
+ const row = cell.parentElement as HTMLTableRowElement | null;
732
+ if (!row) return;
733
+
734
+ const colCount = row.cells.length;
735
+
736
+ // Create new row (use TD cells even if inserting above header)
737
+ const newRow = createRow(colCount, false);
738
+
739
+ // Insert new row
740
+ row.parentNode?.insertBefore(newRow, row);
741
+
742
+ // Focus the first cell of the new row
743
+ const firstNewCell = newRow.cells[0];
744
+ if (firstNewCell) {
745
+ focusCell(firstNewCell);
746
+ }
747
+
748
+ notifyContentChange();
749
+ }
750
+
751
+ /**
752
+ * Insert a new row below the current row
753
+ */
754
+ function insertRowBelow(): void {
755
+ const context = getCurrentCellAndTable();
756
+ if (!context) return;
757
+
758
+ const { cell, table } = context;
759
+ const row = cell.parentElement as HTMLTableRowElement | null;
760
+ if (!row) return;
761
+
762
+ const colCount = row.cells.length;
763
+ const isInHeader = row.parentElement?.tagName === "THEAD";
764
+
765
+ // Create new row with TD cells
766
+ const newRow = createRow(colCount, false);
767
+
768
+ // If inserting below header row, insert into tbody
769
+ if (isInHeader) {
770
+ // Ensure tbody exists
771
+ let tbody = table.tBodies[0];
772
+ if (!tbody) {
773
+ tbody = document.createElement("tbody");
774
+ table.appendChild(tbody);
775
+ }
776
+ // Insert at beginning of tbody
777
+ tbody.insertBefore(newRow, tbody.firstChild);
778
+ } else {
779
+ // Insert after current row
780
+ row.parentNode?.insertBefore(newRow, row.nextSibling);
781
+ }
782
+
783
+ // Focus the first cell of the new row
784
+ const firstNewCell = newRow.cells[0];
785
+ if (firstNewCell) {
786
+ focusCell(firstNewCell);
787
+ }
788
+
789
+ notifyContentChange();
790
+ }
791
+
792
+ /**
793
+ * Delete the current row
794
+ */
795
+ function deleteCurrentRow(): void {
796
+ const context = getCurrentCellAndTable();
797
+ if (!context) return;
798
+
799
+ const { cell, table } = context;
800
+ const row = cell.parentElement as HTMLTableRowElement | null;
801
+ if (!row) return;
802
+
803
+ const rows = getAllTableRows(table);
804
+
805
+ // If this is the last row, delete the entire table
806
+ if (rows.length <= 1) {
807
+ deleteTable();
808
+ return;
809
+ }
810
+
811
+ const { row: rowIndex, col } = getCellCoordinates(cell);
812
+
813
+ // Remove the row
814
+ row.remove();
815
+
816
+ // Focus a cell in an adjacent row
817
+ const newRows = getAllTableRows(table);
818
+ const targetRowIndex = Math.min(rowIndex, newRows.length - 1);
819
+ const targetCell = getCellAt(table, targetRowIndex, col);
820
+
821
+ if (targetCell) {
822
+ focusCell(targetCell);
823
+ }
824
+
825
+ notifyContentChange();
826
+ }
827
+
828
+ /**
829
+ * Insert a new column to the left
830
+ */
831
+ function insertColumnLeft(): void {
832
+ const context = getCurrentCellAndTable();
833
+ if (!context) return;
834
+
835
+ const { cell, table } = context;
836
+ const { col } = getCellCoordinates(cell);
837
+ const rows = getAllTableRows(table);
838
+
839
+ // Insert a cell in each row at the specified column
840
+ for (const row of rows) {
841
+ const isHeader = row.parentElement?.tagName === "THEAD";
842
+ const newCell = createCell(isHeader);
843
+ const referenceCell = row.cells[col];
844
+ if (referenceCell) {
845
+ row.insertBefore(newCell, referenceCell);
846
+ } else {
847
+ row.appendChild(newCell);
848
+ }
849
+ }
850
+
851
+ // Focus the new cell in the current row
852
+ const currentRow = cell.parentElement as HTMLTableRowElement;
853
+ const newCell = currentRow?.cells[col];
854
+ if (newCell) {
855
+ focusCell(newCell);
856
+ }
857
+
858
+ notifyContentChange();
859
+ }
860
+
861
+ /**
862
+ * Insert a new column to the right
863
+ */
864
+ function insertColumnRight(): void {
865
+ const context = getCurrentCellAndTable();
866
+ if (!context) return;
867
+
868
+ const { cell, table } = context;
869
+ const { col } = getCellCoordinates(cell);
870
+ const rows = getAllTableRows(table);
871
+
872
+ // Insert a cell in each row after the specified column
873
+ for (const row of rows) {
874
+ const isHeader = row.parentElement?.tagName === "THEAD";
875
+ const newCell = createCell(isHeader);
876
+ const referenceCell = row.cells[col + 1];
877
+ if (referenceCell) {
878
+ row.insertBefore(newCell, referenceCell);
879
+ } else {
880
+ row.appendChild(newCell);
881
+ }
882
+ }
883
+
884
+ // Focus the new cell in the current row
885
+ const currentRow = cell.parentElement as HTMLTableRowElement;
886
+ const newCell = currentRow?.cells[col + 1];
887
+ if (newCell) {
888
+ focusCell(newCell);
889
+ }
890
+
891
+ notifyContentChange();
892
+ }
893
+
894
+ /**
895
+ * Delete the current column
896
+ */
897
+ function deleteCurrentColumn(): void {
898
+ const context = getCurrentCellAndTable();
899
+ if (!context) return;
900
+
901
+ const { cell, table } = context;
902
+ const { row: rowIndex, col } = getCellCoordinates(cell);
903
+ const colCount = getColumnCount(table);
904
+ const rows = getAllTableRows(table);
905
+
906
+ // If this is the last column, delete the entire table
907
+ if (colCount <= 1) {
908
+ deleteTable();
909
+ return;
910
+ }
911
+
912
+ // Remove the cell from each row
913
+ for (const row of rows) {
914
+ const cellToRemove = row.cells[col];
915
+ if (cellToRemove) {
916
+ cellToRemove.remove();
917
+ }
918
+ }
919
+
920
+ // Focus a cell in an adjacent column
921
+ const targetColIndex = Math.min(col, colCount - 2);
922
+ const targetCell = getCellAt(table, rowIndex, targetColIndex);
923
+
924
+ if (targetCell) {
925
+ focusCell(targetCell);
926
+ }
927
+
928
+ notifyContentChange();
929
+ }
930
+
931
+ /**
932
+ * Delete the entire table
933
+ */
934
+ function deleteTable(): void {
935
+ if (!contentRef.value) return;
936
+
937
+ const table = getCurrentTable();
938
+ if (!table) return;
939
+
940
+ // Get the next sibling to focus after deletion
941
+ const nextSibling = table.nextElementSibling;
942
+ const prevSibling = table.previousElementSibling;
943
+
944
+ // Remove the table
945
+ table.remove();
946
+
947
+ // Try to focus next/previous element or create a paragraph
948
+ if (nextSibling && (nextSibling as HTMLElement).focus) {
949
+ const focusable = nextSibling.querySelector("[contenteditable], input, textarea") as HTMLElement
950
+ || nextSibling as HTMLElement;
951
+ if (focusable.focus) {
952
+ focusable.focus();
953
+ }
954
+ } else if (prevSibling && (prevSibling as HTMLElement).focus) {
955
+ const focusable = prevSibling.querySelector("[contenteditable], input, textarea") as HTMLElement
956
+ || prevSibling as HTMLElement;
957
+ if (focusable.focus) {
958
+ focusable.focus();
959
+ }
960
+ } else {
961
+ // Create a paragraph if the content area is now empty
962
+ if (contentRef.value.children.length === 0) {
963
+ const p = document.createElement("p");
964
+ p.appendChild(document.createElement("br"));
965
+ contentRef.value.appendChild(p);
966
+ focusCell(p as unknown as HTMLTableCellElement);
967
+ }
968
+ }
969
+
970
+ notifyContentChange();
971
+ }
972
+
973
+ /**
974
+ * Set column alignment to left
975
+ */
976
+ function setColumnAlignmentLeft(): void {
977
+ const context = getCurrentCellAndTable();
978
+ if (!context) return;
979
+
980
+ const { cell, table } = context;
981
+ const { col } = getCellCoordinates(cell);
982
+ setColumnAlignment(table, col, "left");
983
+ notifyContentChange();
984
+ }
985
+
986
+ /**
987
+ * Set column alignment to center
988
+ */
989
+ function setColumnAlignmentCenter(): void {
990
+ const context = getCurrentCellAndTable();
991
+ if (!context) return;
992
+
993
+ const { cell, table } = context;
994
+ const { col } = getCellCoordinates(cell);
995
+ setColumnAlignment(table, col, "center");
996
+ notifyContentChange();
997
+ }
998
+
999
+ /**
1000
+ * Set column alignment to right
1001
+ */
1002
+ function setColumnAlignmentRight(): void {
1003
+ const context = getCurrentCellAndTable();
1004
+ if (!context) return;
1005
+
1006
+ const { cell, table } = context;
1007
+ const { col } = getCellCoordinates(cell);
1008
+ setColumnAlignment(table, col, "right");
1009
+ notifyContentChange();
1010
+ }
1011
+
1012
+ /**
1013
+ * Handle Tab key in table
1014
+ * - Tab: navigate to next cell, create new row if at end
1015
+ * - Shift+Tab: navigate to previous cell, exit table if at start
1016
+ * @returns true if handled, false to let browser handle it
1017
+ */
1018
+ function handleTableTab(shift: boolean): boolean {
1019
+ if (!isInTableCell()) return false;
1020
+
1021
+ if (shift) {
1022
+ // Shift+Tab: go to previous cell
1023
+ const moved = navigateToPreviousCell();
1024
+ if (!moved) {
1025
+ // At start of table - exit table (return false to let browser handle)
1026
+ return false;
1027
+ }
1028
+ return true;
1029
+ } else {
1030
+ // Tab: go to next cell
1031
+ const moved = navigateToNextCell();
1032
+ if (!moved) {
1033
+ // At end of table - create new row
1034
+ insertRowBelow();
1035
+ const table = getCurrentTable();
1036
+ if (table) {
1037
+ const rows = getAllTableRows(table);
1038
+ const lastRow = rows[rows.length - 1];
1039
+ if (lastRow && lastRow.cells[0]) {
1040
+ focusCell(lastRow.cells[0]);
1041
+ }
1042
+ }
1043
+ }
1044
+ return true;
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * Handle Enter key in table
1050
+ * - Moves to cell directly below
1051
+ * - Creates new row if at bottom
1052
+ * @returns true if handled, false to let browser handle it
1053
+ */
1054
+ function handleTableEnter(): boolean {
1055
+ if (!isInTableCell()) return false;
1056
+
1057
+ const moved = navigateToCellBelow();
1058
+ if (!moved) {
1059
+ // At bottom of table - create new row
1060
+ insertRowBelow();
1061
+ // Navigate to the new row
1062
+ navigateToCellBelow();
1063
+ }
1064
+
1065
+ return true;
1066
+ }
1067
+
1068
+ return {
1069
+ // Creation
1070
+ insertTable,
1071
+ createTable,
1072
+
1073
+ // Detection
1074
+ isInTable,
1075
+ isInTableCell,
1076
+ getCurrentTable,
1077
+ getCurrentCell,
1078
+
1079
+ // Navigation
1080
+ navigateToNextCell,
1081
+ navigateToPreviousCell,
1082
+ navigateToCellBelow,
1083
+ navigateToCellAbove,
1084
+
1085
+ // Row operations
1086
+ insertRowAbove,
1087
+ insertRowBelow,
1088
+ deleteCurrentRow,
1089
+
1090
+ // Column operations
1091
+ insertColumnLeft,
1092
+ insertColumnRight,
1093
+ deleteCurrentColumn,
1094
+
1095
+ // Table operations
1096
+ deleteTable,
1097
+
1098
+ // Alignment
1099
+ setColumnAlignmentLeft,
1100
+ setColumnAlignmentCenter,
1101
+ setColumnAlignmentRight,
1102
+
1103
+ // Key handlers
1104
+ handleTableTab,
1105
+ handleTableEnter
1106
+ };
1107
+ }