autumnnote 1.0.5 → 1.0.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "A modern, lightweight WYSIWYG editor — built with vanilla JavaScript, no jQuery required.",
5
5
  "main": "dist/autumnnote.umd.js",
6
6
  "module": "dist/autumnnote.es.js",
@@ -19,7 +19,11 @@
19
19
  "prepublishOnly": "npm run build",
20
20
  "test": "vitest run",
21
21
  "test:watch": "vitest",
22
- "lint": "eslint src"
22
+ "lint": "eslint src",
23
+ "typecheck": "tsc --noEmit",
24
+ "build:cdn": "vite build --config vite.cdn.config.js",
25
+ "analyze": "cross-env ANALYZE=1 vite build",
26
+ "bench": "vitest bench"
23
27
  },
24
28
  "keywords": [
25
29
  "wysiwyg",
@@ -40,12 +44,16 @@
40
44
  "author": "Minh Pham",
41
45
  "license": "MIT",
42
46
  "devDependencies": {
43
- "@vitest/browser": "^1.0.0",
44
- "eslint": "^8.57.0",
45
- "jsdom": "^29.0.1",
46
- "sass": "^1.77.0",
47
- "vite": "^5.2.0",
48
- "vitest": "^1.6.0"
47
+ "@vitest/browser": "^4.1.2",
48
+ "@vitest/coverage-v8": "^4.1.2",
49
+ "cross-env": "^10.1.0",
50
+ "eslint": "^10.2.0",
51
+ "jsdom": "^25.0.1",
52
+ "rollup-plugin-visualizer": "^7.0.1",
53
+ "sass": "^1.99.0",
54
+ "typescript": "^6.0.2",
55
+ "vite": "^8.0.3",
56
+ "vitest": "^4.1.2"
49
57
  },
50
58
  "browserslist": [
51
59
  "last 2 versions",
package/src/js/Context.js CHANGED
@@ -201,15 +201,11 @@ export class Context {
201
201
  const [moduleName, methodName] = path.split('.');
202
202
  const module = this._modules.get(moduleName);
203
203
  if (!module) {
204
- if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
205
- console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
206
- }
204
+ console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
207
205
  return undefined;
208
206
  }
209
207
  if (typeof module[methodName] !== 'function') {
210
- if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
211
- console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
212
- }
208
+ console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
213
209
  return undefined;
214
210
  }
215
211
  return module[methodName](...args);
@@ -448,9 +444,15 @@ export class Context {
448
444
  if (disabled) {
449
445
  editable.setAttribute('contenteditable', 'false');
450
446
  this.layoutInfo.container.classList.add('an-disabled');
447
+ editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
448
+ cb.setAttribute('disabled', '');
449
+ });
451
450
  } else {
452
451
  editable.setAttribute('contenteditable', 'true');
453
452
  this.layoutInfo.container.classList.remove('an-disabled');
453
+ editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
454
+ cb.removeAttribute('disabled');
455
+ });
454
456
  }
455
457
  }
456
458
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  /** Tags that are unconditionally removed from editor content. */
9
- const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'];
9
+ const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'button'];
10
10
 
11
11
  /** Attributes whose values must be sanitised as URLs. */
12
12
  const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
@@ -26,7 +26,8 @@ const TRUSTED_IFRAME_HOSTS = new Set([
26
26
  * Uses DOMParser so the sanitisation follows normal browser parsing rules —
27
27
  * no regex shortcuts that can be bypassed by encoding tricks.
28
28
  *
29
- * - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form, input, button)
29
+ * - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form, button)
30
+ * - Allows input[type="checkbox"] only inside ul.an-checklist li; removes all other <input>
30
31
  * - Removes all on* event-handler attributes
31
32
  * - Rejects javascript: and vbscript: URLs in URL attributes
32
33
  * - Rejects data: URIs everywhere except img[src] (base64 uploads)
@@ -81,6 +82,23 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
81
82
  });
82
83
  });
83
84
 
85
+ // Allow only input[type="checkbox"] inside ul.an-checklist li; strip everything else.
86
+ // This preserves checklist state while blocking arbitrary <input> injection.
87
+ doc.querySelectorAll('input').forEach((el) => {
88
+ const inChecklist = el.closest('ul.an-checklist') !== null &&
89
+ el.closest('li') !== null;
90
+ if (!inChecklist || el.getAttribute('type') !== 'checkbox') {
91
+ el.remove();
92
+ } else {
93
+ // Harden: keep only safe attributes on checklist checkboxes
94
+ Array.from(el.attributes).forEach((attr) => {
95
+ if (!['type', 'checked', 'contenteditable'].includes(attr.name)) {
96
+ el.removeAttribute(attr.name);
97
+ }
98
+ });
99
+ }
100
+ });
101
+
84
102
  return doc.body.innerHTML;
85
103
  }
86
104
 
@@ -98,15 +98,54 @@ export const fontName = (name) => execCommand('fontName', name);
98
98
  * @param {HTMLElement} [editable] - scoping element to avoid touching nodes outside this editor
99
99
  */
100
100
  export function fontSize(size, editable = document) {
101
+ const sel = window.getSelection();
102
+ const wasCollapsed = !sel || !sel.rangeCount || sel.getRangeAt(0).collapsed;
103
+
101
104
  execCommand('fontSize', '7'); // placeholder
102
105
  // Replace font elements with spans, scoped to the active editable
103
- editable.querySelectorAll('font[size="7"]').forEach((el) => {
106
+ const scope = editable instanceof HTMLElement ? editable : document;
107
+ const newSpans = [];
108
+ scope.querySelectorAll('font[size="7"]').forEach((el) => {
104
109
  const span = document.createElement('span');
105
110
  span.style.fontSize = size;
106
111
  el.parentNode.insertBefore(span, el);
107
112
  while (el.firstChild) span.appendChild(el.firstChild);
108
113
  el.parentNode.removeChild(el);
114
+ newSpans.push(span);
109
115
  });
116
+
117
+ // Restore the selection inside the new span(s) so:
118
+ // 1. The toolbar getValue() correctly reflects the new font size.
119
+ // 2. For a collapsed (caret) selection, subsequent typing inherits the
120
+ // chosen size rather than the browser's stale execCommand state (which
121
+ // would produce size 7 = 48 px instead of the requested value).
122
+ if (sel && newSpans.length > 0) {
123
+ const first = newSpans[0];
124
+ const last = newSpans[newSpans.length - 1];
125
+ try {
126
+ if (wasCollapsed) {
127
+ // Ensure the span has a text anchor so the cursor can live inside it.
128
+ if (!first.firstChild) {
129
+ first.appendChild(document.createTextNode('\u200B'));
130
+ }
131
+ const nr = document.createRange();
132
+ const anchor = first.firstChild;
133
+ nr.setStart(anchor, anchor.textContent.length);
134
+ nr.collapse(true);
135
+ sel.removeAllRanges();
136
+ sel.addRange(nr);
137
+ } else {
138
+ // Re-select all replaced content so toolbar refresh reads the new size.
139
+ const nr = document.createRange();
140
+ const startNode = first.firstChild || first;
141
+ const endNode = last.lastChild || last;
142
+ nr.setStart(startNode, 0);
143
+ nr.setEnd(endNode, endNode.nodeType === Node.TEXT_NODE ? endNode.textContent.length : endNode.childNodes.length);
144
+ sel.removeAllRanges();
145
+ sel.addRange(nr);
146
+ }
147
+ } catch (_) { /* ignore range errors on unusual DOM structures */ }
148
+ }
110
149
  }
111
150
 
112
151
  // ---------------------------------------------------------------------------
@@ -263,11 +302,36 @@ export function toggleInlineCode(editable) {
263
302
  if (container.nodeType === 3) container = container.parentElement;
264
303
  const codeEl = container && container.closest ? container.closest('code') : null;
265
304
  if (codeEl && !codeEl.closest('pre')) {
266
- // Unwrap
305
+ // Unwrap — save range endpoints relative to surrounding text so we can
306
+ // restore the selection after normalize() merges adjacent text nodes.
267
307
  const parent = codeEl.parentNode;
308
+ // Note the sibling before the code element so we can re-anchor later.
309
+ const prevSibling = codeEl.previousSibling;
310
+ const movedChildren = Array.from(codeEl.childNodes);
268
311
  while (codeEl.firstChild) parent.insertBefore(codeEl.firstChild, codeEl);
269
312
  parent.removeChild(codeEl);
270
- if (editable) editable.normalize();
313
+ // Normalize only the immediate parent to merge adjacent text nodes without
314
+ // invalidating distant selection anchors (full editable.normalize() can
315
+ // cause selection offsets to shift, making subsequent format toggles miss).
316
+ if (parent && parent.normalize) parent.normalize();
317
+ // Restore selection to the text that was inside the unwrapped <code>.
318
+ if (movedChildren.length > 0) {
319
+ try {
320
+ // After normalize, find the merged text node that contains the content.
321
+ const firstMoved = movedChildren[0];
322
+ const lastMoved = movedChildren[movedChildren.length - 1];
323
+ const nr = document.createRange();
324
+ // Use the (possibly merged) live node if still in the DOM.
325
+ const anchorNode = (firstMoved.parentNode === parent) ? firstMoved : (prevSibling ? prevSibling.nextSibling : parent.firstChild);
326
+ if (anchorNode) {
327
+ nr.setStart(anchorNode, 0);
328
+ const endAnchor = (lastMoved.parentNode === parent) ? lastMoved : anchorNode;
329
+ nr.setEnd(endAnchor, endAnchor.nodeType === Node.TEXT_NODE ? endAnchor.textContent.length : endAnchor.childNodes.length);
330
+ sel.removeAllRanges();
331
+ sel.addRange(nr);
332
+ }
333
+ } catch (_) { /* ignore */ }
334
+ }
271
335
  } else {
272
336
  if (range.collapsed) return;
273
337
  try {
@@ -296,14 +360,17 @@ export function toggleInlineCode(editable) {
296
360
  /**
297
361
  * Returns true when the cursor / selection is inside an inline <code>
298
362
  * (not nested in a <pre>).
363
+ * Uses startContainer for reliable cross-browser detection regardless of
364
+ * whether the selection is collapsed or a range (commonAncestorContainer
365
+ * can behave inconsistently for range selections on some browsers).
299
366
  * @returns {boolean}
300
367
  */
301
368
  export function isInlineCode() {
302
369
  const sel = window.getSelection();
303
370
  if (!sel || !sel.rangeCount) return false;
304
- let container = sel.getRangeAt(0).commonAncestorContainer;
305
- if (container.nodeType === 3) container = container.parentElement;
306
- const code = container && container.closest ? container.closest('code') : null;
371
+ let sc = sel.getRangeAt(0).startContainer;
372
+ if (sc.nodeType === 3) sc = sc.parentElement;
373
+ const code = sc && sc.closest ? sc.closest('code') : null;
307
374
  return !!(code && !code.closest('pre'));
308
375
  }
309
376
 
@@ -357,7 +424,53 @@ export function toggleChecklist() {
357
424
  }
358
425
  }
359
426
 
360
- // Otherwise: insert new checklist from selected text
427
+ // Otherwise: insert new checklist from selected text (or current block when collapsed).
428
+ const isCollapsed = range.collapsed;
429
+ if (isCollapsed) {
430
+ // Find the nearest block-level ancestor (p, div, li, h1-h6, blockquote, etc.)
431
+ // and convert it into a single checklist item.
432
+ const BLOCK_TAGS = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'LI']);
433
+ let block = container;
434
+ while (block && block.parentNode && !BLOCK_TAGS.has(block.tagName)) {
435
+ block = block.parentNode;
436
+ }
437
+ // Fallback: if no block element found (e.g. cursor directly in editable root), use the
438
+ // insertion approach with a zero-width-space item so the cursor ends up inside.
439
+ const itemText = (block && BLOCK_TAGS.has(block.tagName))
440
+ ? Array.from(block.childNodes)
441
+ .map((n) => n.textContent)
442
+ .join('')
443
+ .replace(/\u00a0/g, ' ')
444
+ : '';
445
+
446
+ const ul = document.createElement('ul');
447
+ ul.className = 'an-checklist';
448
+ const li = document.createElement('li');
449
+ const checkbox = document.createElement('input');
450
+ checkbox.type = 'checkbox';
451
+ checkbox.contentEditable = 'false';
452
+ li.appendChild(checkbox);
453
+ li.appendChild(document.createTextNode(itemText || '\u200B'));
454
+ ul.appendChild(li);
455
+
456
+ if (block && BLOCK_TAGS.has(block.tagName)) {
457
+ block.parentNode.replaceChild(ul, block);
458
+ } else {
459
+ document.execCommand('insertHTML', false, ul.outerHTML);
460
+ return;
461
+ }
462
+
463
+ // Move caret to the text node inside the new <li>
464
+ const textNode = li.lastChild;
465
+ const nr = document.createRange();
466
+ const offset = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent.length : 0;
467
+ nr.setStart(textNode, offset);
468
+ nr.collapse(true);
469
+ sel.removeAllRanges();
470
+ sel.addRange(nr);
471
+ return;
472
+ }
473
+
361
474
  const text = sel.toString();
362
475
  const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
363
476
  if (lines.length === 0) return;
@@ -8,6 +8,15 @@ import { closestPara, isLi } from '../core/dom.js';
8
8
  import { execCommand } from './Style.js';
9
9
  import { currentRange } from '../core/range.js';
10
10
 
11
+ // ---------------------------------------------------------------------------
12
+ // Module-level predicates — defined once, not re-created on every keypress.
13
+ // Previously these were arrow functions inside handleKeydown() which fires
14
+ // at ~120+ events/sec during normal typing.
15
+ // ---------------------------------------------------------------------------
16
+ const _FA_PATTERN = /\bfa-/;
17
+ const isFAIcon = (n) => !!(n && n.nodeName === 'I' && _FA_PATTERN.test(n.className || ''));
18
+ const isZwsAnchor = (n) => !!(n && n.nodeType === Node.TEXT_NODE && (n.textContent === '\u200B' || n.textContent === ''));
19
+
11
20
  /**
12
21
  * Handles special keydown behaviour inside the editor.
13
22
  * @param {KeyboardEvent} event
@@ -16,8 +25,6 @@ import { currentRange } from '../core/range.js';
16
25
  * @returns {boolean} true if the event was consumed
17
26
  */
18
27
  export function handleKeydown(event, editable, options = {}) {
19
- const isFAIcon = (n) => !!(n && n.nodeName === 'I' && /\bfa-/.test(n.className || ''));
20
- const isZwsAnchor = (n) => !!(n && n.nodeType === Node.TEXT_NODE && (n.textContent === '\u200B' || n.textContent === ''));
21
28
  const moveCaret = (setFn) => {
22
29
  const sel = window.getSelection();
23
30
  if (!sel) return false;
@@ -51,8 +58,26 @@ export function handleKeydown(event, editable, options = {}) {
51
58
  if (r.startOffset === 1 && textNode.textContent === '\u200B' &&
52
59
  isFAIcon(textNode.previousSibling)) {
53
60
  event.preventDefault();
54
- textNode.previousSibling.remove();
61
+ const parent = textNode.parentNode;
62
+ const icon = textNode.previousSibling;
63
+ const prevNode = icon.previousSibling; // node before the icon (e.g. ZWS of prior icon)
64
+ icon.remove();
55
65
  textNode.remove();
66
+ // Explicitly restore the cursor to the node preceding the deleted icon.
67
+ // Without this, the browser collapses the selection to the parent element
68
+ // (not a text node), causing the next Backspace to miss Cases A/B and
69
+ // requiring an extra keypress when two icons are adjacent.
70
+ const nr = document.createRange();
71
+ if (prevNode && prevNode.nodeType === Node.TEXT_NODE) {
72
+ nr.setStart(prevNode, prevNode.textContent.length);
73
+ } else if (prevNode) {
74
+ nr.setStartAfter(prevNode);
75
+ } else if (parent) {
76
+ nr.setStart(parent, 0);
77
+ }
78
+ nr.collapse(true);
79
+ sel.removeAllRanges();
80
+ sel.addRange(nr);
56
81
  return true;
57
82
  }
58
83
  }
@@ -59,13 +59,14 @@ export const boldBtn = btn('bold', 'bold', 'Bold (Ctrl+B)', () => Style.bold(),
59
59
  export const italicBtn = btn('italic', 'italic', 'Italic (Ctrl+I)', () => Style.italic(), () => document.queryCommandState('italic'));
60
60
  export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)', () => Style.underline(), () => {
61
61
  // queryCommandState('underline') is unreliable inside <code> elements;
62
- // also check for a <u> ancestor in the DOM.
62
+ // also check for a <u> ancestor in the DOM using startContainer for
63
+ // consistent behaviour across both collapsed and range selections.
63
64
  if (document.queryCommandState('underline')) return true;
64
65
  const sel = window.getSelection();
65
66
  if (!sel || !sel.rangeCount) return false;
66
- let container = sel.getRangeAt(0).commonAncestorContainer;
67
- if (container.nodeType === 3) container = container.parentElement;
68
- return !!(container && container.closest && container.closest('u'));
67
+ let sc = sel.getRangeAt(0).startContainer;
68
+ if (sc.nodeType === 3) sc = sc.parentElement;
69
+ return !!(sc && sc.closest && sc.closest('u'));
69
70
  });
70
71
  export const strikeBtn = btn('strikethrough', 'strikethrough', 'Strikethrough', () => Style.strikethrough(), () => document.queryCommandState('strikeThrough'));
71
72
  export const superscriptBtn = btn('superscript', 'superscript', 'Superscript', () => Style.superscript(), () => document.queryCommandState('superscript'));
@@ -124,23 +124,22 @@ export class Clipboard {
124
124
  */
125
125
  _cleanSocialHtml(html) {
126
126
  const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
127
- // Unwrap purely presentational wrapper spans/divs with no semantic meaning
128
- const UNWRAP_TAGS = new Set(['span', 'div']);
129
- let changed = true;
130
- // Iteratively unwrap until stable (handles deeply nested span soup)
131
- while (changed) {
132
- changed = false;
133
- doc.querySelectorAll('span, div').forEach((el) => {
134
- if (!UNWRAP_TAGS.has(el.tagName.toLowerCase())) return;
135
- // Keep if it has a meaningful role (link, heading, list item are handled by parent)
136
- if (el.querySelector('a, strong, em, b, i, ul, ol, li, table, img, blockquote, pre, code, h1, h2, h3, h4, h5, h6')) return;
137
- // Unwrap replace el with its children
138
- const parent = el.parentNode;
139
- if (!parent) return;
140
- while (el.firstChild) parent.insertBefore(el.firstChild, el);
141
- parent.removeChild(el);
142
- changed = true;
143
- });
127
+ // Unwrap purely presentational wrapper spans/divs with no semantic meaning.
128
+ // Single-pass reverse traversal: querySelectorAll returns elements in document
129
+ // order, so iterating backwards processes innermost elements first — once a
130
+ // child is unwrapped its parent may become unwrappable in the same pass.
131
+ // This replaces the previous O() while-loop that re-queried the whole tree
132
+ // on every iteration.
133
+ const candidates = Array.from(doc.querySelectorAll('span, div'));
134
+ for (let i = candidates.length - 1; i >= 0; i--) {
135
+ const el = candidates[i];
136
+ if (!el.parentNode) continue; // already detached by an earlier iteration
137
+ // Keep if it contains any semantic child element
138
+ if (el.querySelector('a, strong, em, b, i, ul, ol, li, table, img, blockquote, pre, code, h1, h2, h3, h4, h5, h6')) continue;
139
+ // Unwrap — replace el with its children
140
+ const parent = el.parentNode;
141
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
142
+ parent.removeChild(el);
144
143
  }
145
144
  // Strip class and all data-* attributes from every remaining element
146
145
  doc.querySelectorAll('*').forEach((el) => {
@@ -300,9 +299,17 @@ export class Clipboard {
300
299
  return;
301
300
  }
302
301
 
302
+ // 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'];
303
304
  const maxBytes = (this.options.maxImageSize || 5) * 1024 * 1024;
304
305
  files.forEach((file) => {
305
306
  if (!file || !file.type.startsWith('image/')) return;
307
+ if (UNSUPPORTED.includes(file.type)) {
308
+ const message = `Image format "${file.type}" is not supported for display in web browsers. Please convert to PNG, JPEG, or WebP first.`;
309
+ this.context.triggerEvent('imageError', { file, message });
310
+ console.warn('[AutumnNote]', message);
311
+ return;
312
+ }
306
313
  if (file.size > maxBytes) {
307
314
  const message = `Image "${file.name}" exceeds the ${this.options.maxImageSize || 5} MB size limit.`;
308
315
  this.context.triggerEvent('imageError', { file, message });
@@ -321,9 +328,7 @@ export class Clipboard {
321
328
  }).catch((err) => {
322
329
  const message = `Image "${file.name}" could not be processed.`;
323
330
  this.context.triggerEvent('imageError', { file, message, error: err });
324
- if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
325
- console.warn('[AutumnNote]', message, err);
326
- }
331
+ console.warn('[AutumnNote]', message, err);
327
332
  });
328
333
  });
329
334
  }
@@ -6,6 +6,9 @@ import { createElement, on } from '../core/dom.js';
6
6
  const SHOW_DELAY = 100;
7
7
  const HIDE_DELAY = 180;
8
8
 
9
+ // Cached regex for extracting language class — defined once at module level.
10
+ const _LANG_CLASS_RE = /language-(\S+)/;
11
+
9
12
  const ICONS = {
10
13
  copy: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
11
14
  wrapOn: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 0 1 0 6H3"/><polyline points="6 15 3 18 6 21"/></svg>`,
@@ -36,6 +39,7 @@ export class CodeTooltip {
36
39
 
37
40
  this._disposers.push(
38
41
  on(editable, 'mouseover', (e) => {
42
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
39
43
  const pre = e.target.closest('pre');
40
44
  if (pre && editable.contains(pre)) {
41
45
  this._scheduleShow(pre);
@@ -185,7 +189,9 @@ export class CodeTooltip {
185
189
  _scheduleHide() {
186
190
  clearTimeout(this._showTimer);
187
191
  this._showTimer = null;
188
- if (this._hideTimer) return;
192
+ // Always reset the hide timer so rapid mouseout→mouseover sequences
193
+ // don't leave a stale timer that hides the tooltip prematurely.
194
+ clearTimeout(this._hideTimer);
189
195
  this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
190
196
  }
191
197
 
@@ -242,7 +248,7 @@ export class CodeTooltip {
242
248
  if (!this._activePre || !this._langSelect) return;
243
249
  const codeEl = this._activePre.querySelector('code');
244
250
  const fromAttr = this._activePre.getAttribute('data-language') || '';
245
- const fromClass = codeEl ? (codeEl.className.match(/language-(\S+)/) || [])[1] || '' : '';
251
+ const fromClass = codeEl ? (_LANG_CLASS_RE.exec(codeEl.className) || [])[1] || '' : '';
246
252
  this._langSelect.value = fromAttr || fromClass || '';
247
253
  }
248
254
 
@@ -355,8 +361,7 @@ export class CodeTooltip {
355
361
  */
356
362
  _ensurePrism() {
357
363
  if (!this.context.options.codeHighlight || window.Prism) return;
358
- const cdn = this.context.options.codeHighlightCDN
359
- || 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
364
+ const cdn = this.context.options.codeHighlightCDN;
360
365
  const themeHref = `${cdn}/themes/prism-tomorrow.min.css`;
361
366
  const scriptSrc = `${cdn}/prism.min.js`;
362
367
 
@@ -389,8 +394,7 @@ export class CodeTooltip {
389
394
  * @param {Function} cb – called once the grammar is ready
390
395
  */
391
396
  _loadPrismComponent(lang, cb) {
392
- const cdn = this.context.options.codeHighlightCDN
393
- || 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
397
+ const cdn = this.context.options.codeHighlightCDN;
394
398
  const src = `${cdn}/components/prism-${lang}.min.js`;
395
399
  // Avoid loading the same component twice
396
400
  if (document.querySelector(`script[src="${src}"]`)) {