autumnnote 1.0.0 → 1.0.3

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.
@@ -418,11 +418,23 @@ export class ContextMenu {
418
418
  document.execCommand('hiliteColor', false, fmt.backgroundColor);
419
419
  }
420
420
 
421
+ // Font family — apply BEFORE font-size DOM manipulation so the selection is still intact.
422
+ if (fmt.fontFamily) {
423
+ document.execCommand('fontName', false, fmt.fontFamily);
424
+ }
425
+
421
426
  // Font size — use a unique data-marker to avoid touching pre-existing font[size="7"] nodes.
422
427
  if (fmt.fontSize) {
423
428
  const marker = `fs-${Date.now()}`;
429
+ // Snapshot pre-existing font[size="7"] BEFORE execCommand so we don't accidentally
430
+ // replace nodes that were already in the document (the comment below was aspirational
431
+ // but the original code never actually excluded them — this Set approach is the fix).
432
+ const preExisting = new Set(editable.querySelectorAll('font[size="7"]'));
424
433
  document.execCommand('fontSize', false, '7');
425
- editable.querySelectorAll('font[size="7"]').forEach((el) => el.setAttribute('data-an-tmp', marker));
434
+ // Mark only the NEWLY created font[size="7"] elements.
435
+ editable.querySelectorAll('font[size="7"]').forEach((el) => {
436
+ if (!preExisting.has(el)) el.setAttribute('data-an-tmp', marker);
437
+ });
426
438
  editable.querySelectorAll(`[data-an-tmp="${marker}"]`).forEach((el) => {
427
439
  const span = document.createElement('span');
428
440
  span.style.fontSize = fmt.fontSize;
@@ -432,11 +444,6 @@ export class ContextMenu {
432
444
  });
433
445
  }
434
446
 
435
- // Font family — only apply if it was explicitly set (not just inherited default).
436
- if (fmt.fontFamily) {
437
- document.execCommand('fontName', false, fmt.fontFamily);
438
- }
439
-
440
447
  this.context.invoke('editor.afterCommand');
441
448
  }
442
449
 
@@ -77,12 +77,80 @@ export class Editor {
77
77
  }
78
78
  };
79
79
 
80
+ // Guard: when cursor lands at the <li> element node of a checklist item
81
+ // (before the checkbox), nudge it to the correct text position.
82
+ //
83
+ // mouseup: use caretRangeFromPoint / caretPositionFromPoint so the cursor
84
+ // lands WHERE the user actually clicked (middle, end of text…).
85
+ // keyup : arrow-key navigation may land at <li>[0]; move to start-of-text.
86
+ const fixChecklistCursor = (event) => {
87
+ const sel = window.getSelection();
88
+ if (!sel || !sel.rangeCount) return;
89
+ const r = sel.getRangeAt(0);
90
+ if (!r.collapsed) return;
91
+ const sc = r.startContainer;
92
+
93
+ // Only act when cursor is at the <li> element node itself (not inside
94
+ // a text node — the browser already placed it correctly in that case).
95
+ if (sc.nodeType !== Node.ELEMENT_NODE) return;
96
+ const li = sc.matches && sc.matches('.an-checklist li') ? sc : null;
97
+ if (!li) return;
98
+ const cb = li.querySelector('input[type="checkbox"]');
99
+ if (!cb) return;
100
+
101
+ // For mouse events: ask the browser where the pointer landed so the
102
+ // cursor respects the actual click position inside the text.
103
+ if (event && event.type === 'mouseup') {
104
+ let caret = null;
105
+ if (document.caretRangeFromPoint) {
106
+ caret = document.caretRangeFromPoint(event.clientX, event.clientY);
107
+ } else if (document.caretPositionFromPoint) {
108
+ const cp = document.caretPositionFromPoint(event.clientX, event.clientY);
109
+ if (cp) {
110
+ caret = document.createRange();
111
+ caret.setStart(cp.offsetNode, cp.offset);
112
+ }
113
+ }
114
+ // If the caret from point landed inside a text node of this li, use it
115
+ if (caret && editable.contains(caret.startContainer) &&
116
+ caret.startContainer !== li) {
117
+ caret.collapse(true);
118
+ sel.removeAllRanges();
119
+ sel.addRange(caret);
120
+ return;
121
+ }
122
+ }
123
+
124
+ // Fallback (keyboard nav, or caretRangeFromPoint not available / landed
125
+ // at li again): prefer the first text node after the checkbox so the
126
+ // cursor renders at the padding-left edge (after the visual checkbox)
127
+ // rather than at element-level where the browser may place it at x=0.
128
+ const nr = document.createRange();
129
+ let anchorNode = null;
130
+ for (const child of li.childNodes) {
131
+ if (child !== cb && child.nodeType === Node.TEXT_NODE) {
132
+ anchorNode = child;
133
+ break;
134
+ }
135
+ }
136
+ if (anchorNode) {
137
+ nr.setStart(anchorNode, 0);
138
+ } else {
139
+ nr.setStartAfter(cb);
140
+ }
141
+ nr.collapse(true);
142
+ sel.removeAllRanges();
143
+ sel.addRange(nr);
144
+ };
145
+
80
146
  this._disposers.push(
81
147
  on(editable, 'keydown', onKeydown),
82
148
  on(editable, 'beforeinput', onBeforeInput),
83
149
  on(editable, 'input', onInput),
84
150
  on(document, 'selectionchange', onSelChange),
85
151
  on(editable, 'click', onCheckboxClick),
152
+ on(editable, 'mouseup', fixChecklistCursor),
153
+ on(editable, 'keyup', fixChecklistCursor),
86
154
  );
87
155
  }
88
156
 
@@ -114,8 +182,8 @@ export class Editor {
114
182
  return; // let the native paste event fire
115
183
  }
116
184
 
117
- // Show keyboard shortcuts dialog: Shift+?
118
- if (event.key === '?' && event.shiftKey && !event.ctrlKey && !event.metaKey) {
185
+ // Show keyboard shortcuts dialog: Ctrl+Shift+/
186
+ if (event.key === '/' && event.shiftKey && event.ctrlKey && !event.metaKey) {
119
187
  event.preventDefault();
120
188
  this.context.invoke('shortcutsDialog.show');
121
189
  return;
@@ -318,6 +318,9 @@ export class IconDialog {
318
318
  this._selectedIcon = null;
319
319
  this._activeCat = 'all';
320
320
  this._searchInput.value = '';
321
+ // Clear previously selected icon highlight and disable Insert button
322
+ this._grid.querySelectorAll('.an-icon-cell.active').forEach((c) => c.classList.remove('active'));
323
+ this._insertBtn.setAttribute('disabled', '');
321
324
  this._updateCatTabs();
322
325
  this._filterIcons('', 'all');
323
326
  this._updatePreview(null);
@@ -551,6 +554,7 @@ export class IconDialog {
551
554
  const iconEl = document.createElement('i');
552
555
  iconEl.className = `${cls} fa-${this._selectedIcon}`;
553
556
  iconEl.setAttribute('aria-hidden', 'true');
557
+ iconEl.setAttribute('contenteditable', 'false');
554
558
  if (styleParts.length) iconEl.setAttribute('style', styleParts.join(';'));
555
559
 
556
560
  const savedRange = this._savedRange;
@@ -574,21 +578,26 @@ export class IconDialog {
574
578
  range.deleteContents();
575
579
  range.insertNode(iconEl);
576
580
 
577
- // 4. Insert a zero-width space text node immediately after the icon.
578
- // This is essential when the icon lands at the END of a paragraph:
579
- // browsers cannot place the caret after an inline element that is the
580
- // last child of a block — there is no text node to anchor into.
581
- // The ZWS gives the caret a real text node to sit in, so typing after
582
- // the icon works correctly. It is invisible to the reader.
583
- const zwsNode = document.createTextNode('\u200B');
584
- iconEl.parentNode.insertBefore(zwsNode, iconEl.nextSibling);
585
-
586
- // 5. Place caret inside the ZWS text node (offset 1 = after the ZWS char).
587
- range.setStart(zwsNode, 1);
588
- range.collapse(true);
581
+ // 4. Place cursor after the icon.
582
+ // Keep a text anchor (\u200B) after the icon so ArrowLeft/ArrowRight and
583
+ // caret placement at end-of-line stay stable across browsers.
584
+ let caretTextNode = iconEl.nextSibling;
585
+ if (!caretTextNode || caretTextNode.nodeType !== Node.TEXT_NODE) {
586
+ caretTextNode = document.createTextNode('\u200B');
587
+ iconEl.parentNode.insertBefore(caretTextNode, iconEl.nextSibling);
588
+ } else if (!caretTextNode.textContent) {
589
+ // Split text-node insertions can leave an empty text node after icon.
590
+ // Fill it with ZWS so caret-after-icon remains stable at end-of-line.
591
+ caretTextNode.textContent = '\u200B';
592
+ }
593
+
594
+ const caretOffset = ((caretTextNode.textContent || '').startsWith('\u200B')) ? 1 : 0;
595
+ const caretRange = document.createRange();
596
+ caretRange.setStart(caretTextNode, Math.min(caretOffset, caretTextNode.textContent.length));
597
+ caretRange.collapse(true);
589
598
  if (sel) {
590
599
  sel.removeAllRanges();
591
- sel.addRange(range);
600
+ sel.addRange(caretRange);
592
601
  }
593
602
 
594
603
  // 5. Close dialog last — same as ImageDialog
@@ -596,6 +605,11 @@ export class IconDialog {
596
605
 
597
606
  // 6. Restore editor focus (needed for toolbar refresh via afterCommand)
598
607
  editable.focus();
608
+ if (sel) {
609
+ // Some browsers reset selection when refocusing editable after closing dialog.
610
+ sel.removeAllRanges();
611
+ sel.addRange(caretRange);
612
+ }
599
613
  this.context.invoke('editor.afterCommand');
600
614
  }
601
615
 
@@ -27,11 +27,14 @@ export class ImageResizer {
27
27
  this._activeImg = null;
28
28
  this._overlay = null;
29
29
  this._disposers = [];
30
+ this._positionRaf = null;
30
31
  }
31
32
 
32
33
  initialize() {
33
34
  this._overlay = this._buildOverlay();
34
- document.body.appendChild(this._overlay);
35
+ const container = this.context.layoutInfo.editable.closest('.an-container') || document.body;
36
+ container.appendChild(this._overlay);
37
+ this._container = container;
35
38
 
36
39
  const editable = this.context.layoutInfo.editable;
37
40
 
@@ -58,6 +61,10 @@ export class ImageResizer {
58
61
  this._dragDisposers.forEach((d) => d());
59
62
  this._dragDisposers = null;
60
63
  }
64
+ if (this._positionRaf) {
65
+ cancelAnimationFrame(this._positionRaf);
66
+ this._positionRaf = null;
67
+ }
61
68
  this._deselect();
62
69
  if (this._overlay && this._overlay.parentNode) {
63
70
  this._overlay.parentNode.removeChild(this._overlay);
@@ -148,10 +155,22 @@ export class ImageResizer {
148
155
  }
149
156
 
150
157
  _updateOverlayPosition() {
158
+ if (this._positionRaf) cancelAnimationFrame(this._positionRaf);
159
+ this._positionRaf = requestAnimationFrame(() => {
160
+ this._positionRaf = null;
161
+ this._updateOverlayPositionNow();
162
+ });
163
+ }
164
+
165
+ _updateOverlayPositionNow() {
151
166
  if (!this._activeImg || !this._overlay) return;
167
+ const offsetParent = this._overlay.offsetParent || this._container;
168
+ const containerRect = offsetParent.getBoundingClientRect();
152
169
  const rect = this._activeImg.getBoundingClientRect();
153
- this._overlay.style.left = `${rect.left}px`;
154
- this._overlay.style.top = `${rect.top}px`;
170
+ const left = rect.left - containerRect.left + offsetParent.scrollLeft;
171
+ const top = rect.top - containerRect.top + offsetParent.scrollTop;
172
+ this._overlay.style.left = `${left}px`;
173
+ this._overlay.style.top = `${top}px`;
155
174
  this._overlay.style.width = `${rect.width}px`;
156
175
  this._overlay.style.height = `${rect.height}px`;
157
176
  }
@@ -36,7 +36,8 @@ export class ImageTooltip {
36
36
  this._disposers.push(
37
37
  on(editable, 'mouseover', (e) => {
38
38
  const img = e.target.closest('img');
39
- if (img && editable.contains(img)) {
39
+ // Skip when the image is inside a link — LinkTooltip takes priority there
40
+ if (img && editable.contains(img) && !img.closest('a[href]')) {
40
41
  this._scheduleShow(img);
41
42
  }
42
43
  }),
@@ -205,10 +206,11 @@ export class ImageTooltip {
205
206
  _setFloat(value) {
206
207
  const img = this._activeImg;
207
208
  if (!img) return;
208
- img.style.float = value;
209
- img.style.display = '';
210
- img.style.marginLeft = value === 'right' ? '12px' : '';
211
- img.style.marginRight = value === 'left' ? '12px' : '';
209
+ const target = img.closest('figure.an-figure') || img;
210
+ target.style.float = value;
211
+ target.style.display = '';
212
+ target.style.marginLeft = value === 'right' ? '12px' : '';
213
+ target.style.marginRight = value === 'left' ? '12px' : '';
212
214
  this.context.invoke('editor.afterCommand');
213
215
  this.context.invoke('imageResizer.updateOverlay');
214
216
  this._positionNear(img);
@@ -217,10 +219,11 @@ export class ImageTooltip {
217
219
  _setCenter() {
218
220
  const img = this._activeImg;
219
221
  if (!img) return;
220
- img.style.float = '';
221
- img.style.display = 'block';
222
- img.style.marginLeft = 'auto';
223
- img.style.marginRight = 'auto';
222
+ const target = img.closest('figure.an-figure') || img;
223
+ target.style.float = '';
224
+ target.style.display = 'block';
225
+ target.style.marginLeft = 'auto';
226
+ target.style.marginRight = 'auto';
224
227
  this.context.invoke('editor.afterCommand');
225
228
  this.context.invoke('imageResizer.updateOverlay');
226
229
  this._positionNear(img);
@@ -241,7 +244,12 @@ export class ImageTooltip {
241
244
  if (!img) return;
242
245
  this._hide();
243
246
  this.context.invoke('imageResizer.deselect');
244
- if (img.parentNode) img.parentNode.removeChild(img);
247
+ const figure = img.closest('figure.an-figure');
248
+ if (figure && figure.parentNode) {
249
+ figure.parentNode.removeChild(figure);
250
+ } else if (img.parentNode) {
251
+ img.parentNode.removeChild(img);
252
+ }
245
253
  this.context.invoke('editor.afterCommand');
246
254
  }
247
255
 
@@ -266,6 +274,23 @@ export class ImageTooltip {
266
274
  // Wrap image in <figure><figcaption>
267
275
  const figure = document.createElement('figure');
268
276
  figure.className = 'an-figure';
277
+
278
+ // Transfer existing alignment from <img> to <figure> (C4 fix)
279
+ if (img.style.float) {
280
+ figure.style.float = img.style.float;
281
+ figure.style.marginLeft = img.style.marginLeft;
282
+ figure.style.marginRight = img.style.marginRight;
283
+ img.style.float = '';
284
+ img.style.marginLeft = '';
285
+ img.style.marginRight = '';
286
+ } else if (img.style.display === 'block' && img.style.marginLeft === 'auto') {
287
+ figure.style.display = 'block';
288
+ figure.style.marginLeft = 'auto';
289
+ figure.style.marginRight = 'auto';
290
+ img.style.display = '';
291
+ img.style.marginLeft = '';
292
+ img.style.marginRight = '';
293
+ }
269
294
  const figcaption = document.createElement('figcaption');
270
295
  figcaption.className = 'an-figcaption';
271
296
  figcaption.textContent = 'Caption';
@@ -281,6 +306,8 @@ export class ImageTooltip {
281
306
  if (sel) { sel.removeAllRanges(); sel.addRange(range); }
282
307
 
283
308
  this.context.invoke('editor.afterCommand');
309
+ // Re-sync the resize overlay: wrapping img in <figure> changes its layout position.
310
+ this.context.invoke('imageResizer.updateOverlay');
284
311
  this._hide();
285
312
  }
286
313
  }
@@ -38,7 +38,8 @@ export class Placeholder {
38
38
 
39
39
  _update() {
40
40
  const editable = this.context.layoutInfo.editable;
41
+ const isFocused = document.activeElement === editable;
41
42
  const isEmpty = !editable.textContent.trim() && !editable.querySelector('img, table, hr');
42
- editable.classList.toggle('an-placeholder', isEmpty);
43
+ editable.classList.toggle('an-placeholder', isEmpty && !isFocused);
43
44
  }
44
45
  }
@@ -130,8 +130,15 @@ export class Statusbar {
130
130
 
131
131
  const applyDelta = (clientY) => {
132
132
  const delta = clientY - startY;
133
- const minH = this.options.minHeight || 100;
134
- containerEl.style.height = `${Math.max(minH, startH + delta)}px`;
133
+ // Compute the true minimum: fixed elements (toolbar + statusbar) must fit
134
+ // inside the container. Sum the offsetHeight of every child that is NOT
135
+ // the editable area, then add a small floor so the editable stays visible.
136
+ const MIN_EDITABLE = 40;
137
+ const fixedH = Array.from(containerEl.children)
138
+ .filter(child => !child.classList.contains('an-editable'))
139
+ .reduce((sum, child) => sum + child.offsetHeight, 0);
140
+ const trueMin = Math.max(this.options.minHeight || 100, fixedH + MIN_EDITABLE);
141
+ containerEl.style.height = `${Math.max(trueMin, startH + delta)}px`;
135
142
  };
136
143
 
137
144
  // Mouse drag
@@ -146,6 +153,11 @@ export class Statusbar {
146
153
  const onMouseDown = (event) => {
147
154
  startY = event.clientY;
148
155
  startH = containerEl.offsetHeight;
156
+ // Clear the editable's inline min-height so the flex layout can compress
157
+ // it freely once the container has a fixed height. Without this, the
158
+ // editable's min-height (set from options.height) overflows the container
159
+ // when the user drags the handle to a size smaller than that value.
160
+ this.context.layoutInfo.editable.style.minHeight = '';
149
161
  document.addEventListener('mousemove', onMouseMove);
150
162
  document.addEventListener('mouseup', onMouseUp);
151
163
  // Track drag-phase listeners so destroy() can remove them mid-drag
@@ -173,6 +185,8 @@ export class Statusbar {
173
185
  if (!touch) return;
174
186
  startY = touch.clientY;
175
187
  startH = containerEl.offsetHeight;
188
+ // Same as onMouseDown: clear editable min-height so flex can compress it
189
+ this.context.layoutInfo.editable.style.minHeight = '';
176
190
  document.addEventListener('touchmove', onTouchMove, { passive: false });
177
191
  document.addEventListener('touchend', onTouchEnd);
178
192
  this._dragDisposers = [
@@ -6,6 +6,64 @@ import { createElement, on } from '../core/dom.js';
6
6
  const SHOW_DELAY = 120;
7
7
  const HIDE_DELAY = 200;
8
8
 
9
+ // ---------------------------------------------------------------------------
10
+ // Table helpers — visual column index (accounts for colspan)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Returns the visual (logical) column index of a cell, taking colspan into
15
+ * account for all preceding cells in the same row.
16
+ * @param {HTMLTableCellElement} cell
17
+ * @returns {number} 0-based visual column index, or -1 on failure
18
+ */
19
+ function getVisualColIndex(cell) {
20
+ const row = cell.closest('tr');
21
+ if (!row) return -1;
22
+ let visualIdx = 0;
23
+ for (const c of row.cells) {
24
+ if (c === cell) return visualIdx;
25
+ visualIdx += c.colSpan || 1;
26
+ }
27
+ return -1;
28
+ }
29
+
30
+ /**
31
+ * Finds the first cell in a row whose visual start column equals visualIdx.
32
+ * Returns null if no exact match (e.g. the column is spanned by a merged cell).
33
+ * @param {HTMLTableRowElement} row
34
+ * @param {number} visualIdx
35
+ * @returns {HTMLTableCellElement|null}
36
+ */
37
+ function getCellAtVisualCol(row, visualIdx) {
38
+ let vIdx = 0;
39
+ for (const c of row.cells) {
40
+ if (vIdx === visualIdx) return c;
41
+ if (vIdx > visualIdx) break;
42
+ vIdx += c.colSpan || 1;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Finds the first cell whose visual range ends after visualIdx
49
+ * (used for inserting a new column to the right of visualIdx).
50
+ * @param {HTMLTableRowElement} row
51
+ * @param {number} visualIdx
52
+ * @returns {HTMLTableCellElement|null} reference cell for insertBefore, or null = append
53
+ */
54
+ function getCellAfterVisualCol(row, visualIdx) {
55
+ let vIdx = 0;
56
+ for (const c of row.cells) {
57
+ vIdx += c.colSpan || 1;
58
+ if (vIdx > visualIdx) {
59
+ // next cell after the one that starts at / spans visualIdx
60
+ const next = c.nextElementSibling;
61
+ return (next && next.tagName === 'TD' || next && next.tagName === 'TH') ? next : null;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
9
67
  const ICONS = {
10
68
  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>`,
11
69
  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>`,
@@ -109,6 +167,9 @@ export class TableTooltip {
109
167
  if (_resizing) return;
110
168
  const cell = e.target.closest('td, th');
111
169
  if (!cell || !editable.contains(cell)) { clearHover(); return; }
170
+ if (_nearCell && _nearCell !== cell) {
171
+ _nearCell.style.cursor = '';
172
+ }
112
173
  const rect = cell.getBoundingClientRect();
113
174
  const onRight = Math.abs(e.clientX - rect.right) < HIT;
114
175
  const onBottom = Math.abs(e.clientY - rect.bottom) < HIT;
@@ -136,7 +197,7 @@ export class TableTooltip {
136
197
  _table = _nearCell.closest('table');
137
198
  if (_edge === 'col') {
138
199
  _startW = _nearCell.offsetWidth;
139
- _colIdx = Array.from(_nearCell.closest('tr').cells).indexOf(_nearCell);
200
+ _colIdx = getVisualColIndex(_nearCell);
140
201
  document.body.style.cursor = 'col-resize';
141
202
  } else {
142
203
  _row = _nearCell.closest('tr');
@@ -154,7 +215,7 @@ export class TableTooltip {
154
215
  const newW = Math.max(30, _startW + (e.clientX - _startX));
155
216
  if (_table && _colIdx >= 0) {
156
217
  Array.from(_table.querySelectorAll('tr')).forEach((r) => {
157
- const c = r.cells[_colIdx];
218
+ const c = getCellAtVisualCol(r, _colIdx);
158
219
  if (c) { c.style.width = `${newW}px`; c.style.minWidth = `${newW}px`; }
159
220
  });
160
221
  }
@@ -356,6 +417,16 @@ export class TableTooltip {
356
417
  // ---------------------------------------------------------------------------
357
418
 
358
419
  _getCell() {
420
+ // Prefer the cell under the current text cursor (most intuitive for operations)
421
+ const sel = window.getSelection();
422
+ if (sel && sel.rangeCount) {
423
+ let container = sel.getRangeAt(0).commonAncestorContainer;
424
+ if (container.nodeType === 3) container = container.parentElement;
425
+ const cellFromSel = container && container.closest && container.closest('td, th');
426
+ if (cellFromSel && this._activeTable && this._activeTable.contains(cellFromSel)) {
427
+ return cellFromSel;
428
+ }
429
+ }
359
430
  return this._activeCell
360
431
  || (this._activeTable && this._activeTable.querySelector('td, th'));
361
432
  }
@@ -388,16 +459,19 @@ export class TableTooltip {
388
459
  _addColumn(position) {
389
460
  const cell = this._getCell();
390
461
  if (!cell) return;
391
- const row = cell.closest('tr');
392
462
  const table = cell.closest('table');
393
- if (!row || !table) return;
394
- const colIndex = Array.from(row.cells).indexOf(cell);
463
+ if (!table) return;
464
+ const visualColIdx = getVisualColIndex(cell);
395
465
  Array.from(table.querySelectorAll('tr')).forEach((r) => {
396
- const cells = Array.from(r.cells);
397
466
  const isHeader = r.closest('thead') !== null;
398
467
  const newCell = createElement(isHeader ? 'th' : 'td', {}, ['\u00a0']);
399
- const ref = position === 'left' ? cells[colIndex] : (cells[colIndex + 1] || null);
400
- r.insertBefore(newCell, ref);
468
+ if (position === 'left') {
469
+ const ref = getCellAtVisualCol(r, visualColIdx);
470
+ r.insertBefore(newCell, ref);
471
+ } else {
472
+ const ref = getCellAfterVisualCol(r, visualColIdx);
473
+ r.insertBefore(newCell, ref);
474
+ }
401
475
  });
402
476
  this._positionNear(this._activeTable);
403
477
  this.context.invoke('editor.afterCommand');
@@ -422,14 +496,14 @@ export class TableTooltip {
422
496
  _deleteColumn() {
423
497
  const cell = this._getCell();
424
498
  if (!cell) return;
425
- const row = cell.closest('tr');
426
499
  const table = cell.closest('table');
427
- if (!row || !table) return;
428
- if (row.cells.length <= 1) return;
429
- const colIndex = Array.from(row.cells).indexOf(cell);
500
+ if (!table) return;
501
+ const row = cell.closest('tr');
502
+ if (row && row.cells.length <= 1) return;
503
+ const visualColIdx = getVisualColIndex(cell);
430
504
  this._activeCell = null;
431
505
  Array.from(table.querySelectorAll('tr')).forEach((r) => {
432
- const c = r.cells[colIndex];
506
+ const c = getCellAtVisualCol(r, visualColIdx);
433
507
  if (c) r.removeChild(c);
434
508
  });
435
509
  this._positionNear(this._activeTable);
@@ -439,19 +513,41 @@ export class TableTooltip {
439
513
  _mergeCells() {
440
514
  const cell = this._getCell();
441
515
  if (!cell) return;
442
- const row = cell.closest('tr');
443
- if (!row) return;
444
516
  const sel = window.getSelection();
445
517
  if (!sel || sel.rangeCount === 0) return;
446
518
  const range = sel.getRangeAt(0);
447
- const selected = Array.from(row.cells).filter((c) => {
519
+ const table = cell.closest('table');
520
+ if (!table) return;
521
+
522
+ // Collect all cells (in any row) that intersect the selection
523
+ const allCells = Array.from(table.querySelectorAll('td, th'));
524
+ const selected = allCells.filter((c) => {
448
525
  try { return range.intersectsNode(c); } catch { return false; }
449
526
  });
450
527
  if (selected.length < 2) return;
451
- const first = selected[0];
452
- first.colSpan = selected.reduce((sum, c) => sum + (c.colSpan || 1), 0);
453
- first.innerHTML = selected.map((c) => c.innerHTML).join('');
454
- selected.slice(1).forEach((c) => row.removeChild(c));
528
+
529
+ // Determine if all selected cells are in the same row (horizontal merge)
530
+ const rows = [...new Set(selected.map((c) => c.closest('tr')))];
531
+ if (rows.length === 1) {
532
+ // Horizontal merge within a single row
533
+ const row = rows[0];
534
+ const rowSelected = Array.from(row.cells).filter((c) => selected.includes(c));
535
+ if (rowSelected.length < 2) return;
536
+ const first = rowSelected[0];
537
+ first.colSpan = rowSelected.reduce((sum, c) => sum + (c.colSpan || 1), 0);
538
+ first.innerHTML = rowSelected.map((c) => c.innerHTML).join('');
539
+ rowSelected.slice(1).forEach((c) => row.removeChild(c));
540
+ } else {
541
+ // Vertical merge across rows — merge into first selected cell (rowspan)
542
+ const visualCols = [...new Set(selected.map((c) => getVisualColIndex(c)))];
543
+ if (visualCols.length !== 1) return; // only support single-column vertical merge
544
+ const first = selected[0];
545
+ first.rowSpan = selected.reduce((sum, c) => sum + (c.rowSpan || 1), 0);
546
+ first.innerHTML = selected.map((c) => c.innerHTML).join('');
547
+ selected.slice(1).forEach((c) => {
548
+ if (c.closest('tr')) c.closest('tr').removeChild(c);
549
+ });
550
+ }
455
551
  this.context.invoke('editor.afterCommand');
456
552
  }
457
553
 
@@ -532,10 +628,10 @@ export class TableTooltip {
532
628
 
533
629
  this._sizeApply = (val) => {
534
630
  if (isCol) {
535
- const table = cell.closest('table');
536
- const colIndex = Array.from(cell.closest('tr').cells).indexOf(cell);
631
+ const table = cell.closest('table');
632
+ const visualColIdx = getVisualColIndex(cell);
537
633
  Array.from(table.querySelectorAll('tr')).forEach((r) => {
538
- const c = r.cells[colIndex];
634
+ const c = getCellAtVisualCol(r, visualColIdx);
539
635
  if (c) { c.style.width = `${val}px`; c.style.minWidth = `${val}px`; }
540
636
  });
541
637
  } else {