autumnnote 1.14.0 → 1.16.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.
Files changed (46) hide show
  1. package/README.md +37 -10
  2. package/dist/autumnnote.cjs +61 -0
  3. package/dist/autumnnote.css +2 -2579
  4. package/dist/autumnnote.es.js +4934 -9981
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +61 -0
  7. package/dist/autumnnote.umd.js +39 -17545
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/package.json +24 -15
  10. package/src/js/Context.js +154 -15
  11. package/src/js/core/sanitise.js +47 -11
  12. package/src/js/editing/History.js +38 -6
  13. package/src/js/i18n/de.js +15 -0
  14. package/src/js/i18n/en.js +15 -0
  15. package/src/js/i18n/es.js +15 -0
  16. package/src/js/i18n/fr.js +15 -0
  17. package/src/js/i18n/ja.js +15 -0
  18. package/src/js/i18n/ko.js +15 -0
  19. package/src/js/i18n/vi.js +15 -0
  20. package/src/js/i18n/zh.js +15 -0
  21. package/src/js/index.js +13 -1
  22. package/src/js/module/Clipboard.js +25 -7
  23. package/src/js/module/Editor.js +25 -5
  24. package/src/js/module/ImageCropOverlay.js +5 -0
  25. package/src/js/module/ImageDialog.js +34 -6
  26. package/src/js/module/MarkdownShortcuts.js +2 -2
  27. package/src/js/module/SlashMenu.js +376 -0
  28. package/src/js/module/Statusbar.js +3 -9
  29. package/src/js/module/TableTooltip.js +2 -1
  30. package/src/js/module/VideoDialog.js +3 -13
  31. package/src/js/settings.js +30 -0
  32. package/src/styles/autumnnote.scss +49 -0
  33. package/types/core/dom.d.ts +32 -0
  34. package/types/core/env.d.ts +10 -0
  35. package/types/core/func.d.ts +11 -0
  36. package/types/core/key.d.ts +3 -0
  37. package/types/core/lists.d.ts +10 -0
  38. package/types/core/range.d.ts +22 -0
  39. package/types/core/sanitise.d.ts +9 -0
  40. package/types/index.d.ts +102 -8
  41. package/dist/_headers +0 -5
  42. package/dist/image/banner.png +0 -0
  43. package/dist/image/favicon.ico +0 -0
  44. package/dist/image/og-banner.png +0 -0
  45. package/dist/robots.txt +0 -4
  46. package/dist/sitemap.xml +0 -58
package/src/js/index.js CHANGED
@@ -140,11 +140,23 @@ const AutumnNote = {
140
140
  */
141
141
  registerButton(btnDef) { registerButton(btnDef); return this; },
142
142
 
143
+ /** Registers a slash-menu command for future editor instances. */
144
+ registerSlashCommand(command) {
145
+ if (!command?.id || typeof command.run !== 'function') {
146
+ throw new TypeError('[AutumnNote] Slash command requires an id and run(context) function.');
147
+ }
148
+ const commands = defaultOptions.slashCommands;
149
+ const index = commands.findIndex((item) => item.id === command.id);
150
+ if (index >= 0) commands[index] = command;
151
+ else commands.push(command);
152
+ return this;
153
+ },
154
+
143
155
  /** All pre-built button definitions — accessible in every module format including UMD/CJS. */
144
156
  buttons,
145
157
 
146
158
  /** Library version */
147
- version: '1.14.0',
159
+ version: '1.16.0',
148
160
  };
149
161
 
150
162
  // ---------------------------------------------------------------------------
@@ -371,7 +371,11 @@ export class Clipboard {
371
371
  execCommand('insertHTML', html);
372
372
  this.context.invoke('editor.afterCommand');
373
373
  };
374
- reader.onerror = () => console.warn('[AutumnNote] Failed to read dropped markdown file', file.name);
374
+ reader.onerror = () => {
375
+ const message = `Failed to read dropped markdown file "${file.name}".`;
376
+ console.warn(`[AutumnNote] ${message}`);
377
+ this.context.triggerEvent('pasteError', { message });
378
+ };
375
379
  reader.readAsText(file);
376
380
  }
377
381
 
@@ -412,12 +416,7 @@ export class Clipboard {
412
416
  }
413
417
 
414
418
  const alt = file.name.replace(/\.[^.]+$/, '');
415
- this._compressImage(file).then((dataUrl) => {
416
- // Keep the large data URL in a JS Map; insert a lightweight blob: URL
417
- // into the DOM so editable.innerHTML never contains the big base64 string.
418
- const blob = this._dataUrlToBlob(dataUrl);
419
- const blobUrl = URL.createObjectURL(blob);
420
- this._blobRegistry.set(blobUrl, dataUrl);
419
+ this.compressAndRegister(file).then((blobUrl) => {
421
420
  this.context.invoke('editor.insertImage', blobUrl, alt);
422
421
  }).catch((err) => {
423
422
  const message = `Image "${file.name}" could not be processed.`;
@@ -427,6 +426,25 @@ export class Clipboard {
427
426
  });
428
427
  }
429
428
 
429
+ /**
430
+ * Compresses an image File via canvas and registers the result behind a
431
+ * lightweight blob: URL (see `resolveImages`), so callers never have to hold
432
+ * the full base64 string in the DOM. Shared by paste/drop and ImageDialog's
433
+ * file picker so every image-insertion path gets the same compression.
434
+ * @param {File} file
435
+ * @returns {Promise<string>} blob: URL usable as an <img src>
436
+ */
437
+ async compressAndRegister(file) {
438
+ const processor = this.options.imageProcessor;
439
+ const dataUrl = typeof processor === 'function'
440
+ ? await processor(file, { context: this.context })
441
+ : await this._compressImage(file);
442
+ const blob = this._dataUrlToBlob(dataUrl);
443
+ const blobUrl = URL.createObjectURL(blob);
444
+ this._blobRegistry.set(blobUrl, dataUrl);
445
+ return blobUrl;
446
+ }
447
+
430
448
  /**
431
449
  * Replaces any blob: URLs created by this module with their original data URLs.
432
450
  * Called by Editor.getHTML() so the returned HTML is fully self-contained.
@@ -34,7 +34,11 @@ export class Editor {
34
34
 
35
35
  initialize() {
36
36
  const editable = this.context.layoutInfo.editable;
37
- this._history = new History(editable, this.options.historyLimit || 100);
37
+ this._history = new History(
38
+ editable,
39
+ this.options.historyLimit || 100,
40
+ this.options.historyMaxBytes || 10 * 1024 * 1024,
41
+ );
38
42
  this._bindEvents(editable);
39
43
  return this;
40
44
  }
@@ -497,8 +501,7 @@ export class Editor {
497
501
 
498
502
  undo() {
499
503
  if (this._history) {
500
- clearTimeout(this._snapshotTimer);
501
- this._snapshotTimer = null;
504
+ this._flushPendingSnapshot();
502
505
  this._history.undo();
503
506
  this.context.invoke('toolbar.refresh');
504
507
  this.context.invoke('statusbar.update');
@@ -508,8 +511,7 @@ export class Editor {
508
511
 
509
512
  redo() {
510
513
  if (this._history) {
511
- clearTimeout(this._snapshotTimer);
512
- this._snapshotTimer = null;
514
+ this._flushPendingSnapshot();
513
515
  this._history.redo();
514
516
  this.context.invoke('toolbar.refresh');
515
517
  this.context.invoke('statusbar.update');
@@ -517,6 +519,14 @@ export class Editor {
517
519
  }
518
520
  }
519
521
 
522
+ /** Records a debounced change before an immediate undo/redo command. */
523
+ _flushPendingSnapshot() {
524
+ if (this._snapshotTimer === null) return;
525
+ clearTimeout(this._snapshotTimer);
526
+ this._snapshotTimer = null;
527
+ this._history?.recordUndo();
528
+ }
529
+
520
530
  canUndo() {
521
531
  return this._history ? this._history.canUndo() : false;
522
532
  }
@@ -533,6 +543,16 @@ export class Editor {
533
543
  return this._history ? this._history.getRedoCount() : 0;
534
544
  }
535
545
 
546
+ getSelectionBookmark() {
547
+ return this._history?._serializeSelection() ?? null;
548
+ }
549
+
550
+ restoreSelectionBookmark(bookmark) {
551
+ if (!bookmark || !this._history) return false;
552
+ this._history._restoreSelection(bookmark);
553
+ return true;
554
+ }
555
+
536
556
  // ---------------------------------------------------------------------------
537
557
  // Style commands (delegated to Style module)
538
558
  // ---------------------------------------------------------------------------
@@ -444,6 +444,7 @@ export class ImageCropOverlay {
444
444
  * @param {(e: MouseEvent) => void} onMove
445
445
  */
446
446
  _attachDocDrag(onMove) {
447
+ this._dragDisposers?.();
447
448
  const onTouchMove = (e) => {
448
449
  e.preventDefault();
449
450
  const te3 = /** @type {TouchEvent} */ (e); onMove(/** @type {MouseEvent} */ (/** @type {unknown} */ ({ clientX: te3.touches[0].clientX, clientY: te3.touches[0].clientY })));
@@ -455,7 +456,9 @@ export class ImageCropOverlay {
455
456
  document.removeEventListener('touchend', cleanup);
456
457
  document.body.style.userSelect = '';
457
458
  document.body.style.cursor = '';
459
+ this._dragDisposers = null;
458
460
  };
461
+ this._dragDisposers = cleanup;
459
462
  document.body.style.userSelect = 'none';
460
463
  document.addEventListener('mousemove', onMove);
461
464
  document.addEventListener('mouseup', cleanup, { once: true });
@@ -561,6 +564,8 @@ export class ImageCropOverlay {
561
564
  * @param {boolean} _committed - reserved for future use
562
565
  */
563
566
  _close(_committed) {
567
+ this._dragDisposers?.();
568
+ this._dragDisposers = null;
564
569
  this._disposers.forEach((d) => d());
565
570
  this._disposers = [];
566
571
 
@@ -13,7 +13,11 @@ export class ImageDialog extends BaseDialog {
13
13
  // Public API
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- show() {
16
+ /**
17
+ * @param {{ beforeInsert?: () => void }} [hooks]
18
+ */
19
+ show({ beforeInsert } = {}) {
20
+ this._beforeInsert = typeof beforeInsert === 'function' ? beforeInsert : null;
17
21
  this._saveRange();
18
22
  this._urlInput.value = '';
19
23
  this._altInput.value = '';
@@ -137,11 +141,23 @@ export class ImageDialog extends BaseDialog {
137
141
  return;
138
142
  }
139
143
 
140
- const reader = new FileReader();
141
- reader.onload = (e) => {
142
- this._urlInput.value = /** @type {string} */ (e.target.result);
143
- };
144
- reader.readAsDataURL(file);
144
+ // Resize/compress via Clipboard's shared canvas pipeline so a file chosen
145
+ // through this dialog gets the same treatment as a pasted/dropped image
146
+ // instead of embedding the original (potentially multi-MB) file untouched.
147
+ this.context.invoke('clipboard.compressAndRegister', file).then((blobUrl) => {
148
+ if (this.context._alive === false) return;
149
+ this._urlInput.value = blobUrl;
150
+ }).catch((err) => {
151
+ if (this.context._alive === false) return;
152
+ // Compression failed (e.g. canvas unavailable) — fall back to embedding
153
+ // the original file directly.
154
+ console.warn('[AutumnNote] ImageDialog: image compression failed, using original file.', err);
155
+ const reader = new FileReader();
156
+ reader.onload = (e) => {
157
+ this._urlInput.value = /** @type {string} */ (e.target.result);
158
+ };
159
+ reader.readAsDataURL(file);
160
+ });
145
161
  }
146
162
 
147
163
  _onInsert() {
@@ -159,7 +175,19 @@ export class ImageDialog extends BaseDialog {
159
175
  this._savedRange.select();
160
176
  }
161
177
 
178
+ // Let callers make a deferred DOM change only after the user commits the
179
+ // dialog. SlashMenu uses this to keep "/image" intact when the dialog is
180
+ // cancelled and remove it atomically immediately before image insertion.
181
+ const beforeInsert = this._beforeInsert;
182
+ this._beforeInsert = null;
183
+ beforeInsert?.();
184
+
162
185
  this.context.invoke('editor.insertImage', src, alt, align);
163
186
  this._close();
164
187
  }
188
+
189
+ _close() {
190
+ this._beforeInsert = null;
191
+ super._close();
192
+ }
165
193
  }
@@ -167,13 +167,13 @@ export class MarkdownShortcuts {
167
167
 
168
168
  _convertToChecklist() {
169
169
  this._selectLineAndDelete();
170
- this.context.invoke('editor.checklist');
170
+ this.context.invoke('editor.toggleChecklist');
171
171
  this.context.triggerEvent('change', this.context.getHTML());
172
172
  }
173
173
 
174
174
  _convertToHr() {
175
175
  this._selectLineAndDelete();
176
- this.context.invoke('editor.insertHR');
176
+ this.context.invoke('editor.insertHr');
177
177
  this.context.triggerEvent('change', this.context.getHTML());
178
178
  }
179
179
 
@@ -0,0 +1,376 @@
1
+ /**
2
+ * SlashMenu.js — Notion-style "/" command palette for quick block insertion.
3
+ *
4
+ * Typing "/" as the very first character of an otherwise-empty block opens a
5
+ * filterable list of quick-insert commands (headings, lists, table, image, …).
6
+ * Arrow keys navigate, Enter/Tab selects, Escape or deleting back past the
7
+ * "/" closes it. Disabled via `slashMenu: false`.
8
+ *
9
+ * Positioning follows the same synchronous-caret-rect technique as Mention.js:
10
+ * getBoundingClientRect() on a zero-width Range over the trigger character is
11
+ * reliable while the triggering DOM event is still live, but often returns an
12
+ * empty rect once deferred into a callback — so the rect is captured eagerly
13
+ * in `_onInput`, not lazily when the menu is rendered.
14
+ */
15
+
16
+ import { on } from '../core/dom.js';
17
+
18
+ export class SlashMenu {
19
+ /** @param {import('../Context.js').Context} context */
20
+ constructor(context) {
21
+ this.context = context;
22
+ this.options = context.options;
23
+ this._disposers = [];
24
+
25
+ /** @type {HTMLElement|null} */
26
+ this._menu = null;
27
+ /** @type {Array<{id: string, label: string, keywords: string, run: () => void}>} */
28
+ this._filtered = [];
29
+ this._activeIndex = -1;
30
+ this._open = false;
31
+ this._query = '';
32
+ /** @type {Text|null} */
33
+ this._textNode = null;
34
+ this._triggerOffset = 0;
35
+ /** @type {DOMRect|null} */
36
+ this._caretRect = null;
37
+ }
38
+
39
+ initialize() {
40
+ if (this.options.slashMenu === false) return this;
41
+ const editable = this.context.layoutInfo.editable;
42
+ const d1 = on(editable, 'input', () => this._onInput());
43
+ const d2 = on(editable, 'keydown', (e) => this._onKeydown(e));
44
+ const d3 = on(document, 'click', (e) => this._onDocClick(e));
45
+ this._disposers.push(d1, d2, d3);
46
+ return this;
47
+ }
48
+
49
+ destroy() {
50
+ this._menu?.remove();
51
+ this._menu = null;
52
+ this._disposers.forEach((d) => d());
53
+ this._disposers = [];
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Command list
58
+ // ---------------------------------------------------------------------------
59
+
60
+ _commands() {
61
+ const L = this.context.locale.slashMenu;
62
+ const ctx = this.context;
63
+ const builtIns = [
64
+ { id: 'h1', label: L.heading1, keywords: 'h1 heading title', run: () => ctx.invoke('editor.formatBlock', 'h1') },
65
+ { id: 'h2', label: L.heading2, keywords: 'h2 heading subtitle', run: () => ctx.invoke('editor.formatBlock', 'h2') },
66
+ { id: 'h3', label: L.heading3, keywords: 'h3 heading', run: () => ctx.invoke('editor.formatBlock', 'h3') },
67
+ { id: 'ul', label: L.bulletList, keywords: 'ul bullet list unordered', run: () => ctx.invoke('editor.insertUL') },
68
+ { id: 'ol', label: L.numberedList, keywords: 'ol numbered list ordered', run: () => ctx.invoke('editor.insertOL') },
69
+ { id: 'checklist', label: L.checklist, keywords: 'checklist todo checkbox task', run: () => ctx.invoke('editor.toggleChecklist') },
70
+ { id: 'quote', label: L.blockquote, keywords: 'quote blockquote', run: () => ctx.invoke('editor.formatBlock', 'blockquote') },
71
+ { id: 'code', label: L.codeBlock, keywords: 'code pre block', run: () => ctx.invoke('editor.formatBlock', 'pre') },
72
+ { id: 'hr', label: L.horizontalRule, keywords: 'hr divider rule line', run: () => ctx.invoke('editor.insertHr') },
73
+ { id: 'table', label: L.table, keywords: 'table grid', run: () => ctx.invoke('editor.insertTable', 3, 3) },
74
+ { id: 'image', label: L.image, keywords: 'image picture photo upload', run: () => ctx.invoke('imageDialog.show') },
75
+ ];
76
+ const custom = (this.options.slashCommands || []).map((command) => ({
77
+ id: command.id,
78
+ label: command.label || command.id,
79
+ keywords: command.keywords || '',
80
+ run: () => command.run(ctx),
81
+ }));
82
+ return [...builtIns, ...custom];
83
+ }
84
+
85
+ refresh() {
86
+ if (this._open) this._filterAndRender();
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Menu DOM
91
+ // ---------------------------------------------------------------------------
92
+
93
+ _buildMenu() {
94
+ const el = document.createElement('div');
95
+ el.className = 'an-slash-menu';
96
+ el.setAttribute('role', 'listbox');
97
+ el.style.display = 'none';
98
+
99
+ el.addEventListener('mousedown', (e) => e.preventDefault());
100
+ el.addEventListener('click', (e) => {
101
+ const item = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('.an-slash-menu-item'));
102
+ if (item) this._select(+item.dataset.index);
103
+ });
104
+ el.addEventListener('mousemove', (e) => {
105
+ const item = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('.an-slash-menu-item'));
106
+ if (item) this._highlight(+item.dataset.index);
107
+ });
108
+
109
+ document.body.appendChild(el);
110
+ this._menu = el;
111
+ return el;
112
+ }
113
+
114
+ _renderItems() {
115
+ if (!this._menu) this._buildMenu();
116
+ const menu = this._menu;
117
+ const L = this.context.locale.slashMenu;
118
+
119
+ if (this._filtered.length === 0) {
120
+ menu.innerHTML = '';
121
+ const empty = document.createElement('div');
122
+ empty.className = 'an-slash-menu-empty';
123
+ empty.textContent = L.noResults;
124
+ menu.appendChild(empty);
125
+ this._activeIndex = -1;
126
+ return;
127
+ }
128
+
129
+ const frag = document.createDocumentFragment();
130
+ this._filtered.forEach((item, i) => {
131
+ const row = document.createElement('div');
132
+ row.className = 'an-slash-menu-item';
133
+ row.setAttribute('role', 'option');
134
+ row.id = `an-slash-option-${item.id}`;
135
+ row.dataset.index = String(i);
136
+ row.textContent = item.label;
137
+ frag.appendChild(row);
138
+ });
139
+ menu.innerHTML = '';
140
+ menu.appendChild(frag);
141
+ this._highlight(0);
142
+ }
143
+
144
+ _highlight(index) {
145
+ if (!this._menu) return;
146
+ this._menu.querySelectorAll('.an-slash-menu-item').forEach((el, i) => {
147
+ const active = i === index;
148
+ el.classList.toggle('an-slash-menu-active', active);
149
+ el.setAttribute('aria-selected', String(active));
150
+ if (active) this._menu.setAttribute('aria-activedescendant', el.id);
151
+ });
152
+ this._activeIndex = index;
153
+ }
154
+
155
+ _position() {
156
+ const menu = this._menu;
157
+ const rect = this._caretRect;
158
+ if (!menu || !rect || rect.height === 0) return;
159
+
160
+ menu.style.visibility = 'hidden';
161
+ menu.style.display = 'block';
162
+ const mh = menu.offsetHeight;
163
+ const mw = menu.offsetWidth;
164
+
165
+ let top = rect.bottom + 4;
166
+ let left = rect.left;
167
+ if (rect.bottom + mh + 8 > globalThis.innerHeight) {
168
+ top = rect.top - mh - 4;
169
+ }
170
+ left = Math.max(8, Math.min(left, globalThis.innerWidth - mw - 8));
171
+
172
+ menu.style.top = `${top}px`;
173
+ menu.style.left = `${left}px`;
174
+ menu.style.visibility = '';
175
+ }
176
+
177
+ _open_() {
178
+ this._open = true;
179
+ this._filterAndRender();
180
+ this._position();
181
+ }
182
+
183
+ _close() {
184
+ if (this._menu) this._menu.style.display = 'none';
185
+ this._open = false;
186
+ this._filtered = [];
187
+ this._activeIndex = -1;
188
+ this._menu?.removeAttribute('aria-activedescendant');
189
+ this._textNode = null;
190
+ this._caretRect = null;
191
+ this._query = '';
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Trigger detection
196
+ // ---------------------------------------------------------------------------
197
+
198
+ _isBlock(node) {
199
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
200
+ const display = globalThis.getComputedStyle(node).display;
201
+ return display === 'block' || display === 'list-item' || display === 'table-cell';
202
+ }
203
+
204
+ /**
205
+ * Detects a "/" trigger at the caret, requiring it to be the only content
206
+ * typed so far in its block (i.e. the block's text is exactly "/" + query,
207
+ * nothing before or after). This deliberately avoids firing mid-sentence
208
+ * (e.g. "10/20", "and/or").
209
+ * @returns {{ query: string, textNode: Text, triggerOffset: number } | null}
210
+ */
211
+ _getTriggerContext() {
212
+ const sel = globalThis.getSelection();
213
+ if (!sel?.rangeCount) return null;
214
+ const range = sel.getRangeAt(0);
215
+ if (!range.collapsed) return null;
216
+
217
+ const editable = this.context.layoutInfo.editable;
218
+ if (!editable.contains(range.startContainer)) return null;
219
+ if (range.startContainer.nodeType !== Node.TEXT_NODE) return null;
220
+
221
+ let block = range.startContainer.parentNode;
222
+ while (block && block !== editable && !this._isBlock(block)) block = block.parentNode;
223
+ if (!block || block === editable) return null;
224
+
225
+ const fullText = block.textContent || '';
226
+ const beforeRange = document.createRange();
227
+ beforeRange.setStart(block, 0);
228
+ beforeRange.setEnd(range.startContainer, range.startOffset);
229
+ const before = beforeRange.toString();
230
+
231
+ const m = /^\/(\S*)$/.exec(before);
232
+ if (!m) return null;
233
+ if (fullText.length > before.length) return null; // content after the caret
234
+
235
+ return {
236
+ query: m[1],
237
+ textNode: /** @type {Text} */ (range.startContainer),
238
+ triggerOffset: range.startOffset - m[0].length,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Captures the caret rect synchronously — see file header for why this
244
+ * can't be deferred to a later tick.
245
+ */
246
+ _captureCaretRect(textNode, triggerOffset) {
247
+ try {
248
+ const r = document.createRange();
249
+ const end = Math.min(triggerOffset + 1, textNode.textContent.length);
250
+ r.setStart(textNode, triggerOffset);
251
+ r.setEnd(textNode, end);
252
+ const candidate = r.getBoundingClientRect();
253
+ if (candidate.height > 0) return candidate;
254
+ } catch (_) { void _; }
255
+
256
+ const sel = globalThis.getSelection();
257
+ if (!sel?.rangeCount) return null;
258
+ const rects = sel.getRangeAt(0).getClientRects();
259
+ return rects.length > 0 ? rects[rects.length - 1] : null;
260
+ }
261
+
262
+ _filterAndRender() {
263
+ const q = this._query.toLowerCase();
264
+ this._filtered = q
265
+ ? this._commands().filter((c) => c.keywords.includes(q) || c.label.toLowerCase().includes(q))
266
+ : this._commands();
267
+ this._renderItems();
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Events
272
+ // ---------------------------------------------------------------------------
273
+
274
+ _onInput() {
275
+ const ctx = this._getTriggerContext();
276
+ if (!ctx) {
277
+ if (this._open) this._close();
278
+ return;
279
+ }
280
+
281
+ this._textNode = ctx.textNode;
282
+ this._triggerOffset = ctx.triggerOffset;
283
+ this._query = ctx.query;
284
+ this._caretRect = this._captureCaretRect(ctx.textNode, ctx.triggerOffset);
285
+
286
+ if (!this._open) this._open_();
287
+ else { this._filterAndRender(); this._position(); }
288
+ }
289
+
290
+ _onKeydown(e) {
291
+ if (!this._open) return;
292
+
293
+ if (e.key === 'ArrowDown') {
294
+ e.preventDefault();
295
+ if (this._filtered.length === 0) return;
296
+ this._highlight((this._activeIndex + 1) % this._filtered.length);
297
+ } else if (e.key === 'ArrowUp') {
298
+ e.preventDefault();
299
+ if (this._filtered.length === 0) return;
300
+ this._highlight((this._activeIndex - 1 + this._filtered.length) % this._filtered.length);
301
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
302
+ if (this._activeIndex >= 0) {
303
+ e.preventDefault();
304
+ this._select(this._activeIndex);
305
+ }
306
+ } else if (e.key === 'Escape') {
307
+ e.preventDefault();
308
+ this._close();
309
+ }
310
+ }
311
+
312
+ _onDocClick(e) {
313
+ if (!this._open) return;
314
+ if (this._menu?.contains(e.target)) return;
315
+ this._close();
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Selection
320
+ // ---------------------------------------------------------------------------
321
+
322
+ _deleteTriggerText() {
323
+ const node = this._textNode;
324
+ if (!node?.isConnected) return;
325
+ const before = node.textContent.slice(0, this._triggerOffset);
326
+ const after = node.textContent.slice(this._triggerOffset + 1 + this._query.length);
327
+ node.textContent = before + after;
328
+
329
+ const sel = globalThis.getSelection();
330
+ if (!sel) return;
331
+ const range = document.createRange();
332
+ range.setStart(node, this._triggerOffset);
333
+ range.collapse(true);
334
+ sel.removeAllRanges();
335
+ sel.addRange(range);
336
+ }
337
+
338
+ _makeTriggerRemover() {
339
+ const node = this._textNode;
340
+ const triggerOffset = this._triggerOffset;
341
+ const query = this._query;
342
+ return () => {
343
+ if (!node?.isConnected) return;
344
+ const before = node.textContent.slice(0, triggerOffset);
345
+ const after = node.textContent.slice(triggerOffset + 1 + query.length);
346
+ node.textContent = before + after;
347
+
348
+ const sel = globalThis.getSelection();
349
+ if (!sel) return;
350
+ const range = document.createRange();
351
+ range.setStart(node, triggerOffset);
352
+ range.collapse(true);
353
+ sel.removeAllRanges();
354
+ sel.addRange(range);
355
+ };
356
+ }
357
+
358
+ _select(index) {
359
+ const item = this._filtered[index];
360
+ if (!item) return;
361
+
362
+ if (item.id === 'image') {
363
+ const beforeInsert = this._makeTriggerRemover();
364
+ this._close();
365
+ this.context.invoke('imageDialog.show', { beforeInsert });
366
+ return;
367
+ }
368
+
369
+ this._deleteTriggerText();
370
+ this._close();
371
+ // Editor.* methods invoked by commands already call afterCommand()
372
+ // internally (toolbar/statusbar refresh + debounced undo snapshot + the
373
+ // 'change' event), so no manual triggerEvent here — see Editor.js.
374
+ item.run();
375
+ }
376
+ }
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import { createElement, on } from '../core/dom.js';
7
- import { debounce } from '../core/func.js';
8
7
 
9
8
  // Cache the segmenter instance at module level to avoid per-call allocation
10
9
  const _segmenter =
@@ -97,7 +96,6 @@ export class Statusbar {
97
96
  info.appendChild(this._charCountEl);
98
97
  this.el.appendChild(info);
99
98
 
100
- this._bindContentEvents();
101
99
  this.update();
102
100
  return this;
103
101
  }
@@ -202,13 +200,9 @@ export class Statusbar {
202
200
  // Counter update
203
201
  // ---------------------------------------------------------------------------
204
202
 
205
- _bindContentEvents() {
206
- const editable = this.context.layoutInfo.editable;
207
- const updateDebounced = debounce(() => this.update(), 200);
208
- const d = on(editable, 'input', /** @type {EventListener} */ (updateDebounced));
209
- this._disposers.push(d);
210
- }
211
-
203
+ // Editor.afterCommand() already invokes 'statusbar.update' on every native
204
+ // 'input' event and after every toolbar/formatting command, so a separate
205
+ // content listener here would just re-run this on the same keystroke.
212
206
  update() {
213
207
  if (!this._wordCountEl || !this._charCountEl) return;
214
208
  const editable = this.context.layoutInfo.editable;
@@ -239,6 +239,7 @@ export class TableTooltip {
239
239
  !this._activeTable.contains(et) &&
240
240
  !this._el.contains(et) &&
241
241
  !this._sizePopover?.contains(et) &&
242
+ !this._shadePopover?.contains(et) &&
242
243
  !this._borderColorPopover?.contains(et)) {
243
244
  this._hide();
244
245
  }
@@ -1058,7 +1059,7 @@ export class TableTooltip {
1058
1059
 
1059
1060
  const d1 = on(applyBtn, 'click', () => {
1060
1061
  const val = Number.parseInt(this._sizeInputEl.value, 10);
1061
- if (val > 0 && typeof this._sizeApply === 'function') this._sizeApply(val);
1062
+ if (val >= 0 && typeof this._sizeApply === 'function') this._sizeApply(val);
1062
1063
  this._hideSizePopover();
1063
1064
  });
1064
1065
  const d2 = on(cancelBtn, 'click', () => this._hideSizePopover());