autumnnote 2.0.0 → 2.1.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/dist/autumnnote.cjs +20 -20
- package/dist/autumnnote.css +1 -1
- package/dist/autumnnote.es.js +633 -536
- 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/package.json +2 -2
- package/src/js/core/env.js +23 -10
- package/src/js/core/sanitise.js +81 -8
- package/src/js/index.js +1 -1
- package/src/js/module/BaseResizer.js +19 -9
- package/src/js/module/TableTooltip.js +91 -220
- package/src/js/module/Toolbar.js +107 -2
- package/src/js/module/table-grid.js +102 -0
- package/src/js/module/table-icons.js +33 -0
- package/src/styles/autumnnote.scss +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "WYSIWYG rich-text editor built with vanilla JavaScript — zero dependencies, no jQuery. Dark mode, @mention, markdown shortcuts, bubble toolbar. React and Vue 3 wrappers included.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/autumnnote.cjs",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"@vitest/browser-playwright": "^4.1.10",
|
|
105
105
|
"@vitest/coverage-v8": "^4.1.8",
|
|
106
106
|
"cross-env": "^10.1.0",
|
|
107
|
-
"eslint": "10.7.0",
|
|
107
|
+
"eslint": "^10.7.0",
|
|
108
108
|
"jsdom": "^25.0.1",
|
|
109
109
|
"playwright": "^1.61.1",
|
|
110
110
|
"rollup-plugin-visualizer": "^7.0.1",
|
package/src/js/core/env.js
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* env.js - Environment / browser detection
|
|
3
3
|
* Inspired by Summernote's env.js
|
|
4
|
+
*
|
|
5
|
+
* Every field is a lazy getter rather than a value computed at module load.
|
|
6
|
+
* This module is re-exported from the package entry point, so reading
|
|
7
|
+
* `navigator` eagerly meant that merely `import`ing autumnnote threw
|
|
8
|
+
* `ReferenceError: navigator is not defined` under SSR on any runtime without
|
|
9
|
+
* a global `navigator` — including Node 20, which package.json still supports.
|
|
10
|
+
* Nothing inside the library reads these fields, so the crash happened before
|
|
11
|
+
* an editor was ever created.
|
|
4
12
|
*/
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
/** @returns {string} the current user agent, or '' when there is no navigator (SSR). */
|
|
15
|
+
function ua() {
|
|
16
|
+
return globalThis.navigator?.userAgent ?? '';
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
export const env = {
|
|
9
|
-
/** True if browser is Chrome */
|
|
10
|
-
isChrome
|
|
20
|
+
/** True if browser is Chrome (excludes Edge, whose UA also contains "Chrome/") */
|
|
21
|
+
get isChrome() { return /Chrome\//.test(ua()) && !/Edg\//.test(ua()); },
|
|
11
22
|
/** True if browser is Firefox */
|
|
12
|
-
isFF
|
|
23
|
+
get isFF() { return /Firefox\//.test(ua()); },
|
|
13
24
|
/** True if browser is Safari (not Chrome) */
|
|
14
|
-
isSafari
|
|
25
|
+
get isSafari() { return /^((?!chrome|android).)*safari/i.test(ua()); },
|
|
15
26
|
/** True if browser is Edge (Chromium) */
|
|
16
|
-
isEdge
|
|
27
|
+
get isEdge() { return /Edg\//.test(ua()); },
|
|
17
28
|
/** True if running on macOS */
|
|
18
|
-
isMac
|
|
29
|
+
get isMac() { return /Macintosh/.test(ua()); },
|
|
19
30
|
/** True if running on mobile */
|
|
20
|
-
isMobile
|
|
31
|
+
get isMobile() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua()); },
|
|
21
32
|
/** True if touch is supported */
|
|
22
|
-
isTouch
|
|
33
|
+
get isTouch() {
|
|
34
|
+
return 'ontouchstart' in globalThis || (globalThis.navigator?.maxTouchPoints ?? 0) > 0;
|
|
35
|
+
},
|
|
23
36
|
/** Modifier key name depending on platform */
|
|
24
|
-
modifierKey
|
|
37
|
+
get modifierKey() { return /Macintosh/.test(ua()) ? 'metaKey' : 'ctrlKey'; },
|
|
25
38
|
};
|
package/src/js/core/sanitise.js
CHANGED
|
@@ -5,14 +5,49 @@
|
|
|
5
5
|
* DOM-parser based — no regex-based stripping of HTML (avoids bypass tricks).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Tags that are unconditionally removed from editor content.
|
|
10
|
+
*
|
|
11
|
+
* Beyond the obvious script hosts this covers two SVG/MathML-specific classes:
|
|
12
|
+
*
|
|
13
|
+
* - SMIL animation (`animate`, `set`, `animateTransform`, `animateMotion`)
|
|
14
|
+
* can rewrite an attribute *after* sanitisation finishes, so
|
|
15
|
+
* `<svg><a><animate attributeName="href" values="javascript:…">` survives an
|
|
16
|
+
* attribute-level filter untouched and still navigates on click. The editor
|
|
17
|
+
* only ever emits static `<svg>` icons, so animation is pure attack surface.
|
|
18
|
+
*
|
|
19
|
+
* - `mglyph` / `malignmark` / `annotation-xml` are HTML integration points
|
|
20
|
+
* inside the MathML namespace. They make the parser switch namespaces
|
|
21
|
+
* mid-tree, which is what lets a crafted fragment re-parse into different
|
|
22
|
+
* markup than it serialised from (mXSS). The rest of MathML is left alone so
|
|
23
|
+
* pasted formulae survive.
|
|
24
|
+
*/
|
|
25
|
+
const PROHIBITED_TAGS = [
|
|
26
|
+
'script', 'style', 'iframe', 'object', 'embed', 'form', 'base', 'template',
|
|
27
|
+
'link', 'meta', 'noscript', 'portal', 'frame', 'frameset', 'applet',
|
|
28
|
+
'animate', 'set', 'animatetransform', 'animatemotion',
|
|
29
|
+
'mglyph', 'malignmark', 'annotation-xml',
|
|
30
|
+
];
|
|
10
31
|
|
|
11
32
|
/** Tags whose element wrapper is stripped but content (child nodes) is preserved. */
|
|
12
33
|
const UNWRAP_TAGS = new Set(['button']);
|
|
13
34
|
|
|
14
35
|
/** Attributes whose values must be sanitised as URLs. */
|
|
15
|
-
const URL_ATTRS = ['href', 'src', 'action', 'formaction', 'xlink:href'];
|
|
36
|
+
const URL_ATTRS = ['href', 'src', 'action', 'formaction', 'xlink:href', 'poster', 'background', 'srcset'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* URL attributes that address media rather than navigation, so they are
|
|
40
|
+
* validated against SAFE_MEDIA_PROTOCOLS regardless of which element carries
|
|
41
|
+
* them (unlike `src`, whose meaning depends on the owning tag).
|
|
42
|
+
*/
|
|
43
|
+
const MEDIA_URL_ATTRS = new Set(['poster', 'background', 'srcset']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Attributes removed outright: the editor never emits them and their only
|
|
47
|
+
* effect is an outbound request the author did not ask for. `ping` fires a
|
|
48
|
+
* POST beacon to arbitrary hosts when a link is clicked.
|
|
49
|
+
*/
|
|
50
|
+
const BEACON_ATTRS = new Set(['ping']);
|
|
16
51
|
|
|
17
52
|
/** Inline style properties the editor's own toolbar/table features persist on saved content. */
|
|
18
53
|
const ALLOWED_STYLE_PROPS = new Set([
|
|
@@ -22,8 +57,12 @@ const ALLOWED_STYLE_PROPS = new Set([
|
|
|
22
57
|
'border-width', 'border-style', 'border-color', 'padding',
|
|
23
58
|
]);
|
|
24
59
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Value patterns that are never safe regardless of property.
|
|
62
|
+
* `image-set()` and `src()` are covered alongside `url()` — all three fetch an
|
|
63
|
+
* external resource, so allowing them would let pasted content phone home.
|
|
64
|
+
*/
|
|
65
|
+
const DANGEROUS_STYLE_VALUE_RE = /url\s*\(|image-set\s*\(|src\s*\(|expression\s*\(|@import|javascript:|vbscript:|behavior\s*:|-moz-binding/i;
|
|
27
66
|
|
|
28
67
|
/** Trusted hosts for iframe embeds when allowIframes is enabled. */
|
|
29
68
|
const TRUSTED_IFRAME_HOSTS = new Set([
|
|
@@ -106,12 +145,21 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
|
106
145
|
else el.removeAttribute('style');
|
|
107
146
|
continue;
|
|
108
147
|
}
|
|
148
|
+
// Drop tracking-beacon attributes outright
|
|
149
|
+
if (BEACON_ATTRS.has(attr.name)) {
|
|
150
|
+
el.removeAttribute(attr.name);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
109
153
|
// Sanitise URL attributes
|
|
110
154
|
if (URL_ATTRS.includes(attr.name)) {
|
|
111
155
|
const val = attr.value.trim();
|
|
112
|
-
const isMediaSource = attr.name
|
|
113
|
-
['IMG', 'VIDEO', 'AUDIO', 'SOURCE'].includes(el.tagName);
|
|
114
|
-
|
|
156
|
+
const isMediaSource = MEDIA_URL_ATTRS.has(attr.name) ||
|
|
157
|
+
(attr.name === 'src' && ['IMG', 'VIDEO', 'AUDIO', 'SOURCE'].includes(el.tagName));
|
|
158
|
+
const allowData = el.tagName === 'IMG';
|
|
159
|
+
const safe = attr.name === 'srcset'
|
|
160
|
+
? isSafeSrcset(val, { allowData })
|
|
161
|
+
: isSafeUrl(val, { media: isMediaSource, allowData });
|
|
162
|
+
if (!safe) {
|
|
115
163
|
el.removeAttribute(attr.name);
|
|
116
164
|
continue;
|
|
117
165
|
}
|
|
@@ -174,6 +222,31 @@ function sanitiseStyleValue(value) {
|
|
|
174
222
|
return kept.join('; ');
|
|
175
223
|
}
|
|
176
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Validates every candidate URL in a `srcset` attribute.
|
|
227
|
+
*
|
|
228
|
+
* Per the HTML srcset grammar a candidate URL is a run of non-whitespace
|
|
229
|
+
* characters — commas may appear *inside* it, which is why `data:` URLs work
|
|
230
|
+
* there — optionally followed by a width (`300w`) or density (`2x`) descriptor.
|
|
231
|
+
* Splitting on whitespace and skipping descriptor tokens therefore yields the
|
|
232
|
+
* URL set. Anything unparseable makes the whole attribute fail, since a
|
|
233
|
+
* partially-trusted candidate list is not something we can express.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} value
|
|
236
|
+
* @param {{ allowData?: boolean }} [options]
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
function isSafeSrcset(value, { allowData = false } = {}) {
|
|
240
|
+
const tokens = (value || '').trim().split(/\s+/).filter(Boolean);
|
|
241
|
+
for (const token of tokens) {
|
|
242
|
+
if (/^[\d.]+[xw],?$/i.test(token)) continue; // width/density descriptor
|
|
243
|
+
const url = token.replace(/,+$/, ''); // trailing comma = candidate separator
|
|
244
|
+
if (!url) continue;
|
|
245
|
+
if (!isSafeUrl(url, { media: true, allowData })) return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
177
250
|
/**
|
|
178
251
|
* Returns true if iframe src points to an approved video host.
|
|
179
252
|
* Relative, protocol-relative and invalid URLs are rejected.
|
package/src/js/index.js
CHANGED
|
@@ -168,7 +168,11 @@ export class BaseResizer {
|
|
|
168
168
|
h.dataset.handle = pos;
|
|
169
169
|
// Attach handle listeners here so they're torn down in destroy() via _disposers
|
|
170
170
|
this._disposers.push(
|
|
171
|
-
|
|
171
|
+
// Pointer events rather than mouse events, so the same code path serves
|
|
172
|
+
// mouse, touch and pen. The handles also set `touch-action: none` in
|
|
173
|
+
// CSS — without it a touch drag is claimed by the browser as a scroll
|
|
174
|
+
// gesture and the pointer stream is cancelled before it starts.
|
|
175
|
+
on(h, 'pointerdown', (e) => {
|
|
172
176
|
e.preventDefault();
|
|
173
177
|
e.stopPropagation();
|
|
174
178
|
this._startResize(e, pos);
|
|
@@ -293,20 +297,26 @@ export class BaseResizer {
|
|
|
293
297
|
});
|
|
294
298
|
};
|
|
295
299
|
|
|
300
|
+
const stop = () => {
|
|
301
|
+
document.removeEventListener('pointermove', onMove);
|
|
302
|
+
document.removeEventListener('pointerup', onUp);
|
|
303
|
+
document.removeEventListener('pointercancel', onUp);
|
|
304
|
+
};
|
|
305
|
+
|
|
296
306
|
const onUp = () => {
|
|
297
307
|
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
|
|
298
|
-
|
|
299
|
-
document.removeEventListener('mouseup', onUp);
|
|
308
|
+
stop();
|
|
300
309
|
this._dragDisposers = null;
|
|
301
310
|
this.context.invoke('editor.afterCommand');
|
|
302
311
|
};
|
|
303
312
|
|
|
304
|
-
document.addEventListener('
|
|
305
|
-
document.addEventListener('
|
|
313
|
+
document.addEventListener('pointermove', onMove);
|
|
314
|
+
document.addEventListener('pointerup', onUp);
|
|
315
|
+
// A touch drag interrupted by the system (incoming call, gesture takeover)
|
|
316
|
+
// ends with pointercancel and no pointerup, which would otherwise leave the
|
|
317
|
+
// move listener attached for the rest of the session.
|
|
318
|
+
document.addEventListener('pointercancel', onUp);
|
|
306
319
|
// Track these so destroy() can clean them up if called during an active drag
|
|
307
|
-
this._dragDisposers = [
|
|
308
|
-
() => document.removeEventListener('mousemove', onMove),
|
|
309
|
-
() => document.removeEventListener('mouseup', onUp),
|
|
310
|
-
];
|
|
320
|
+
this._dragDisposers = [stop];
|
|
311
321
|
}
|
|
312
322
|
}
|