@editora/plugin-table 1.0.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.
@@ -0,0 +1,1053 @@
1
+ import { Plugin } from '@editora/core';
2
+ import { findEditorContainerFromSelection, getContentElement } from '../../shared/editorContainerHelpers';
3
+ import './table.css';
4
+
5
+ /**
6
+ * Advanced Table Plugin - Native Implementation
7
+ *
8
+ * Exactly matches React version functionality:
9
+ * - Direct table insertion (3x3 with thead/tbody) - NO DIALOG
10
+ * - Floating contextual toolbar (appears when cursor in table)
11
+ * - 10 table operations (rows, columns, headers, merge, delete)
12
+ * - Column resizing with drag handles
13
+ * - Table-level resizing
14
+ * - Keyboard shortcuts (Ctrl+Shift+R, Ctrl+Shift+C)
15
+ */
16
+
17
+ // ============================================
18
+ // MODULE-LEVEL STATE
19
+ // ============================================
20
+
21
+ let toolbarElement: HTMLDivElement | null = null;
22
+ let currentTable: HTMLTableElement | null = null;
23
+ let selectionChangeHandler: (() => void) | null = null;
24
+ let mouseDownHandler: ((e: MouseEvent) => void) | null = null;
25
+ let tableDeletedHandler: (() => void) | null = null;
26
+ let scrollHandler: (() => void) | null = null;
27
+ let resizeHandler: (() => void) | null = null;
28
+ const DARK_THEME_SELECTOR = '[data-theme="dark"], .dark, .editora-theme-dark';
29
+
30
+ // Column resizing state
31
+ let isResizing = false;
32
+ let resizeColumn: number | null = null;
33
+ let startX = 0;
34
+ let startWidth = 0;
35
+
36
+ // Table resizing state
37
+ let isTableResizing = false;
38
+ let tableStartX = 0;
39
+ let tableStartY = 0;
40
+ let tableStartWidth = 0;
41
+ let tableStartHeight = 0;
42
+
43
+ declare global {
44
+ interface Window {
45
+ __tablePluginInitialized?: boolean;
46
+ }
47
+ }
48
+
49
+ // ============================================
50
+ // TABLE INSERTION - Direct (NO DIALOG)
51
+ // ============================================
52
+
53
+ export const insertTableCommand = () => {
54
+
55
+ // Find editor container from current selection instead of activeElement
56
+ const editorContainer = findEditorContainerFromSelection();
57
+
58
+ const contentEl = getContentElement(editorContainer);
59
+
60
+ if (!contentEl) {
61
+ alert('Please place your cursor in the editor before inserting a table');
62
+ return false;
63
+ }
64
+
65
+ const selection = window.getSelection();
66
+ if (!selection || selection.rangeCount === 0) return;
67
+
68
+ const range = selection.getRangeAt(0);
69
+
70
+ // Create table
71
+ const table = document.createElement('table');
72
+ table.className = 'rte-table';
73
+
74
+ // ===== THEAD =====
75
+ const thead = document.createElement('thead');
76
+ const headerRow = document.createElement('tr');
77
+
78
+ for (let i = 0; i < 3; i++) {
79
+ const th = document.createElement('th');
80
+ const p = document.createElement('p');
81
+ p.appendChild(document.createElement('br'));
82
+ th.appendChild(p);
83
+ headerRow.appendChild(th);
84
+ }
85
+
86
+ thead.appendChild(headerRow);
87
+
88
+ // ===== TBODY =====
89
+ const tbody = document.createElement('tbody');
90
+
91
+ for (let rowIndex = 0; rowIndex < 2; rowIndex++) {
92
+ const row = document.createElement('tr');
93
+
94
+ for (let colIndex = 0; colIndex < 3; colIndex++) {
95
+ const td = document.createElement('td');
96
+ const p = document.createElement('p');
97
+ p.appendChild(document.createElement('br'));
98
+ td.appendChild(p);
99
+ row.appendChild(td);
100
+ }
101
+
102
+ tbody.appendChild(row);
103
+ }
104
+
105
+ table.appendChild(thead);
106
+ table.appendChild(tbody);
107
+
108
+ // Insert table
109
+ range.deleteContents();
110
+ range.insertNode(table);
111
+
112
+ // Move cursor to first header cell paragraph
113
+ const firstParagraph = table.querySelector('th p');
114
+ if (firstParagraph) {
115
+ const newRange = document.createRange();
116
+ newRange.setStart(firstParagraph, 0);
117
+ newRange.collapse(true);
118
+
119
+ selection.removeAllRanges();
120
+ selection.addRange(newRange);
121
+ }
122
+
123
+ contentEl.focus();
124
+ };
125
+
126
+ // ============================================
127
+ // TABLE OPERATIONS (10 COMMANDS)
128
+ // ============================================
129
+
130
+ export const addRowAboveCommand = () => {
131
+ const tableInfo = getTableInfoFromDOM();
132
+ if (!tableInfo) return;
133
+
134
+ const { table, rowIndex } = tableInfo;
135
+
136
+ // Create new row with same number of cells
137
+ const newRow = document.createElement('tr');
138
+ const cellCount = table.rows[0]?.cells.length || 0;
139
+
140
+ for (let i = 0; i < cellCount; i++) {
141
+ const cell = document.createElement('td');
142
+ const paragraph = document.createElement('p');
143
+ paragraph.innerHTML = '<br>';
144
+ cell.appendChild(paragraph);
145
+ newRow.appendChild(cell);
146
+ }
147
+
148
+ // Find the correct tbody/thead element and insert row
149
+ const currentRow = table.rows[rowIndex];
150
+ if (currentRow && currentRow.parentElement) {
151
+ currentRow.parentElement.insertBefore(newRow, currentRow);
152
+ } else {
153
+ table.appendChild(newRow);
154
+ }
155
+
156
+ updateTableInfo();
157
+ };
158
+
159
+ export const addRowBelowCommand = () => {
160
+ const tableInfo = getTableInfoFromDOM();
161
+ if (!tableInfo) return;
162
+
163
+ const { table, rowIndex } = tableInfo;
164
+
165
+ // Create new row with same number of cells
166
+ const newRow = document.createElement('tr');
167
+ const cellCount = table.rows[0]?.cells.length || 0;
168
+
169
+ for (let i = 0; i < cellCount; i++) {
170
+ const cell = document.createElement('td');
171
+ const paragraph = document.createElement('p');
172
+ paragraph.innerHTML = '<br>';
173
+ cell.appendChild(paragraph);
174
+ newRow.appendChild(cell);
175
+ }
176
+
177
+ // Insert row after current position
178
+ if (rowIndex >= table.rows.length - 1) {
179
+ table.appendChild(newRow);
180
+ } else {
181
+ table.insertBefore(newRow, table.rows[rowIndex + 1]);
182
+ }
183
+
184
+ updateTableInfo();
185
+ };
186
+
187
+ export const addColumnLeftCommand = () => {
188
+ const tableInfo = getTableInfoFromDOM();
189
+ if (!tableInfo) return;
190
+
191
+ const { table, colIndex } = tableInfo;
192
+
193
+ // Add cell to each row at specified column index
194
+ for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex++) {
195
+ const row = table.rows[rowIndex];
196
+ const cell = document.createElement('td');
197
+ const paragraph = document.createElement('p');
198
+ paragraph.innerHTML = '<br>';
199
+ cell.appendChild(paragraph);
200
+
201
+ if (colIndex === 0) {
202
+ row.insertBefore(cell, row.cells[0]);
203
+ } else {
204
+ row.insertBefore(cell, row.cells[colIndex]);
205
+ }
206
+ }
207
+
208
+ updateTableInfo();
209
+ };
210
+
211
+ export const addColumnRightCommand = () => {
212
+ const tableInfo = getTableInfoFromDOM();
213
+ if (!tableInfo) return;
214
+
215
+ const { table, colIndex } = tableInfo;
216
+
217
+ // Add cell to each row after specified column index
218
+ for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex++) {
219
+ const row = table.rows[rowIndex];
220
+ const cell = document.createElement('td');
221
+ const paragraph = document.createElement('p');
222
+ paragraph.innerHTML = '<br>';
223
+ cell.appendChild(paragraph);
224
+
225
+ if (colIndex >= row.cells.length - 1) {
226
+ row.appendChild(cell);
227
+ } else {
228
+ row.insertBefore(cell, row.cells[colIndex + 1]);
229
+ }
230
+ }
231
+
232
+ updateTableInfo();
233
+ };
234
+
235
+ export const deleteRowCommand = () => {
236
+ const tableInfo = getTableInfoFromDOM();
237
+ if (!tableInfo || tableInfo.rowCount <= 1) return;
238
+
239
+ const { table, rowIndex } = tableInfo;
240
+ table.deleteRow(rowIndex);
241
+
242
+ updateTableInfo();
243
+ };
244
+
245
+ export const deleteColumnCommand = () => {
246
+ const tableInfo = getTableInfoFromDOM();
247
+ if (!tableInfo || tableInfo.cellCount <= 1) return;
248
+
249
+ const { table, colIndex } = tableInfo;
250
+
251
+ // Delete cell from each row at specified column index
252
+ for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex++) {
253
+ const row = table.rows[rowIndex];
254
+ if (row.cells[colIndex]) {
255
+ row.deleteCell(colIndex);
256
+ }
257
+ }
258
+
259
+ updateTableInfo();
260
+ };
261
+
262
+ export const toggleHeaderRowCommand = () => {
263
+ const tableInfo = getTableInfoFromDOM();
264
+ if (!tableInfo) return;
265
+
266
+ const { table, rowIndex } = tableInfo;
267
+ const targetRow = table.rows[rowIndex];
268
+
269
+ const isCurrentlyHeader = targetRow.parentElement?.tagName.toLowerCase() === 'thead';
270
+
271
+ if (isCurrentlyHeader) {
272
+ const tbody = table.querySelector('tbody') || table.appendChild(document.createElement('tbody'));
273
+ const thead = table.querySelector('thead');
274
+ if (thead) {
275
+ tbody.insertBefore(targetRow, tbody.firstChild);
276
+ if (thead.rows.length === 0) {
277
+ thead.remove();
278
+ }
279
+ }
280
+ } else {
281
+ let thead = table.querySelector('thead');
282
+ if (!thead) {
283
+ thead = document.createElement('thead');
284
+ table.insertBefore(thead, table.firstChild);
285
+ }
286
+ thead.appendChild(targetRow);
287
+ }
288
+
289
+ updateTableInfo();
290
+ };
291
+
292
+ export const toggleHeaderColumnCommand = () => {
293
+ const tableInfo = getTableInfoFromDOM();
294
+ if (!tableInfo) return;
295
+
296
+ const { table, colIndex } = tableInfo;
297
+
298
+ for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex++) {
299
+ const cell = table.rows[rowIndex].cells[colIndex];
300
+ if (cell) {
301
+ const newTag = cell.tagName.toLowerCase() === 'th' ? 'td' : 'th';
302
+ const newCell = document.createElement(newTag);
303
+ newCell.innerHTML = cell.innerHTML;
304
+
305
+ for (let i = 0; i < cell.attributes.length; i++) {
306
+ const attr = cell.attributes[i];
307
+ newCell.setAttribute(attr.name, attr.value);
308
+ }
309
+
310
+ cell.parentNode?.replaceChild(newCell, cell);
311
+ }
312
+ }
313
+
314
+ updateTableInfo();
315
+ };
316
+
317
+ export const deleteTableCommand = () => {
318
+ const tableInfo = getTableInfoFromDOM();
319
+ if (!tableInfo) return;
320
+
321
+ const table = tableInfo.table;
322
+ table.remove();
323
+
324
+ // Trigger toolbar hide event
325
+ document.dispatchEvent(new CustomEvent('tableDeleted'));
326
+ };
327
+
328
+ export const mergeCellsCommand = () => {
329
+ const selection = window.getSelection();
330
+ if (!selection || selection.rangeCount === 0) return;
331
+
332
+ const range = selection.getRangeAt(0);
333
+ const startContainer = range.startContainer;
334
+
335
+ let tableElement = startContainer.nodeType === Node.TEXT_NODE
336
+ ? startContainer.parentElement?.closest('table')
337
+ : (startContainer as Element).closest('table');
338
+
339
+ if (!tableElement) return;
340
+
341
+ const table = tableElement as HTMLTableElement;
342
+
343
+ let firstCell: HTMLTableCellElement | null = null;
344
+ if (startContainer.nodeType === Node.TEXT_NODE) {
345
+ firstCell = startContainer.parentElement?.closest('td, th') as HTMLTableCellElement;
346
+ } else if (startContainer.nodeType === Node.ELEMENT_NODE) {
347
+ firstCell = (startContainer as Element).closest('td, th') as HTMLTableCellElement;
348
+ }
349
+
350
+ if (!firstCell) return;
351
+
352
+ const firstRow = firstCell.parentElement as HTMLTableRowElement;
353
+ if (!firstRow) return;
354
+
355
+ let cellIndex = -1;
356
+ for (let i = 0; i < firstRow.cells.length; i++) {
357
+ if (firstRow.cells[i] === firstCell) {
358
+ cellIndex = i;
359
+ break;
360
+ }
361
+ }
362
+
363
+ if (cellIndex === -1 || cellIndex === firstRow.cells.length - 1) return;
364
+
365
+ const secondCell = firstRow.cells[cellIndex + 1];
366
+ if (!secondCell) return;
367
+
368
+ const colspan1 = parseInt(firstCell.getAttribute('colspan') || '1');
369
+ const colspan2 = parseInt(secondCell.getAttribute('colspan') || '1');
370
+ firstCell.setAttribute('colspan', String(colspan1 + colspan2));
371
+
372
+ const secondCellContent = Array.from(secondCell.childNodes);
373
+ secondCellContent.forEach(node => {
374
+ firstCell.appendChild(node);
375
+ });
376
+
377
+ secondCell.remove();
378
+
379
+ updateTableInfo();
380
+ };
381
+
382
+ // ============================================
383
+ // UTILITY FUNCTIONS
384
+ // ============================================
385
+
386
+ interface TableDOMInfo {
387
+ table: HTMLTableElement;
388
+ rowIndex: number;
389
+ colIndex: number;
390
+ rowCount: number;
391
+ cellCount: number;
392
+ }
393
+
394
+ function getTableInfoFromDOM(): TableDOMInfo | null {
395
+ const selection = window.getSelection();
396
+ if (!selection || selection.rangeCount === 0) return null;
397
+
398
+ const range = selection.getRangeAt(0);
399
+ const startContainer = range.startContainer;
400
+
401
+ let tableElement = startContainer.nodeType === Node.TEXT_NODE
402
+ ? startContainer.parentElement?.closest('table')
403
+ : (startContainer as Element).closest('table');
404
+
405
+ if (!tableElement) return null;
406
+
407
+ const table = tableElement as HTMLTableElement;
408
+
409
+ let rowIndex = 0;
410
+ let colIndex = 0;
411
+
412
+ const cellElement = startContainer.nodeType === Node.TEXT_NODE
413
+ ? startContainer.parentElement?.closest('td, th')
414
+ : (startContainer as Element).closest('td, th');
415
+
416
+ if (cellElement) {
417
+ let currentRow = cellElement.parentElement as HTMLTableRowElement;
418
+ while (currentRow && currentRow !== table.rows[rowIndex]) {
419
+ rowIndex++;
420
+ if (rowIndex >= table.rows.length) break;
421
+ }
422
+
423
+ const row = currentRow;
424
+ if (row) {
425
+ for (let i = 0; i < row.cells.length; i++) {
426
+ if (row.cells[i] === cellElement) {
427
+ colIndex = i;
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ return {
435
+ table,
436
+ rowIndex,
437
+ colIndex,
438
+ rowCount: table.rows.length,
439
+ cellCount: table.rows[0]?.cells.length || 0
440
+ };
441
+ }
442
+
443
+ function updateTableInfo(): void {
444
+ if (!toolbarElement || !currentTable) return;
445
+
446
+ const tableInfo = getTableInfoFromDOM();
447
+ if (!tableInfo) return;
448
+
449
+ const canDeleteRow = tableInfo.rowCount > 1;
450
+ const canDeleteColumn = tableInfo.cellCount > 1;
451
+
452
+ updateToolbarButtonStates(canDeleteRow, canDeleteColumn);
453
+ }
454
+
455
+ // ============================================
456
+ // FLOATING TOOLBAR MANAGEMENT
457
+ // ============================================
458
+
459
+ function initTableToolbar(): void {
460
+ selectionChangeHandler = () => {
461
+ const tableInfo = getTableInfoFromDOM();
462
+ if (tableInfo) {
463
+ showTableToolbar(tableInfo.table);
464
+ } else {
465
+ hideTableToolbar();
466
+ }
467
+ };
468
+
469
+ mouseDownHandler = (e: MouseEvent) => {
470
+ const target = e.target as Element;
471
+ const isInsideTable = target.closest('table');
472
+ const isInsideToolbar = target.closest('.table-toolbar');
473
+
474
+ if (!isInsideTable && !isInsideToolbar) {
475
+ hideTableToolbar();
476
+ }
477
+ };
478
+
479
+ tableDeletedHandler = () => {
480
+ hideTableToolbar();
481
+ };
482
+
483
+ scrollHandler = () => {
484
+ if (currentTable && toolbarElement && toolbarElement.style.display !== 'none') {
485
+ updateToolbarPosition(currentTable);
486
+ }
487
+ };
488
+
489
+ resizeHandler = () => {
490
+ if (currentTable && toolbarElement && toolbarElement.style.display !== 'none') {
491
+ updateToolbarPosition(currentTable);
492
+ }
493
+ };
494
+
495
+ document.addEventListener('selectionchange', selectionChangeHandler);
496
+ document.addEventListener('mousedown', mouseDownHandler);
497
+ document.addEventListener('tableDeleted', tableDeletedHandler as EventListener);
498
+ window.addEventListener('scroll', scrollHandler, true); // Use capture to catch all scroll events
499
+ window.addEventListener('resize', resizeHandler);
500
+ }
501
+
502
+ function cleanupTableToolbar(): void {
503
+ if (selectionChangeHandler) {
504
+ document.removeEventListener('selectionchange', selectionChangeHandler);
505
+ }
506
+ if (mouseDownHandler) {
507
+ document.removeEventListener('mousedown', mouseDownHandler);
508
+ }
509
+ if (tableDeletedHandler) {
510
+ document.removeEventListener('tableDeleted', tableDeletedHandler as EventListener);
511
+ }
512
+ if (scrollHandler) {
513
+ window.removeEventListener('scroll', scrollHandler, true);
514
+ }
515
+ if (resizeHandler) {
516
+ window.removeEventListener('resize', resizeHandler);
517
+ }
518
+
519
+ hideTableToolbar();
520
+ }
521
+
522
+ function updateToolbarPosition(table: HTMLTableElement): void {
523
+ if (!toolbarElement) return;
524
+
525
+ const rect = table.getBoundingClientRect();
526
+ const toolbarRect = toolbarElement.getBoundingClientRect();
527
+ const toolbarHeight = toolbarRect.height || 40;
528
+ const toolbarWidth = toolbarRect.width || 280;
529
+ const padding = 10;
530
+
531
+ // Smart viewport collision detection
532
+ let top = rect.top - toolbarHeight - padding;
533
+ let left = rect.left + (rect.width / 2) - (toolbarWidth / 2);
534
+
535
+ // Adjust if off-screen (top) - show below if no room above
536
+ if (top < padding) {
537
+ top = rect.bottom + padding;
538
+ }
539
+
540
+ // Adjust if off-screen (left)
541
+ if (left < padding) {
542
+ left = padding;
543
+ }
544
+
545
+ // Adjust if off-screen (right)
546
+ const viewportWidth = window.innerWidth;
547
+ if (left + toolbarWidth > viewportWidth - padding) {
548
+ left = viewportWidth - toolbarWidth - padding;
549
+ }
550
+
551
+ // Adjust if off-screen (bottom)
552
+ const viewportHeight = window.innerHeight;
553
+ if (top + toolbarHeight > viewportHeight - padding) {
554
+ top = viewportHeight - toolbarHeight - padding;
555
+ }
556
+
557
+ toolbarElement.style.top = top + 'px';
558
+ toolbarElement.style.left = left + 'px';
559
+ }
560
+
561
+ function showTableToolbar(table: HTMLTableElement): void {
562
+ currentTable = table;
563
+
564
+ if (!toolbarElement) {
565
+ toolbarElement = createTableToolbar();
566
+ document.body.appendChild(toolbarElement);
567
+ }
568
+
569
+ const isDarkTheme =
570
+ !!table.closest(DARK_THEME_SELECTOR) ||
571
+ document.body.matches(DARK_THEME_SELECTOR) ||
572
+ document.documentElement.matches(DARK_THEME_SELECTOR);
573
+ toolbarElement.classList.toggle('rte-theme-dark', isDarkTheme);
574
+
575
+ // Make toolbar visible temporarily to measure its dimensions
576
+ toolbarElement.style.display = 'flex';
577
+ toolbarElement.style.visibility = 'hidden';
578
+
579
+ // Small delay to ensure toolbar is rendered
580
+ requestAnimationFrame(() => {
581
+ updateToolbarPosition(table);
582
+ if (toolbarElement) {
583
+ toolbarElement.style.visibility = 'visible';
584
+ }
585
+ });
586
+
587
+ // Update button states
588
+ const tableInfo = getTableInfoFromDOM();
589
+ if (tableInfo) {
590
+ updateToolbarButtonStates(tableInfo.rowCount > 1, tableInfo.cellCount > 1);
591
+ }
592
+
593
+ // Attach resize handles
594
+ attachResizeHandles(table);
595
+ }
596
+
597
+ function hideTableToolbar(): void {
598
+ if (toolbarElement) {
599
+ toolbarElement.style.display = 'none';
600
+ }
601
+
602
+ // Remove resize handles
603
+ if (currentTable) {
604
+ const handles = currentTable.querySelectorAll('.resize-handle');
605
+ handles.forEach(handle => handle.remove());
606
+
607
+ const tableResizeHandle = currentTable.querySelector('.table-resize-handle');
608
+ if (tableResizeHandle) {
609
+ tableResizeHandle.remove();
610
+ }
611
+ }
612
+
613
+ currentTable = null;
614
+ }
615
+
616
+ function updateToolbarButtonStates(canDeleteRow: boolean, canDeleteColumn: boolean): void {
617
+ if (!toolbarElement) return;
618
+
619
+ const deleteRowBtn = toolbarElement.querySelector('[data-action="deleteRow"]') as HTMLButtonElement;
620
+ const deleteColBtn = toolbarElement.querySelector('[data-action="deleteColumn"]') as HTMLButtonElement;
621
+
622
+ if (deleteRowBtn) deleteRowBtn.disabled = !canDeleteRow;
623
+ if (deleteColBtn) deleteColBtn.disabled = !canDeleteColumn;
624
+ }
625
+
626
+ function createTableToolbar(): HTMLDivElement {
627
+ const toolbar = document.createElement('div');
628
+ toolbar.className = 'table-toolbar';
629
+ toolbar.style.cssText = `
630
+ position: fixed;
631
+ z-index: 1000;
632
+ display: none;
633
+ `;
634
+ toolbar.setAttribute('role', 'toolbar');
635
+ toolbar.setAttribute('aria-label', 'Table editing toolbar');
636
+
637
+ // Helper function to create icon button
638
+ const createButton = (config: {
639
+ icon: string;
640
+ title: string;
641
+ action: string;
642
+ danger?: boolean;
643
+ delete?: boolean;
644
+ }) => {
645
+ const btn = document.createElement('button');
646
+ btn.className = 'toolbar-icon-btn';
647
+ if (config.danger) btn.classList.add('toolbar-icon-btn-danger');
648
+ if (config.delete) btn.classList.add('toolbar-icon-btn-delete');
649
+ btn.innerHTML = config.icon;
650
+ btn.title = config.title;
651
+ btn.setAttribute('aria-label', config.title);
652
+ btn.setAttribute('type', 'button');
653
+ btn.setAttribute('data-action', config.action);
654
+ btn.onclick = () => executeTableCommand(config.action);
655
+ return btn;
656
+ };
657
+
658
+ const createDivider = () => {
659
+ const divider = document.createElement('div');
660
+ divider.className = 'toolbar-divider';
661
+ return divider;
662
+ };
663
+
664
+ const createSection = (...buttons: HTMLButtonElement[]) => {
665
+ const section = document.createElement('div');
666
+ section.className = 'toolbar-section';
667
+ buttons.forEach(btn => section.appendChild(btn));
668
+ return section;
669
+ };
670
+
671
+ // Row operations section
672
+ const rowSection = createSection(
673
+ createButton({
674
+ icon: getIconAddRowAbove(),
675
+ title: 'Add row above (Ctrl+Shift+R)',
676
+ action: 'addRowAbove'
677
+ }),
678
+ createButton({
679
+ icon: getIconAddRowBelow(),
680
+ title: 'Add row below',
681
+ action: 'addRowBelow'
682
+ }),
683
+ createButton({
684
+ icon: getIconDeleteRow(),
685
+ title: 'Delete row',
686
+ action: 'deleteRow',
687
+ danger: true
688
+ })
689
+ );
690
+
691
+ // Column operations section
692
+ const colSection = createSection(
693
+ createButton({
694
+ icon: getIconAddColumnLeft(),
695
+ title: 'Add column left',
696
+ action: 'addColumnLeft'
697
+ }),
698
+ createButton({
699
+ icon: getIconAddColumnRight(),
700
+ title: 'Add column right (Ctrl+Shift+C)',
701
+ action: 'addColumnRight'
702
+ }),
703
+ createButton({
704
+ icon: getIconDeleteColumn(),
705
+ title: 'Delete column',
706
+ action: 'deleteColumn',
707
+ danger: true
708
+ })
709
+ );
710
+
711
+ // Header operations section
712
+ const headerSection = createSection(
713
+ createButton({
714
+ icon: getIconToggleHeaderRow(),
715
+ title: 'Toggle header row',
716
+ action: 'toggleHeaderRow'
717
+ }),
718
+ createButton({
719
+ icon: getIconToggleHeaderColumn(),
720
+ title: 'Toggle header column',
721
+ action: 'toggleHeaderColumn'
722
+ })
723
+ );
724
+
725
+ // Merge section
726
+ const mergeSection = createSection(
727
+ createButton({
728
+ icon: getIconMergeCells(),
729
+ title: 'Merge cells (horizontally)',
730
+ action: 'mergeCells'
731
+ })
732
+ );
733
+
734
+ // Delete table section
735
+ const deleteSection = createSection(
736
+ createButton({
737
+ icon: getIconDeleteTable(),
738
+ title: 'Delete table',
739
+ action: 'deleteTable',
740
+ delete: true
741
+ })
742
+ );
743
+
744
+ // Assemble toolbar with dividers
745
+ toolbar.appendChild(rowSection);
746
+ toolbar.appendChild(createDivider());
747
+ toolbar.appendChild(colSection);
748
+ toolbar.appendChild(createDivider());
749
+ toolbar.appendChild(headerSection);
750
+ toolbar.appendChild(createDivider());
751
+ toolbar.appendChild(mergeSection);
752
+ toolbar.appendChild(createDivider());
753
+ toolbar.appendChild(deleteSection);
754
+
755
+ // Add keyboard shortcuts
756
+ const handleKeyDown = (e: KeyboardEvent) => {
757
+ if (!toolbarElement || toolbarElement.style.display === 'none') return;
758
+
759
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
760
+ if (e.key === 'r' || e.key === 'R') {
761
+ e.preventDefault();
762
+ addRowBelowCommand();
763
+ } else if (e.key === 'c' || e.key === 'C') {
764
+ e.preventDefault();
765
+ addColumnRightCommand();
766
+ }
767
+ }
768
+ };
769
+
770
+ window.addEventListener('keydown', handleKeyDown);
771
+
772
+ return toolbar;
773
+ }
774
+
775
+ function executeTableCommand(action: string): void {
776
+ switch (action) {
777
+ case 'addRowAbove': addRowAboveCommand(); break;
778
+ case 'addRowBelow': addRowBelowCommand(); break;
779
+ case 'addColumnLeft': addColumnLeftCommand(); break;
780
+ case 'addColumnRight': addColumnRightCommand(); break;
781
+ case 'deleteRow': deleteRowCommand(); break;
782
+ case 'deleteColumn': deleteColumnCommand(); break;
783
+ case 'toggleHeaderRow': toggleHeaderRowCommand(); break;
784
+ case 'toggleHeaderColumn': toggleHeaderColumnCommand(); break;
785
+ case 'deleteTable': deleteTableCommand(); break;
786
+ case 'mergeCells': mergeCellsCommand(); break;
787
+ }
788
+ }
789
+
790
+ // ============================================
791
+ // COLUMN & TABLE RESIZING
792
+ // ============================================
793
+
794
+ function attachResizeHandles(table: HTMLTableElement): void {
795
+ // Remove existing handles first
796
+ const existingHandles = table.querySelectorAll('.resize-handle');
797
+ existingHandles.forEach(handle => handle.remove());
798
+
799
+ const existingTableHandle = table.querySelector('.table-resize-handle');
800
+ if (existingTableHandle) existingTableHandle.remove();
801
+
802
+ const headerRow = table.querySelector('thead tr, tbody tr:first-child') as HTMLTableRowElement;
803
+ if (!headerRow) return;
804
+
805
+ const cells = headerRow.querySelectorAll('td, th');
806
+
807
+ // Add column resize handles (skip last column)
808
+ cells.forEach((cell, index) => {
809
+ if (index === cells.length - 1) return;
810
+
811
+ const handle = document.createElement('div');
812
+ handle.className = 'resize-handle';
813
+ handle.style.cssText = `
814
+ position: absolute;
815
+ right: -4px;
816
+ top: 0;
817
+ bottom: 0;
818
+ width: 8px;
819
+ background: transparent;
820
+ cursor: col-resize;
821
+ z-index: 10;
822
+ transition: background 0.15s ease;
823
+ `;
824
+
825
+ handle.addEventListener('mouseenter', () => {
826
+ if (!isResizing) {
827
+ handle.style.background = 'rgba(0, 102, 204, 0.3)';
828
+ }
829
+ });
830
+
831
+ handle.addEventListener('mouseleave', () => {
832
+ if (!isResizing) {
833
+ handle.style.background = 'transparent';
834
+ }
835
+ });
836
+
837
+ handle.addEventListener('mousedown', (e) => {
838
+ e.preventDefault();
839
+ e.stopPropagation();
840
+ startColumnResize(e as MouseEvent, index);
841
+ });
842
+
843
+ (cell as HTMLElement).style.position = 'relative';
844
+ cell.appendChild(handle);
845
+ });
846
+
847
+ // Add table-level resize handle
848
+ const tableResizeHandle = document.createElement('div');
849
+ tableResizeHandle.className = 'table-resize-handle';
850
+ tableResizeHandle.addEventListener('mousedown', (e) => {
851
+ e.preventDefault();
852
+ e.stopPropagation();
853
+ startTableResize(e as MouseEvent);
854
+ });
855
+ table.appendChild(tableResizeHandle);
856
+ }
857
+
858
+ function startColumnResize(e: MouseEvent, columnIndex: number): void {
859
+ isResizing = true;
860
+ resizeColumn = columnIndex;
861
+ startX = e.clientX;
862
+
863
+ if (!currentTable) return;
864
+
865
+ const headerRow = currentTable.querySelector('thead tr, tbody tr:first-child') as HTMLTableRowElement;
866
+ if (headerRow && headerRow.cells[columnIndex]) {
867
+ startWidth = (headerRow.cells[columnIndex] as HTMLElement).offsetWidth;
868
+ }
869
+
870
+ document.body.style.cursor = 'col-resize';
871
+ document.body.style.userSelect = 'none';
872
+
873
+ const handleMouseMove = (e: MouseEvent) => {
874
+ if (!isResizing || resizeColumn === null || !currentTable) return;
875
+
876
+ const deltaX = e.clientX - startX;
877
+ const newWidth = Math.max(50, startWidth + deltaX);
878
+
879
+ // Set width for all cells in this column
880
+ const allRows = currentTable.querySelectorAll('tr') as NodeListOf<HTMLTableRowElement>;
881
+ allRows.forEach(row => {
882
+ if (row.cells[resizeColumn!]) {
883
+ (row.cells[resizeColumn!] as HTMLElement).style.width = newWidth + 'px';
884
+ }
885
+ });
886
+ };
887
+
888
+ const handleMouseUp = () => {
889
+ isResizing = false;
890
+ resizeColumn = null;
891
+ document.body.style.cursor = '';
892
+ document.body.style.userSelect = '';
893
+ document.removeEventListener('mousemove', handleMouseMove);
894
+ document.removeEventListener('mouseup', handleMouseUp);
895
+ };
896
+
897
+ document.addEventListener('mousemove', handleMouseMove);
898
+ document.addEventListener('mouseup', handleMouseUp);
899
+ }
900
+
901
+ function startTableResize(e: MouseEvent): void {
902
+ if (!currentTable) return;
903
+
904
+ isTableResizing = true;
905
+ tableStartX = e.clientX;
906
+ tableStartY = e.clientY;
907
+ tableStartWidth = currentTable.offsetWidth;
908
+ tableStartHeight = currentTable.offsetHeight;
909
+
910
+ document.body.style.cursor = 'nwse-resize';
911
+ document.body.style.userSelect = 'none';
912
+
913
+ const handleMouseMove = (e: MouseEvent) => {
914
+ if (!isTableResizing || !currentTable) return;
915
+
916
+ const deltaX = e.clientX - tableStartX;
917
+ const deltaY = e.clientY - tableStartY;
918
+ const newWidth = Math.max(200, tableStartWidth + deltaX);
919
+ const newHeight = Math.max(100, tableStartHeight + deltaY);
920
+
921
+ currentTable.style.width = newWidth + 'px';
922
+ currentTable.style.height = newHeight + 'px';
923
+ };
924
+
925
+ const handleMouseUp = () => {
926
+ isTableResizing = false;
927
+ document.body.style.cursor = '';
928
+ document.body.style.userSelect = '';
929
+ document.removeEventListener('mousemove', handleMouseMove);
930
+ document.removeEventListener('mouseup', handleMouseUp);
931
+ };
932
+
933
+ document.addEventListener('mousemove', handleMouseMove);
934
+ document.addEventListener('mouseup', handleMouseUp);
935
+ }
936
+
937
+ // ============================================
938
+ // SVG ICONS (matching React version exactly)
939
+ // ============================================
940
+
941
+ function getIconAddRowAbove(): string {
942
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
943
+ <path d="M2 7h12V5H2v2zm0 4h12V9H2v2zM8 1v3H5v2h3v3h2V6h3V4h-3V1H8z"/>
944
+ </svg>`;
945
+ }
946
+
947
+ function getIconAddRowBelow(): string {
948
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
949
+ <path d="M2 3h12V1H2v2zm0 4h12V5H2v2zm6 4v3h3v-2h2v-2h-2v-3h-2v3H5v2h3z"/>
950
+ </svg>`;
951
+ }
952
+
953
+ function getIconDeleteRow(): string {
954
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
955
+ <path d="M2 5h12v2H2V5zm0 4h12v2H2V9zm4-6v2H4v2h2v2h2V7h2V5H8V3H6z"/>
956
+ </svg>`;
957
+ }
958
+
959
+ function getIconAddColumnLeft(): string {
960
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
961
+ <path d="M7 2v12h2V2H7zm4 0v12h2V2h-2zM1 8h3v-3H1v3zm3 2H1v3h3v-3z"/>
962
+ </svg>`;
963
+ }
964
+
965
+ function getIconAddColumnRight(): string {
966
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
967
+ <path d="M2 2v12h2V2H2zm4 0v12h2V2H6zM12 8h3v-3h-3v3zm0 2h3v3h-3v-3z"/>
968
+ </svg>`;
969
+ }
970
+
971
+ function getIconDeleteColumn(): string {
972
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
973
+ <path d="M5 2v12h2V2H5zm4 0v12h2V2H9zm3 2h3V1h-3v3zm3 2h-3v3h3V6zm0 4h-3v3h3v-3z"/>
974
+ </svg>`;
975
+ }
976
+
977
+ function getIconToggleHeaderRow(): string {
978
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
979
+ <path d="M2 2h12v3H2V2zm0 5h12v8H2V7zm2 2v4h2V9H4zm4 0v4h2V9H8zm4 0v4h2V9h-2z"/>
980
+ </svg>`;
981
+ }
982
+
983
+ function getIconToggleHeaderColumn(): string {
984
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
985
+ <path d="M2 2v12h3V2H2zm5 0v12h8V2H7zm2 2h4v2H9V4zm0 4h4v2H9V8zm0 4h4v2H9v-2z"/>
986
+ </svg>`;
987
+ }
988
+
989
+ function getIconDeleteTable(): string {
990
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
991
+ <path d="M3 1h10v1H3V1zm1 2v11h8V3H4zM6 5h1v6H6V5zm3 0h1v6H9V5z"/>
992
+ </svg>`;
993
+ }
994
+
995
+ function getIconMergeCells(): string {
996
+ return `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
997
+ <path d="M2 2h4v3H2V2zm5 0h4v3H7V2zm5 0h2v3h-2V2zm-10 4h4v3H2V6zm5 0h4v3H7V6zm5 0h2v3h-2V6zm-10 4h4v3H2v-3zm5 0h4v3H7v-3zm5 0h2v3h-2v-3z"/>
998
+ </svg>`;
999
+ }
1000
+
1001
+ // ============================================
1002
+ // MODULE-LEVEL INITIALIZATION
1003
+ // ============================================
1004
+
1005
+ // Initialize table toolbar monitoring
1006
+ if (typeof window !== 'undefined' && !window.__tablePluginInitialized) {
1007
+ window.__tablePluginInitialized = true;
1008
+
1009
+ const initTablePlugin = () => {
1010
+ initTableToolbar();
1011
+ };
1012
+
1013
+ if (document.readyState === 'loading') {
1014
+ document.addEventListener('DOMContentLoaded', initTablePlugin);
1015
+ } else {
1016
+ // If DOM is already ready, init immediately
1017
+ setTimeout(initTablePlugin, 100);
1018
+ }
1019
+ }
1020
+
1021
+ // ============================================
1022
+ // PLUGIN DEFINITION
1023
+ // ============================================
1024
+
1025
+ export const TablePlugin = (): Plugin => ({
1026
+ name: "table",
1027
+
1028
+ toolbar: [
1029
+ {
1030
+ label: "Insert Table",
1031
+ command: "insertTable",
1032
+ icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" focusable="false" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3 10h18M3 15h18M9 4v16M15 4v16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
1033
+ },
1034
+ ],
1035
+
1036
+ commands: {
1037
+ insertTable: () => {
1038
+ insertTableCommand();
1039
+ return true;
1040
+ },
1041
+ },
1042
+
1043
+ keymap: {
1044
+ "Mod-Shift-r": () => {
1045
+ addRowBelowCommand();
1046
+ return true;
1047
+ },
1048
+ "Mod-Shift-c": () => {
1049
+ addColumnRightCommand();
1050
+ return true;
1051
+ },
1052
+ },
1053
+ });