autumnnote 1.0.6 → 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,9 +148,40 @@ 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) => {
184
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
108
185
  const table = e.target.closest('table');
109
186
  if (table && editable.contains(table)) {
110
187
  const cell = e.target.closest('td, th');
@@ -113,6 +190,7 @@ export class TableTooltip {
113
190
  }
114
191
  }, { passive: true }),
115
192
  on(editable, 'mouseout', (e) => {
193
+ if (this._selectMode) return; // keep tooltip alive during cell selection
116
194
  const to = e.relatedTarget;
117
195
  if (!to || (
118
196
  !editable.contains(to) &&
@@ -123,6 +201,7 @@ export class TableTooltip {
123
201
  }
124
202
  }, { passive: true }),
125
203
  on(document, 'click', (e) => {
204
+ if (this._selectMode && this._activeTable && this._activeTable.contains(e.target)) return;
126
205
  if (this._activeTable &&
127
206
  !this._activeTable.contains(e.target) &&
128
207
  !this._el.contains(e.target) &&
@@ -169,6 +248,10 @@ export class TableTooltip {
169
248
 
170
249
  const onEditorMove = (e) => {
171
250
  if (_resizing) return;
251
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) {
252
+ clearHover();
253
+ return;
254
+ }
172
255
  // Throttle to one check per animation frame — getBoundingClientRect forces reflow
173
256
  if (_rafEditorMove !== null) return;
174
257
  const target = e.target;
@@ -195,6 +278,7 @@ export class TableTooltip {
195
278
  };
196
279
 
197
280
  const onEditorDown = (e) => {
281
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
198
282
  if (!_nearCell || !_nearEdge) return;
199
283
  _resizing = true;
200
284
  _edge = _nearEdge;
@@ -204,11 +288,16 @@ export class TableTooltip {
204
288
  if (_edge === 'col') {
205
289
  _startW = _nearCell.offsetWidth;
206
290
  _colIdx = getVisualColIndex(_nearCell);
207
- // 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.
208
296
  _colCells = _colIdx >= 0
209
297
  ? Array.from(_table.querySelectorAll('tr'))
210
298
  .map(r => getCellAtVisualCol(r, _colIdx))
211
299
  .filter(Boolean)
300
+ .filter(c => (c.colSpan || 1) === 1)
212
301
  : [];
213
302
  document.body.style.cursor = 'col-resize';
214
303
  } else {
@@ -301,6 +390,12 @@ export class TableTooltip {
301
390
 
302
391
  el.appendChild(this._sep());
303
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
+
304
399
  // Row operations
305
400
  el.appendChild(this._makeBtn(ICONS.rowAbove, 'Add Row Above', () => this._addRow('above')));
306
401
  el.appendChild(this._makeBtn(ICONS.rowBelow, 'Add Row Below', () => this._addRow('below')));
@@ -316,7 +411,8 @@ export class TableTooltip {
316
411
  el.appendChild(this._sep());
317
412
 
318
413
  // Merge cells
319
- 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()));
320
416
 
321
417
  el.appendChild(this._sep());
322
418
 
@@ -336,6 +432,7 @@ export class TableTooltip {
336
432
  this._disposers.push(
337
433
  on(el, 'mouseenter', () => this._clearTimers()),
338
434
  on(el, 'mouseleave', () => {
435
+ if (this._selectMode) return; // keep tooltip alive during cell selection
339
436
  if (this._sizePopover && this._sizePopover.style.display !== 'none') return;
340
437
  this._scheduleHide();
341
438
  }),
@@ -406,6 +503,13 @@ export class TableTooltip {
406
503
  this._el.style.display = 'none';
407
504
  this._activeTable = null;
408
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();
409
513
  this._clearTimers();
410
514
  this._hideSizePopover();
411
515
  }
@@ -455,18 +559,129 @@ export class TableTooltip {
455
559
  || (this._activeTable && this._activeTable.querySelector('td, th'));
456
560
  }
457
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
+
458
665
  // ---------------------------------------------------------------------------
459
666
  // Table operations
460
667
  // ---------------------------------------------------------------------------
461
668
 
462
669
  _addRow(position) {
463
- const cell = this._getCell();
464
- if (!cell) return;
465
- const row = cell.closest('tr');
466
- if (!row) return;
467
- 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);
468
683
  const newRow = document.createElement('tr');
469
- const refCells = Array.from(row.cells);
684
+ const refCells = Array.from(refRow.cells);
470
685
  for (let i = 0; i < colCount; i++) {
471
686
  const td = createElement('td', {}, ['\u00a0']);
472
687
  const ref = refCells[i];
@@ -474,25 +689,24 @@ export class TableTooltip {
474
689
  if (ref && ref.style.minWidth) td.style.minWidth = ref.style.minWidth;
475
690
  newRow.appendChild(td);
476
691
  }
477
- if (position === 'above') row.parentElement?.insertBefore(newRow, row);
478
- else row.insertAdjacentElement('afterend', newRow);
479
- // 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);
480
694
  requestAnimationFrame(() => this._positionNear(this._activeTable));
481
695
  this.context.invoke('editor.afterCommand');
482
696
  }
483
697
 
484
698
  _addColumn(position) {
485
- const cell = this._getCell();
486
- if (!cell) return;
487
- const table = cell.closest('table');
699
+ const cells = this._getSelectedCells();
700
+ if (!cells.length) return;
701
+ const table = cells[0].closest('table');
488
702
  if (!table) return;
489
- const visualColIdx = getVisualColIndex(cell);
703
+ const colIndices = cells.map((c) => getVisualColIndex(c));
704
+ const targetColIdx = position === 'left' ? Math.min(...colIndices) : Math.max(...colIndices);
490
705
  const rows = Array.from(table.querySelectorAll('tr'));
491
- // Batch reads first, then writes — prevents layout thrashing inside the loop
492
- const refs = rows.map(r => position === 'left'
493
- ? getCellAtVisualCol(r, visualColIdx)
494
- : getCellAfterVisualCol(r, visualColIdx));
495
- 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);
496
710
  rows.forEach((r, i) => {
497
711
  r.insertBefore(createElement(isHeaders[i] ? 'th' : 'td', {}, ['\u00a0']), refs[i]);
498
712
  });
@@ -501,34 +715,45 @@ export class TableTooltip {
501
715
  }
502
716
 
503
717
  _deleteRow() {
504
- const cell = this._getCell();
505
- if (!cell) return;
506
- const row = cell.closest('tr');
507
- const table = cell.closest('table');
508
- if (!row || !table) return;
509
- // 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;
510
722
  const tbody = table.querySelector('tbody');
511
- const bodyRows = tbody ? tbody.querySelectorAll('tr').length : table.querySelectorAll('tr').length;
512
- 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;
513
730
  this._activeCell = null;
514
- row.parentElement?.removeChild(row);
731
+ this._clearSelection();
732
+ selectedRows.forEach((r) => r.parentElement?.removeChild(r));
515
733
  requestAnimationFrame(() => this._positionNear(this._activeTable));
516
734
  this.context.invoke('editor.afterCommand');
517
735
  }
518
736
 
519
737
  _deleteColumn() {
520
- const cell = this._getCell();
521
- if (!cell) return;
522
- const table = cell.closest('table');
738
+ const cells = this._getSelectedCells();
739
+ if (!cells.length) return;
740
+ const table = cells[0].closest('table');
523
741
  if (!table) return;
524
- const row = cell.closest('tr');
525
- if (row && row.cells.length <= 1) return;
526
- 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
+ });
527
754
  this._activeCell = null;
528
- const rows = Array.from(table.querySelectorAll('tr'));
529
- // Batch reads before writes to avoid forced reflows inside the loop
530
- const cells = rows.map(r => getCellAtVisualCol(r, visualColIdx));
531
- cells.forEach((c, i) => { if (c) rows[i].removeChild(c); });
755
+ this._clearSelection();
756
+ cellsToDelete.forEach((c) => c.parentElement?.removeChild(c));
532
757
  requestAnimationFrame(() => this._positionNear(this._activeTable));
533
758
  this.context.invoke('editor.afterCommand');
534
759
  }
@@ -536,41 +761,57 @@ export class TableTooltip {
536
761
  _mergeCells() {
537
762
  const cell = this._getCell();
538
763
  if (!cell) return;
539
- const sel = window.getSelection();
540
- if (!sel || sel.rangeCount === 0) return;
541
- const range = sel.getRangeAt(0);
542
764
  const table = cell.closest('table');
543
765
  if (!table) return;
544
766
 
545
- // Collect all cells (in any row) that intersect the selection
546
- const allCells = Array.from(table.querySelectorAll('td, th'));
547
- const selected = allCells.filter((c) => {
548
- try { return range.intersectsNode(c); } catch { return false; }
549
- });
550
- if (selected.length < 2) return;
551
-
552
- // Determine if all selected cells are in the same row (horizontal merge)
553
- const rows = [...new Set(selected.map((c) => c.closest('tr')))];
554
- if (rows.length === 1) {
555
- // Horizontal merge within a single row
556
- const row = rows[0];
557
- const rowSelected = Array.from(row.cells).filter((c) => selected.includes(c));
558
- if (rowSelected.length < 2) return;
559
- const first = rowSelected[0];
560
- first.colSpan = rowSelected.reduce((sum, c) => sum + (c.colSpan || 1), 0);
561
- first.innerHTML = rowSelected.map((c) => c.innerHTML).join('');
562
- rowSelected.slice(1).forEach((c) => row.removeChild(c));
563
- } else {
564
- // Vertical merge across rows — merge into first selected cell (rowspan)
565
- const visualCols = [...new Set(selected.map((c) => getVisualColIndex(c)))];
566
- if (visualCols.length !== 1) return; // only support single-column vertical merge
567
- const first = selected[0];
568
- first.rowSpan = selected.reduce((sum, c) => sum + (c.rowSpan || 1), 0);
569
- first.innerHTML = selected.map((c) => c.innerHTML).join('');
570
- selected.slice(1).forEach((c) => {
571
- 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; }
572
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
+ }
573
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();
574
815
  this.context.invoke('editor.afterCommand');
575
816
  }
576
817
 
@@ -582,6 +823,73 @@ export class TableTooltip {
582
823
  this.context.invoke('editor.afterCommand');
583
824
  }
584
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
+
585
893
  // ---------------------------------------------------------------------------
586
894
  // Size popover (column width / row height)
587
895
  // ---------------------------------------------------------------------------
@@ -647,7 +955,6 @@ export class TableTooltip {
647
955
  if (type === 'border') {
648
956
  const table = cell.closest('table');
649
957
  if (!table) return;
650
- // Read current inline border-width, fall back to computed value, then default 1px
651
958
  const firstCell = table.querySelector('td, th');
652
959
  const currentPx = firstCell
653
960
  ? (parseInt(firstCell.style.borderWidth, 10) ||
@@ -658,19 +965,20 @@ export class TableTooltip {
658
965
  this._sizeInputEl.max = '10';
659
966
  this._sizeInputEl.value = currentPx;
660
967
  this._sizeApply = (val) => {
661
- // Apply border-width to every cell; with border-collapse:collapse this
662
- // controls all grid lines including the outer table border.
663
- // Setting only borderWidth preserves the existing border-color from CSS.
664
968
  const cells = Array.from(table.querySelectorAll('td, th'));
665
969
  if (val === 0) {
666
- cells.forEach(c => { c.style.borderWidth = '0'; c.style.borderStyle = 'none'; });
970
+ cells.forEach((c) => { c.style.borderWidth = '0'; c.style.borderStyle = 'none'; });
667
971
  } else {
668
- 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'; });
669
973
  }
670
974
  this.context.invoke('editor.afterCommand');
671
975
  };
672
976
  } else {
673
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
+ });
674
982
  this._sizeTitleEl.textContent = isCol ? 'Column Width (px)' : 'Row Height (px)';
675
983
  this._sizeInputEl.min = '1';
676
984
  this._sizeInputEl.max = '2000';
@@ -678,20 +986,25 @@ export class TableTooltip {
678
986
  ? (cell.offsetWidth || 120)
679
987
  : (cell.closest('tr') ? (cell.closest('tr').offsetHeight || 40) : 40);
680
988
  this._sizeApply = (val) => {
989
+ const table = cell.closest('table');
990
+ if (!table) return;
681
991
  if (isCol) {
682
- const table = cell.closest('table');
683
- const visualColIdx = getVisualColIndex(cell);
684
- const rows = Array.from(table.querySelectorAll('tr'));
685
- // Batch reads, then writes
686
- const cells = rows.map(r => getCellAtVisualCol(r, visualColIdx));
687
- cells.forEach(c => {
688
- 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
+ });
689
1001
  });
690
1002
  } else {
691
- const row = cell.closest('tr');
692
- 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) => {
693
1006
  for (const c of row.cells) { c.style.height = `${val}px`; c.style.minHeight = `${val}px`; }
694
- }
1007
+ });
695
1008
  }
696
1009
  this.context.invoke('editor.afterCommand');
697
1010
  };