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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "2.0.0",
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",
@@ -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
- const userAgent = navigator.userAgent;
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: /Chrome\//.test(userAgent),
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: /Firefox\//.test(userAgent),
23
+ get isFF() { return /Firefox\//.test(ua()); },
13
24
  /** True if browser is Safari (not Chrome) */
14
- isSafari: /^((?!chrome|android).)*safari/i.test(userAgent),
25
+ get isSafari() { return /^((?!chrome|android).)*safari/i.test(ua()); },
15
26
  /** True if browser is Edge (Chromium) */
16
- isEdge: /Edg\//.test(userAgent),
27
+ get isEdge() { return /Edg\//.test(ua()); },
17
28
  /** True if running on macOS */
18
- isMac: /Macintosh/.test(userAgent),
29
+ get isMac() { return /Macintosh/.test(ua()); },
19
30
  /** True if running on mobile */
20
- isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent),
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: 'ontouchstart' in globalThis || navigator.maxTouchPoints > 0,
33
+ get isTouch() {
34
+ return 'ontouchstart' in globalThis || (globalThis.navigator?.maxTouchPoints ?? 0) > 0;
35
+ },
23
36
  /** Modifier key name depending on platform */
24
- modifierKey: /Macintosh/.test(userAgent) ? 'metaKey' : 'ctrlKey',
37
+ get modifierKey() { return /Macintosh/.test(ua()) ? 'metaKey' : 'ctrlKey'; },
25
38
  };
@@ -5,14 +5,49 @@
5
5
  * DOM-parser based — no regex-based stripping of HTML (avoids bypass tricks).
6
6
  */
7
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'];
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
- /** 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;
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 === 'src' &&
113
- ['IMG', 'VIDEO', 'AUDIO', 'SOURCE'].includes(el.tagName);
114
- if (!isSafeUrl(val, { media: isMediaSource, allowData: el.tagName === 'IMG' })) {
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
@@ -166,7 +166,7 @@ const AutumnNote = {
166
166
  buttons,
167
167
 
168
168
  /** Library version */
169
- version: '2.0.0',
169
+ version: '2.1.0',
170
170
  };
171
171
 
172
172
  // ---------------------------------------------------------------------------
@@ -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
- on(h, 'mousedown', (e) => {
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
- document.removeEventListener('mousemove', onMove);
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('mousemove', onMove);
305
- document.addEventListener('mouseup', onUp);
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
  }