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
@@ -44,16 +44,16 @@ const _ACTIONS = {
44
44
  strikethrough: (ctx) => ctx.invoke('editor.strikethrough'),
45
45
  link: (ctx) => ctx.invoke('linkDialog.show'),
46
46
  removeFormat: (ctx) => {
47
- const editable = ctx.layoutInfo && ctx.layoutInfo.editable;
47
+ const editable = ctx.layoutInfo?.editable;
48
48
  if (!editable) return;
49
49
  editable.focus();
50
50
  document.execCommand('removeFormat');
51
51
  // Also strip inline style attributes which execCommand('removeFormat') misses
52
- const sel = window.getSelection();
53
- if (sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed) {
52
+ const sel = globalThis.getSelection();
53
+ if (sel?.rangeCount > 0 && !sel.getRangeAt(0).collapsed) {
54
54
  const range = sel.getRangeAt(0);
55
55
  const ancestor = range.commonAncestorContainer;
56
- const root = ancestor.nodeType === 1 ? ancestor : ancestor.parentElement;
56
+ const root = /** @type {Element|null} */ (ancestor.nodeType === 1 ? ancestor : ancestor.parentElement);
57
57
  if (root) {
58
58
  const candidates = [root, ...root.querySelectorAll('[style]')];
59
59
  for (const el of candidates) {
@@ -115,14 +115,16 @@ export class BubbleToolbar {
115
115
  const d6 = this.context.on('contextMenu:hide', () => {
116
116
  this._contextMenuOpen = false;
117
117
  });
118
- this._disposers.push(d1, d2, d3, d4, d5, d6);
118
+ const d7 = on(globalThis, 'scroll', () => this._hide(), { passive: true });
119
+ const d8 = on(globalThis, 'resize', () => this._hide(), { passive: true });
120
+ this._disposers.push(d1, d2, d3, d4, d5, d6, d7, d8);
119
121
  return this;
120
122
  }
121
123
 
122
124
  destroy() {
123
- if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
125
+ this._el?.remove();
124
126
  this._el = null;
125
- if (this._picker && this._picker.parentNode) this._picker.parentNode.removeChild(this._picker);
127
+ this._picker?.remove();
126
128
  this._picker = null;
127
129
  this._disposers.forEach((d) => d());
128
130
  this._disposers = [];
@@ -249,31 +251,33 @@ export class BubbleToolbar {
249
251
  document.body.appendChild(picker);
250
252
 
251
253
  this._picker = picker;
252
- this._picker._paletteEl = palette;
253
- this._picker._noColorBtn = noColorBtn;
254
- this._picker._colorInput = colorInput;
254
+ const pickerAny = /** @type {any} */ (picker);
255
+ pickerAny._paletteEl = palette;
256
+ pickerAny._noColorBtn = noColorBtn;
257
+ pickerAny._colorInput = colorInput;
255
258
  }
256
259
 
257
260
  _openColorPicker(type, anchorBtn) {
258
261
  // Save current selection before the picker might shift focus
259
- const sel = window.getSelection();
260
- if (sel && sel.rangeCount > 0) {
262
+ const sel = globalThis.getSelection();
263
+ if (sel?.rangeCount > 0) {
261
264
  this._savedRange = sel.getRangeAt(0).cloneRange();
262
265
  }
263
266
 
264
267
  this._pickerType = type;
265
268
 
266
269
  // Toggle "No highlight" swatch based on type
267
- const palette = this._picker._paletteEl;
268
- const noColorBtn = this._picker._noColorBtn;
270
+ const pickerAny = /** @type {any} */ (this._picker);
271
+ const palette = pickerAny._paletteEl;
272
+ const noColorBtn = pickerAny._noColorBtn;
269
273
  if (type === 'hiliteColor') {
270
274
  if (!palette.contains(noColorBtn)) palette.appendChild(noColorBtn);
271
- } else {
272
- if (palette.contains(noColorBtn)) palette.removeChild(noColorBtn);
275
+ } else if (palette.contains(noColorBtn)) {
276
+ noColorBtn.remove();
273
277
  }
274
278
 
275
279
  // Seed the custom color input
276
- this._picker._colorInput.value = type === 'foreColor' ? '#000000' : '#ffff00';
280
+ /** @type {any} */ (this._picker)._colorInput.value = type === 'foreColor' ? '#000000' : '#ffff00';
277
281
 
278
282
  // Position the picker above the bubble toolbar (not blocking the content below it).
279
283
  // Fall back to below the toolbar if there's not enough room above.
@@ -288,7 +292,7 @@ export class BubbleToolbar {
288
292
  // Align horizontally with the clicked button, clamped to viewport
289
293
  const btnRect = anchorBtn.getBoundingClientRect();
290
294
  let left = btnRect.left;
291
- left = Math.max(8, Math.min(left, window.innerWidth - pw - 8));
295
+ left = Math.max(8, Math.min(left, globalThis.innerWidth - pw - 8));
292
296
 
293
297
  this._picker.style.left = `${left}px`;
294
298
  this._picker.style.top = `${top}px`;
@@ -301,11 +305,11 @@ export class BubbleToolbar {
301
305
 
302
306
  /** Restore the saved selection, apply execCommand, update the color strip, then close the picker. */
303
307
  _applyColor(type, color) {
304
- const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
308
+ const editable = this.context.layoutInfo?.editable;
305
309
  if (!editable || !this._savedRange) return;
306
310
 
307
311
  editable.focus();
308
- const sel = window.getSelection();
312
+ const sel = globalThis.getSelection();
309
313
  sel.removeAllRanges();
310
314
  try { sel.addRange(this._savedRange.cloneRange()); } catch (_) { return; }
311
315
 
@@ -318,9 +322,9 @@ export class BubbleToolbar {
318
322
 
319
323
  // Update the color strip on the corresponding button
320
324
  const name = type === 'hiliteColor' ? 'hiliteColor' : 'foreColor';
321
- const btn = this._el && this._el.querySelector(`[data-name="${name}"]`);
322
- const strip = btn && btn.querySelector('.an-bubble-color-strip');
323
- if (strip) strip.style.background = color === 'transparent' ? 'transparent' : color;
325
+ const btn = this._el?.querySelector(`[data-name="${name}"]`);
326
+ const strip = btn?.querySelector('.an-bubble-color-strip');
327
+ if (strip) /** @type {HTMLElement} */ (strip).style.background = color === 'transparent' ? 'transparent' : color;
324
328
 
325
329
  this._closeColorPicker();
326
330
  this._syncActive();
@@ -346,12 +350,25 @@ export class BubbleToolbar {
346
350
  let left = rect.left + rect.width / 2 - bw / 2;
347
351
  let top = rect.top - bh - gap;
348
352
 
349
- left = Math.max(8, Math.min(left, window.innerWidth - bw - 8));
353
+ left = Math.max(8, Math.min(left, globalThis.innerWidth - bw - 8));
350
354
 
351
355
  if (top < 8) {
352
356
  top = rect.bottom + gap;
353
357
  }
354
358
 
359
+ // If the Table Tooltip is visible, avoid overlapping it by flipping below the selection.
360
+ // The Table Tooltip sits above the table, which is the same vertical zone the bubble
361
+ // toolbar would normally occupy when text is selected inside a table cell.
362
+ const tableTooltipEl = document.querySelector('.an-table-tooltip');
363
+ if (tableTooltipEl && /** @type {HTMLElement} */ (tableTooltipEl).style.display !== 'none') {
364
+ const ttRect = tableTooltipEl.getBoundingClientRect();
365
+ const overlapsVertically = top < ttRect.bottom + gap && top + bh > ttRect.top - gap;
366
+ if (overlapsVertically) {
367
+ top = rect.bottom + gap;
368
+ if (top + bh > globalThis.innerHeight - 8) top = ttRect.bottom + gap;
369
+ }
370
+ }
371
+
355
372
  el.style.top = `${top}px`;
356
373
  el.style.left = `${left}px`;
357
374
  el.style.visibility = '';
@@ -371,7 +388,7 @@ export class BubbleToolbar {
371
388
  _syncActive() {
372
389
  if (!this._btnCache) return;
373
390
  this._btnCache.forEach((btn) => {
374
- const activeFn = _ACTIVE[btn.dataset.name];
391
+ const activeFn = _ACTIVE[/** @type {HTMLElement} */ (btn).dataset.name];
375
392
  btn.classList.toggle('an-active', !!(activeFn && activeFn()));
376
393
  });
377
394
  }
@@ -379,22 +396,22 @@ export class BubbleToolbar {
379
396
  /** Read the current selection's color and update the color-strip indicators. */
380
397
  _syncColorStrips() {
381
398
  if (!this._el) return;
382
- const sel = window.getSelection();
399
+ const sel = globalThis.getSelection();
383
400
  if (!sel || !sel.rangeCount) return;
384
401
  let node = sel.getRangeAt(0).startContainer;
385
402
  if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
386
403
  if (!node) return;
387
- const cs = window.getComputedStyle(node);
404
+ const cs = globalThis.getComputedStyle(/** @type {Element} */ (node));
388
405
 
389
406
  const foreBtn = this._el.querySelector('[data-name="foreColor"]');
390
- const foreStrip = foreBtn && foreBtn.querySelector('.an-bubble-color-strip');
391
- if (foreStrip) foreStrip.style.background = cs.color || '#000000';
407
+ const foreStrip = foreBtn?.querySelector('.an-bubble-color-strip');
408
+ if (foreStrip) /** @type {HTMLElement} */ (foreStrip).style.background = cs.color || '#000000';
392
409
 
393
410
  const hiliteBtn = this._el.querySelector('[data-name="hiliteColor"]');
394
- const hiliteStrip = hiliteBtn && hiliteBtn.querySelector('.an-bubble-color-strip');
411
+ const hiliteStrip = hiliteBtn?.querySelector('.an-bubble-color-strip');
395
412
  if (hiliteStrip) {
396
413
  const bg = cs.backgroundColor;
397
- hiliteStrip.style.background = (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') ? 'transparent' : bg;
414
+ /** @type {HTMLElement} */ (hiliteStrip).style.background = (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') ? 'transparent' : bg;
398
415
  }
399
416
  }
400
417
 
@@ -409,7 +426,7 @@ export class BubbleToolbar {
409
426
  // Keep toolbar visible while color picker is open
410
427
  if (this._picker && this._picker.style.display !== 'none') return;
411
428
 
412
- const sel = window.getSelection();
429
+ const sel = globalThis.getSelection();
413
430
  if (!sel || sel.isCollapsed || !sel.rangeCount) {
414
431
  this._hide();
415
432
  return;
@@ -441,8 +458,8 @@ export class BubbleToolbar {
441
458
  _onMousedown(e) {
442
459
  // Hide when clicking outside both the editable, the bubble toolbar, and the color picker
443
460
  if (!this._visible) return;
444
- if (this._el && this._el.contains(e.target)) return;
445
- if (this._picker && this._picker.contains(e.target)) return;
461
+ if (this._el?.contains(e.target)) return;
462
+ if (this._picker?.contains(e.target)) return;
446
463
  const editable = this.context.layoutInfo.editable;
447
464
  if (editable.contains(e.target)) return;
448
465
  this._hide();
@@ -12,12 +12,14 @@ import * as Style from '../editing/Style.js';
12
12
 
13
13
  /**
14
14
  * @typedef {object} DropdownDef
15
- * @property {string} name - unique identifier
16
- * @property {'select'} type - discriminator for Toolbar renderer
15
+ * @property {string} name - unique identifier
16
+ * @property {'select'} type - discriminator for Toolbar renderer
17
17
  * @property {string} tooltip
18
- * @property {string[]} [items] - overridden at render time from options
19
- * @property {Function} action - called with (context, value)
20
- * @property {Function} [getValue] - called with (context) to get current value
18
+ * @property {Array<string|{value:string,label:string,disabled?:boolean}>} [items] - overridden at render time from options
19
+ * @property {Function} action - called with (context, value)
20
+ * @property {Function} [getValue] - called with (context) to get current value
21
+ * @property {string} [selectClass] - extra CSS class(es) for the <select>
22
+ * @property {string} [placeholder] - placeholder text for the empty option
21
23
  */
22
24
 
23
25
  // ---------------------------------------------------------------------------
@@ -99,11 +101,11 @@ export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)',
99
101
  // also check for a <u> ancestor in the DOM using startContainer for
100
102
  // consistent behaviour across both collapsed and range selections.
101
103
  if (document.queryCommandState('underline')) return true;
102
- const sel = window.getSelection();
104
+ const sel = globalThis.getSelection();
103
105
  if (!sel || !sel.rangeCount) return false;
104
106
  let sc = sel.getRangeAt(0).startContainer;
105
107
  if (sc.nodeType === 3) sc = sc.parentElement;
106
- return !!(sc && sc.closest && sc.closest('u'));
108
+ return !!(sc && /** @type {Element} */ (sc).closest('u'));
107
109
  });
108
110
  export const strikeBtn = btn('strikethrough', 'strikethrough', 'Strikethrough', () => Style.strikethrough(), () => document.queryCommandState('strikeThrough'));
109
111
  export const superscriptBtn = btn('superscript', 'superscript', 'Superscript', () => Style.superscript(), () => document.queryCommandState('superscript'));
@@ -177,16 +179,16 @@ export const fontSizeBtn = {
177
179
  action: (ctx, value) => Style.fontSize(value, ctx.layoutInfo.editable),
178
180
  getValue: (ctx) => {
179
181
  try {
180
- const sel = window.getSelection();
182
+ const sel = globalThis.getSelection();
181
183
  if (sel && sel.rangeCount) {
182
- let el = sel.getRangeAt(0).startContainer;
183
- if (el.nodeType === 3) el = el.parentElement;
184
- while (el && el.nodeType === 1 && !el.style.fontSize) el = el.parentElement;
185
- const size = (el && el.style && el.style.fontSize) ? el.style.fontSize : '';
184
+ let el = /** @type {Element|null} */ (sel.getRangeAt(0).startContainer);
185
+ if (el && el.nodeType === 3) el = el.parentElement;
186
+ while (el && el.nodeType === 1 && !/** @type {HTMLElement} */ (el).style.fontSize) el = el.parentElement;
187
+ const size = (el && /** @type {HTMLElement} */ (el).style.fontSize) ? /** @type {HTMLElement} */ (el).style.fontSize : '';
186
188
  if (size) return size;
187
189
  }
188
190
  // Fallback: read the base font size from the editable element itself
189
- const editable = ctx && ctx.layoutInfo && ctx.layoutInfo.editable;
191
+ const editable = ctx?.layoutInfo?.editable;
190
192
  if (editable) return editable.style.fontSize || '';
191
193
  return '';
192
194
  } catch { return ''; }
@@ -278,14 +280,14 @@ export const lineHeightBtn = {
278
280
  action: (_ctx, value) => Style.lineHeight(value),
279
281
  getValue: () => {
280
282
  try {
281
- const sel = window.getSelection();
283
+ const sel = globalThis.getSelection();
282
284
  if (!sel || !sel.rangeCount) return '';
283
285
  const BLOCKS = new Set(['P','DIV','H1','H2','H3','H4','H5','H6','LI','BLOCKQUOTE','PRE','TD','TH']);
284
- let el = sel.getRangeAt(0).startContainer;
285
- if (el.nodeType === 3) el = el.parentElement;
286
- while (el && !BLOCKS.has(el.tagName)) el = el.parentElement;
286
+ let el = /** @type {Element|null} */ (sel.getRangeAt(0).startContainer);
287
+ if (el && el.nodeType === 3) el = el.parentElement;
288
+ while (el && !BLOCKS.has(/** @type {Element} */ (el).tagName)) el = el.parentElement;
287
289
  if (!el) return '';
288
- return el.style.lineHeight || getComputedStyle(el).lineHeight || '';
290
+ return /** @type {HTMLElement} */ (el).style.lineHeight || getComputedStyle(/** @type {Element} */ (el)).lineHeight || '';
289
291
  } catch { return ''; }
290
292
  },
291
293
  };
@@ -65,11 +65,11 @@ export class Clipboard {
65
65
  */
66
66
  _revokeRemovedBlobs(node) {
67
67
  if (!this._blobRegistry || !this._blobRegistry.size) return;
68
- const imgs = [];
68
+ const imgs = /** @type {Element[]} */ ([]);
69
69
  if (node.nodeName === 'IMG') {
70
- imgs.push(node);
71
- } else if (node.querySelectorAll) {
72
- imgs.push(...node.querySelectorAll('img'));
70
+ imgs.push(/** @type {Element} */ (node));
71
+ } else if (/** @type {Element} */ (node).querySelectorAll) {
72
+ imgs.push(.../** @type {Element} */ (node).querySelectorAll('img'));
73
73
  }
74
74
  imgs.forEach((img) => {
75
75
  const src = img.getAttribute('src') || '';
@@ -139,7 +139,7 @@ export class Clipboard {
139
139
  // Unwrap — replace el with its children
140
140
  const parent = el.parentNode;
141
141
  while (el.firstChild) parent.insertBefore(el.firstChild, el);
142
- parent.removeChild(el);
142
+ el.remove();
143
143
  }
144
144
  // Strip class and all data-* attributes from every remaining element
145
145
  doc.querySelectorAll('*').forEach((el) => {
@@ -180,7 +180,7 @@ export class Clipboard {
180
180
  }
181
181
 
182
182
  _onPaste(event) {
183
- const clipboardData = event.clipboardData || window.clipboardData;
183
+ const clipboardData = event.clipboardData || /** @type {any} */ (globalThis).clipboardData;
184
184
  if (!clipboardData) return;
185
185
 
186
186
  // Consume and reset the one-shot plain-paste flag
@@ -247,7 +247,6 @@ export class Clipboard {
247
247
  if (this.options.pasteStripAttributes) html = this._stripAttributes(html);
248
248
  execCommand('insertHTML', html);
249
249
  this.context.invoke('editor.afterCommand');
250
- return;
251
250
  }
252
251
 
253
252
  // Otherwise let the browser handle paste natively
@@ -300,11 +299,11 @@ export class Clipboard {
300
299
  }
301
300
 
302
301
  // C2: Reject image formats that browsers cannot decode/display.
303
- const UNSUPPORTED = ['image/tiff', 'image/x-tiff', 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp'];
302
+ const UNSUPPORTED = new Set(['image/tiff', 'image/x-tiff', 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp']);
304
303
  const maxBytes = (this.options.maxImageSize || 5) * 1024 * 1024;
305
304
  files.forEach((file) => {
306
305
  if (!file || !file.type.startsWith('image/')) return;
307
- if (UNSUPPORTED.includes(file.type)) {
306
+ if (UNSUPPORTED.has(file.type)) {
308
307
  const message = `Image format "${file.type}" is not supported for display in web browsers. Please convert to PNG, JPEG, or WebP first.`;
309
308
  this.context.triggerEvent('imageError', { file, message });
310
309
  console.warn('[AutumnNote]', message);
@@ -351,10 +350,10 @@ export class Clipboard {
351
350
  */
352
351
  _dataUrlToBlob(dataUrl) {
353
352
  const [header, b64] = dataUrl.split(',');
354
- const mime = header.match(/:(.*?);/)?.[1] ?? 'image/png';
353
+ const mime = /:(.*?);/.exec(header)?.[1] ?? 'image/png';
355
354
  const binary = atob(b64);
356
355
  const arr = new Uint8Array(binary.length);
357
- for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i);
356
+ for (let i = 0; i < binary.length; i++) arr[i] = binary.codePointAt(i);
358
357
  return new Blob([arr], { type: mime });
359
358
  }
360
359
 
@@ -438,7 +437,7 @@ export class Clipboard {
438
437
  }
439
438
  }
440
439
  if (!range) return;
441
- const sel = window.getSelection();
440
+ const sel = globalThis.getSelection();
442
441
  if (sel) {
443
442
  sel.removeAllRanges();
444
443
  sel.addRange(range);
@@ -456,10 +455,10 @@ export class Clipboard {
456
455
  */
457
456
  _escapeHTML(str) {
458
457
  return str
459
- .replace(/&/g, '&amp;')
460
- .replace(/</g, '&lt;')
461
- .replace(/>/g, '&gt;')
462
- .replace(/"/g, '&quot;')
463
- .replace(/'/g, '&#039;');
458
+ .replaceAll('&', '&amp;')
459
+ .replaceAll('<', '&lt;')
460
+ .replaceAll('>', '&gt;')
461
+ .replaceAll('"', '&quot;')
462
+ .replaceAll("'", '&#039;');
464
463
  }
465
464
  }
@@ -40,21 +40,22 @@ export class CodeTooltip {
40
40
  this._disposers.push(
41
41
  on(editable, 'mouseover', (e) => {
42
42
  if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
43
- const pre = e.target.closest('pre');
43
+ const pre = /** @type {Element} */ (e.target)?.closest('pre');
44
44
  if (pre && editable.contains(pre)) {
45
45
  this._scheduleShow(pre);
46
46
  }
47
47
  }),
48
48
  on(editable, 'mouseout', (e) => {
49
- const to = e.relatedTarget;
49
+ const to = /** @type {Node|null} */ (/** @type {MouseEvent} */ (e).relatedTarget);
50
50
  if (!to || (!editable.contains(to) && !this._el.contains(to))) {
51
51
  this._scheduleHide();
52
52
  }
53
53
  }),
54
54
  on(document, 'click', (e) => {
55
+ const et = /** @type {Node} */ (e.target);
55
56
  if (this._activePre &&
56
- !this._activePre.contains(e.target) &&
57
- !this._el.contains(e.target)) {
57
+ !this._activePre.contains(et) &&
58
+ !this._el.contains(et)) {
58
59
  this._hide();
59
60
  }
60
61
  }),
@@ -67,7 +68,7 @@ export class CodeTooltip {
67
68
  this._clearTimers();
68
69
  this._disposers.forEach((d) => d());
69
70
  this._disposers = [];
70
- if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
71
+ this._el?.remove();
71
72
  this._el = null;
72
73
  }
73
74
 
@@ -92,15 +93,15 @@ export class CodeTooltip {
92
93
  el.appendChild(this._sep());
93
94
 
94
95
  // Language selector
95
- this._langSelect = createElement('select', {
96
+ this._langSelect = /** @type {HTMLSelectElement} */ (createElement('select', {
96
97
  class: 'an-code-lang-select',
97
98
  title: L.syntaxLanguage,
98
99
  'aria-label': L.syntaxAriaLabel,
99
- });
100
+ }));
100
101
  const LANGUAGES = [
101
102
  ['', 'Plain text'], ['javascript', 'JavaScript'], ['typescript', 'TypeScript'],
102
- ['python', 'Python'], ['html', 'HTML'], ['css', 'CSS'], ['json', 'JSON'],
103
- ['xml', 'XML'], ['bash', 'Bash / Shell'], ['sql', 'SQL'],
103
+ ['python', 'Python'], ['html', 'HTML'], ['css', 'CSS'], ['scss', 'SCSS'],
104
+ ['json', 'JSON'], ['xml', 'XML'], ['bash', 'Bash / Shell'], ['sql', 'SQL'],
104
105
  ['java', 'Java'], ['csharp', 'C#'], ['php', 'PHP'], ['ruby', 'Ruby'],
105
106
  ['go', 'Go'], ['rust', 'Rust'], ['cpp', 'C++'], ['c', 'C'],
106
107
  ['kotlin', 'Kotlin'], ['swift', 'Swift'],
@@ -225,7 +226,7 @@ export class CodeTooltip {
225
226
  let left = rect.left + (rect.width - tipW) / 2;
226
227
 
227
228
  if (top < margin) top = rect.bottom + margin;
228
- if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
229
+ if (left + tipW > globalThis.innerWidth - margin) left = globalThis.innerWidth - tipW - margin;
229
230
  if (left < margin) left = margin;
230
231
 
231
232
  // Tooltip uses position:fixed, so viewport coordinates are used directly.
@@ -240,7 +241,7 @@ export class CodeTooltip {
240
241
  _syncWrapBtn() {
241
242
  if (!this._activePre || !this._wrapBtn) return;
242
243
  const wrapped = (this._activePre.style.whiteSpace || '').includes('pre-wrap')
243
- || window.getComputedStyle(this._activePre).whiteSpace === 'pre-wrap';
244
+ || globalThis.getComputedStyle(this._activePre).whiteSpace === 'pre-wrap';
244
245
  this._wrapBtn.classList.toggle('active', wrapped);
245
246
  this._wrapBtn.title = wrapped
246
247
  ? this.context.locale.tooltips.code.disableWordWrap
@@ -250,7 +251,7 @@ export class CodeTooltip {
250
251
  _syncLangSelect() {
251
252
  if (!this._activePre || !this._langSelect) return;
252
253
  const codeEl = this._activePre.querySelector('code');
253
- const fromAttr = this._activePre.getAttribute('data-language') || '';
254
+ const fromAttr = this._activePre.dataset.language || '';
254
255
  const fromClass = codeEl ? (_LANG_CLASS_RE.exec(codeEl.className) || [])[1] || '' : '';
255
256
  this._langSelect.value = fromAttr || fromClass || '';
256
257
  }
@@ -273,7 +274,7 @@ export class CodeTooltip {
273
274
  document.body.appendChild(ta);
274
275
  ta.select();
275
276
  try { document.execCommand('copy'); this._flashCopied(); } catch (_) {}
276
- document.body.removeChild(ta);
277
+ ta.remove();
277
278
  }
278
279
  }
279
280
 
@@ -300,10 +301,31 @@ export class CodeTooltip {
300
301
  this._positionNear(pre);
301
302
  }
302
303
 
304
+ /**
305
+ * Applies a language to a given <pre> element: sets classes, data-language,
306
+ * and triggers Prism highlighting. Called by the auto-detect flow.
307
+ * @param {HTMLElement} pre
308
+ * @param {string} lang - Prism language identifier, e.g. 'javascript'
309
+ */
310
+ applyLanguage(pre, lang) {
311
+ if (!pre || !lang) return;
312
+ // Temporarily set activePre so _onLangChange can target it
313
+ const savedPre = this._activePre;
314
+ this._activePre = pre;
315
+ if (this._langSelect) this._langSelect.value = lang;
316
+ this._onLangChange();
317
+ // Update the select to reflect the detected language when tooltip is shown
318
+ if (this._langSelect) this._langSelect.value = lang;
319
+ this._activePre = savedPre || pre;
320
+ // Don't restore savedPre if it was null — keep `pre` as activePre so that
321
+ // the tooltip select is correct the first time the user hovers over it.
322
+ }
323
+
303
324
  _onLangChange() {
304
325
  const pre = this._activePre;
305
326
  if (!pre) return;
306
327
  const lang = this._langSelect.value;
328
+ const _w = /** @type {any} */ (globalThis);
307
329
 
308
330
  // Ensure a <code> child exists (Prism targets <pre><code class="language-xxx">)
309
331
  let codeEl = pre.querySelector('code');
@@ -318,9 +340,9 @@ export class CodeTooltip {
318
340
  // Mirror language class on <pre> so Prism CSS theme targets it (pre[class*='language-'])
319
341
  pre.className = lang ? `language-${lang}` : '';
320
342
  if (lang) {
321
- pre.setAttribute('data-language', lang);
343
+ pre.dataset.language = lang;
322
344
  } else {
323
- pre.removeAttribute('data-language');
345
+ delete pre.dataset.language;
324
346
  }
325
347
 
326
348
  // Trigger Prism if available.
@@ -328,14 +350,14 @@ export class CodeTooltip {
328
350
  // which drops <br> entirely, collapsing all lines into one. Convert first.
329
351
  const applyPrism = () => {
330
352
  codeEl.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
331
- window.Prism.highlightElement(codeEl);
353
+ _w.Prism.highlightElement(codeEl);
332
354
  this.context.invoke('editor.afterCommand');
333
355
  };
334
356
 
335
357
  if (lang) {
336
- if (typeof window.Prism !== 'undefined') {
358
+ if (_w.Prism !== undefined) {
337
359
  // Grammar already loaded — highlight immediately
338
- if (window.Prism.languages[lang]) {
360
+ if (_w.Prism.languages[lang]) {
339
361
  applyPrism();
340
362
  return;
341
363
  }
@@ -345,7 +367,7 @@ export class CodeTooltip {
345
367
  } else if (this._prismScript) {
346
368
  // Prism core is still loading — highlight once it arrives, then load grammar if needed
347
369
  this._prismScript.addEventListener('load', () => {
348
- if (window.Prism.languages[lang]) {
370
+ if (_w.Prism.languages[lang]) {
349
371
  applyPrism();
350
372
  } else {
351
373
  this._loadPrismComponent(lang, applyPrism);
@@ -363,7 +385,8 @@ export class CodeTooltip {
363
385
  * Called once at initialize time. Fire-and-forget; errors are silent.
364
386
  */
365
387
  _ensurePrism() {
366
- if (!this.context.options.codeHighlight || window.Prism) return;
388
+ const _w = /** @type {any} */ (globalThis);
389
+ if (!this.context.options.codeHighlight || _w.Prism) return;
367
390
  const cdn = this.context.options.codeHighlightCDN;
368
391
  const themeHref = `${cdn}/themes/prism-tomorrow.min.css`;
369
392
  const scriptSrc = `${cdn}/prism.min.js`;
@@ -377,7 +400,7 @@ export class CodeTooltip {
377
400
 
378
401
  const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
379
402
  if (existingScript) {
380
- this._prismScript = window.Prism ? null : existingScript;
403
+ this._prismScript = _w.Prism ? null : existingScript;
381
404
  return;
382
405
  }
383
406
 
@@ -397,13 +420,14 @@ export class CodeTooltip {
397
420
  * @param {Function} cb – called once the grammar is ready
398
421
  */
399
422
  _loadPrismComponent(lang, cb) {
423
+ const _w = /** @type {any} */ (globalThis);
400
424
  const cdn = this.context.options.codeHighlightCDN;
401
425
  const src = `${cdn}/components/prism-${lang}.min.js`;
402
426
  // Avoid loading the same component twice
403
427
  if (document.querySelector(`script[src="${src}"]`)) {
404
428
  // Already in DOM — might still be loading; poll briefly then call cb
405
429
  const poll = setInterval(() => {
406
- if (window.Prism && window.Prism.languages[lang]) {
430
+ if (_w.Prism?.languages[lang]) {
407
431
  clearInterval(poll);
408
432
  cb();
409
433
  }
@@ -413,7 +437,7 @@ export class CodeTooltip {
413
437
  }
414
438
  const s = document.createElement('script');
415
439
  s.src = src;
416
- s.addEventListener('load', cb, { once: true });
440
+ s.addEventListener('load', /** @type {EventListener} */ (cb), { once: true });
417
441
  document.head.appendChild(s);
418
442
  }
419
443
 
@@ -440,7 +464,7 @@ export class CodeTooltip {
440
464
  const pre = this._activePre;
441
465
  if (!pre) return;
442
466
  this._hide();
443
- if (pre.parentNode) pre.parentNode.removeChild(pre);
467
+ pre.remove();
444
468
  this.context.invoke('editor.afterCommand');
445
469
  }
446
470
  }
@@ -26,9 +26,7 @@ export class Codeview {
26
26
  destroy() {
27
27
  this._disposers.forEach((d) => d());
28
28
  this._disposers = [];
29
- if (this._textarea && this._textarea.parentNode) {
30
- this._textarea.parentNode.removeChild(this._textarea);
31
- }
29
+ this._textarea?.remove();
32
30
  this._textarea = null;
33
31
  }
34
32
 
@@ -53,13 +51,13 @@ export class Codeview {
53
51
  const { editable } = this.context.layoutInfo;
54
52
  const html = editable.innerHTML;
55
53
 
56
- this._textarea = createElement('textarea', {
54
+ this._textarea = /** @type {HTMLTextAreaElement} */ (createElement('textarea', {
57
55
  class: 'an-codeview',
58
56
  spellcheck: 'false',
59
57
  autocomplete: 'off',
60
58
  autocorrect: 'off',
61
59
  autocapitalize: 'off',
62
- });
60
+ }));
63
61
  this._textarea.value = this._prettyPrint(html);
64
62
 
65
63
  editable.style.display = 'none';
@@ -74,7 +72,7 @@ export class Codeview {
74
72
  const { editable } = this.context.layoutInfo;
75
73
  // Sanitise the HTML typed in the textarea before applying (allow iframes for video embeds)
76
74
  editable.innerHTML = sanitiseHTML(this._textarea.value, { allowIframes: true });
77
- this._textarea.parentNode.removeChild(this._textarea);
75
+ this._textarea.remove();
78
76
  this._textarea = null;
79
77
  editable.style.display = '';
80
78
  this._active = false;
@@ -108,7 +106,7 @@ export class Codeview {
108
106
  .map((line) => {
109
107
  const stripped = line.trim();
110
108
  if (!stripped) return '';
111
- if (/^<\//.test(stripped)) indent = Math.max(0, indent - 1);
109
+ if (stripped.startsWith('</')) indent = Math.max(0, indent - 1);
112
110
  const out = ' '.repeat(indent) + stripped;
113
111
  if (
114
112
  /^<[^/!][^>]*[^/]>/.test(stripped) &&