autumnnote 2.0.0 → 2.2.0

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 (64) hide show
  1. package/README.md +72 -5
  2. package/dist/autumnnote.cjs +20 -20
  3. package/dist/autumnnote.css +1 -1
  4. package/dist/autumnnote.es.js +661 -798
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +20 -20
  7. package/dist/autumnnote.umd.js +20 -20
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/dist/icon-data-V0Xqv-wX.js +255 -0
  10. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  11. package/package.json +13 -4
  12. package/types/index.d.ts +8 -0
  13. package/src/js/Context.js +0 -854
  14. package/src/js/core/detectLang.js +0 -98
  15. package/src/js/core/dom.js +0 -372
  16. package/src/js/core/env.js +0 -25
  17. package/src/js/core/key.js +0 -66
  18. package/src/js/core/lists.js +0 -121
  19. package/src/js/core/markdown.js +0 -695
  20. package/src/js/core/range.js +0 -194
  21. package/src/js/core/sanitise.js +0 -231
  22. package/src/js/editing/History.js +0 -266
  23. package/src/js/editing/Style.js +0 -812
  24. package/src/js/editing/Table.js +0 -105
  25. package/src/js/editing/Typing.js +0 -397
  26. package/src/js/index.js +0 -193
  27. package/src/js/index.umd.js +0 -17
  28. package/src/js/module/AutoSaveRestore.js +0 -125
  29. package/src/js/module/BaseDialog.js +0 -133
  30. package/src/js/module/BaseMediaTooltip.js +0 -142
  31. package/src/js/module/BaseResizer.js +0 -312
  32. package/src/js/module/BubbleToolbar.js +0 -483
  33. package/src/js/module/Buttons.js +0 -399
  34. package/src/js/module/Clipboard.js +0 -579
  35. package/src/js/module/CodeTooltip.js +0 -493
  36. package/src/js/module/Codeview.js +0 -125
  37. package/src/js/module/ContextMenu.js +0 -621
  38. package/src/js/module/Editor.js +0 -747
  39. package/src/js/module/EmojiDialog.js +0 -254
  40. package/src/js/module/FindReplace.js +0 -512
  41. package/src/js/module/Fullscreen.js +0 -80
  42. package/src/js/module/IconDialog.js +0 -618
  43. package/src/js/module/ImageCropOverlay.js +0 -586
  44. package/src/js/module/ImageDialog.js +0 -193
  45. package/src/js/module/ImageResizer.js +0 -42
  46. package/src/js/module/ImageTooltip.js +0 -285
  47. package/src/js/module/LinkDialog.js +0 -145
  48. package/src/js/module/LinkTooltip.js +0 -250
  49. package/src/js/module/MarkdownShortcuts.js +0 -250
  50. package/src/js/module/Mention.js +0 -365
  51. package/src/js/module/Placeholder.js +0 -51
  52. package/src/js/module/ShortcutsDialog.js +0 -111
  53. package/src/js/module/SlashMenu.js +0 -376
  54. package/src/js/module/Statusbar.js +0 -246
  55. package/src/js/module/TableTooltip.js +0 -1521
  56. package/src/js/module/Toolbar.js +0 -750
  57. package/src/js/module/VideoDialog.js +0 -193
  58. package/src/js/module/VideoResizer.js +0 -66
  59. package/src/js/module/VideoTooltip.js +0 -248
  60. package/src/js/module/emoji-data.js +0 -496
  61. package/src/js/renderer.js +0 -120
  62. package/src/js/settings.js +0 -214
  63. package/src/styles/_variables.scss +0 -48
  64. package/src/styles/autumnnote.scss +0 -2866
@@ -1,194 +0,0 @@
1
- /**
2
- * range.js - Selection and Range utilities
3
- * Inspired by Summernote's range.js — rewritten as vanilla JS
4
- */
5
-
6
- import { isElement, closest } from './dom.js';
7
-
8
- // ---------------------------------------------------------------------------
9
- // WrappedRange — a convenience wrapper over the native Range API
10
- // ---------------------------------------------------------------------------
11
-
12
- export class WrappedRange {
13
- /**
14
- * @param {Node} sc - start container
15
- * @param {number} so - start offset
16
- * @param {Node} ec - end container
17
- * @param {number} eo - end offset
18
- */
19
- constructor(sc, so, ec, eo) {
20
- this.sc = sc;
21
- this.so = so;
22
- this.ec = ec;
23
- this.eo = eo;
24
- }
25
-
26
- /** @returns {boolean} */
27
- isCollapsed() {
28
- return this.sc === this.ec && this.so === this.eo;
29
- }
30
-
31
- /** @returns {Range} */
32
- toNativeRange() {
33
- const range = document.createRange();
34
- try {
35
- range.setStart(this.sc, this.so);
36
- range.setEnd(this.ec, this.eo);
37
- } catch (_e) {
38
- void _e; // guard against detached nodes
39
- }
40
- return range;
41
- }
42
-
43
- /**
44
- * Select this wrapped range in the globalThis.
45
- */
46
- select() {
47
- const sel = globalThis.getSelection();
48
- if (!sel) return;
49
- sel.removeAllRanges();
50
- sel.addRange(this.toNativeRange());
51
- }
52
-
53
- /**
54
- * Returns the common ancestor element of this range.
55
- * @returns {Element|null}
56
- */
57
- commonAncestor() {
58
- const native = this.toNativeRange();
59
- const ancestor = native.commonAncestorContainer;
60
- return /** @type {Element|null} */ (isElement(ancestor) ? ancestor : ancestor.parentElement);
61
- }
62
-
63
- /**
64
- * Returns the nearest paragraph/block ancestor within the editable area.
65
- * @param {HTMLElement} editable
66
- * @returns {Element|null}
67
- */
68
- blockNode(editable) {
69
- return /** @type {Element|null} */ (closest(this.sc, (n) => isElement(n) && n !== editable, editable));
70
- }
71
-
72
- /**
73
- * Returns either the selected text string or empty string.
74
- * @returns {string}
75
- */
76
- toString() {
77
- return this.toNativeRange().toString();
78
- }
79
-
80
- /**
81
- * Returns the bounding DOMRect of the range (or null).
82
- * @returns {DOMRect|null}
83
- */
84
- getClientRects() {
85
- const rects = this.toNativeRange().getClientRects();
86
- return rects.length > 0 ? rects[rects.length - 1] : null;
87
- }
88
-
89
- /**
90
- * Inserts a node at the start of this range.
91
- * @param {Node} node
92
- */
93
- insertNode(node) {
94
- const native = this.toNativeRange();
95
- native.insertNode(node);
96
- }
97
-
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // Factory helpers
102
- // ---------------------------------------------------------------------------
103
-
104
- /**
105
- * Creates a WrappedRange from a native Range object.
106
- * @param {Range} range
107
- * @returns {WrappedRange}
108
- */
109
- export function fromNativeRange(range) {
110
- return new WrappedRange(
111
- range.startContainer,
112
- range.startOffset,
113
- range.endContainer,
114
- range.endOffset,
115
- );
116
- }
117
-
118
- /**
119
- * Returns a WrappedRange for the current globalThis selection,
120
- * optionally restricted to a given editable element.
121
- * @param {HTMLElement} [editable]
122
- * @returns {WrappedRange|null}
123
- */
124
- export function currentRange(editable) {
125
- const sel = globalThis.getSelection();
126
- if (!sel || sel.rangeCount === 0) return null;
127
- const native = sel.getRangeAt(0);
128
- // Optionally check that the selection is inside the editable element
129
- if (editable && !editable.contains(native.commonAncestorContainer)) {
130
- return null;
131
- }
132
- return fromNativeRange(native);
133
- }
134
-
135
- /**
136
- * Creates a WrappedRange that covers the entire content of an element.
137
- * @param {HTMLElement} el
138
- * @returns {WrappedRange}
139
- */
140
- export function rangeFromElement(el) {
141
- return new WrappedRange(el, 0, el, el.childNodes.length);
142
- }
143
-
144
- /**
145
- * Creates a collapsed range (cursor) at the given node / offset.
146
- * @param {Node} node
147
- * @param {number} offset
148
- * @returns {WrappedRange}
149
- */
150
- export function collapsedRange(node, offset = 0) {
151
- return new WrappedRange(node, offset, node, offset);
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Utility helpers
156
- // ---------------------------------------------------------------------------
157
-
158
- /**
159
- * Returns true if the current selection is inside the given element.
160
- * @param {HTMLElement} el
161
- * @returns {boolean}
162
- */
163
- export function isSelectionInside(el) {
164
- const sel = globalThis.getSelection();
165
- if (!sel || sel.rangeCount === 0) return false;
166
- return el.contains(sel.getRangeAt(0).commonAncestorContainer);
167
- }
168
-
169
- /**
170
- * Saves the current selection, executes fn, then restores the selection.
171
- * @param {Function} fn
172
- */
173
- export function withSavedRange(fn) {
174
- const sel = globalThis.getSelection();
175
- if (!sel || sel.rangeCount === 0) {
176
- fn(null);
177
- return;
178
- }
179
- const saved = sel.getRangeAt(0).cloneRange();
180
- fn(fromNativeRange(saved));
181
- sel.removeAllRanges();
182
- sel.addRange(saved);
183
- }
184
-
185
- /**
186
- * Splits the text node at the given offset and returns the two halves.
187
- * @param {Text} textNode
188
- * @param {number} offset
189
- * @returns {[Text, Text]}
190
- */
191
- export function splitText(textNode, offset) {
192
- const after = textNode.splitText(offset);
193
- return [textNode, after];
194
- }
@@ -1,231 +0,0 @@
1
- /**
2
- * sanitise.js - Shared HTML and URL sanitisation utilities
3
- *
4
- * Single source of truth used by Editor, Clipboard, Codeview, and renderer.
5
- * DOM-parser based — no regex-based stripping of HTML (avoids bypass tricks).
6
- */
7
-
8
- /** Tags that are unconditionally removed from editor content. */
9
- const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'base', 'template', 'link', 'meta', 'noscript', 'portal', 'frame', 'frameset', 'applet'];
10
-
11
- /** Tags whose element wrapper is stripped but content (child nodes) is preserved. */
12
- const UNWRAP_TAGS = new Set(['button']);
13
-
14
- /** Attributes whose values must be sanitised as URLs. */
15
- const URL_ATTRS = ['href', 'src', 'action', 'formaction', 'xlink:href'];
16
-
17
- /** Inline style properties the editor's own toolbar/table features persist on saved content. */
18
- const ALLOWED_STYLE_PROPS = new Set([
19
- 'color', 'background-color', 'font-size', 'line-height',
20
- 'text-align', 'vertical-align',
21
- 'width', 'min-width', 'height', 'min-height',
22
- 'border-width', 'border-style', 'border-color', 'padding',
23
- ]);
24
-
25
- /** Value patterns that are never safe regardless of property. */
26
- const DANGEROUS_STYLE_VALUE_RE = /url\s*\(|expression\s*\(|@import|javascript:|vbscript:|behavior\s*:|-moz-binding/i;
27
-
28
- /** Trusted hosts for iframe embeds when allowIframes is enabled. */
29
- const TRUSTED_IFRAME_HOSTS = new Set([
30
- 'www.youtube.com',
31
- 'youtube.com',
32
- 'm.youtube.com',
33
- 'www.youtube-nocookie.com',
34
- 'youtube-nocookie.com',
35
- 'player.vimeo.com',
36
- ]);
37
-
38
- const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
39
- const SAFE_MEDIA_PROTOCOLS = new Set(['http:', 'https:', 'blob:']);
40
- const SAFE_RASTER_DATA_RE = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\s]+$/i;
41
- const URL_BASE = 'https://autumnnote.invalid/';
42
-
43
- /**
44
- * Produce a sanitized HTML string with dangerous elements and attributes removed.
45
- *
46
- * Removes disallowed tags and wrappers, strips event-handler attributes, rejects
47
- * `javascript:`/`vbscript:` URLs and most `data:` URIs, restricts iframe `src`
48
- * to trusted hosts when enabled, and permits only checklist checkboxes as inputs.
49
- *
50
- * @param {string} html - HTML fragment to sanitize.
51
- * @param {Object} [options]
52
- * @param {boolean} [options.allowIframes=false] - If true, `iframe` elements are not removed but their `src` is restricted to trusted hosts and `srcdoc` is removed.
53
- * @returns {string} The sanitized HTML fragment.
54
- */
55
- export function sanitiseHTML(html, { allowIframes = false } = {}) {
56
- const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
57
-
58
- // Single querySelectorAll pass — collect all elements once to avoid
59
- // repeated full-tree traversals for each category of check.
60
- const allElements = Array.from(doc.querySelectorAll('*'));
61
-
62
- // Build the prohibited tag set for fast O(1) lookup
63
- const prohibited = new Set(
64
- allowIframes ? PROHIBITED_TAGS.filter((t) => t !== 'iframe') : PROHIBITED_TAGS,
65
- );
66
-
67
- for (const el of allElements) {
68
- const tag = el.tagName.toLowerCase();
69
-
70
- // Unwrap elements whose wrapper is unsafe but whose content should be kept
71
- if (UNWRAP_TAGS.has(tag)) {
72
- el.replaceWith(...el.childNodes);
73
- continue;
74
- }
75
-
76
- // Remove outright dangerous elements
77
- if (prohibited.has(tag)) {
78
- el.remove();
79
- continue;
80
- }
81
-
82
- // Invalid embeds are removed rather than retained as empty iframes.
83
- if (tag === 'iframe') {
84
- const src = el.getAttribute('src');
85
- if (!src || !isTrustedIframeSrc(src)) {
86
- el.remove();
87
- continue;
88
- }
89
- }
90
-
91
- // Strip dangerous attributes
92
- for (const attr of Array.from(el.attributes)) {
93
- // Remove all event handlers (onclick, onload, onerror, …)
94
- if (attr.name.startsWith('on')) {
95
- el.removeAttribute(attr.name);
96
- continue;
97
- }
98
- // Filter the style attribute down to an allowlisted set of safe
99
- // properties (see ALLOWED_STYLE_PROPS) — not a blanket strip, since
100
- // the editor's own toolbar/table features persist inline styles
101
- // (text color/highlight, font size, line height, table alignment/
102
- // sizing/borders) that must survive sanitisation.
103
- if (attr.name === 'style') {
104
- const cleaned = sanitiseStyleValue(attr.value);
105
- if (cleaned) el.setAttribute('style', cleaned);
106
- else el.removeAttribute('style');
107
- continue;
108
- }
109
- // Sanitise URL attributes
110
- if (URL_ATTRS.includes(attr.name)) {
111
- const val = attr.value.trim();
112
- const isMediaSource = attr.name === 'src' &&
113
- ['IMG', 'VIDEO', 'AUDIO', 'SOURCE'].includes(el.tagName);
114
- if (!isSafeUrl(val, { media: isMediaSource, allowData: el.tagName === 'IMG' })) {
115
- el.removeAttribute(attr.name);
116
- continue;
117
- }
118
- }
119
- // Strip iframe HTML-injection vectors; limit src to trusted hosts
120
- if (el.tagName === 'IFRAME') {
121
- if (attr.name === 'srcdoc') {
122
- el.removeAttribute(attr.name);
123
- continue;
124
- }
125
- if (attr.name === 'src' && !isTrustedIframeSrc(attr.value)) {
126
- el.removeAttribute(attr.name);
127
- }
128
- }
129
- }
130
-
131
- if (tag === 'a' && el.getAttribute('target') === '_blank') {
132
- el.setAttribute('rel', 'noopener noreferrer');
133
- }
134
-
135
- // Allow only input[type="checkbox"] inside ul.an-checklist li
136
- if (tag === 'input') {
137
- const inChecklist = el.closest('ul.an-checklist') !== null &&
138
- el.closest('li') !== null;
139
- if (!inChecklist || el.getAttribute('type') !== 'checkbox') {
140
- el.remove();
141
- } else {
142
- for (const attr of Array.from(el.attributes)) {
143
- if (!['type', 'checked', 'contenteditable'].includes(attr.name)) {
144
- el.removeAttribute(attr.name);
145
- }
146
- }
147
- }
148
- }
149
- }
150
-
151
- return doc.body.innerHTML;
152
- }
153
-
154
- /**
155
- * Filters a style attribute value down to an allowlisted set of CSS
156
- * properties (ALLOWED_STYLE_PROPS), dropping any declaration whose value
157
- * contains a dangerous construct — url(), expression(), an "import" rule,
158
- * javascript:/vbscript:, IE behavior/-moz-binding — regardless of property.
159
- * @param {string} value
160
- * @returns {string} The filtered declaration list, or '' if nothing survives.
161
- */
162
- function sanitiseStyleValue(value) {
163
- const kept = [];
164
- for (const decl of (value || '').split(';')) {
165
- const idx = decl.indexOf(':');
166
- if (idx === -1) continue;
167
- const prop = decl.slice(0, idx).trim().toLowerCase();
168
- const val = decl.slice(idx + 1).trim();
169
- if (!prop || !val) continue;
170
- if (!ALLOWED_STYLE_PROPS.has(prop)) continue;
171
- if (DANGEROUS_STYLE_VALUE_RE.test(val)) continue;
172
- kept.push(`${prop}: ${val}`);
173
- }
174
- return kept.join('; ');
175
- }
176
-
177
- /**
178
- * Returns true if iframe src points to an approved video host.
179
- * Relative, protocol-relative and invalid URLs are rejected.
180
- * @param {string} src
181
- * @returns {boolean}
182
- */
183
- function isTrustedIframeSrc(src) {
184
- const trimmed = (src || '').trim();
185
- if (!trimmed) return false;
186
- if (trimmed.startsWith('//') || trimmed.startsWith('/')) return false;
187
- try {
188
- const url = new URL(trimmed);
189
- if (url.protocol !== 'https:') return false;
190
- return TRUSTED_IFRAME_HOSTS.has(url.hostname.toLowerCase());
191
- } catch {
192
- return false;
193
- }
194
- }
195
-
196
- /**
197
- * Sanitises a URL string, rejecting dangerous protocols.
198
- *
199
- * Blocked protocols: javascript:, vbscript:
200
- * Optionally blocked: data: (safe to allow for img/src base64 embeds)
201
- *
202
- * @param {string} url
203
- * @param {{ allowData?: boolean, media?: boolean }} [opts]
204
- * @returns {string|null} The original URL if safe, null if rejected.
205
- */
206
- export function sanitiseUrl(url, { allowData = false, media = allowData } = {}) {
207
- const trimmed = (url || '').trim();
208
- if (!trimmed && url == null) return null;
209
- return isSafeUrl(trimmed, { media, allowData }) ? url : null;
210
- }
211
-
212
- /**
213
- * Validate a URL using the browser URL parser so ASCII whitespace/control
214
- * characters cannot disguise a dangerous protocol (for example java\nscript:).
215
- * @param {string} value
216
- * @param {{media?: boolean, allowData?: boolean}} [options]
217
- * @returns {boolean}
218
- */
219
- function isSafeUrl(value, { media = false, allowData = false } = {}) {
220
- const trimmed = (value || '').trim();
221
- if (!trimmed) return true;
222
- if (allowData && SAFE_RASTER_DATA_RE.test(trimmed)) return true;
223
-
224
- try {
225
- const parsed = new URL(trimmed, URL_BASE);
226
- const protocols = media ? SAFE_MEDIA_PROTOCOLS : SAFE_LINK_PROTOCOLS;
227
- return protocols.has(parsed.protocol);
228
- } catch {
229
- return false;
230
- }
231
- }
@@ -1,266 +0,0 @@
1
- /**
2
- * History.js - Undo / redo stack for editor content
3
- * Inspired by Summernote's History module, rewritten without jQuery
4
- */
5
-
6
- export class History {
7
- /**
8
- * @param {HTMLElement} editable - the contenteditable element
9
- * @param {number} [limit=100] - maximum number of undo/redo states
10
- * @param {number} [maxBytes=10485760] - maximum combined size (chars) of all
11
- * stacked snapshots (html + tokenized image data). Oldest states are
12
- * evicted first when exceeded, even if `limit` hasn't been reached —
13
- * documents with many large embedded images can otherwise hold dozens of
14
- * full-size copies in memory despite the step-count limit.
15
- */
16
- constructor(editable, limit = 100, maxBytes = 10 * 1024 * 1024) {
17
- this.editable = editable;
18
- this._limit = limit;
19
- this._maxBytes = maxBytes;
20
- this._bytes = 0;
21
- /** @type {Array<{html: string, images?: Record<string,string>, sel: {start: number, end: number}|null}>} */
22
- this.stack = [];
23
- this.stackOffset = -1;
24
- this._savePoint();
25
- }
26
-
27
- /**
28
- * Approximate in-memory size (chars) of one stacked snapshot: the tokenized
29
- * HTML string plus every image data URL it references.
30
- * @param {{html: string, images?: Record<string,string>}} entry
31
- * @returns {number}
32
- */
33
- _entrySize(entry) {
34
- let size = entry.html.length;
35
- if (entry.images) {
36
- for (const key in entry.images) size += entry.images[key].length;
37
- }
38
- return size;
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
- // Private helpers
43
- // ---------------------------------------------------------------------------
44
-
45
- _serialize() {
46
- return this.editable.innerHTML;
47
- }
48
-
49
- /**
50
- * Serializes the current selection as character offsets from the start of
51
- * the editable element, so it can be restored after innerHTML replacement.
52
- * @returns {{ start: number, end: number }|null}
53
- */
54
- _serializeSelection() {
55
- const sel = globalThis.getSelection();
56
- if (!sel || sel.rangeCount === 0) return null;
57
- const range = sel.getRangeAt(0);
58
- if (!this.editable.contains(range.startContainer)) return null;
59
- return {
60
- start: this._charOffset(range.startContainer, range.startOffset),
61
- end: this._charOffset(range.endContainer, range.endOffset),
62
- };
63
- }
64
-
65
- /**
66
- * Returns the character offset of (node, offset) from the beginning of
67
- * the editable's text content.
68
- * @param {Node} node
69
- * @param {number} offset
70
- * @returns {number}
71
- */
72
- _charOffset(node, offset) {
73
- let count = 0;
74
- const walker = document.createTreeWalker(this.editable, NodeFilter.SHOW_TEXT, null);
75
- let cur;
76
- while ((cur = walker.nextNode())) {
77
- if (cur === node) return count + offset;
78
- count += /** @type {Text} */ (cur).length;
79
- }
80
- return 0;
81
- }
82
-
83
- /**
84
- * Restores a previously serialized selection inside the editable.
85
- * @param {{ start: number, end: number }|null} saved
86
- */
87
- _restoreSelection(saved) {
88
- if (!saved) return;
89
- let startNode = null, startOff = 0;
90
- let endNode = null, endOff = 0;
91
- let count = 0;
92
- const walker = document.createTreeWalker(this.editable, NodeFilter.SHOW_TEXT, null);
93
- let cur;
94
- while ((cur = walker.nextNode())) {
95
- const len = /** @type {Text} */ (cur).length;
96
- if (!startNode && count + len >= saved.start) {
97
- startNode = cur;
98
- startOff = saved.start - count;
99
- }
100
- if (!endNode && count + len >= saved.end) {
101
- endNode = cur;
102
- endOff = saved.end - count;
103
- break;
104
- }
105
- count += len;
106
- }
107
- if (!startNode) {
108
- // Offset exceeds content (e.g. undo to a shorter state): place at end
109
- const lastWalker = document.createTreeWalker(this.editable, NodeFilter.SHOW_TEXT, null);
110
- let lastNode = null;
111
- while ((lastNode = lastWalker.nextNode())) { startNode = lastNode; }
112
- startOff = startNode ? /** @type {Text} */ (startNode).length : 0;
113
- endNode = startNode;
114
- endOff = startOff;
115
- }
116
- if (!endNode) { endNode = startNode; endOff = startOff; }
117
- try {
118
- const range = document.createRange();
119
- range.setStart(startNode, startOff);
120
- range.setEnd(endNode, endOff);
121
- const sel = globalThis.getSelection();
122
- sel.removeAllRanges();
123
- sel.addRange(range);
124
- } catch (_) {
125
- void _; // detached node — fall back to placing cursor at start of editable
126
- try {
127
- const fb = document.createRange();
128
- fb.setStart(this.editable, 0);
129
- fb.collapse(true);
130
- const s = globalThis.getSelection();
131
- if (s) { s.removeAllRanges(); s.addRange(fb); }
132
- } catch (_2) { void _2; /* fully give up */ }
133
- }
134
- }
135
-
136
- _savePoint() {
137
- // Trim future history if we're mid-stack
138
- if (this.stackOffset < this.stack.length - 1) {
139
- for (const entry of this.stack.slice(this.stackOffset + 1)) {
140
- this._bytes -= this._entrySize(entry);
141
- }
142
- this.stack = this.stack.slice(0, this.stackOffset + 1);
143
- }
144
- const raw = this._serialize();
145
- const { html, images } = this._tokenizeImages(raw);
146
- const entry = { html, images, sel: this._serializeSelection() };
147
- this.stack.push(entry);
148
- this._bytes += this._entrySize(entry);
149
-
150
- // Evict oldest states first, whichever budget (step count or byte size)
151
- // is exceeded — always keep at least the just-pushed current state.
152
- while (this.stack.length > 1 && (this.stack.length > this._limit || this._bytes > this._maxBytes)) {
153
- this._bytes -= this._entrySize(this.stack.shift());
154
- }
155
- // The newly-pushed current state is always the final entry. Recompute the
156
- // offset from the resulting stack instead of incrementing conditionally:
157
- // a single oversized snapshot may evict several older entries at once.
158
- this.stackOffset = this.stack.length - 1;
159
- }
160
-
161
- _restore(point) {
162
- if (!point) return;
163
- this.editable.innerHTML = this._detokenizeImages(point);
164
- this._restoreSelection(point.sel);
165
- }
166
-
167
- // ---------------------------------------------------------------------------
168
- // Base64 tokenisation — keeps snapshot strings small so that the
169
- // per-keystroke `recordUndo` string comparison stays fast even when the
170
- // editor contains large embedded images.
171
- // ---------------------------------------------------------------------------
172
-
173
- /**
174
- * Replaces every `data:…;base64,…` occurrence in `html` with a compact
175
- * token `__asn_img_0__`, `__asn_img_1__`, … and returns the tokenized
176
- * string together with a map from token → original data URL.
177
- * @param {string} html
178
- * @returns {{ html: string, images: Object<string,string> }}
179
- */
180
- _tokenizeImages(html) {
181
- // Fast-path: skip regex entirely when there are no data URIs (common case)
182
- if (!html.includes('data:')) return { html, images: /** @type {Record<string,string>} */ ({}) };
183
- const images = /** @type {Record<string,string>} */ ({});
184
- let index = 0;
185
- const tokenized = html.replace(/data:[^;]+;base64,[^"' >]*/g, (match) => {
186
- const token = `__asn_img_${index}__`;
187
- images[token] = match;
188
- index++;
189
- return token;
190
- });
191
- return { html: tokenized, images };
192
- }
193
-
194
- /**
195
- * Restores a snapshot by replacing tokens back with their data URLs.
196
- * @param {{ html: string, images: Object<string,string> }} point
197
- * @returns {string}
198
- */
199
- _detokenizeImages(point) {
200
- if (!point.images || Object.keys(point.images).length === 0) return point.html;
201
- return point.html.replace(/__asn_img_\d+__/g, (token) => point.images[token] || token);
202
- }
203
-
204
- // ---------------------------------------------------------------------------
205
- // Public API
206
- // ---------------------------------------------------------------------------
207
-
208
- /**
209
- * Records the current editor state as a history checkpoint.
210
- */
211
- recordUndo() {
212
- const current = this._serialize();
213
- const { html: tokenized } = this._tokenizeImages(current);
214
- const prev = this.stack[this.stackOffset];
215
- if (prev?.html === tokenized) return; // No change
216
- this._savePoint();
217
- }
218
-
219
- /**
220
- * Undo to the previous state.
221
- */
222
- undo() {
223
- if (this.stackOffset <= 0) return;
224
- this.stackOffset--;
225
- this._restore(this.stack[this.stackOffset]);
226
- }
227
-
228
- /**
229
- * Redo to the next state.
230
- */
231
- redo() {
232
- if (this.stackOffset >= this.stack.length - 1) return;
233
- this.stackOffset++;
234
- this._restore(this.stack[this.stackOffset]);
235
- }
236
-
237
- /**
238
- * Resets the history stack (e.g. on editor destroy or full content replace).
239
- */
240
- reset() {
241
- this.stack = [];
242
- this.stackOffset = -1;
243
- this._bytes = 0;
244
- this._savePoint();
245
- }
246
-
247
- /** @returns {boolean} */
248
- canUndo() {
249
- return this.stackOffset > 0;
250
- }
251
-
252
- /** @returns {boolean} */
253
- canRedo() {
254
- return this.stackOffset < this.stack.length - 1;
255
- }
256
-
257
- /** @returns {number} */
258
- getUndoCount() {
259
- return Math.max(0, this.stackOffset);
260
- }
261
-
262
- /** @returns {number} */
263
- getRedoCount() {
264
- return Math.max(0, this.stack.length - 1 - this.stackOffset);
265
- }
266
- }