autumnnote 1.0.7 → 1.0.8

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.
@@ -64,6 +64,43 @@ function getCellAfterVisualCol(row, visualIdx) {
64
64
  return null;
65
65
  }
66
66
 
67
+ /**
68
+ * Build a 2D grid map of the table, accounting for both rowspan and colspan.
69
+ *
70
+ * gridMap[r][c] = the DOM cell occupying visual grid position (r, c).
71
+ * cellPos = WeakMap: cell → { r, c, rs, cs } (top-left grid origin + span).
72
+ *
73
+ * Uses HTMLTableElement.rows which is scoped to the table itself and never
74
+ * includes rows from nested tables.
75
+ *
76
+ * @param {HTMLTableElement} table
77
+ * @returns {{ gridMap: Object, cellPos: WeakMap }}
78
+ */
79
+ function buildGridMap(table) {
80
+ const rows = Array.from(table.rows);
81
+ const gridMap = {};
82
+ const cellPos = new WeakMap();
83
+ rows.forEach((row, r) => {
84
+ if (!gridMap[r]) gridMap[r] = {};
85
+ let c = 0;
86
+ for (const cell of row.cells) {
87
+ // Skip positions already occupied by a rowspan from a previous row
88
+ while (gridMap[r][c]) c++;
89
+ const rs = cell.rowSpan || 1;
90
+ const cs = cell.colSpan || 1;
91
+ cellPos.set(cell, { r, c, rs, cs });
92
+ for (let dr = 0; dr < rs; dr++) {
93
+ if (!gridMap[r + dr]) gridMap[r + dr] = {};
94
+ for (let dc = 0; dc < cs; dc++) {
95
+ gridMap[r + dr][c + dc] = cell;
96
+ }
97
+ }
98
+ c += cs;
99
+ }
100
+ });
101
+ return { gridMap, cellPos };
102
+ }
103
+
67
104
  const ICONS = {
68
105
  rowAbove: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 3v7"/><path d="M9 7l3-4 3 4"/></svg>`,
69
106
  rowBelow: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 12v7"/><path d="M9 17l3 4 3-4"/></svg>`,
@@ -71,11 +108,13 @@ const ICONS = {
71
108
  colLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="12" y1="3" x2="12" y2="21"/><path d="M3 12h7"/><path d="M7 8l-4 4 4 4"/></svg>`,
72
109
  colRight: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="12" y1="3" x2="12" y2="21"/><path d="M12 12h9"/><path d="M17 8l4 4-4 4"/></svg>`,
73
110
  deleteCol: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="12" y1="3" x2="12" y2="21"/><line x1="15" y1="6" x2="21" y2="12"/><line x1="21" y1="6" x2="15" y2="12"/></svg>`,
74
- mergeCells: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="8" height="10" rx="1"/><rect x="14" y="7" width="8" height="10" rx="1"/><path d="M10 12h4"/><path d="M12 10l2 2-2 2"/></svg>`,
111
+ mergeCells: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="8" height="10" rx="1"/><rect x="14" y="7" width="8" height="10" rx="1"/><path d="M10 12h4"/><path d="M12 10l2 2-2 2"/></svg>`,
112
+ unmergeCells: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="1"/><line x1="12" y1="5" x2="12" y2="19" stroke-dasharray="2.5 2"/><line x1="2" y1="12" x2="22" y2="12" stroke-dasharray="2.5 2"/><path d="M9 9 L6 12 L9 15"/><path d="M15 9 L18 12 L15 15"/></svg>`,
75
113
  colWidth: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="4" x2="7" y2="20"/><line x1="17" y1="4" x2="17" y2="20"/><line x1="7" y1="12" x2="17" y2="12"/><path d="M10 9l-3 3 3 3"/><path d="M14 9l3 3-3 3"/></svg>`,
76
114
  rowHeight: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/><line x1="12" y1="7" x2="12" y2="17"/><path d="M9 10l3-3 3 3"/><path d="M9 14l3 3 3-3"/></svg>`,
77
115
  tableBorder: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6" stroke-width="1"/><line x1="3" y1="13" x2="21" y2="13" stroke-width="2"/><line x1="3" y1="20" x2="21" y2="20" stroke-width="3"/></svg>`,
78
116
  deleteTable: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/><line x1="16" y1="16" x2="22" y2="22" stroke="#ef4444"/><line x1="22" y1="16" x2="16" y2="22" stroke="#ef4444"/></svg>`,
117
+ selectCells: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4 L4 20 L9 15 L12 21 L14 20 L11 14 L17 14 Z" fill="currentColor" opacity="0.15"/><path d="M4 4 L4 20 L9 15 L12 21 L14 20 L11 14 L17 14 Z"/></svg>`,
79
118
  };
80
119
 
81
120
  export class TableTooltip {
@@ -92,6 +131,13 @@ export class TableTooltip {
92
131
  this._sizeApply = null;
93
132
  this._sizeTitleEl = null;
94
133
  this._sizeInputEl = null;
134
+ // Cell selection
135
+ this._selectMode = false;
136
+ this._selectedCells = [];
137
+ this._selectStart = null;
138
+ this._selectDragging = false;
139
+ this._selectBtn = null;
140
+ this._editable = null;
95
141
  }
96
142
 
97
143
  initialize() {
@@ -102,6 +148,36 @@ export class TableTooltip {
102
148
  document.body.appendChild(this._sizePopover);
103
149
 
104
150
  const editable = this.context.layoutInfo.editable;
151
+ this._editable = editable;
152
+
153
+ // ── Cell selection drag ──────────────────────────────────────────────────
154
+ const onSelMousedown = (e) => {
155
+ if (!this._selectMode) return;
156
+ const cell = e.target.closest('td, th');
157
+ if (!cell || !editable.contains(cell)) return;
158
+ // Don't interfere with col/row resize (inline cursor is set by resize logic)
159
+ if (cell.style.cursor === 'col-resize' || cell.style.cursor === 'row-resize') return;
160
+ e.preventDefault(); // suppress text-cursor placement while selecting cells
161
+ this._activeTable = cell.closest('table');
162
+ this._selectStart = cell;
163
+ this._selectDragging = true;
164
+ this._setSelection([cell]);
165
+ };
166
+ const onSelMousemove = (e) => {
167
+ if (!this._selectMode || !this._selectDragging || !this._selectStart) return;
168
+ const cell = e.target.closest('td, th');
169
+ if (!cell || !editable.contains(cell)) return;
170
+ if (cell.closest('table') !== this._activeTable) return;
171
+ this._setSelection(this._getRectCells(this._selectStart, cell));
172
+ };
173
+ const onSelMouseup = () => { this._selectDragging = false; };
174
+
175
+ this._disposers.push(
176
+ on(editable, 'mousedown', onSelMousedown),
177
+ on(editable, 'mousemove', onSelMousemove),
178
+ on(document, 'mouseup', onSelMouseup),
179
+ );
180
+ // ────────────────────────────────────────────────────────────────────────
105
181
 
106
182
  this._disposers.push(
107
183
  on(editable, 'mouseover', (e) => {
@@ -114,6 +190,7 @@ export class TableTooltip {
114
190
  }
115
191
  }, { passive: true }),
116
192
  on(editable, 'mouseout', (e) => {
193
+ if (this._selectMode) return; // keep tooltip alive during cell selection
117
194
  const to = e.relatedTarget;
118
195
  if (!to || (
119
196
  !editable.contains(to) &&
@@ -124,6 +201,7 @@ export class TableTooltip {
124
201
  }
125
202
  }, { passive: true }),
126
203
  on(document, 'click', (e) => {
204
+ if (this._selectMode && this._activeTable && this._activeTable.contains(e.target)) return;
127
205
  if (this._activeTable &&
128
206
  !this._activeTable.contains(e.target) &&
129
207
  !this._el.contains(e.target) &&
@@ -210,11 +288,16 @@ export class TableTooltip {
210
288
  if (_edge === 'col') {
211
289
  _startW = _nearCell.offsetWidth;
212
290
  _colIdx = getVisualColIndex(_nearCell);
213
- // Cache column cells once so onDocMove never runs querySelectorAll per frame
291
+ // Cache column cells once so onDocMove never runs querySelectorAll per frame.
292
+ // F-1: skip merged cells (colSpan > 1) — setting width on a merged cell
293
+ // distributes it equally across all spanned columns instead of resizing
294
+ // only the target column. Rows that have an individual cell at this
295
+ // visual column index are resized independently as expected.
214
296
  _colCells = _colIdx >= 0
215
297
  ? Array.from(_table.querySelectorAll('tr'))
216
298
  .map(r => getCellAtVisualCol(r, _colIdx))
217
299
  .filter(Boolean)
300
+ .filter(c => (c.colSpan || 1) === 1)
218
301
  : [];
219
302
  document.body.style.cursor = 'col-resize';
220
303
  } else {
@@ -307,6 +390,12 @@ export class TableTooltip {
307
390
 
308
391
  el.appendChild(this._sep());
309
392
 
393
+ // Select-cells toggle
394
+ this._selectBtn = this._makeBtn(ICONS.selectCells, 'Select Cells', () => this._toggleSelectMode());
395
+ el.appendChild(this._selectBtn);
396
+
397
+ el.appendChild(this._sep());
398
+
310
399
  // Row operations
311
400
  el.appendChild(this._makeBtn(ICONS.rowAbove, 'Add Row Above', () => this._addRow('above')));
312
401
  el.appendChild(this._makeBtn(ICONS.rowBelow, 'Add Row Below', () => this._addRow('below')));
@@ -322,7 +411,8 @@ export class TableTooltip {
322
411
  el.appendChild(this._sep());
323
412
 
324
413
  // Merge cells
325
- el.appendChild(this._makeBtn(ICONS.mergeCells, 'Merge Cells', () => this._mergeCells()));
414
+ el.appendChild(this._makeBtn(ICONS.mergeCells, 'Merge Cells', () => this._mergeCells()));
415
+ el.appendChild(this._makeBtn(ICONS.unmergeCells, 'Unmerge Cells', () => this._unmergeCells()));
326
416
 
327
417
  el.appendChild(this._sep());
328
418
 
@@ -342,6 +432,7 @@ export class TableTooltip {
342
432
  this._disposers.push(
343
433
  on(el, 'mouseenter', () => this._clearTimers()),
344
434
  on(el, 'mouseleave', () => {
435
+ if (this._selectMode) return; // keep tooltip alive during cell selection
345
436
  if (this._sizePopover && this._sizePopover.style.display !== 'none') return;
346
437
  this._scheduleHide();
347
438
  }),
@@ -412,6 +503,13 @@ export class TableTooltip {
412
503
  this._el.style.display = 'none';
413
504
  this._activeTable = null;
414
505
  this._activeCell = null;
506
+ // Reset select mode
507
+ if (this._selectMode) {
508
+ this._selectMode = false;
509
+ if (this._selectBtn) this._selectBtn.classList.remove('an-link-tooltip-btn--active');
510
+ if (this._editable) this._editable.classList.remove('an-table-select-mode');
511
+ }
512
+ this._clearSelection();
415
513
  this._clearTimers();
416
514
  this._hideSizePopover();
417
515
  }
@@ -461,18 +559,129 @@ export class TableTooltip {
461
559
  || (this._activeTable && this._activeTable.querySelector('td, th'));
462
560
  }
463
561
 
562
+ // ---------------------------------------------------------------------------
563
+ // Cell selection helpers
564
+ // ---------------------------------------------------------------------------
565
+
566
+ _toggleSelectMode() {
567
+ this._selectMode = !this._selectMode;
568
+ if (this._selectBtn) {
569
+ this._selectBtn.classList.toggle('an-link-tooltip-btn--active', this._selectMode);
570
+ }
571
+ if (this._editable) {
572
+ this._editable.classList.toggle('an-table-select-mode', this._selectMode);
573
+ }
574
+ if (!this._selectMode) {
575
+ this._clearSelection();
576
+ }
577
+ }
578
+
579
+ _clearSelection() {
580
+ this._selectedCells.forEach((c) => c.classList.remove('an-cell-selected'));
581
+ this._selectedCells = [];
582
+ this._selectStart = null;
583
+ }
584
+
585
+ _setSelection(cells) {
586
+ // Remove highlight from cells no longer in selection
587
+ this._selectedCells.forEach((c) => {
588
+ if (!cells.includes(c)) c.classList.remove('an-cell-selected');
589
+ });
590
+ this._selectedCells = cells;
591
+ cells.forEach((c) => c.classList.add('an-cell-selected'));
592
+ }
593
+
594
+ /**
595
+ * Returns all cells in the rectangular area between startCell and endCell,
596
+ * correctly handling rowspan/colspan by using the grid map.
597
+ * The rect is expanded iteratively until it is stable — this ensures any
598
+ * merged cell that starts outside the initial rect but spans into it is
599
+ * fully included.
600
+ */
601
+ _getRectCells(startCell, endCell) {
602
+ if (!startCell) return [];
603
+ if (!endCell || startCell === endCell) return [startCell];
604
+ const table = startCell.closest('table');
605
+ if (!table || !table.contains(endCell)) return [startCell];
606
+
607
+ const { gridMap, cellPos } = buildGridMap(table);
608
+ const sp = cellPos.get(startCell);
609
+ const ep = cellPos.get(endCell);
610
+ if (!sp || !ep) return [startCell];
611
+
612
+ let minR = Math.min(sp.r, ep.r);
613
+ let maxR = Math.max(sp.r + sp.rs - 1, ep.r + ep.rs - 1);
614
+ let minC = Math.min(sp.c, ep.c);
615
+ let maxC = Math.max(sp.c + sp.cs - 1, ep.c + ep.cs - 1);
616
+
617
+ // Iteratively expand until stable — handles spans that exceed the current rect
618
+ let changed = true;
619
+ while (changed) {
620
+ changed = false;
621
+ for (let r = minR; r <= maxR; r++) {
622
+ const rowMap = gridMap[r];
623
+ if (!rowMap) continue;
624
+ for (let c = minC; c <= maxC; c++) {
625
+ const cell = rowMap[c];
626
+ if (!cell) continue;
627
+ const pos = cellPos.get(cell);
628
+ if (!pos) continue;
629
+ if (pos.r < minR) { minR = pos.r; changed = true; }
630
+ if (pos.r + pos.rs - 1 > maxR) { maxR = pos.r + pos.rs - 1; changed = true; }
631
+ if (pos.c < minC) { minC = pos.c; changed = true; }
632
+ if (pos.c + pos.cs - 1 > maxC) { maxC = pos.c + pos.cs - 1; changed = true; }
633
+ }
634
+ }
635
+ }
636
+
637
+ // Collect unique cells in document order
638
+ const seen = new Set();
639
+ const result = [];
640
+ for (let r = minR; r <= maxR; r++) {
641
+ const rowMap = gridMap[r];
642
+ if (!rowMap) continue;
643
+ for (let c = minC; c <= maxC; c++) {
644
+ const cell = rowMap[c];
645
+ if (cell && !seen.has(cell)) {
646
+ seen.add(cell);
647
+ result.push(cell);
648
+ }
649
+ }
650
+ }
651
+ return result.length > 0 ? result : [startCell];
652
+ }
653
+
654
+ /**
655
+ * Returns the active cell set: user-selected cells when available,
656
+ * otherwise the single active/cursor cell.
657
+ * @returns {HTMLTableCellElement[]}
658
+ */
659
+ _getSelectedCells() {
660
+ return this._selectedCells.length > 0
661
+ ? this._selectedCells
662
+ : [this._getCell()].filter(Boolean);
663
+ }
664
+
464
665
  // ---------------------------------------------------------------------------
465
666
  // Table operations
466
667
  // ---------------------------------------------------------------------------
467
668
 
468
669
  _addRow(position) {
469
- const cell = this._getCell();
470
- if (!cell) return;
471
- const row = cell.closest('tr');
472
- if (!row) return;
473
- const colCount = Array.from(row.cells).reduce((sum, c) => sum + (c.colSpan || 1), 0);
670
+ const cells = this._getSelectedCells();
671
+ if (!cells.length) return;
672
+ const table = cells[0].closest('table');
673
+ if (!table) return;
674
+ const allRows = Array.from(table.querySelectorAll('tr'));
675
+ const selectedRows = [...new Set(cells.map((c) => c.closest('tr')).filter(Boolean))];
676
+ // Reference row: topmost for 'above', bottommost for 'below'
677
+ const refRow = selectedRows.reduce((best, r) => {
678
+ const bi = allRows.indexOf(best);
679
+ const ri = allRows.indexOf(r);
680
+ return position === 'above' ? (ri < bi ? r : best) : (ri > bi ? r : best);
681
+ });
682
+ const colCount = Array.from(refRow.cells).reduce((sum, c) => sum + (c.colSpan || 1), 0);
474
683
  const newRow = document.createElement('tr');
475
- const refCells = Array.from(row.cells);
684
+ const refCells = Array.from(refRow.cells);
476
685
  for (let i = 0; i < colCount; i++) {
477
686
  const td = createElement('td', {}, ['\u00a0']);
478
687
  const ref = refCells[i];
@@ -480,25 +689,24 @@ export class TableTooltip {
480
689
  if (ref && ref.style.minWidth) td.style.minWidth = ref.style.minWidth;
481
690
  newRow.appendChild(td);
482
691
  }
483
- if (position === 'above') row.parentElement?.insertBefore(newRow, row);
484
- else row.insertAdjacentElement('afterend', newRow);
485
- // Defer positioning until the browser has painted the new layout
692
+ if (position === 'above') refRow.parentElement?.insertBefore(newRow, refRow);
693
+ else refRow.insertAdjacentElement('afterend', newRow);
486
694
  requestAnimationFrame(() => this._positionNear(this._activeTable));
487
695
  this.context.invoke('editor.afterCommand');
488
696
  }
489
697
 
490
698
  _addColumn(position) {
491
- const cell = this._getCell();
492
- if (!cell) return;
493
- const table = cell.closest('table');
699
+ const cells = this._getSelectedCells();
700
+ if (!cells.length) return;
701
+ const table = cells[0].closest('table');
494
702
  if (!table) return;
495
- const visualColIdx = getVisualColIndex(cell);
703
+ const colIndices = cells.map((c) => getVisualColIndex(c));
704
+ const targetColIdx = position === 'left' ? Math.min(...colIndices) : Math.max(...colIndices);
496
705
  const rows = Array.from(table.querySelectorAll('tr'));
497
- // Batch reads first, then writes — prevents layout thrashing inside the loop
498
- const refs = rows.map(r => position === 'left'
499
- ? getCellAtVisualCol(r, visualColIdx)
500
- : getCellAfterVisualCol(r, visualColIdx));
501
- const isHeaders = rows.map(r => r.closest('thead') !== null);
706
+ const refs = rows.map((r) => position === 'left'
707
+ ? getCellAtVisualCol(r, targetColIdx)
708
+ : getCellAfterVisualCol(r, targetColIdx));
709
+ const isHeaders = rows.map((r) => r.closest('thead') !== null);
502
710
  rows.forEach((r, i) => {
503
711
  r.insertBefore(createElement(isHeaders[i] ? 'th' : 'td', {}, ['\u00a0']), refs[i]);
504
712
  });
@@ -507,34 +715,45 @@ export class TableTooltip {
507
715
  }
508
716
 
509
717
  _deleteRow() {
510
- const cell = this._getCell();
511
- if (!cell) return;
512
- const row = cell.closest('tr');
513
- const table = cell.closest('table');
514
- if (!row || !table) return;
515
- // Guard: do not delete the last body row (thead rows are not counted)
718
+ const cells = this._getSelectedCells();
719
+ if (!cells.length) return;
720
+ const table = cells[0].closest('table');
721
+ if (!table) return;
516
722
  const tbody = table.querySelector('tbody');
517
- const bodyRows = tbody ? tbody.querySelectorAll('tr').length : table.querySelectorAll('tr').length;
518
- if (bodyRows <= 1 && row.closest('tbody')) return;
723
+ const totalBodyRows = tbody
724
+ ? tbody.querySelectorAll('tr').length
725
+ : table.querySelectorAll('tr').length;
726
+ const selectedRows = [...new Set(cells.map((c) => c.closest('tr')).filter(Boolean))];
727
+ const bodyRowsToDelete = selectedRows.filter((r) => r.closest('tbody'));
728
+ // Guard: keep at least one body row
729
+ if (bodyRowsToDelete.length >= totalBodyRows) return;
519
730
  this._activeCell = null;
520
- row.parentElement?.removeChild(row);
731
+ this._clearSelection();
732
+ selectedRows.forEach((r) => r.parentElement?.removeChild(r));
521
733
  requestAnimationFrame(() => this._positionNear(this._activeTable));
522
734
  this.context.invoke('editor.afterCommand');
523
735
  }
524
736
 
525
737
  _deleteColumn() {
526
- const cell = this._getCell();
527
- if (!cell) return;
528
- const table = cell.closest('table');
738
+ const cells = this._getSelectedCells();
739
+ if (!cells.length) return;
740
+ const table = cells[0].closest('table');
529
741
  if (!table) return;
530
- const row = cell.closest('tr');
531
- if (row && row.cells.length <= 1) return;
532
- const visualColIdx = getVisualColIndex(cell);
742
+ const tableRows = Array.from(table.querySelectorAll('tr'));
743
+ if (tableRows[0] && tableRows[0].cells.length <= 1) return; // guard: keep ≥1 col
744
+ const colIndices = [...new Set(cells.map((c) => getVisualColIndex(c)))];
745
+ if (colIndices.length >= (tableRows[0]?.cells.length ?? 1)) return;
746
+ // Collect all cell references upfront before any removal (avoids stale visual indices)
747
+ const cellsToDelete = [];
748
+ colIndices.forEach((colIdx) => {
749
+ tableRows.forEach((r) => {
750
+ const c = getCellAtVisualCol(r, colIdx);
751
+ if (c) cellsToDelete.push(c);
752
+ });
753
+ });
533
754
  this._activeCell = null;
534
- const rows = Array.from(table.querySelectorAll('tr'));
535
- // Batch reads before writes to avoid forced reflows inside the loop
536
- const cells = rows.map(r => getCellAtVisualCol(r, visualColIdx));
537
- cells.forEach((c, i) => { if (c) rows[i].removeChild(c); });
755
+ this._clearSelection();
756
+ cellsToDelete.forEach((c) => c.parentElement?.removeChild(c));
538
757
  requestAnimationFrame(() => this._positionNear(this._activeTable));
539
758
  this.context.invoke('editor.afterCommand');
540
759
  }
@@ -542,41 +761,57 @@ export class TableTooltip {
542
761
  _mergeCells() {
543
762
  const cell = this._getCell();
544
763
  if (!cell) return;
545
- const sel = window.getSelection();
546
- if (!sel || sel.rangeCount === 0) return;
547
- const range = sel.getRangeAt(0);
548
764
  const table = cell.closest('table');
549
765
  if (!table) return;
550
766
 
551
- // Collect all cells (in any row) that intersect the selection
552
- const allCells = Array.from(table.querySelectorAll('td, th'));
553
- const selected = allCells.filter((c) => {
554
- try { return range.intersectsNode(c); } catch { return false; }
555
- });
556
- if (selected.length < 2) return;
557
-
558
- // Determine if all selected cells are in the same row (horizontal merge)
559
- const rows = [...new Set(selected.map((c) => c.closest('tr')))];
560
- if (rows.length === 1) {
561
- // Horizontal merge within a single row
562
- const row = rows[0];
563
- const rowSelected = Array.from(row.cells).filter((c) => selected.includes(c));
564
- if (rowSelected.length < 2) return;
565
- const first = rowSelected[0];
566
- first.colSpan = rowSelected.reduce((sum, c) => sum + (c.colSpan || 1), 0);
567
- first.innerHTML = rowSelected.map((c) => c.innerHTML).join('');
568
- rowSelected.slice(1).forEach((c) => row.removeChild(c));
569
- } else {
570
- // Vertical merge across rows — merge into first selected cell (rowspan)
571
- const visualCols = [...new Set(selected.map((c) => getVisualColIndex(c)))];
572
- if (visualCols.length !== 1) return; // only support single-column vertical merge
573
- const first = selected[0];
574
- first.rowSpan = selected.reduce((sum, c) => sum + (c.rowSpan || 1), 0);
575
- first.innerHTML = selected.map((c) => c.innerHTML).join('');
576
- selected.slice(1).forEach((c) => {
577
- if (c.closest('tr')) c.closest('tr').removeChild(c);
767
+ // Prefer user panel-selected cells; fall back to text-selection range
768
+ let selected = this._getSelectedCells().filter((c) => table.contains(c));
769
+ if (selected.length < 2) {
770
+ const sel = window.getSelection();
771
+ if (!sel || sel.rangeCount === 0) return;
772
+ const range = sel.getRangeAt(0);
773
+ const allCells = Array.from(table.querySelectorAll('td, th'));
774
+ selected = allCells.filter((c) => {
775
+ try { return range.intersectsNode(c); } catch { return false; }
578
776
  });
777
+ if (selected.length < 2) return;
778
+ }
779
+
780
+ const { gridMap, cellPos } = buildGridMap(table);
781
+
782
+ // Bounding rect that encompasses every selected cell's full span
783
+ let minR = Infinity, maxR = -Infinity, minC = Infinity, maxC = -Infinity;
784
+ selected.forEach((c) => {
785
+ const pos = cellPos.get(c);
786
+ if (!pos) return;
787
+ if (pos.r < minR) minR = pos.r;
788
+ if (pos.r + pos.rs - 1 > maxR) maxR = pos.r + pos.rs - 1;
789
+ if (pos.c < minC) minC = pos.c;
790
+ if (pos.c + pos.cs - 1 > maxC) maxC = pos.c + pos.cs - 1;
791
+ });
792
+ if (minR === Infinity) return;
793
+
794
+ // Collect every unique cell in the bounding rect
795
+ const seen = new Set();
796
+ const rectCells = [];
797
+ for (let r = minR; r <= maxR; r++) {
798
+ const rowMap = gridMap[r];
799
+ if (!rowMap) continue;
800
+ for (let c = minC; c <= maxC; c++) {
801
+ const tc = rowMap[c];
802
+ if (tc && !seen.has(tc)) { seen.add(tc); rectCells.push(tc); }
803
+ }
579
804
  }
805
+ if (rectCells.length < 2) return;
806
+
807
+ const first = rectCells[0];
808
+ first.colSpan = maxC - minC + 1;
809
+ first.rowSpan = maxR - minR + 1;
810
+ first.style.verticalAlign = 'middle';
811
+ first.innerHTML = rectCells.map((c) => c.innerHTML).join('');
812
+ rectCells.slice(1).forEach((c) => c.parentElement?.removeChild(c));
813
+
814
+ this._clearSelection();
580
815
  this.context.invoke('editor.afterCommand');
581
816
  }
582
817
 
@@ -588,6 +823,73 @@ export class TableTooltip {
588
823
  this.context.invoke('editor.afterCommand');
589
824
  }
590
825
 
826
+ _unmergeCells() {
827
+ const cells = this._getSelectedCells();
828
+ if (!cells.length) return;
829
+ const table = cells[0].closest('table');
830
+ if (!table) return;
831
+ const mergedCells = cells.filter(
832
+ (c) => table.contains(c) && ((c.colSpan || 1) > 1 || (c.rowSpan || 1) > 1)
833
+ );
834
+ if (!mergedCells.length) return;
835
+ mergedCells.forEach((cell) => {
836
+ if (table.contains(cell)) this._unmergeOne(cell, table);
837
+ });
838
+ this._clearSelection();
839
+ requestAnimationFrame(() => this._positionNear(this._activeTable));
840
+ this.context.invoke('editor.afterCommand');
841
+ }
842
+
843
+ /**
844
+ * Split a single merged cell (colspan/rowspan > 1) back into individual cells.
845
+ * New cells are empty (&nbsp;); the original cell retains its content.
846
+ * @param {HTMLTableCellElement} cell
847
+ * @param {HTMLTableElement} table
848
+ */
849
+ _unmergeOne(cell, table) {
850
+ const cs = cell.colSpan || 1;
851
+ const rs = cell.rowSpan || 1;
852
+ if (cs === 1 && rs === 1) return;
853
+
854
+ // Build grid map from current DOM state (before any mutation)
855
+ const { cellPos } = buildGridMap(table);
856
+ const pos = cellPos.get(cell);
857
+ if (!pos) return;
858
+
859
+ const { r, c } = pos;
860
+ const tableRows = Array.from(table.rows);
861
+ const tag = cell.tagName.toLowerCase(); // preserve td / th
862
+
863
+ // Reset the original cell
864
+ cell.rowSpan = 1;
865
+ cell.colSpan = 1;
866
+ cell.style.verticalAlign = '';
867
+
868
+ // Same row: insert (cs - 1) sibling cells after the original cell
869
+ if (cs > 1) {
870
+ const insertRef = cell.nextElementSibling;
871
+ for (let dc = 1; dc < cs; dc++) {
872
+ tableRows[r].insertBefore(createElement(tag, {}, ['\u00a0']), insertRef);
873
+ }
874
+ }
875
+
876
+ // Rows below: insert cs cells at the correct visual column position (for rowspan)
877
+ for (let dr = 1; dr < rs; dr++) {
878
+ const targetRow = tableRows[r + dr];
879
+ if (!targetRow) continue;
880
+ // Find the first cell in this row whose visual-col origin is beyond c
881
+ // (using cellPos built before mutations — valid for these untouched rows)
882
+ let ref = null;
883
+ for (const tc of targetRow.cells) {
884
+ const tp = cellPos.get(tc);
885
+ if (tp && tp.c > c) { ref = tc; break; }
886
+ }
887
+ for (let dc = 0; dc < cs; dc++) {
888
+ targetRow.insertBefore(createElement(tag, {}, ['\u00a0']), ref);
889
+ }
890
+ }
891
+ }
892
+
591
893
  // ---------------------------------------------------------------------------
592
894
  // Size popover (column width / row height)
593
895
  // ---------------------------------------------------------------------------
@@ -653,7 +955,6 @@ export class TableTooltip {
653
955
  if (type === 'border') {
654
956
  const table = cell.closest('table');
655
957
  if (!table) return;
656
- // Read current inline border-width, fall back to computed value, then default 1px
657
958
  const firstCell = table.querySelector('td, th');
658
959
  const currentPx = firstCell
659
960
  ? (parseInt(firstCell.style.borderWidth, 10) ||
@@ -664,19 +965,20 @@ export class TableTooltip {
664
965
  this._sizeInputEl.max = '10';
665
966
  this._sizeInputEl.value = currentPx;
666
967
  this._sizeApply = (val) => {
667
- // Apply border-width to every cell; with border-collapse:collapse this
668
- // controls all grid lines including the outer table border.
669
- // Setting only borderWidth preserves the existing border-color from CSS.
670
968
  const cells = Array.from(table.querySelectorAll('td, th'));
671
969
  if (val === 0) {
672
- cells.forEach(c => { c.style.borderWidth = '0'; c.style.borderStyle = 'none'; });
970
+ cells.forEach((c) => { c.style.borderWidth = '0'; c.style.borderStyle = 'none'; });
673
971
  } else {
674
- cells.forEach(c => { c.style.borderWidth = `${val}px`; c.style.borderStyle = 'solid'; });
972
+ cells.forEach((c) => { c.style.borderWidth = `${val}px`; c.style.borderStyle = 'solid'; });
675
973
  }
676
974
  this.context.invoke('editor.afterCommand');
677
975
  };
678
976
  } else {
679
977
  const isCol = type === 'col';
978
+ const activeCells = this._getSelectedCells().filter((c) => {
979
+ const t = c.closest('table');
980
+ return t && t === cell.closest('table');
981
+ });
680
982
  this._sizeTitleEl.textContent = isCol ? 'Column Width (px)' : 'Row Height (px)';
681
983
  this._sizeInputEl.min = '1';
682
984
  this._sizeInputEl.max = '2000';
@@ -684,20 +986,25 @@ export class TableTooltip {
684
986
  ? (cell.offsetWidth || 120)
685
987
  : (cell.closest('tr') ? (cell.closest('tr').offsetHeight || 40) : 40);
686
988
  this._sizeApply = (val) => {
989
+ const table = cell.closest('table');
990
+ if (!table) return;
687
991
  if (isCol) {
688
- const table = cell.closest('table');
689
- const visualColIdx = getVisualColIndex(cell);
690
- const rows = Array.from(table.querySelectorAll('tr'));
691
- // Batch reads, then writes
692
- const cells = rows.map(r => getCellAtVisualCol(r, visualColIdx));
693
- cells.forEach(c => {
694
- if (c) { c.style.width = `${val}px`; c.style.minWidth = `${val}px`; }
992
+ // Apply to all selected columns (or just the current column)
993
+ const colIndices = [...new Set(activeCells.map((c) => getVisualColIndex(c)))];
994
+ const tableRows = Array.from(table.querySelectorAll('tr'));
995
+ colIndices.forEach((colIdx) => {
996
+ tableRows.forEach((r) => {
997
+ const c = getCellAtVisualCol(r, colIdx);
998
+ // F-1: skip merged cells same reason as drag-resize _colCells.
999
+ if (c && (c.colSpan || 1) === 1) { c.style.width = `${val}px`; c.style.minWidth = `${val}px`; }
1000
+ });
695
1001
  });
696
1002
  } else {
697
- const row = cell.closest('tr');
698
- if (row) {
1003
+ // Apply to all selected rows (or just the current row)
1004
+ const selectedRows = [...new Set(activeCells.map((c) => c.closest('tr')).filter(Boolean))];
1005
+ selectedRows.forEach((row) => {
699
1006
  for (const c of row.cells) { c.style.height = `${val}px`; c.style.minHeight = `${val}px`; }
700
- }
1007
+ });
701
1008
  }
702
1009
  this.context.invoke('editor.afterCommand');
703
1010
  };