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/README.md +2 -2
- package/dist/autumnnote.css +1769 -1
- package/dist/autumnnote.es.js +11885 -6463
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +11579 -84
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +16 -8
- package/src/js/Context.js +8 -6
- package/src/js/core/sanitise.js +20 -2
- package/src/js/editing/Style.js +120 -7
- package/src/js/editing/Typing.js +28 -3
- package/src/js/module/Buttons.js +5 -4
- package/src/js/module/Clipboard.js +25 -20
- package/src/js/module/CodeTooltip.js +10 -6
- package/src/js/module/ContextMenu.js +146 -8
- package/src/js/module/Editor.js +61 -0
- package/src/js/module/FindReplace.js +21 -7
- package/src/js/module/ImageCropOverlay.js +2 -2
- package/src/js/module/ImageDialog.js +17 -2
- package/src/js/module/ImageResizer.js +3 -1
- package/src/js/module/ImageTooltip.js +12 -1
- package/src/js/module/LinkTooltip.js +4 -0
- package/src/js/module/Placeholder.js +2 -1
- package/src/js/module/TableTooltip.js +62 -25
- package/src/js/module/Toolbar.js +129 -100
- package/src/js/module/VideoDialog.js +5 -5
- package/src/js/module/VideoResizer.js +11 -0
- package/src/js/module/VideoTooltip.js +1 -0
- package/src/js/renderer.js +3 -0
- package/src/styles/autumnnote.scss +146 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/js/core/sanitise.js
CHANGED
|
@@ -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', '
|
|
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,
|
|
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
|
|
package/src/js/editing/Style.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
if (
|
|
306
|
-
const code =
|
|
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;
|
package/src/js/editing/Typing.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/js/module/Buttons.js
CHANGED
|
@@ -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
|
|
67
|
-
if (
|
|
68
|
-
return !!(
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(n²) 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
|
-
|
|
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
|
-
|
|
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
|
|
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}"]`)) {
|