autumnnote 1.0.4 → 1.0.6
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 +1641 -1
- package/dist/autumnnote.es.js +11545 -6224
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +11235 -73
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +14 -7
- package/src/js/Context.js +9 -8
- package/src/js/editing/Style.js +45 -48
- package/src/js/editing/Typing.js +28 -3
- package/src/js/index.js +1 -1
- package/src/js/module/Clipboard.js +17 -20
- package/src/js/module/CodeTooltip.js +9 -6
- package/src/js/module/FindReplace.js +21 -7
- package/src/js/module/ImageCropOverlay.js +541 -0
- package/src/js/module/ImageResizer.js +1 -1
- package/src/js/module/ImageTooltip.js +18 -1
- package/src/js/module/ShortcutsDialog.js +1 -1
- package/src/js/module/TableTooltip.js +56 -25
- package/src/js/module/Toolbar.js +108 -97
- package/src/styles/autumnnote.scss +49 -0
- package/types/index.d.ts +56 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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,15 @@
|
|
|
40
44
|
"author": "Minh Pham",
|
|
41
45
|
"license": "MIT",
|
|
42
46
|
"devDependencies": {
|
|
43
|
-
"@vitest/browser": "^1.
|
|
44
|
-
"
|
|
47
|
+
"@vitest/browser": "^4.1.2",
|
|
48
|
+
"cross-env": "^10.1.0",
|
|
49
|
+
"eslint": "^10.2.0",
|
|
45
50
|
"jsdom": "^29.0.1",
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
51
|
+
"rollup-plugin-visualizer": "^7.0.1",
|
|
52
|
+
"sass": "^1.99.0",
|
|
53
|
+
"typescript": "^6.0.2",
|
|
54
|
+
"vite": "^8.0.3",
|
|
55
|
+
"vitest": "^4.1.2"
|
|
49
56
|
},
|
|
50
57
|
"browserslist": [
|
|
51
58
|
"last 2 versions",
|
package/src/js/Context.js
CHANGED
|
@@ -32,6 +32,7 @@ import { IconDialog } from './module/IconDialog.js';
|
|
|
32
32
|
import { ContextMenu } from './module/ContextMenu.js';
|
|
33
33
|
import { ShortcutsDialog } from './module/ShortcutsDialog.js';
|
|
34
34
|
import { FindReplace } from './module/FindReplace.js';
|
|
35
|
+
import { ImageCropOverlay } from './module/ImageCropOverlay.js';
|
|
35
36
|
|
|
36
37
|
/** Module registry shared across all Context instances (populated via AutumnNote.registerModule). */
|
|
37
38
|
export const _customModules = new Map();
|
|
@@ -107,8 +108,8 @@ export class Context {
|
|
|
107
108
|
_registerModules() {
|
|
108
109
|
const register = (name, ModuleClass) => {
|
|
109
110
|
const instance = new ModuleClass(this);
|
|
110
|
-
instance.initialize();
|
|
111
111
|
this._modules.set(name, instance);
|
|
112
|
+
instance.initialize();
|
|
112
113
|
};
|
|
113
114
|
|
|
114
115
|
register('editor', Editor);
|
|
@@ -133,6 +134,7 @@ export class Context {
|
|
|
133
134
|
register('iconDialog', IconDialog);
|
|
134
135
|
register('shortcutsDialog', ShortcutsDialog);
|
|
135
136
|
register('findReplace', FindReplace);
|
|
137
|
+
register('imageCropOverlay', ImageCropOverlay);
|
|
136
138
|
|
|
137
139
|
// Custom modules registered via AutumnNote.registerModule()
|
|
138
140
|
for (const [name, ModuleClass] of _customModules) {
|
|
@@ -155,6 +157,9 @@ export class Context {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
_bindEditorEvents(editable) {
|
|
160
|
+
// Keep the original textarea/input value in sync immediately on every input.
|
|
161
|
+
// This guarantees form.submit() sees fresh data even before debounced change.
|
|
162
|
+
const d0 = on(editable, 'input', () => this._syncToTarget());
|
|
158
163
|
const d1 = on(editable, 'focus', () => {
|
|
159
164
|
this.layoutInfo.container.classList.add('an-focused');
|
|
160
165
|
if (typeof this.options.onFocus === 'function') {
|
|
@@ -170,7 +175,7 @@ export class Context {
|
|
|
170
175
|
});
|
|
171
176
|
// Sync textarea/input value on every change so form.submit() always gets fresh content
|
|
172
177
|
const d3 = this.on('change', () => this._syncToTarget());
|
|
173
|
-
this._disposers.push(d1, d2, d3);
|
|
178
|
+
this._disposers.push(d0, d1, d2, d3);
|
|
174
179
|
|
|
175
180
|
// Auto-save to localStorage on every change
|
|
176
181
|
if (this.options.autoSave && this.options.autoSaveKey) {
|
|
@@ -196,15 +201,11 @@ export class Context {
|
|
|
196
201
|
const [moduleName, methodName] = path.split('.');
|
|
197
202
|
const module = this._modules.get(moduleName);
|
|
198
203
|
if (!module) {
|
|
199
|
-
|
|
200
|
-
console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
|
|
201
|
-
}
|
|
204
|
+
console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
|
|
202
205
|
return undefined;
|
|
203
206
|
}
|
|
204
207
|
if (typeof module[methodName] !== 'function') {
|
|
205
|
-
|
|
206
|
-
console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
|
|
207
|
-
}
|
|
208
|
+
console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
|
|
208
209
|
return undefined;
|
|
209
210
|
}
|
|
210
211
|
return module[methodName](...args);
|
package/src/js/editing/Style.js
CHANGED
|
@@ -313,8 +313,8 @@ export function isInlineCode() {
|
|
|
313
313
|
|
|
314
314
|
/**
|
|
315
315
|
* Toggles a task-list at the cursor.
|
|
316
|
-
* If inside a checklist <li>, converts it back to
|
|
317
|
-
* Otherwise inserts a new <ul class="an-checklist"> with one item.
|
|
316
|
+
* If inside a checklist <li>, converts it (and any other selected items) back to <p> elements.
|
|
317
|
+
* Otherwise inserts a new <ul class="an-checklist"> with one item per selected line.
|
|
318
318
|
*/
|
|
319
319
|
export function toggleChecklist() {
|
|
320
320
|
const sel = window.getSelection();
|
|
@@ -322,59 +322,56 @@ export function toggleChecklist() {
|
|
|
322
322
|
const range = sel.getRangeAt(0);
|
|
323
323
|
let container = range.commonAncestorContainer;
|
|
324
324
|
if (container.nodeType === 3) container = container.parentElement;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const MARKER = 'data-an-new-cli';
|
|
351
|
-
execCommand('insertHTML',
|
|
352
|
-
`<ul class="an-checklist"><li ${MARKER}><input type="checkbox" contenteditable="false"></li></ul>`);
|
|
353
|
-
const newLi = document.querySelector(`[${MARKER}]`);
|
|
354
|
-
if (newLi) {
|
|
355
|
-
newLi.removeAttribute(MARKER);
|
|
356
|
-
const cbEl = newLi.querySelector('input[type="checkbox"]');
|
|
357
|
-
if (cbEl) {
|
|
358
|
-
// Use \u200B (zero-width space) instead of an empty string.
|
|
359
|
-
// Chrome does not reliably honour a Selection pointing into an empty
|
|
360
|
-
// text node and may silently normalise it to an element-level offset,
|
|
361
|
-
// rendering the caret before the absolutely-positioned checkbox.
|
|
362
|
-
// \u200B is invisible/zero-width and is stripped by getHTML().
|
|
363
|
-
let textNode = cbEl.nextSibling;
|
|
364
|
-
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
|
365
|
-
textNode = document.createTextNode('\u200B');
|
|
366
|
-
newLi.appendChild(textNode);
|
|
367
|
-
} else if (!textNode.textContent) {
|
|
368
|
-
textNode.textContent = '\u200B';
|
|
369
|
-
}
|
|
325
|
+
|
|
326
|
+
const ul = container.closest && container.closest('.an-checklist');
|
|
327
|
+
if (ul) {
|
|
328
|
+
// If selection covers multiple <li>, convert them all
|
|
329
|
+
const selectedLis = Array.from(ul.querySelectorAll('li')).filter((li) =>
|
|
330
|
+
sel.containsNode(li, true),
|
|
331
|
+
);
|
|
332
|
+
if (selectedLis.length > 0) {
|
|
333
|
+
let firstP = null;
|
|
334
|
+
selectedLis.forEach((li) => {
|
|
335
|
+
const text = Array.from(li.childNodes)
|
|
336
|
+
.filter((n) => !(n.nodeType === 1 && n.tagName === 'INPUT'))
|
|
337
|
+
.map((n) => n.textContent)
|
|
338
|
+
.join('')
|
|
339
|
+
.replace(/\u00a0/g, ' ')
|
|
340
|
+
.trim();
|
|
341
|
+
const p = document.createElement('p');
|
|
342
|
+
p.textContent = text || '\u00a0';
|
|
343
|
+
ul.parentNode.insertBefore(p, ul);
|
|
344
|
+
if (!firstP) firstP = p;
|
|
345
|
+
ul.removeChild(li);
|
|
346
|
+
});
|
|
347
|
+
if (ul.children.length === 0) ul.remove();
|
|
348
|
+
// Move caret to first converted paragraph
|
|
349
|
+
if (firstP) {
|
|
370
350
|
const nr = document.createRange();
|
|
371
|
-
nr.setStart(
|
|
351
|
+
nr.setStart(firstP.firstChild || firstP, 0);
|
|
372
352
|
nr.collapse(true);
|
|
373
353
|
sel.removeAllRanges();
|
|
374
354
|
sel.addRange(nr);
|
|
375
355
|
}
|
|
356
|
+
return;
|
|
376
357
|
}
|
|
377
358
|
}
|
|
359
|
+
|
|
360
|
+
// Otherwise: insert new checklist from selected text
|
|
361
|
+
const text = sel.toString();
|
|
362
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
363
|
+
if (lines.length === 0) return;
|
|
364
|
+
const items = lines
|
|
365
|
+
.map(
|
|
366
|
+
(l) =>
|
|
367
|
+
`<li><input type="checkbox" contenteditable="false">${l || '\u200B'}</li>`,
|
|
368
|
+
)
|
|
369
|
+
.join('');
|
|
370
|
+
document.execCommand(
|
|
371
|
+
'insertHTML',
|
|
372
|
+
false,
|
|
373
|
+
`<ul class="an-checklist">${items}</ul>`,
|
|
374
|
+
);
|
|
378
375
|
}
|
|
379
376
|
|
|
380
377
|
/**
|
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/index.js
CHANGED
|
@@ -99,7 +99,7 @@ const AutumnNote = {
|
|
|
99
99
|
registerModule(name, ModuleClass) { _customModules.set(name, ModuleClass); },
|
|
100
100
|
|
|
101
101
|
/** Library version */
|
|
102
|
-
version: '1.0.
|
|
102
|
+
version: '1.0.4',
|
|
103
103
|
};
|
|
104
104
|
|
|
105
105
|
// ---------------------------------------------------------------------------
|
|
@@ -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) => {
|
|
@@ -321,9 +320,7 @@ export class Clipboard {
|
|
|
321
320
|
}).catch((err) => {
|
|
322
321
|
const message = `Image "${file.name}" could not be processed.`;
|
|
323
322
|
this.context.triggerEvent('imageError', { file, message, error: err });
|
|
324
|
-
|
|
325
|
-
console.warn('[AutumnNote]', message, err);
|
|
326
|
-
}
|
|
323
|
+
console.warn('[AutumnNote]', message, err);
|
|
327
324
|
});
|
|
328
325
|
});
|
|
329
326
|
}
|
|
@@ -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>`,
|
|
@@ -185,7 +188,9 @@ export class CodeTooltip {
|
|
|
185
188
|
_scheduleHide() {
|
|
186
189
|
clearTimeout(this._showTimer);
|
|
187
190
|
this._showTimer = null;
|
|
188
|
-
|
|
191
|
+
// Always reset the hide timer so rapid mouseout→mouseover sequences
|
|
192
|
+
// don't leave a stale timer that hides the tooltip prematurely.
|
|
193
|
+
clearTimeout(this._hideTimer);
|
|
189
194
|
this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
|
|
190
195
|
}
|
|
191
196
|
|
|
@@ -242,7 +247,7 @@ export class CodeTooltip {
|
|
|
242
247
|
if (!this._activePre || !this._langSelect) return;
|
|
243
248
|
const codeEl = this._activePre.querySelector('code');
|
|
244
249
|
const fromAttr = this._activePre.getAttribute('data-language') || '';
|
|
245
|
-
const fromClass = codeEl ? (codeEl.className
|
|
250
|
+
const fromClass = codeEl ? (_LANG_CLASS_RE.exec(codeEl.className) || [])[1] || '' : '';
|
|
246
251
|
this._langSelect.value = fromAttr || fromClass || '';
|
|
247
252
|
}
|
|
248
253
|
|
|
@@ -355,8 +360,7 @@ export class CodeTooltip {
|
|
|
355
360
|
*/
|
|
356
361
|
_ensurePrism() {
|
|
357
362
|
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';
|
|
363
|
+
const cdn = this.context.options.codeHighlightCDN;
|
|
360
364
|
const themeHref = `${cdn}/themes/prism-tomorrow.min.css`;
|
|
361
365
|
const scriptSrc = `${cdn}/prism.min.js`;
|
|
362
366
|
|
|
@@ -389,8 +393,7 @@ export class CodeTooltip {
|
|
|
389
393
|
* @param {Function} cb – called once the grammar is ready
|
|
390
394
|
*/
|
|
391
395
|
_loadPrismComponent(lang, cb) {
|
|
392
|
-
const cdn = this.context.options.codeHighlightCDN
|
|
393
|
-
|| 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
|
|
396
|
+
const cdn = this.context.options.codeHighlightCDN;
|
|
394
397
|
const src = `${cdn}/components/prism-${lang}.min.js`;
|
|
395
398
|
// Avoid loading the same component twice
|
|
396
399
|
if (document.querySelector(`script[src="${src}"]`)) {
|
|
@@ -34,6 +34,11 @@ export class FindReplace {
|
|
|
34
34
|
/** @type {'find'|'replace'} */
|
|
35
35
|
this._mode = 'find';
|
|
36
36
|
|
|
37
|
+
/** Cached compiled regex — reused when query and case-sensitivity are unchanged */
|
|
38
|
+
this._queryRegex = null;
|
|
39
|
+
this._lastQuery = null;
|
|
40
|
+
this._lastCaseSensitive = null;
|
|
41
|
+
|
|
37
42
|
this._disposers = [];
|
|
38
43
|
this._removeTrap = null;
|
|
39
44
|
this._focusTimer = null;
|
|
@@ -267,7 +272,10 @@ export class FindReplace {
|
|
|
267
272
|
|
|
268
273
|
this._currentIndex = 0;
|
|
269
274
|
|
|
270
|
-
// Wrap in reverse order so earlier
|
|
275
|
+
// Wrap matches in reverse order so earlier text offsets stay valid when
|
|
276
|
+
// later sections of the same text node are split by surroundContents().
|
|
277
|
+
// Use push() instead of unshift() to avoid O(n²) shifting on every insert;
|
|
278
|
+
// reverse() at the end restores forward document order in O(n).
|
|
271
279
|
for (let i = rawMatches.length - 1; i >= 0; i--) {
|
|
272
280
|
const { node, start, end } = rawMatches[i];
|
|
273
281
|
try {
|
|
@@ -277,13 +285,14 @@ export class FindReplace {
|
|
|
277
285
|
const mark = document.createElement('mark');
|
|
278
286
|
mark.className = 'an-highlight';
|
|
279
287
|
range.surroundContents(mark);
|
|
280
|
-
this._matches.
|
|
288
|
+
this._matches.push({ mark });
|
|
281
289
|
} catch (_) {
|
|
282
290
|
// surroundContents fails when the range crosses element boundaries.
|
|
283
291
|
// This can happen with <br> inside matched text — skip safely.
|
|
284
|
-
this._matches.
|
|
292
|
+
this._matches.push({ mark: null });
|
|
285
293
|
}
|
|
286
294
|
}
|
|
295
|
+
this._matches.reverse(); // O(n) — restore forward document order
|
|
287
296
|
|
|
288
297
|
// Drop entries where wrapping failed so the counter and navigation are accurate
|
|
289
298
|
this._matches = this._matches.filter((m) => m.mark);
|
|
@@ -304,10 +313,15 @@ export class FindReplace {
|
|
|
304
313
|
*/
|
|
305
314
|
_findRawMatches(query, root) {
|
|
306
315
|
const results = [];
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
316
|
+
// Reuse compiled regex when query and case-sensitivity haven't changed
|
|
317
|
+
if (this._lastQuery !== query || this._lastCaseSensitive !== this._caseSensitive) {
|
|
318
|
+
const flags = this._caseSensitive ? 'g' : 'gi';
|
|
319
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
320
|
+
this._queryRegex = new RegExp(escaped, flags);
|
|
321
|
+
this._lastQuery = query;
|
|
322
|
+
this._lastCaseSensitive = this._caseSensitive;
|
|
323
|
+
}
|
|
324
|
+
const re = this._queryRegex;
|
|
311
325
|
|
|
312
326
|
const walker = document.createTreeWalker(root, 0x4 /* NodeFilter.SHOW_TEXT */);
|
|
313
327
|
let node;
|