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.
- package/README.md +72 -5
- package/dist/autumnnote.cjs +20 -20
- package/dist/autumnnote.css +1 -1
- package/dist/autumnnote.es.js +661 -798
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +20 -20
- package/dist/autumnnote.umd.js +20 -20
- package/dist/autumnnote.umd.js.map +1 -1
- package/dist/icon-data-V0Xqv-wX.js +255 -0
- package/dist/icon-data-V0Xqv-wX.js.map +1 -0
- package/package.json +13 -4
- package/types/index.d.ts +8 -0
- package/src/js/Context.js +0 -854
- package/src/js/core/detectLang.js +0 -98
- package/src/js/core/dom.js +0 -372
- package/src/js/core/env.js +0 -25
- package/src/js/core/key.js +0 -66
- package/src/js/core/lists.js +0 -121
- package/src/js/core/markdown.js +0 -695
- package/src/js/core/range.js +0 -194
- package/src/js/core/sanitise.js +0 -231
- package/src/js/editing/History.js +0 -266
- package/src/js/editing/Style.js +0 -812
- package/src/js/editing/Table.js +0 -105
- package/src/js/editing/Typing.js +0 -397
- package/src/js/index.js +0 -193
- package/src/js/index.umd.js +0 -17
- package/src/js/module/AutoSaveRestore.js +0 -125
- package/src/js/module/BaseDialog.js +0 -133
- package/src/js/module/BaseMediaTooltip.js +0 -142
- package/src/js/module/BaseResizer.js +0 -312
- package/src/js/module/BubbleToolbar.js +0 -483
- package/src/js/module/Buttons.js +0 -399
- package/src/js/module/Clipboard.js +0 -579
- package/src/js/module/CodeTooltip.js +0 -493
- package/src/js/module/Codeview.js +0 -125
- package/src/js/module/ContextMenu.js +0 -621
- package/src/js/module/Editor.js +0 -747
- package/src/js/module/EmojiDialog.js +0 -254
- package/src/js/module/FindReplace.js +0 -512
- package/src/js/module/Fullscreen.js +0 -80
- package/src/js/module/IconDialog.js +0 -618
- package/src/js/module/ImageCropOverlay.js +0 -586
- package/src/js/module/ImageDialog.js +0 -193
- package/src/js/module/ImageResizer.js +0 -42
- package/src/js/module/ImageTooltip.js +0 -285
- package/src/js/module/LinkDialog.js +0 -145
- package/src/js/module/LinkTooltip.js +0 -250
- package/src/js/module/MarkdownShortcuts.js +0 -250
- package/src/js/module/Mention.js +0 -365
- package/src/js/module/Placeholder.js +0 -51
- package/src/js/module/ShortcutsDialog.js +0 -111
- package/src/js/module/SlashMenu.js +0 -376
- package/src/js/module/Statusbar.js +0 -246
- package/src/js/module/TableTooltip.js +0 -1521
- package/src/js/module/Toolbar.js +0 -750
- package/src/js/module/VideoDialog.js +0 -193
- package/src/js/module/VideoResizer.js +0 -66
- package/src/js/module/VideoTooltip.js +0 -248
- package/src/js/module/emoji-data.js +0 -496
- package/src/js/renderer.js +0 -120
- package/src/js/settings.js +0 -214
- package/src/styles/_variables.scss +0 -48
- package/src/styles/autumnnote.scss +0 -2866
package/src/js/core/range.js
DELETED
|
@@ -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
|
-
}
|
package/src/js/core/sanitise.js
DELETED
|
@@ -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
|
-
}
|