autumnnote 2.0.0 → 2.1.0

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.
@@ -2,132 +2,17 @@
2
2
  // Shows a horizontal action bar above (or below) the hovered table,
3
3
  // similar in appearance and interaction to ImageTooltip / VideoTooltip.
4
4
  import { createElement, on } from '../core/dom.js';
5
+ import { ICONS } from './table-icons.js';
6
+ import {
7
+ getVisualColIndex,
8
+ getCellAtVisualCol,
9
+ getCellAfterVisualCol,
10
+ buildGridMap,
11
+ } from './table-grid.js';
5
12
 
6
13
  const SHOW_DELAY = 120;
7
14
  const HIDE_DELAY = 200;
8
15
 
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?.tagName === 'TD' || next?.tagName === 'TH') ? /** @type {HTMLTableCellElement} */ (next) : null;
62
- }
63
- }
64
- return null;
65
- }
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
-
104
- const ICONS = {
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>`,
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>`,
107
- deleteRow: `<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"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="21" y1="15" x2="15" y2="21"/></svg>`,
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>`,
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>`,
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>`,
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>`,
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>`,
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>`,
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>`,
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>`,
118
- cellShade: `<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"><path d="M19 11L8.93 3.36a1 1 0 0 0-1.29.08L3.22 7.8a1 1 0 0 0-.07 1.29L11 20"/><path d="m5 14 5-5"/><path d="M22 22a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2c0-1.5 2.5-5 3.5-5s3.5 3.5 3.5 5z"/></svg>`,
119
- borderColor: `<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="14" rx="1"/><line x1="3" y1="10" x2="21" y2="10" stroke-width="1.5"/><line x1="12" y1="3" x2="12" y2="17" stroke-width="1.5"/><path d="M3 21h18" stroke-width="3"/></svg>`,
120
- alignLeft: `<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="21" y1="6" x2="3" y2="6"/><line x1="15" y1="12" x2="3" y2="12"/><line x1="17" y1="18" x2="3" y2="18"/></svg>`,
121
- alignCenter: `<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="21" y1="6" x2="3" y2="6"/><line x1="17" y1="12" x2="7" y2="12"/><line x1="19" y1="18" x2="5" y2="18"/></svg>`,
122
- alignRight: `<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="21" y1="6" x2="3" y2="6"/><line x1="21" y1="12" x2="9" y2="12"/><line x1="21" y1="18" x2="7" y2="18"/></svg>`,
123
- alignJustify: `<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="21" y1="6" x2="3" y2="6"/><line x1="21" y1="12" x2="3" y2="12"/><line x1="21" y1="18" x2="3" y2="18"/></svg>`,
124
- headerRow: `<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"/><rect x="3" y="3" width="18" height="8" rx="1" fill="currentColor" opacity="0.2"/><line x1="3" y1="11" x2="21" y2="11"/><line x1="3" y1="16" x2="21" y2="16"/><line x1="9" y1="11" x2="9" y2="21"/><line x1="15" y1="11" x2="15" y2="21"/></svg>`,
125
- sortAsc: `<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="6" x2="11" y2="6"/><line x1="4" y1="12" x2="11" y2="12"/><line x1="4" y1="18" x2="13" y2="18"/><path d="M15 9l3-3 3 3"/><line x1="18" y1="6" x2="18" y2="18"/></svg>`,
126
- sortDesc: `<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="6" x2="13" y2="6"/><line x1="4" y1="12" x2="11" y2="12"/><line x1="4" y1="18" x2="11" y2="18"/><path d="M15 15l3 3 3-3"/><line x1="18" y1="6" x2="18" y2="18"/></svg>`,
127
- exportCSV: `<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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
128
- cellPadding: `<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"/><rect x="7" y="7" width="10" height="10" rx="0.5" stroke-dasharray="2 1.5"/></svg>`,
129
- };
130
-
131
16
  const SHADE_PRESETS = [
132
17
  '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#efefef', '#ffffff',
133
18
  '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
@@ -1189,16 +1074,33 @@ export class TableTooltip {
1189
1074
  }
1190
1075
 
1191
1076
  // ---------------------------------------------------------------------------
1192
- // Cell background shade popover
1077
+ // Colour popovers (cell shading + table border colour)
1193
1078
  // ---------------------------------------------------------------------------
1194
1079
 
1195
- _buildCellShadePopover() {
1080
+ /**
1081
+ * Builds one of the two colour popovers. Both are the same widget — a preset
1082
+ * palette, a "no colour" reset row, and a custom `<input type="color">` — and
1083
+ * they were previously two near-identical 55-line methods, so a fix applied
1084
+ * to one silently missed the other. They differ only in the swatch the custom
1085
+ * input starts on and in what the chosen colour is applied to.
1086
+ *
1087
+ * `getPopover` is read lazily rather than closed over: `destroy()` nulls the
1088
+ * field, and the deferred handlers must see that instead of holding the
1089
+ * detached element alive.
1090
+ *
1091
+ * @param {object} config
1092
+ * @param {string} config.defaultColor - initial value of the custom input
1093
+ * @param {(color: string) => void} config.apply - called with '' to clear
1094
+ * @param {() => HTMLElement|null} config.getPopover
1095
+ * @param {() => void} config.hide
1096
+ * @returns {{ el: HTMLElement, titleEl: HTMLElement, noColorBtn: HTMLElement }}
1097
+ */
1098
+ _buildColorPopover({ defaultColor, apply, getPopover, hide }) {
1196
1099
  const pop = createElement('div', { class: 'an-cell-shade-popover' });
1197
1100
  pop.style.display = 'none';
1198
1101
 
1199
- const title = createElement('div', { class: 'an-size-popover-title' });
1200
- pop.appendChild(title);
1201
- this._shadeTitleEl = title;
1102
+ const titleEl = createElement('div', { class: 'an-size-popover-title' });
1103
+ pop.appendChild(titleEl);
1202
1104
 
1203
1105
  // 24-color palette — reuse existing CSS classes
1204
1106
  const palette = createElement('div', { class: 'an-context-color-palette' });
@@ -1207,26 +1109,25 @@ export class TableTooltip {
1207
1109
  sw.style.background = color;
1208
1110
  this._disposers.push(on(sw, 'click', (e) => {
1209
1111
  e.stopPropagation();
1210
- this._applyCellShade(color);
1112
+ apply(color);
1211
1113
  }));
1212
1114
  palette.appendChild(sw);
1213
1115
  });
1214
1116
  pop.appendChild(palette);
1215
1117
 
1216
- // "No shading" row — clears background color
1217
- const noShadeRow = createElement('div', { class: 'an-context-color-custom' });
1218
- const noShadeBtn = createElement('button', { type: 'button', class: 'an-shade-no-color' });
1219
- this._disposers.push(on(noShadeBtn, 'click', () => this._applyCellShade('')));
1220
- noShadeRow.appendChild(noShadeBtn);
1221
- pop.appendChild(noShadeRow);
1222
- this._shadeNoBtn = noShadeBtn;
1118
+ // Reset row — clears the colour
1119
+ const noColorRow = createElement('div', { class: 'an-context-color-custom' });
1120
+ const noColorBtn = createElement('button', { type: 'button', class: 'an-shade-no-color' });
1121
+ this._disposers.push(on(noColorBtn, 'click', () => apply('')));
1122
+ noColorRow.appendChild(noColorBtn);
1123
+ pop.appendChild(noColorRow);
1223
1124
 
1224
1125
  // Custom color input
1225
1126
  const customRow = createElement('div', { class: 'an-context-color-custom' });
1226
- const colorInput = /** @type {HTMLInputElement} */ (createElement('input', { type: 'color', class: 'an-shade-color-input', value: '#ffffff' }));
1127
+ const colorInput = /** @type {HTMLInputElement} */ (createElement('input', { type: 'color', class: 'an-shade-color-input', value: defaultColor }));
1227
1128
  const customLabel = createElement('span');
1228
1129
  customLabel.textContent = this.context.locale.contextMenu.customColorLabel;
1229
- this._disposers.push(on(colorInput, 'change', () => this._applyCellShade(colorInput.value)));
1130
+ this._disposers.push(on(colorInput, 'change', () => apply(colorInput.value)));
1230
1131
  customRow.appendChild(colorInput);
1231
1132
  customRow.appendChild(customLabel);
1232
1133
  pop.appendChild(customRow);
@@ -1239,39 +1140,66 @@ export class TableTooltip {
1239
1140
  // Close on outside click
1240
1141
  on(document, 'click', (e) => {
1241
1142
  const et = /** @type {Node} */ (e.target);
1242
- if (this._shadePopover &&
1243
- this._shadePopover.style.display !== 'none' &&
1244
- !this._shadePopover.contains(et) &&
1143
+ const current = getPopover();
1144
+ if (current &&
1145
+ current.style.display !== 'none' &&
1146
+ !current.contains(et) &&
1245
1147
  !this._el?.contains(et)) {
1246
- this._hideCellShadePopover();
1148
+ hide();
1247
1149
  }
1248
1150
  }),
1249
1151
  );
1250
1152
 
1251
- return pop;
1153
+ return { el: pop, titleEl, noColorBtn };
1252
1154
  }
1253
1155
 
1254
- _openCellShadePopover() {
1255
- if (!this._shadePopover) return;
1256
- const L = this.context.locale.tooltips.table;
1257
- if (this._shadeTitleEl) this._shadeTitleEl.textContent = L.cellBackground;
1258
- if (this._shadeNoBtn) this._shadeNoBtn.textContent = L.noShading;
1259
-
1260
- this._shadePopover.style.display = 'block';
1156
+ /**
1157
+ * Shows a colour popover under the tooltip, flipping above it when there is
1158
+ * no room below. Measurement has to wait a frame because the popover is
1159
+ * display:none until now and would otherwise report zero size.
1160
+ * @param {() => HTMLElement|null} getPopover
1161
+ */
1162
+ _showColorPopover(getPopover) {
1163
+ const pop = getPopover();
1164
+ if (!pop) return;
1165
+ pop.style.display = 'block';
1261
1166
  requestAnimationFrame(() => {
1262
- if (!this._shadePopover || !this._el) return;
1263
- const pw = this._shadePopover.offsetWidth || 170;
1264
- const ph = this._shadePopover.offsetHeight || 120;
1167
+ const current = getPopover();
1168
+ if (!current || !this._el) return;
1169
+ const pw = current.offsetWidth || 170;
1170
+ const ph = current.offsetHeight || 120;
1265
1171
  const tipRect = this._el.getBoundingClientRect();
1266
1172
  let left = tipRect.left;
1267
1173
  let top = tipRect.bottom + 6;
1268
1174
  if (left + pw > globalThis.innerWidth - 8) left = globalThis.innerWidth - pw - 8;
1269
1175
  if (top + ph > globalThis.innerHeight - 8) top = tipRect.top - ph - 6;
1270
- this._shadePopover.style.left = `${Math.max(8, left)}px`;
1271
- this._shadePopover.style.top = `${Math.max(8, top)}px`;
1176
+ current.style.left = `${Math.max(8, left)}px`;
1177
+ current.style.top = `${Math.max(8, top)}px`;
1272
1178
  });
1273
1179
  }
1274
1180
 
1181
+ // --- Cell background shade ---
1182
+
1183
+ _buildCellShadePopover() {
1184
+ const { el, titleEl, noColorBtn } = this._buildColorPopover({
1185
+ defaultColor: '#ffffff',
1186
+ apply: (color) => this._applyCellShade(color),
1187
+ getPopover: () => this._shadePopover,
1188
+ hide: () => this._hideCellShadePopover(),
1189
+ });
1190
+ this._shadeTitleEl = titleEl;
1191
+ this._shadeNoBtn = noColorBtn;
1192
+ return el;
1193
+ }
1194
+
1195
+ _openCellShadePopover() {
1196
+ if (!this._shadePopover) return;
1197
+ const L = this.context.locale.tooltips.table;
1198
+ if (this._shadeTitleEl) this._shadeTitleEl.textContent = L.cellBackground;
1199
+ if (this._shadeNoBtn) this._shadeNoBtn.textContent = L.noShading;
1200
+ this._showColorPopover(() => this._shadePopover);
1201
+ }
1202
+
1275
1203
  _hideCellShadePopover() {
1276
1204
  if (this._shadePopover) this._shadePopover.style.display = 'none';
1277
1205
  }
@@ -1289,62 +1217,18 @@ export class TableTooltip {
1289
1217
  this.context.invoke('editor.afterCommand');
1290
1218
  }
1291
1219
 
1292
- // ---------------------------------------------------------------------------
1293
- // Table border color popover
1294
- // ---------------------------------------------------------------------------
1220
+ // --- Table border colour ---
1295
1221
 
1296
1222
  _buildBorderColorPopover() {
1297
- const pop = createElement('div', { class: 'an-cell-shade-popover' });
1298
- pop.style.display = 'none';
1299
-
1300
- const title = createElement('div', { class: 'an-size-popover-title' });
1301
- pop.appendChild(title);
1302
- this._borderColorTitleEl = title;
1303
-
1304
- const palette = createElement('div', { class: 'an-context-color-palette' });
1305
- SHADE_PRESETS.forEach((color) => {
1306
- const sw = createElement('div', { class: 'an-context-color-swatch', title: color });
1307
- sw.style.background = color;
1308
- this._disposers.push(on(sw, 'click', (e) => {
1309
- e.stopPropagation();
1310
- this._applyBorderColor(color);
1311
- }));
1312
- palette.appendChild(sw);
1223
+ const { el, titleEl, noColorBtn } = this._buildColorPopover({
1224
+ defaultColor: '#000000',
1225
+ apply: (color) => this._applyBorderColor(color),
1226
+ getPopover: () => this._borderColorPopover,
1227
+ hide: () => this._hideBorderColorPopover(),
1313
1228
  });
1314
- pop.appendChild(palette);
1315
-
1316
- const noColorRow = createElement('div', { class: 'an-context-color-custom' });
1317
- const noColorBtn = createElement('button', { type: 'button', class: 'an-shade-no-color' });
1318
- this._disposers.push(on(noColorBtn, 'click', () => this._applyBorderColor('')));
1319
- noColorRow.appendChild(noColorBtn);
1320
- pop.appendChild(noColorRow);
1229
+ this._borderColorTitleEl = titleEl;
1321
1230
  this._borderColorNoBtn = noColorBtn;
1322
-
1323
- const customRow = createElement('div', { class: 'an-context-color-custom' });
1324
- const colorInput = /** @type {HTMLInputElement} */ (createElement('input', { type: 'color', class: 'an-shade-color-input', value: '#000000' }));
1325
- const customLabel = createElement('span');
1326
- customLabel.textContent = this.context.locale.contextMenu.customColorLabel;
1327
- this._disposers.push(on(colorInput, 'change', () => this._applyBorderColor(colorInput.value)));
1328
- customRow.appendChild(colorInput);
1329
- customRow.appendChild(customLabel);
1330
- pop.appendChild(customRow);
1331
-
1332
- this._disposers.push(
1333
- on(pop, 'mousedown', (e) => e.preventDefault()),
1334
- on(pop, 'mouseenter', () => this._clearTimers()),
1335
- on(pop, 'mouseleave', () => this._scheduleHide()),
1336
- on(document, 'click', (e) => {
1337
- const et = /** @type {Node} */ (e.target);
1338
- if (this._borderColorPopover &&
1339
- this._borderColorPopover.style.display !== 'none' &&
1340
- !this._borderColorPopover.contains(et) &&
1341
- !this._el?.contains(et)) {
1342
- this._hideBorderColorPopover();
1343
- }
1344
- }),
1345
- );
1346
-
1347
- return pop;
1231
+ return el;
1348
1232
  }
1349
1233
 
1350
1234
  _openBorderColorPopover() {
@@ -1352,20 +1236,7 @@ export class TableTooltip {
1352
1236
  const L = this.context.locale.tooltips.table;
1353
1237
  if (this._borderColorTitleEl) this._borderColorTitleEl.textContent = L.tableBorderColor;
1354
1238
  if (this._borderColorNoBtn) this._borderColorNoBtn.textContent = L.noBorderColor;
1355
-
1356
- this._borderColorPopover.style.display = 'block';
1357
- requestAnimationFrame(() => {
1358
- if (!this._borderColorPopover || !this._el) return;
1359
- const pw = this._borderColorPopover.offsetWidth || 170;
1360
- const ph = this._borderColorPopover.offsetHeight || 120;
1361
- const tipRect = this._el.getBoundingClientRect();
1362
- let left = tipRect.left;
1363
- let top = tipRect.bottom + 6;
1364
- if (left + pw > globalThis.innerWidth - 8) left = globalThis.innerWidth - pw - 8;
1365
- if (top + ph > globalThis.innerHeight - 8) top = tipRect.top - ph - 6;
1366
- this._borderColorPopover.style.left = `${Math.max(8, left)}px`;
1367
- this._borderColorPopover.style.top = `${Math.max(8, top)}px`;
1368
- });
1239
+ this._showColorPopover(() => this._borderColorPopover);
1369
1240
  }
1370
1241
 
1371
1242
  _hideBorderColorPopover() {
@@ -128,11 +128,18 @@ export class Toolbar {
128
128
  // ---------------------------------------------------------------------------
129
129
 
130
130
  initialize() {
131
- this.el = createElement('div', { class: 'an-toolbar' });
131
+ this.el = createElement('div', {
132
+ class: 'an-toolbar',
133
+ role: 'toolbar',
134
+ 'aria-orientation': 'horizontal',
135
+ // Matches the hardcoded English label renderer.js gives the editable.
136
+ 'aria-label': 'Editor toolbar',
137
+ });
132
138
  // Detect FontAwesome once at toolbar build time to avoid re-querying the DOM
133
139
  // for every button rendered.
134
140
  this._faReady = this._detectFontAwesome();
135
141
  this._buildButtons();
142
+ this._initRovingFocus();
136
143
  this._btnMap = new Map(
137
144
  (this.options.toolbar || []).flat()
138
145
  .map(_resolveBtn).filter(Boolean).map((b) => [b.name, b]),
@@ -606,6 +613,9 @@ export class Toolbar {
606
613
  title: this.context.locale.toolbar[btnDef.name] || btnDef.tooltip || '',
607
614
  'data-btn': btnDef.name,
608
615
  'aria-label': this.context.locale.toolbar[btnDef.name] || btnDef.tooltip || btnDef.name,
616
+ // A button that reports an active state is a toggle, so its state has to
617
+ // be exposed to assistive tech and not only through the `.active` class.
618
+ ...(typeof btnDef.isActive === 'function' ? { 'aria-pressed': 'false' } : {}),
609
619
  });
610
620
 
611
621
  // Render icon: prefer FontAwesome if enabled; otherwise fall back to SVG or text.
@@ -642,6 +652,93 @@ export class Toolbar {
642
652
  return /** @type {HTMLButtonElement} */ (btn);
643
653
  }
644
654
 
655
+ // ---------------------------------------------------------------------------
656
+ // Keyboard navigation — WAI-ARIA toolbar pattern
657
+ // ---------------------------------------------------------------------------
658
+
659
+ /**
660
+ * Every control the toolbar contains, in visual order. Disabled ones are
661
+ * included so their tabindex can be cleared; `_navigable()` filters them out.
662
+ * @returns {HTMLElement[]}
663
+ */
664
+ _controls() {
665
+ if (!this.el) return [];
666
+ return /** @type {HTMLElement[]} */ (Array.from(this.el.querySelectorAll('button, select')));
667
+ }
668
+
669
+ /** @returns {HTMLElement[]} controls that can actually receive focus */
670
+ _navigable() {
671
+ return this._controls().filter(
672
+ (el) => !(/** @type {HTMLButtonElement} */ (el).disabled),
673
+ );
674
+ }
675
+
676
+ /**
677
+ * Enforces the roving-tabindex invariant: exactly one control sits in the tab
678
+ * order and the rest are reached with arrow keys. The default toolbar renders
679
+ * 39 controls, so without this a keyboard user pressed Tab 39 times to get
680
+ * past the toolbar and into the editable area.
681
+ *
682
+ * @param {HTMLElement} [focused] - control that should own the tab stop;
683
+ * omitted on refresh, where the user's current position is preserved.
684
+ */
685
+ _syncRovingTabindex(focused) {
686
+ const all = this._controls();
687
+ const navigable = this._navigable();
688
+ if (!navigable.length) return;
689
+ const active = (focused && navigable.includes(focused))
690
+ ? focused
691
+ : navigable.find((el) => el.getAttribute('tabindex') === '0') || navigable[0];
692
+ all.forEach((el) => el.setAttribute('tabindex', el === active ? '0' : '-1'));
693
+ }
694
+
695
+ /** Wires arrow-key navigation. Called on build and again after rebuild(). */
696
+ _initRovingFocus() {
697
+ if (!this.el) return;
698
+ this._syncRovingTabindex();
699
+ this._disposers.push(
700
+ on(this.el, 'keydown', (e) => this._onToolbarKeydown(/** @type {KeyboardEvent} */ (e))),
701
+ // Clicking or programmatically focusing a control moves the tab stop with
702
+ // it, so Tab always leaves from wherever the user actually is.
703
+ on(this.el, 'focusin', (e) => {
704
+ const el = /** @type {Element} */ (e.target)?.closest?.('button, select');
705
+ if (el) this._syncRovingTabindex(/** @type {HTMLElement} */ (el));
706
+ }),
707
+ );
708
+ }
709
+
710
+ /**
711
+ * Home/End jump to the ends; Left/Right step between controls and wrap.
712
+ * Up/Down are deliberately left alone so `<select>` controls keep their
713
+ * native value-changing behaviour.
714
+ * @param {KeyboardEvent} event
715
+ */
716
+ _onToolbarKeydown(event) {
717
+ if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return;
718
+ const controls = this._navigable();
719
+ const current = /** @type {HTMLElement|null} */ (
720
+ /** @type {Element} */ (event.target)?.closest?.('button, select')
721
+ );
722
+ const idx = current ? controls.indexOf(current) : -1;
723
+ if (idx === -1) return;
724
+
725
+ let next;
726
+ if (event.key === 'Home') {
727
+ next = controls[0];
728
+ } else if (event.key === 'End') {
729
+ next = controls.at(-1);
730
+ } else {
731
+ // In RTL the arrow that points at the next control is the left one.
732
+ const rtl = this.options.direction === 'rtl';
733
+ const forward = (event.key === 'ArrowRight') !== rtl;
734
+ next = controls[(idx + (forward ? 1 : -1) + controls.length) % controls.length];
735
+ }
736
+ if (!next) return;
737
+ event.preventDefault();
738
+ this._syncRovingTabindex(next);
739
+ next.focus();
740
+ }
741
+
645
742
  // ---------------------------------------------------------------------------
646
743
  // FontAwesome detection (run once at initialize time)
647
744
  // ---------------------------------------------------------------------------
@@ -687,13 +784,18 @@ export class Toolbar {
687
784
  this.el.querySelectorAll('button[data-btn]').forEach((btn) => {
688
785
  const def = btnMap.get(/** @type {HTMLElement} */ (btn).dataset.btn);
689
786
  if (def && typeof def.isActive === 'function') {
690
- btn.classList.toggle('active', !!def.isActive(this.context));
787
+ const active = !!def.isActive(this.context);
788
+ btn.classList.toggle('active', active);
789
+ btn.setAttribute('aria-pressed', String(active));
691
790
  }
692
791
  if (def && typeof def.isDisabled === 'function') {
693
792
  /** @type {HTMLButtonElement} */ (btn).disabled = !!def.isDisabled(this.context);
694
793
  }
695
794
  });
696
795
 
796
+ // A button disabled just now must not be the one holding the tab stop.
797
+ this._syncRovingTabindex();
798
+
697
799
  // Sync select dropdowns (e.g. font family) with current cursor position
698
800
  this.el.querySelectorAll('select[data-btn]').forEach((select) => {
699
801
  const def = btnMap.get(/** @type {HTMLElement} */ (select).dataset.btn);
@@ -741,6 +843,9 @@ export class Toolbar {
741
843
  if (this.el) this.el.innerHTML = '';
742
844
  this._faReady = this._detectFontAwesome();
743
845
  this._buildButtons();
846
+ // rebuild() cleared the disposers, taking the keydown/focusin listeners
847
+ // with them — re-arm navigation over the new controls.
848
+ this._initRovingFocus();
744
849
  this._btnMap = new Map(
745
850
  (this.options.toolbar || []).flat()
746
851
  .map(_resolveBtn).filter(Boolean).map((b) => [b.name, b]),
@@ -0,0 +1,102 @@
1
+ /**
2
+ * table-grid.js - Geometry helpers for tables inside the editor.
3
+ *
4
+ * A table's DOM order is not its visual order once colspan/rowspan are in play,
5
+ * so every structural operation (insert, delete, merge, sort) needs to reason
6
+ * about the *visual* grid rather than about `row.cells` indices. These helpers
7
+ * are pure functions of the table element, which keeps them independently
8
+ * testable and free of any tooltip state.
9
+ */
10
+
11
+ /**
12
+ * Returns the visual (logical) column index of a cell, taking colspan into
13
+ * account for all preceding cells in the same row.
14
+ * @param {HTMLTableCellElement} cell
15
+ * @returns {number} 0-based visual column index, or -1 on failure
16
+ */
17
+ function getVisualColIndex(cell) {
18
+ const row = cell.closest('tr');
19
+ if (!row) return -1;
20
+ let visualIdx = 0;
21
+ for (const c of row.cells) {
22
+ if (c === cell) return visualIdx;
23
+ visualIdx += c.colSpan || 1;
24
+ }
25
+ return -1;
26
+ }
27
+
28
+ /**
29
+ * Finds the first cell in a row whose visual start column equals visualIdx.
30
+ * Returns null if no exact match (e.g. the column is spanned by a merged cell).
31
+ * @param {HTMLTableRowElement} row
32
+ * @param {number} visualIdx
33
+ * @returns {HTMLTableCellElement|null}
34
+ */
35
+ function getCellAtVisualCol(row, visualIdx) {
36
+ let vIdx = 0;
37
+ for (const c of row.cells) {
38
+ if (vIdx === visualIdx) return c;
39
+ if (vIdx > visualIdx) break;
40
+ vIdx += c.colSpan || 1;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Finds the first cell whose visual range ends after visualIdx
47
+ * (used for inserting a new column to the right of visualIdx).
48
+ * @param {HTMLTableRowElement} row
49
+ * @param {number} visualIdx
50
+ * @returns {HTMLTableCellElement|null} reference cell for insertBefore, or null = append
51
+ */
52
+ function getCellAfterVisualCol(row, visualIdx) {
53
+ let vIdx = 0;
54
+ for (const c of row.cells) {
55
+ vIdx += c.colSpan || 1;
56
+ if (vIdx > visualIdx) {
57
+ // next cell after the one that starts at / spans visualIdx
58
+ const next = c.nextElementSibling;
59
+ return (next?.tagName === 'TD' || next?.tagName === 'TH') ? /** @type {HTMLTableCellElement} */ (next) : null;
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Build a 2D grid map of the table, accounting for both rowspan and colspan.
67
+ *
68
+ * gridMap[r][c] = the DOM cell occupying visual grid position (r, c).
69
+ * cellPos = WeakMap: cell → { r, c, rs, cs } (top-left grid origin + span).
70
+ *
71
+ * Uses HTMLTableElement.rows which is scoped to the table itself and never
72
+ * includes rows from nested tables.
73
+ *
74
+ * @param {HTMLTableElement} table
75
+ * @returns {{ gridMap: Object, cellPos: WeakMap }}
76
+ */
77
+ function buildGridMap(table) {
78
+ const rows = Array.from(table.rows);
79
+ const gridMap = {};
80
+ const cellPos = new WeakMap();
81
+ rows.forEach((row, r) => {
82
+ if (!gridMap[r]) gridMap[r] = {};
83
+ let c = 0;
84
+ for (const cell of row.cells) {
85
+ // Skip positions already occupied by a rowspan from a previous row
86
+ while (gridMap[r][c]) c++;
87
+ const rs = cell.rowSpan || 1;
88
+ const cs = cell.colSpan || 1;
89
+ cellPos.set(cell, { r, c, rs, cs });
90
+ for (let dr = 0; dr < rs; dr++) {
91
+ if (!gridMap[r + dr]) gridMap[r + dr] = {};
92
+ for (let dc = 0; dc < cs; dc++) {
93
+ gridMap[r + dr][c + dc] = cell;
94
+ }
95
+ }
96
+ c += cs;
97
+ }
98
+ });
99
+ return { gridMap, cellPos };
100
+ }
101
+
102
+ export { getVisualColIndex, getCellAtVisualCol, getCellAfterVisualCol, buildGridMap };