autumnnote 1.5.0 → 1.6.1

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 (53) hide show
  1. package/README.md +10 -8
  2. package/dist/autumnnote.css +324 -4
  3. package/dist/autumnnote.es.js +943 -526
  4. package/dist/autumnnote.es.js.map +1 -1
  5. package/dist/autumnnote.umd.js +935 -519
  6. package/dist/autumnnote.umd.js.map +1 -1
  7. package/package.json +21 -3
  8. package/src/js/Context.js +23 -16
  9. package/src/js/core/detectLang.js +98 -0
  10. package/src/js/core/dom.js +67 -11
  11. package/src/js/core/env.js +1 -1
  12. package/src/js/core/func.js +1 -1
  13. package/src/js/core/lists.js +1 -1
  14. package/src/js/core/markdown.js +32 -31
  15. package/src/js/core/range.js +8 -8
  16. package/src/js/editing/History.js +10 -10
  17. package/src/js/editing/Style.js +44 -44
  18. package/src/js/editing/Table.js +5 -7
  19. package/src/js/editing/Typing.js +19 -19
  20. package/src/js/i18n/en.js +2 -0
  21. package/src/js/i18n/vi.js +2 -0
  22. package/src/js/index.js +3 -2
  23. package/src/js/module/AutoSaveRestore.js +2 -4
  24. package/src/js/module/BubbleToolbar.js +51 -34
  25. package/src/js/module/Buttons.js +20 -18
  26. package/src/js/module/Clipboard.js +16 -17
  27. package/src/js/module/CodeTooltip.js +48 -24
  28. package/src/js/module/Codeview.js +5 -7
  29. package/src/js/module/ContextMenu.js +37 -37
  30. package/src/js/module/Editor.js +77 -26
  31. package/src/js/module/EmojiDialog.js +24 -18
  32. package/src/js/module/FindReplace.js +93 -66
  33. package/src/js/module/Fullscreen.js +1 -1
  34. package/src/js/module/IconDialog.js +39 -35
  35. package/src/js/module/ImageCropOverlay.js +13 -13
  36. package/src/js/module/ImageDialog.js +19 -13
  37. package/src/js/module/ImageResizer.js +5 -5
  38. package/src/js/module/ImageTooltip.js +20 -16
  39. package/src/js/module/LinkDialog.js +20 -14
  40. package/src/js/module/LinkTooltip.js +15 -12
  41. package/src/js/module/MarkdownShortcuts.js +11 -14
  42. package/src/js/module/Mention.js +11 -13
  43. package/src/js/module/Placeholder.js +1 -1
  44. package/src/js/module/ShortcutsDialog.js +2 -4
  45. package/src/js/module/Statusbar.js +5 -7
  46. package/src/js/module/TableTooltip.js +196 -36
  47. package/src/js/module/Toolbar.js +35 -34
  48. package/src/js/module/VideoDialog.js +16 -10
  49. package/src/js/module/VideoResizer.js +7 -9
  50. package/src/js/module/VideoTooltip.js +15 -10
  51. package/src/js/renderer.js +5 -3
  52. package/src/js/settings.js +53 -36
  53. package/src/styles/autumnnote.scss +332 -7
@@ -1,4 +1,4 @@
1
- // ContextMenu.js - Right-click context menu for editor actions
1
+ // ContextMenu.js - Right-click context menu for editor actions
2
2
  import { createElement, on } from '../core/dom.js';
3
3
  import { sanitiseHTML } from '../core/sanitise.js';
4
4
 
@@ -60,7 +60,7 @@ const defaultItems = [
60
60
  { name: 'copy', label: 'Copy', icon: ICONS.copy, action: () => document.execCommand('copy') },
61
61
  { name: 'paste', label: 'Paste', icon: ICONS.paste, action: (ctx) => {
62
62
  if (!navigator.clipboard) return;
63
- const editable = ctx.layoutInfo && ctx.layoutInfo.editable;
63
+ const editable = ctx.layoutInfo?.editable;
64
64
  if (!editable) return;
65
65
  // Prefer read() to get rich HTML; fall back to readText() for plain text
66
66
  const doInsert = (html, text) => {
@@ -131,24 +131,24 @@ export class ContextMenu {
131
131
 
132
132
  this._renderItems(this._items);
133
133
 
134
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
134
+ const editable = this.context.layoutInfo?.editable;
135
135
  if (editable) {
136
136
  this._disposers.push(on(editable, 'contextmenu', (e) => this._onContextMenu(e)));
137
137
  }
138
138
 
139
139
  this._disposers.push(on(document, 'click', (e) => this._maybeHide(e)));
140
- this._disposers.push(on(document, 'keydown', (e) => { if (e.key === 'Escape') this.hide(); }));
141
- this._disposers.push(on(window, 'scroll', () => this.hide(), { passive: true }));
140
+ this._disposers.push(on(document, 'keydown', (e) => { if (/** @type {KeyboardEvent} */ (e).key === 'Escape') this.hide(); }));
141
+ this._disposers.push(on(globalThis, 'scroll', () => this.hide(), { passive: true }));
142
142
 
143
143
  return this;
144
144
  }
145
145
 
146
146
  destroy() {
147
- this._menuDisposers.forEach((d) => { try { d(); } catch (e) {} });
147
+ this._menuDisposers.forEach((d) => { try { d(); } catch (_e) {} });
148
148
  this._menuDisposers = [];
149
- this._disposers.forEach((d) => { try { d(); } catch (e) {} });
149
+ this._disposers.forEach((d) => { try { d(); } catch (_e) {} });
150
150
  this._disposers = [];
151
- if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
151
+ if (this.el) this.el.remove();
152
152
  this.el = null;
153
153
  }
154
154
 
@@ -176,8 +176,8 @@ export class ContextMenu {
176
176
  backBtn.appendChild(createElement('span', { class: 'an-context-label' }, [backLabel]));
177
177
  const off = on(backBtn, 'click', (e) => {
178
178
  e.stopPropagation();
179
- const curLeft = parseFloat(this.el.style.left);
180
- const curTop = parseFloat(this.el.style.top);
179
+ const curLeft = Number.parseFloat(this.el.style.left);
180
+ const curTop = Number.parseFloat(this.el.style.top);
181
181
  this._renderItems(it.navigate());
182
182
  this._reposition(curLeft, curTop);
183
183
  });
@@ -212,8 +212,8 @@ export class ContextMenu {
212
212
  btn.appendChild(chevron);
213
213
  const off = on(btn, 'click', (e) => {
214
214
  e.stopPropagation();
215
- const curLeft = parseFloat(this.el.style.left);
216
- const curTop = parseFloat(this.el.style.top);
215
+ const curLeft = Number.parseFloat(this.el.style.left);
216
+ const curTop = Number.parseFloat(this.el.style.top);
217
217
  this._renderItems(it.navigate());
218
218
  this._reposition(curLeft, curTop);
219
219
  });
@@ -253,12 +253,12 @@ export class ContextMenu {
253
253
 
254
254
  // Custom color row
255
255
  const customRow = createElement('div', { class: 'an-context-color-custom' });
256
- const colorInput = createElement('input', {
256
+ const colorInput = /** @type {HTMLInputElement} */ (createElement('input', {
257
257
  type: 'color',
258
258
  value: it.colorType === 'foreColor' ? '#000000' : '#ffff00',
259
259
  title: this.context.locale.contextMenu.customColor || 'Custom color',
260
260
  'aria-label': this.context.locale.contextMenu.customColor || 'Custom color',
261
- });
261
+ }));
262
262
  const customLabel = createElement('span', {}, [this.context.locale.contextMenu.customColorLabel || 'Custom…']);
263
263
  const offCustom = on(colorInput, 'change', () => this._applyColor(it.colorType, colorInput.value));
264
264
  this._menuDisposers.push(offCustom);
@@ -324,20 +324,20 @@ export class ContextMenu {
324
324
  this._menuDisposers.push(offHeader);
325
325
 
326
326
  const offMove = on(gridEl, 'mousemove', (e) => {
327
- const cell = e.target.closest('[data-row]');
327
+ const cell = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('[data-row]'));
328
328
  if (!cell) return;
329
329
  setHighlight(+cell.dataset.row, +cell.dataset.col);
330
330
  });
331
331
  const offLeave = on(gridEl, 'mouseleave', () => setHighlight(0, 0));
332
332
  const offClick = on(gridEl, 'click', (e) => {
333
- const cell = e.target.closest('[data-row]');
333
+ const cell = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('[data-row]'));
334
334
  if (!cell) return;
335
335
  const rows = +cell.dataset.row;
336
336
  const cols = +cell.dataset.col;
337
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
337
+ const editable = this.context.layoutInfo?.editable;
338
338
  if (editable && this._savedRange) {
339
339
  editable.focus();
340
- const sel = window.getSelection();
340
+ const sel = globalThis.getSelection();
341
341
  sel.removeAllRanges();
342
342
  sel.addRange(this._savedRange.cloneRange());
343
343
  }
@@ -354,7 +354,7 @@ export class ContextMenu {
354
354
 
355
355
  // Regular item
356
356
  const btn = createElement('button', { type: 'button', class: 'an-context-item', 'data-name': it.name || '' });
357
- if (typeof it.disabled === 'function' ? it.disabled(this.context) : !!it.disabled) btn.disabled = true;
357
+ if (typeof it.disabled === 'function' ? it.disabled(this.context) : !!it.disabled) /** @type {HTMLButtonElement} */ (btn).disabled = true;
358
358
  if (it.icon) {
359
359
  const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
360
360
  iconSpan.innerHTML = it.icon;
@@ -372,13 +372,13 @@ export class ContextMenu {
372
372
  }
373
373
 
374
374
  _onContextMenu(event) {
375
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
375
+ const editable = this.context.layoutInfo?.editable;
376
376
  if (!editable) return;
377
377
  if (!editable.contains(event.target)) return;
378
378
  if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
379
379
  event.preventDefault();
380
380
 
381
- const winSel = window.getSelection();
381
+ const winSel = globalThis.getSelection();
382
382
  this._savedRange = (winSel && winSel.rangeCount > 0) ? winSel.getRangeAt(0).cloneRange() : null;
383
383
  this._renderItems(this._items);
384
384
 
@@ -386,7 +386,7 @@ export class ContextMenu {
386
386
  // Only apply when there is a real (non-collapsed) selection — a collapsed range
387
387
  // (cursor only) also has height > 0, which would misplace the menu relative
388
388
  // to the actual click point.
389
- let openX = event.clientX;
389
+ const openX = event.clientX;
390
390
  let openY = event.clientY;
391
391
  if (this._savedRange && !this._savedRange.collapsed) {
392
392
  try {
@@ -425,9 +425,9 @@ export class ContextMenu {
425
425
  let left = rx;
426
426
  let top = ry;
427
427
  // Clamp to viewport so the menu never overflows the screen edge
428
- if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8;
428
+ if (left + w > globalThis.innerWidth - 8) left = globalThis.innerWidth - w - 8;
429
429
  if (left < 8) left = 8;
430
- if (top + h > window.innerHeight - 8) top = window.innerHeight - h - 8;
430
+ if (top + h > globalThis.innerHeight - 8) top = globalThis.innerHeight - h - 8;
431
431
  if (top < 8) top = 8;
432
432
  this.el.style.left = `${left}px`;
433
433
  this.el.style.top = `${top}px`;
@@ -454,7 +454,7 @@ export class ContextMenu {
454
454
  let node = range.startContainer;
455
455
  if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
456
456
  if (!node) return type === 'foreColor' ? '#000000' : 'transparent';
457
- const cs = window.getComputedStyle(node);
457
+ const cs = globalThis.getComputedStyle(/** @type {Element} */ (node));
458
458
  if (type === 'foreColor') {
459
459
  return cs.color || '#000000';
460
460
  }
@@ -464,10 +464,10 @@ export class ContextMenu {
464
464
 
465
465
  /** Restore selection, apply a color command, then hide the menu. */
466
466
  _applyColor(type, color) {
467
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
467
+ const editable = this.context.layoutInfo?.editable;
468
468
  if (!editable || !this._savedRange) return;
469
469
  editable.focus();
470
- const sel = window.getSelection();
470
+ const sel = globalThis.getSelection();
471
471
  sel.removeAllRanges();
472
472
  sel.addRange(this._savedRange.cloneRange());
473
473
  document.execCommand(type, false, color);
@@ -482,20 +482,20 @@ export class ContextMenu {
482
482
  copyFormat() {
483
483
  const range = this._savedRange;
484
484
  if (!range) return;
485
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
485
+ const editable = this.context.layoutInfo?.editable;
486
486
  let node = range.startContainer;
487
487
  if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
488
488
  if (!node || !editable || !editable.contains(node)) return;
489
489
 
490
490
  // Walk up to collect explicitly-set inline properties from the nearest styled ancestor
491
- const cs = window.getComputedStyle(node);
491
+ const cs = globalThis.getComputedStyle(/** @type {Element} */ (node));
492
492
 
493
493
  // Detect if an explicit font-family / font-size is set on an element (not just inherited)
494
494
  const explicitFontFamily = this._findExplicitStyle(node, editable, 'fontFamily');
495
495
  const explicitFontSize = this._findExplicitStyle(node, editable, 'fontSize');
496
496
 
497
497
  this._copiedFormat = {
498
- bold: parseInt(cs.fontWeight, 10) >= 700,
498
+ bold: Number.parseInt(cs.fontWeight, 10) >= 700,
499
499
  italic: cs.fontStyle === 'italic' || cs.fontStyle === 'oblique',
500
500
  underline: (cs.textDecorationLine || '').includes('underline'),
501
501
  strikethrough: (cs.textDecorationLine || '').includes('line-through'),
@@ -533,13 +533,13 @@ export class ContextMenu {
533
533
  pasteFormat() {
534
534
  if (!this._copiedFormat || !this._savedRange) return;
535
535
  const fmt = this._copiedFormat;
536
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
536
+ const editable = this.context.layoutInfo?.editable;
537
537
  if (!editable) return;
538
538
 
539
539
  // CRITICAL: focus the editable FIRST, then restore selection.
540
540
  // Calling focus() after addRange() can wipe the selection in Chrome/Firefox.
541
541
  editable.focus();
542
- const sel = window.getSelection();
542
+ const sel = globalThis.getSelection();
543
543
  sel.removeAllRanges();
544
544
  sel.addRange(this._savedRange.cloneRange());
545
545
 
@@ -575,14 +575,14 @@ export class ContextMenu {
575
575
  document.execCommand('fontSize', false, '7');
576
576
  // Mark only the NEWLY created font[size="7"] elements.
577
577
  editable.querySelectorAll('font[size="7"]').forEach((el) => {
578
- if (!preExisting.has(el)) el.setAttribute('data-an-tmp', marker);
578
+ if (!preExisting.has(el)) /** @type {HTMLElement} */ (el).dataset.anTmp = marker;
579
579
  });
580
580
  editable.querySelectorAll(`[data-an-tmp="${marker}"]`).forEach((el) => {
581
581
  const span = document.createElement('span');
582
582
  span.style.fontSize = fmt.fontSize;
583
583
  el.parentNode.insertBefore(span, el);
584
584
  while (el.firstChild) span.appendChild(el.firstChild);
585
- el.parentNode.removeChild(el);
585
+ el.remove();
586
586
  });
587
587
  }
588
588
 
@@ -592,11 +592,11 @@ export class ContextMenu {
592
592
  /** Strip all inline formatting from the saved selection. */
593
593
  removeFormat() {
594
594
  if (!this._savedRange) return;
595
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
595
+ const editable = this.context.layoutInfo?.editable;
596
596
  if (!editable) return;
597
597
 
598
598
  editable.focus();
599
- const sel = window.getSelection();
599
+ const sel = globalThis.getSelection();
600
600
  sel.removeAllRanges();
601
601
  sel.addRange(this._savedRange.cloneRange());
602
602
 
@@ -609,7 +609,7 @@ export class ContextMenu {
609
609
  let el;
610
610
  while ((el = iter.nextNode())) {
611
611
  if (!editable.contains(el) || el === editable) continue;
612
- try { if (range.intersectsNode(el)) el.removeAttribute('style'); } catch { /* ignore */ }
612
+ try { if (range.intersectsNode(el)) /** @type {Element} */ (el).removeAttribute('style'); } catch { /* ignore */ }
613
613
  }
614
614
 
615
615
  this.context.invoke('editor.afterCommand');
@@ -12,6 +12,7 @@ import { handleKeydown } from '../editing/Typing.js';
12
12
  import { on } from '../core/dom.js';
13
13
  import { sanitiseHTML, sanitiseUrl } from '../core/sanitise.js';
14
14
  import { markdownToHTML, htmlToMarkdown } from '../core/markdown.js';
15
+ import { detectLang } from '../core/detectLang.js';
15
16
 
16
17
  export class Editor {
17
18
  /**
@@ -60,7 +61,7 @@ export class Editor {
60
61
  // Refresh toolbar on selection change, scoped to this editor
61
62
  const onSelChange = () => {
62
63
  if (!this.context._alive) return;
63
- const sel = window.getSelection();
64
+ const sel = globalThis.getSelection();
64
65
  if (sel && sel.rangeCount > 0 && editable.contains(sel.anchorNode)) {
65
66
  this.context.invoke('toolbar.refresh');
66
67
  if (typeof this.options.onSelectionChange === 'function') {
@@ -84,7 +85,7 @@ export class Editor {
84
85
  // lands WHERE the user actually clicked (middle, end of text…).
85
86
  // keyup : arrow-key navigation may land at <li>[0]; move to start-of-text.
86
87
  const fixChecklistCursor = (event) => {
87
- const sel = window.getSelection();
88
+ const sel = globalThis.getSelection();
88
89
  if (!sel || !sel.rangeCount) return;
89
90
  const r = sel.getRangeAt(0);
90
91
  if (!r.collapsed) return;
@@ -93,7 +94,8 @@ export class Editor {
93
94
  // Only act when cursor is at the <li> element node itself (not inside
94
95
  // a text node — the browser already placed it correctly in that case).
95
96
  if (sc.nodeType !== Node.ELEMENT_NODE) return;
96
- const li = sc.matches && sc.matches('.an-checklist li') ? sc : null;
97
+ const scEl = /** @type {Element} */ (sc);
98
+ const li = scEl.matches('.an-checklist li') ? scEl : null;
97
99
  if (!li) return;
98
100
  const cb = li.querySelector('input[type="checkbox"]');
99
101
  if (!cb) return;
@@ -160,9 +162,9 @@ export class Editor {
160
162
  // the mouse and moving outside the wrapper before releasing.
161
163
  on(editable, 'dragstart', (e) => {
162
164
  if (isReadOnly()) { e.preventDefault(); return; }
163
- const target = e.target;
165
+ const target = /** @type {Element} */ (e.target);
164
166
  if (target && (target.nodeName === 'IFRAME' ||
165
- (target.closest && target.closest('.an-video-wrapper')))) {
167
+ target.closest('.an-video-wrapper'))) {
166
168
  e.preventDefault();
167
169
  }
168
170
  }),
@@ -178,13 +180,14 @@ export class Editor {
178
180
  /** @type {string|null} 'superscript' | 'subscript' | null */
179
181
  let _compositionSupSub = null;
180
182
  const onCompositionStart = () => {
181
- const sel = window.getSelection();
183
+ const sel = globalThis.getSelection();
182
184
  if (!sel || !sel.rangeCount) { _compositionSupSub = null; return; }
183
185
  let node = sel.getRangeAt(0).startContainer;
184
186
  if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
185
- if (node && node.closest) {
186
- if (node.closest('sup')) _compositionSupSub = 'superscript';
187
- else if (node.closest('sub')) _compositionSupSub = 'subscript';
187
+ if (node) {
188
+ const el = /** @type {Element} */ (node);
189
+ if (el.closest('sup')) _compositionSupSub = 'superscript';
190
+ else if (el.closest('sub')) _compositionSupSub = 'subscript';
188
191
  else _compositionSupSub = null;
189
192
  }
190
193
  };
@@ -192,12 +195,12 @@ export class Editor {
192
195
  const tag = _compositionSupSub;
193
196
  _compositionSupSub = null;
194
197
  if (!tag) return;
195
- const sel = window.getSelection();
198
+ const sel = globalThis.getSelection();
196
199
  if (!sel || !sel.rangeCount) return;
197
200
  let node = sel.getRangeAt(0).startContainer;
198
201
  if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
199
- const inContext = node && node.closest &&
200
- (tag === 'superscript' ? node.closest('sup') : node.closest('sub'));
202
+ const el = /** @type {Element} */ (node);
203
+ const inContext = tag === 'superscript' ? el?.closest('sup') : el?.closest('sub');
201
204
  if (!inContext) {
202
205
  // The composed character escaped the sup/sub — re-apply the format.
203
206
  document.execCommand(tag);
@@ -284,7 +287,7 @@ export class Editor {
284
287
  if (!type.startsWith('insert')) return;
285
288
 
286
289
  const text = this.context.layoutInfo.editable.innerText || '';
287
- const chars = text.replace(/\n/g, '').length;
290
+ const chars = text.replaceAll('\n', '').length;
288
291
 
289
292
  if (maxChars && chars >= maxChars) {
290
293
  event.preventDefault();
@@ -314,6 +317,9 @@ export class Editor {
314
317
  // C4: Remove figure.an-figure elements whose <img> has been deleted so
315
318
  // orphaned figcaptions do not accumulate in the DOM.
316
319
  this._cleanOrphanedFigures();
320
+ // Ensure the editable always ends with a paragraph so the user can click
321
+ // and type after block elements that trap the cursor (pre, table, etc.).
322
+ this._ensureTrailingParagraph();
317
323
  // Immediate: keep toolbar and statusbar in sync on every mutation.
318
324
  this.context.invoke('toolbar.refresh');
319
325
  this.context.invoke('statusbar.update');
@@ -346,11 +352,30 @@ export class Editor {
346
352
  const editable = this.context.layoutInfo.editable;
347
353
  editable.querySelectorAll('figure.an-figure').forEach((fig) => {
348
354
  if (!fig.querySelector('img')) {
349
- fig.parentNode.removeChild(fig);
355
+ fig.remove();
350
356
  }
351
357
  });
352
358
  }
353
359
 
360
+ /**
361
+ * Ensures the editable always ends with a plain paragraph so the cursor can
362
+ * be placed after block elements that do not naturally allow it
363
+ * (pre, blockquote, table, figure, ul, ol, hr).
364
+ * Without this, clicking below the last such element does nothing.
365
+ */
366
+ _ensureTrailingParagraph() {
367
+ const editable = this.context.layoutInfo.editable;
368
+ if (!editable) return;
369
+ const last = editable.lastElementChild;
370
+ if (!last) return;
371
+ const TRAPPING = new Set(['PRE', 'BLOCKQUOTE', 'TABLE', 'FIGURE', 'UL', 'OL', 'HR']);
372
+ if (TRAPPING.has(last.nodeName)) {
373
+ const p = document.createElement('p');
374
+ p.innerHTML = '<br>';
375
+ editable.appendChild(p);
376
+ }
377
+ }
378
+
354
379
  // ---------------------------------------------------------------------------
355
380
  // Focus management
356
381
  // ---------------------------------------------------------------------------
@@ -370,7 +395,7 @@ export class Editor {
370
395
  */
371
396
  getHTML() {
372
397
  // Strip zero-width spaces inserted after icons to allow caret placement.
373
- const raw = this.context.layoutInfo.editable.innerHTML.replace(/\u200B/g, '');
398
+ const raw = this.context.layoutInfo.editable.innerHTML.replaceAll('\u200B', '');
374
399
  // Replace any blob: URLs (lightweight DOM references to pasted/dropped images)
375
400
  // with their original data URLs so the returned HTML is fully self-contained.
376
401
  return this.context.invoke('clipboard.resolveImages', raw) ?? raw;
@@ -425,7 +450,7 @@ export class Editor {
425
450
  isEmpty() {
426
451
  const text = (this.context.layoutInfo.editable.innerText || '')
427
452
  .trim()
428
- .replace(/\u00a0/g, '');
453
+ .replaceAll('\u00a0', '');
429
454
  const hasMedia = !!this.context.layoutInfo.editable.querySelector('img, video, iframe, table');
430
455
  return !text && !hasMedia;
431
456
  }
@@ -523,9 +548,35 @@ export class Editor {
523
548
  print() { this.context.print(); }
524
549
 
525
550
  /**
526
- * @param {string} tagName - e.g. 'h1', 'p', 'blockquote'
551
+ * @param {string} tagName - e.g. 'h1', 'p', 'blockquote', 'pre'
527
552
  */
528
- formatBlock(tagName) { Style.formatBlock(tagName); this.afterCommand(); }
553
+ formatBlock(tagName) {
554
+ Style.formatBlock(tagName);
555
+
556
+ // Auto-detect the programming language when the user formats a code block.
557
+ // Only runs when converting TO <pre> and the block has no language yet.
558
+ if (tagName === 'pre') {
559
+ const sel = globalThis.getSelection();
560
+ if (sel && sel.rangeCount > 0) {
561
+ const container = sel.getRangeAt(0).commonAncestorContainer;
562
+ const pre = /** @type {Element|null} */ (
563
+ container.nodeType === 1
564
+ ? /** @type {Element} */ (container).closest('pre')
565
+ : (/** @type {Element|null} */ (container.parentElement))?.closest('pre')
566
+ );
567
+ if (pre && !/** @type {HTMLElement} */ (pre).dataset.language) {
568
+ const code = pre.textContent || '';
569
+ const lang = detectLang(code);
570
+ if (lang) {
571
+ this.context.invoke('codeTooltip.applyLanguage', pre, lang);
572
+ return; // applyLanguage already calls afterCommand internally
573
+ }
574
+ }
575
+ }
576
+ }
577
+
578
+ this.afterCommand();
579
+ }
529
580
 
530
581
  /**
531
582
  * @param {string} color
@@ -566,7 +617,7 @@ export class Editor {
566
617
  * @param {boolean} [openInNewTab=false]
567
618
  */
568
619
  insertLink(url, text, openInNewTab = false) {
569
- const sel = window.getSelection();
620
+ const sel = globalThis.getSelection();
570
621
  if (!sel || sel.rangeCount === 0) return;
571
622
  const safeUrl = sanitiseUrl(url);
572
623
  if (!safeUrl) return;
@@ -580,8 +631,8 @@ export class Editor {
580
631
  if (openInNewTab) {
581
632
  const link = this._getClosestAnchor();
582
633
  if (link) {
583
- link.setAttribute('target', '_blank');
584
- link.setAttribute('rel', 'noopener noreferrer');
634
+ /** @type {Element} */ (link).setAttribute('target', '_blank');
635
+ /** @type {Element} */ (link).setAttribute('rel', 'noopener noreferrer');
585
636
  }
586
637
  }
587
638
  }
@@ -641,7 +692,7 @@ export class Editor {
641
692
  // ---------------------------------------------------------------------------
642
693
 
643
694
  _getClosestAnchor() {
644
- const sel = window.getSelection();
695
+ const sel = globalThis.getSelection();
645
696
  if (!sel || sel.rangeCount === 0) return null;
646
697
  let node = sel.getRangeAt(0).startContainer;
647
698
  while (node) {
@@ -658,10 +709,10 @@ export class Editor {
658
709
  */
659
710
  _escapeAttr(str) {
660
711
  return String(str ?? '')
661
- .replace(/&/g, '&amp;')
662
- .replace(/"/g, '&quot;')
663
- .replace(/</g, '&lt;')
664
- .replace(/>/g, '&gt;');
712
+ .replaceAll('&', '&amp;')
713
+ .replaceAll('"', '&quot;')
714
+ .replaceAll('<', '&lt;')
715
+ .replaceAll('>', '&gt;');
665
716
  }
666
717
 
667
718
  // --- delegated to shared sanitise.js ---
@@ -3,7 +3,7 @@
3
3
  * Click an emoji to insert it directly at the caret — no extra "Insert" step.
4
4
  */
5
5
 
6
- import { createElement, on, trapFocus } from '../core/dom.js';
6
+ import { createElement, on, trapFocus, makeDraggable } from '../core/dom.js';
7
7
  import { withSavedRange } from '../core/range.js';
8
8
 
9
9
  // ---------------------------------------------------------------------------
@@ -526,7 +526,7 @@ export class EmojiDialog {
526
526
  this._disposers.forEach((d) => d());
527
527
  this._disposers = [];
528
528
  if (this._dialog && this._dialog.parentNode) {
529
- this._dialog.parentNode.removeChild(this._dialog);
529
+ this._dialog.remove();
530
530
  }
531
531
  this._dialog = null;
532
532
  }
@@ -566,19 +566,23 @@ export class EmojiDialog {
566
566
 
567
567
  // Title row
568
568
  const titleRow = createElement('div', { class: 'an-icon-title-row' });
569
+ const titleGroup = createElement('div', { class: 'an-dialog-title-group' });
570
+ const iconEl = createElement('span', { class: 'an-dialog-icon an-dialog-icon--sm' });
571
+ iconEl.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 13s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`;
569
572
  const title = createElement('h3', { class: 'an-dialog-title' });
570
573
  title.textContent = L.title;
574
+ titleGroup.append(iconEl, title);
571
575
  const closeBtn = createElement('button', { type: 'button', class: 'an-icon-close', 'aria-label': L.close });
572
576
  closeBtn.innerHTML = '&times;';
573
- titleRow.append(title, closeBtn);
577
+ titleRow.append(titleGroup, closeBtn);
574
578
 
575
579
  // Search
576
- const searchInput = createElement('input', {
580
+ const searchInput = /** @type {HTMLInputElement} */ (createElement('input', {
577
581
  type: 'search',
578
582
  class: 'an-input an-icon-search',
579
583
  placeholder: L.searchPlaceholder,
580
584
  autocomplete: 'off',
581
- });
585
+ }));
582
586
  this._searchInput = searchInput;
583
587
 
584
588
  // Category tabs
@@ -588,7 +592,7 @@ export class EmojiDialog {
588
592
  catBar.appendChild(allTab);
589
593
  EMOJI_CATS.forEach(({ id, label }) => {
590
594
  const tab = createElement('button', { type: 'button', class: 'an-icon-cat', 'data-cat': id });
591
- tab.textContent = (L.categories && L.categories[id]) || label;
595
+ tab.textContent = L.categories?.[id] || label;
592
596
  catBar.appendChild(tab);
593
597
  });
594
598
  this._catBar = catBar;
@@ -617,6 +621,7 @@ export class EmojiDialog {
617
621
 
618
622
  box.append(titleRow, searchInput, catBar, grid, btnRow);
619
623
  overlay.appendChild(box);
624
+ makeDraggable(titleRow, box);
620
625
 
621
626
  // Events
622
627
  const d1 = on(closeBtn, 'click', () => this._close());
@@ -624,7 +629,7 @@ export class EmojiDialog {
624
629
  const d3 = on(overlay, 'click', (e) => { if (e.target === overlay) this._close(); });
625
630
  const d4 = on(searchInput, 'input', () => this._filterEmojis(searchInput.value, this._activeCat));
626
631
  const d5 = on(catBar, 'click', (e) => {
627
- const tab = e.target.closest('[data-cat]');
632
+ const tab = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('[data-cat]'));
628
633
  if (tab) {
629
634
  this._activeCat = tab.dataset.cat;
630
635
  this._updateCatTabs();
@@ -632,7 +637,7 @@ export class EmojiDialog {
632
637
  }
633
638
  });
634
639
  const d6 = on(grid, 'click', (e) => {
635
- const cell = e.target.closest('.an-emoji-cell');
640
+ const cell = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('.an-emoji-cell'));
636
641
  if (cell) this._onEmojiClick(cell.dataset.char);
637
642
  });
638
643
  this._disposers.push(d1, d2, d3, d4, d5, d6);
@@ -645,7 +650,7 @@ export class EmojiDialog {
645
650
 
646
651
  _updateCatTabs() {
647
652
  this._catBar.querySelectorAll('.an-icon-cat').forEach((tab) => {
648
- tab.classList.toggle('active', tab.dataset.cat === this._activeCat);
653
+ tab.classList.toggle('active', /** @type {HTMLElement} */ (tab).dataset.cat === this._activeCat);
649
654
  });
650
655
  }
651
656
 
@@ -653,10 +658,11 @@ export class EmojiDialog {
653
658
  const q = (query || '').trim().toLowerCase();
654
659
  let count = 0;
655
660
  this._grid.querySelectorAll('.an-emoji-cell').forEach((cell) => {
656
- const matchCat = !cat || cat === 'all' || cell.dataset.cat === cat;
657
- const matchQuery = !q || cell.dataset.keywords.includes(q) || cell.dataset.char === q;
661
+ const hCell = /** @type {HTMLElement} */ (cell);
662
+ const matchCat = !cat || cat === 'all' || hCell.dataset.cat === cat;
663
+ const matchQuery = !q || hCell.dataset.keywords.includes(q) || hCell.dataset.char === q;
658
664
  const visible = matchCat && matchQuery;
659
- cell.style.display = visible ? '' : 'none';
665
+ hCell.style.display = visible ? '' : 'none';
660
666
  if (visible) count++;
661
667
  });
662
668
  let empty = this._grid.querySelector('.an-icon-empty');
@@ -665,7 +671,7 @@ export class EmojiDialog {
665
671
  empty.textContent = 'No emojis found';
666
672
  this._grid.appendChild(empty);
667
673
  }
668
- empty.style.display = count > 0 ? 'none' : '';
674
+ /** @type {HTMLElement} */ (empty).style.display = count > 0 ? 'none' : '';
669
675
  }
670
676
 
671
677
  // ---------------------------------------------------------------------------
@@ -679,7 +685,7 @@ export class EmojiDialog {
679
685
  // 1. Restore selection while dialog is still mounted (same pattern as ImageDialog)
680
686
  if (savedRange) savedRange.select();
681
687
 
682
- const sel = window.getSelection();
688
+ const sel = globalThis.getSelection();
683
689
  let range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
684
690
  if (!range) {
685
691
  range = document.createRange();
@@ -694,10 +700,10 @@ export class EmojiDialog {
694
700
  // deleteContents() can drift the range endpoint outside the cell in some
695
701
  // browsers. Save the cell reference first and re-anchor after deletion.
696
702
  const _sc = range.startContainer;
697
- const _tdAnchor = (_sc.nodeType === 1 ? _sc : _sc.parentElement)
698
- ?.closest?.('td, th');
703
+ const _tdAnchor = /** @type {Element|null} */ (_sc.nodeType === 1 ? _sc : _sc.parentElement)
704
+ ?.closest('td, th');
699
705
  range.deleteContents();
700
- if (_tdAnchor && _tdAnchor.isConnected && !_tdAnchor.contains(range.startContainer)) {
706
+ if (_tdAnchor?.isConnected && !_tdAnchor.contains(range.startContainer)) {
701
707
  range.setStart(_tdAnchor, 0);
702
708
  range.collapse(true);
703
709
  }
@@ -726,7 +732,7 @@ export class EmojiDialog {
726
732
  if (this._dialog) {
727
733
  this._dialog.style.display = 'flex';
728
734
  this._removeTrap = trapFocus(this._dialog, () => this._close());
729
- setTimeout(() => this._searchInput && this._searchInput.focus(), 50);
735
+ setTimeout(() => this._searchInput?.focus(), 50);
730
736
  }
731
737
  }
732
738