autumnnote 1.14.0 → 1.16.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 +37 -10
- package/dist/autumnnote.cjs +61 -0
- package/dist/autumnnote.css +2 -2579
- package/dist/autumnnote.es.js +4934 -9981
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +61 -0
- package/dist/autumnnote.umd.js +39 -17545
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +24 -15
- package/src/js/Context.js +154 -15
- package/src/js/core/sanitise.js +47 -11
- package/src/js/editing/History.js +38 -6
- package/src/js/i18n/de.js +15 -0
- package/src/js/i18n/en.js +15 -0
- package/src/js/i18n/es.js +15 -0
- package/src/js/i18n/fr.js +15 -0
- package/src/js/i18n/ja.js +15 -0
- package/src/js/i18n/ko.js +15 -0
- package/src/js/i18n/vi.js +15 -0
- package/src/js/i18n/zh.js +15 -0
- package/src/js/index.js +13 -1
- package/src/js/module/Clipboard.js +25 -7
- package/src/js/module/Editor.js +25 -5
- package/src/js/module/ImageCropOverlay.js +5 -0
- package/src/js/module/ImageDialog.js +34 -6
- package/src/js/module/MarkdownShortcuts.js +2 -2
- package/src/js/module/SlashMenu.js +376 -0
- package/src/js/module/Statusbar.js +3 -9
- package/src/js/module/TableTooltip.js +2 -1
- package/src/js/module/VideoDialog.js +3 -13
- package/src/js/settings.js +30 -0
- package/src/styles/autumnnote.scss +49 -0
- package/types/core/dom.d.ts +32 -0
- package/types/core/env.d.ts +10 -0
- package/types/core/func.d.ts +11 -0
- package/types/core/key.d.ts +3 -0
- package/types/core/lists.d.ts +10 -0
- package/types/core/range.d.ts +22 -0
- package/types/core/sanitise.d.ts +9 -0
- package/types/index.d.ts +102 -8
- package/dist/_headers +0 -5
- package/dist/image/banner.png +0 -0
- package/dist/image/favicon.ico +0 -0
- package/dist/image/og-banner.png +0 -0
- package/dist/robots.txt +0 -4
- package/dist/sitemap.xml +0 -58
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.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
|
+
"main": "dist/autumnnote.cjs",
|
|
6
7
|
"module": "dist/autumnnote.es.js",
|
|
7
8
|
"types": "types/index.d.ts",
|
|
8
9
|
"style": "dist/autumnnote.css",
|
|
9
10
|
"exports": {
|
|
10
11
|
".": {
|
|
12
|
+
"types": "./types/index.d.ts",
|
|
11
13
|
"import": "./dist/autumnnote.es.js",
|
|
12
|
-
"require": "./dist/autumnnote.
|
|
13
|
-
"types": "./types/index.d.ts"
|
|
14
|
+
"require": "./dist/autumnnote.cjs"
|
|
14
15
|
},
|
|
15
16
|
"./dist/autumnnote.css": "./dist/autumnnote.css"
|
|
16
17
|
},
|
|
@@ -21,18 +22,23 @@
|
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
23
24
|
"dev": "vite",
|
|
24
|
-
"build": "vite build && vite build --config vite.umd.config.js",
|
|
25
|
+
"build": "vite build && vite build --config vite.umd.config.js && vite build --config vite.cdn.config.js && node scripts/create-cjs-alias.mjs",
|
|
25
26
|
"build:demo": "vite build --config vite.demo.config.js",
|
|
26
27
|
"preview": "vite preview",
|
|
27
28
|
"prepublishOnly": "npm run build",
|
|
28
29
|
"test": "vitest run",
|
|
30
|
+
"test:browser": "vitest run --config vitest.browser.config.js",
|
|
29
31
|
"test:watch": "vitest",
|
|
30
|
-
"lint": "eslint src",
|
|
31
|
-
"typecheck": "tsc --noEmit",
|
|
32
|
+
"lint": "eslint src test",
|
|
33
|
+
"typecheck": "tsc --noEmit && pnpm --filter autumnnote-react typecheck && pnpm --filter autumnnote-vue typecheck",
|
|
32
34
|
"build:cdn": "vite build --config vite.cdn.config.js",
|
|
33
35
|
"analyze": "cross-env ANALYZE=1 vite build",
|
|
34
36
|
"bench": "vitest bench",
|
|
35
|
-
"test:coverage": "vitest run --coverage"
|
|
37
|
+
"test:coverage": "vitest run --coverage",
|
|
38
|
+
"test:wrappers": "pnpm --filter autumnnote-react test && pnpm --filter autumnnote-vue test",
|
|
39
|
+
"build:wrappers": "pnpm --filter autumnnote-react build && pnpm --filter autumnnote-vue build",
|
|
40
|
+
"check:bundle": "node scripts/check-bundle-size.mjs",
|
|
41
|
+
"check": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && pnpm test:wrappers && pnpm check:bundle && pnpm build:demo && pnpm build:wrappers"
|
|
36
42
|
},
|
|
37
43
|
"keywords": [
|
|
38
44
|
"wysiwyg",
|
|
@@ -90,19 +96,22 @@
|
|
|
90
96
|
"author": "Minh Pham",
|
|
91
97
|
"license": "MIT",
|
|
92
98
|
"devDependencies": {
|
|
93
|
-
"@vitest/browser": "^4.1.
|
|
94
|
-
"@vitest/
|
|
99
|
+
"@vitest/browser": "^4.1.8",
|
|
100
|
+
"@vitest/browser-playwright": "^4.1.10",
|
|
101
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
95
102
|
"cross-env": "^10.1.0",
|
|
96
|
-
"eslint": "
|
|
103
|
+
"eslint": "10.7.0",
|
|
97
104
|
"jsdom": "^25.0.1",
|
|
105
|
+
"playwright": "^1.61.1",
|
|
98
106
|
"rollup-plugin-visualizer": "^7.0.1",
|
|
99
|
-
"sass": "^1.
|
|
107
|
+
"sass": "^1.101.0",
|
|
100
108
|
"typescript": "^6.0.2",
|
|
101
|
-
"vite": "^8.
|
|
102
|
-
"vitest": "^4.1.
|
|
109
|
+
"vite": "^8.1.5",
|
|
110
|
+
"vitest": "^4.1.8"
|
|
103
111
|
},
|
|
112
|
+
"packageManager": "pnpm@11.1.3",
|
|
104
113
|
"engines": {
|
|
105
|
-
"node": ">=
|
|
114
|
+
"node": ">=20.19.0"
|
|
106
115
|
},
|
|
107
116
|
"browserslist": [
|
|
108
117
|
"last 2 versions",
|
package/src/js/Context.js
CHANGED
|
@@ -39,6 +39,7 @@ import { AutoSaveRestore } from './module/AutoSaveRestore.js';
|
|
|
39
39
|
import { MarkdownShortcuts } from './module/MarkdownShortcuts.js';
|
|
40
40
|
import { BubbleToolbar } from './module/BubbleToolbar.js';
|
|
41
41
|
import { Mention } from './module/Mention.js';
|
|
42
|
+
import { SlashMenu } from './module/SlashMenu.js';
|
|
42
43
|
|
|
43
44
|
/** Module registry shared across all Context instances (populated via AutumnNote.registerModule). */
|
|
44
45
|
export const _customModules = new Map();
|
|
@@ -72,6 +73,9 @@ export class Context {
|
|
|
72
73
|
|
|
73
74
|
this._disposers = [];
|
|
74
75
|
this._alive = false;
|
|
76
|
+
this._autoSaveTimer = null;
|
|
77
|
+
this._pendingAutoSave = null;
|
|
78
|
+
this._suppressedRemoteHTML = null;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
// ---------------------------------------------------------------------------
|
|
@@ -153,10 +157,11 @@ export class Context {
|
|
|
153
157
|
register('shortcutsDialog', ShortcutsDialog);
|
|
154
158
|
register('findReplace', FindReplace);
|
|
155
159
|
register('imageCropOverlay', ImageCropOverlay);
|
|
156
|
-
register('autoSaveRestore', AutoSaveRestore);
|
|
157
|
-
register('markdownShortcuts', MarkdownShortcuts);
|
|
158
|
-
register('bubbleToolbar', BubbleToolbar);
|
|
159
|
-
register('mention', Mention);
|
|
160
|
+
if (this.options.autoSaveRestore) register('autoSaveRestore', AutoSaveRestore);
|
|
161
|
+
if (this.options.markdownShortcuts !== false) register('markdownShortcuts', MarkdownShortcuts);
|
|
162
|
+
if (this.options.bubbleToolbar) register('bubbleToolbar', BubbleToolbar);
|
|
163
|
+
if (this.options.mention) register('mention', Mention);
|
|
164
|
+
if (this.options.slashMenu !== false) register('slashMenu', SlashMenu);
|
|
160
165
|
|
|
161
166
|
// Custom modules registered via AutumnNote.registerModule()
|
|
162
167
|
if (_customModules.size > 0) {
|
|
@@ -180,6 +185,18 @@ export class Context {
|
|
|
180
185
|
return this;
|
|
181
186
|
}
|
|
182
187
|
|
|
188
|
+
registerSlashCommand(command) {
|
|
189
|
+
if (!command?.id || typeof command.run !== 'function') {
|
|
190
|
+
throw new TypeError('[AutumnNote] Slash command requires an id and run(context) function.');
|
|
191
|
+
}
|
|
192
|
+
const commands = this.options.slashCommands || (this.options.slashCommands = []);
|
|
193
|
+
const index = commands.findIndex((item) => item.id === command.id);
|
|
194
|
+
if (index >= 0) commands[index] = command;
|
|
195
|
+
else commands.push(command);
|
|
196
|
+
this.invoke('slashMenu.refresh');
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
|
|
183
200
|
/**
|
|
184
201
|
* Installs a plugin on this editor instance.
|
|
185
202
|
* If called after create(), buttons are registered immediately but the toolbar
|
|
@@ -246,22 +263,57 @@ export class Context {
|
|
|
246
263
|
}
|
|
247
264
|
});
|
|
248
265
|
// Sync textarea/input value on every change so form.submit() always gets fresh content
|
|
249
|
-
const d3 = this.on('change', () => this._syncToTarget());
|
|
250
|
-
this.
|
|
266
|
+
const d3 = this.on('change', (html) => this._syncToTarget(html));
|
|
267
|
+
const dRemote = this.on('change', (html) => {
|
|
268
|
+
if (this.options.blockIds) { this.ensureBlockIds(); html = this.getHTML(); }
|
|
269
|
+
if (html === this._suppressedRemoteHTML) { this._suppressedRemoteHTML = null; return; }
|
|
270
|
+
this.options.collaborationAdapter?.onLocalChange?.(html, this);
|
|
271
|
+
});
|
|
272
|
+
this._disposers.push(d0, d1, d2, d3, dRemote);
|
|
251
273
|
|
|
252
274
|
// Auto-save to localStorage on every change (also writes :asrmeta for restore banner)
|
|
253
275
|
if (this.options.autoSave && this.options.autoSaveKey) {
|
|
254
|
-
const d4 = this.on('change', () =>
|
|
255
|
-
try {
|
|
256
|
-
const key = this.options.autoSaveKey;
|
|
257
|
-
localStorage.setItem(key, this.getHTML());
|
|
258
|
-
localStorage.setItem(key + ':asrmeta', JSON.stringify({ savedAt: Date.now() }));
|
|
259
|
-
} catch (_) { void _; }
|
|
260
|
-
});
|
|
276
|
+
const d4 = this.on('change', (html) => this._scheduleAutoSave(html));
|
|
261
277
|
this._disposers.push(d4);
|
|
262
278
|
}
|
|
263
279
|
}
|
|
264
280
|
|
|
281
|
+
_scheduleAutoSave(html) {
|
|
282
|
+
this._pendingAutoSave = html;
|
|
283
|
+
clearTimeout(this._autoSaveTimer);
|
|
284
|
+
this._autoSaveTimer = setTimeout(() => this.flushAutoSave(), this.options.autoSaveDelay ?? 400);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async flushAutoSave() {
|
|
288
|
+
clearTimeout(this._autoSaveTimer);
|
|
289
|
+
this._autoSaveTimer = null;
|
|
290
|
+
if (this._pendingAutoSave == null) return;
|
|
291
|
+
const html = this._pendingAutoSave;
|
|
292
|
+
this._pendingAutoSave = null;
|
|
293
|
+
const key = this.options.autoSaveKey;
|
|
294
|
+
const savedAt = Date.now();
|
|
295
|
+
try {
|
|
296
|
+
const adapter = this.options.autoSaveAdapter;
|
|
297
|
+
if (typeof adapter?.save === 'function') {
|
|
298
|
+
await adapter.save({ key, html, savedAt, context: this });
|
|
299
|
+
} else {
|
|
300
|
+
localStorage.setItem(key, html);
|
|
301
|
+
localStorage.setItem(key + ':asrmeta', JSON.stringify({ savedAt }));
|
|
302
|
+
}
|
|
303
|
+
this.triggerEvent('autoSave', { key, html, savedAt });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
this.triggerEvent('autoSaveError', { key, error });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async loadAutoSave() {
|
|
310
|
+
const adapter = this.options.autoSaveAdapter;
|
|
311
|
+
if (typeof adapter?.load === 'function') {
|
|
312
|
+
return adapter.load({ key: this.options.autoSaveKey, context: this });
|
|
313
|
+
}
|
|
314
|
+
try { return localStorage.getItem(this.options.autoSaveKey); } catch (_) { return null; }
|
|
315
|
+
}
|
|
316
|
+
|
|
265
317
|
// ---------------------------------------------------------------------------
|
|
266
318
|
// Module invocation
|
|
267
319
|
// ---------------------------------------------------------------------------
|
|
@@ -337,6 +389,34 @@ export class Context {
|
|
|
337
389
|
// Public editor API
|
|
338
390
|
// ---------------------------------------------------------------------------
|
|
339
391
|
|
|
392
|
+
/** Updates runtime-safe options without recreating the editor. */
|
|
393
|
+
updateOptions(overrides = {}) {
|
|
394
|
+
const next = mergeDeep(this.options, overrides);
|
|
395
|
+
Object.keys(this.options).forEach((key) => delete this.options[key]);
|
|
396
|
+
Object.assign(this.options, next);
|
|
397
|
+
|
|
398
|
+
const { container, editable } = this.layoutInfo;
|
|
399
|
+
if (Object.hasOwn(overrides, 'readOnly')) this.setDisabled(Boolean(this.options.readOnly));
|
|
400
|
+
if (Object.hasOwn(overrides, 'spellcheck')) editable.spellcheck = this.options.spellcheck !== false;
|
|
401
|
+
if (Object.hasOwn(overrides, 'placeholder')) editable.dataset.placeholder = this.options.placeholder || '';
|
|
402
|
+
if (Object.hasOwn(overrides, 'direction')) {
|
|
403
|
+
const rtl = this.options.direction === 'rtl';
|
|
404
|
+
editable.setAttribute('dir', rtl ? 'rtl' : 'ltr');
|
|
405
|
+
container.classList.toggle('an-dir-rtl', rtl);
|
|
406
|
+
}
|
|
407
|
+
if (Object.hasOwn(overrides, 'height') || Object.hasOwn(overrides, 'minHeight')) {
|
|
408
|
+
const height = this.options.height || this.options.minHeight || 0;
|
|
409
|
+
editable.style.minHeight = height ? `${height}px` : '';
|
|
410
|
+
}
|
|
411
|
+
if (Object.hasOwn(overrides, 'maxHeight')) {
|
|
412
|
+
editable.style.maxHeight = this.options.maxHeight ? `${this.options.maxHeight}px` : '';
|
|
413
|
+
}
|
|
414
|
+
if (Object.hasOwn(overrides, 'toolbar')) this.invoke('toolbar.rebuild');
|
|
415
|
+
this.invoke('statusbar.update');
|
|
416
|
+
this.triggerEvent('optionsChange', { ...overrides });
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
|
|
340
420
|
/**
|
|
341
421
|
* Returns the current HTML content of the editor.
|
|
342
422
|
* Zero-width spaces (U+200B) inserted by inline editing helpers are stripped
|
|
@@ -444,6 +524,64 @@ export class Context {
|
|
|
444
524
|
return this.invoke('editor.getMarkdown');
|
|
445
525
|
}
|
|
446
526
|
|
|
527
|
+
getSelectionBookmark() {
|
|
528
|
+
return this.invoke('editor.getSelectionBookmark') ?? null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
restoreSelectionBookmark(bookmark) {
|
|
532
|
+
return this.invoke('editor.restoreSelectionBookmark', bookmark);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async importDocument(format, data) {
|
|
536
|
+
const adapter = this.options.documentAdapters?.[format];
|
|
537
|
+
let html;
|
|
538
|
+
if (typeof adapter?.import === 'function') html = await adapter.import(data, this);
|
|
539
|
+
else if (format === 'html') html = String(data ?? '');
|
|
540
|
+
else if (format === 'markdown') { this.setMarkdown(String(data ?? '')); return this; }
|
|
541
|
+
else if (format === 'text') { this.setText(String(data ?? '')); return this; }
|
|
542
|
+
else throw new Error(`[AutumnNote] No importer registered for "${format}".`);
|
|
543
|
+
this.setHTML(html);
|
|
544
|
+
return this;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async exportDocument(format) {
|
|
548
|
+
const adapter = this.options.documentAdapters?.[format];
|
|
549
|
+
if (typeof adapter?.export === 'function') return adapter.export(this, this.getHTML());
|
|
550
|
+
if (format === 'html') return this.getHTML();
|
|
551
|
+
if (format === 'markdown') return this.getMarkdown();
|
|
552
|
+
if (format === 'text') return this.getText();
|
|
553
|
+
throw new Error(`[AutumnNote] No exporter registered for "${format}".`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
ensureBlockIds() {
|
|
557
|
+
const blocks = this.layoutInfo.editable.children;
|
|
558
|
+
for (const block of blocks) {
|
|
559
|
+
if (!block.hasAttribute('data-an-block-id')) {
|
|
560
|
+
const id = globalThis.crypto?.randomUUID?.() || `an-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
561
|
+
block.setAttribute('data-an-block-id', id);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return this;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
getDocument() {
|
|
568
|
+
if (this.options.blockIds) this.ensureBlockIds();
|
|
569
|
+
return { version: 1, html: this.getHTML(), markdown: this.getMarkdown() };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
loadDocument(documentData) {
|
|
573
|
+
this.setHTML(documentData?.html || '');
|
|
574
|
+
this.clearHistory();
|
|
575
|
+
return this;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
applyRemoteHTML(html) {
|
|
579
|
+
this.setHTML(html);
|
|
580
|
+
this._suppressedRemoteHTML = this.getHTML();
|
|
581
|
+
this.clearHistory();
|
|
582
|
+
return this;
|
|
583
|
+
}
|
|
584
|
+
|
|
447
585
|
/**
|
|
448
586
|
* Returns the current word count of the editor content.
|
|
449
587
|
* @returns {number}
|
|
@@ -601,6 +739,7 @@ export class Context {
|
|
|
601
739
|
destroy() {
|
|
602
740
|
if (!this._alive) return;
|
|
603
741
|
|
|
742
|
+
if (this._pendingAutoSave != null) void this.flushAutoSave();
|
|
604
743
|
this._modules.forEach((module) => {
|
|
605
744
|
if (typeof module.destroy === 'function') module.destroy();
|
|
606
745
|
});
|
|
@@ -647,9 +786,9 @@ export class Context {
|
|
|
647
786
|
/**
|
|
648
787
|
* Syncs editor HTML back into the original textarea/input for form submission.
|
|
649
788
|
*/
|
|
650
|
-
_syncToTarget() {
|
|
789
|
+
_syncToTarget(html) {
|
|
651
790
|
if (this.targetEl.tagName === 'TEXTAREA' || this.targetEl.tagName === 'INPUT') {
|
|
652
|
-
/** @type {HTMLInputElement} */ (this.targetEl).value = this.getHTML();
|
|
791
|
+
/** @type {HTMLInputElement} */ (this.targetEl).value = typeof html === 'string' ? html : this.getHTML();
|
|
653
792
|
}
|
|
654
793
|
}
|
|
655
794
|
}
|
package/src/js/core/sanitise.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/** Tags that are unconditionally removed from editor content. */
|
|
9
|
-
const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'base', 'template', 'link', 'meta'];
|
|
9
|
+
const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'base', 'template', 'link', 'meta', 'noscript', 'portal', 'frame', 'frameset', 'applet'];
|
|
10
10
|
|
|
11
11
|
/** Tags whose element wrapper is stripped but content (child nodes) is preserved. */
|
|
12
12
|
const UNWRAP_TAGS = new Set(['button']);
|
|
@@ -35,6 +35,11 @@ const TRUSTED_IFRAME_HOSTS = new Set([
|
|
|
35
35
|
'player.vimeo.com',
|
|
36
36
|
]);
|
|
37
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
|
+
|
|
38
43
|
/**
|
|
39
44
|
* Produce a sanitized HTML string with dangerous elements and attributes removed.
|
|
40
45
|
*
|
|
@@ -74,6 +79,15 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
|
74
79
|
continue;
|
|
75
80
|
}
|
|
76
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
|
+
|
|
77
91
|
// Strip dangerous attributes
|
|
78
92
|
for (const attr of Array.from(el.attributes)) {
|
|
79
93
|
// Remove all event handlers (onclick, onload, onerror, …)
|
|
@@ -95,14 +109,12 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
|
95
109
|
// Sanitise URL attributes
|
|
96
110
|
if (URL_ATTRS.includes(attr.name)) {
|
|
97
111
|
const val = attr.value.trim();
|
|
98
|
-
|
|
112
|
+
const isMediaSource = attr.name === 'src' &&
|
|
113
|
+
['IMG', 'VIDEO', 'AUDIO', 'SOURCE'].includes(el.tagName);
|
|
114
|
+
if (!isSafeUrl(val, { media: isMediaSource, allowData: el.tagName === 'IMG' })) {
|
|
99
115
|
el.removeAttribute(attr.name);
|
|
100
116
|
continue;
|
|
101
117
|
}
|
|
102
|
-
// Allow data: URIs only on img[src] (base64 image uploads); block elsewhere
|
|
103
|
-
if (/^data:/i.test(val) && !(attr.name === 'src' && el.tagName === 'IMG')) {
|
|
104
|
-
el.removeAttribute(attr.name);
|
|
105
|
-
}
|
|
106
118
|
}
|
|
107
119
|
// Strip iframe HTML-injection vectors; limit src to trusted hosts
|
|
108
120
|
if (el.tagName === 'IFRAME') {
|
|
@@ -116,6 +128,10 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
|
116
128
|
}
|
|
117
129
|
}
|
|
118
130
|
|
|
131
|
+
if (tag === 'a' && el.getAttribute('target') === '_blank') {
|
|
132
|
+
el.setAttribute('rel', 'noopener noreferrer');
|
|
133
|
+
}
|
|
134
|
+
|
|
119
135
|
// Allow only input[type="checkbox"] inside ul.an-checklist li
|
|
120
136
|
if (tag === 'input') {
|
|
121
137
|
const inChecklist = el.closest('ul.an-checklist') !== null &&
|
|
@@ -184,12 +200,32 @@ function isTrustedIframeSrc(src) {
|
|
|
184
200
|
* Optionally blocked: data: (safe to allow for img/src base64 embeds)
|
|
185
201
|
*
|
|
186
202
|
* @param {string} url
|
|
187
|
-
* @param {{ allowData?: boolean }} [opts]
|
|
203
|
+
* @param {{ allowData?: boolean, media?: boolean }} [opts]
|
|
188
204
|
* @returns {string|null} The original URL if safe, null if rejected.
|
|
189
205
|
*/
|
|
190
|
-
export function sanitiseUrl(url, { allowData = false } = {}) {
|
|
206
|
+
export function sanitiseUrl(url, { allowData = false, media = allowData } = {}) {
|
|
191
207
|
const trimmed = (url || '').trim();
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|
|
195
231
|
}
|
|
@@ -7,16 +7,37 @@ export class History {
|
|
|
7
7
|
/**
|
|
8
8
|
* @param {HTMLElement} editable - the contenteditable element
|
|
9
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.
|
|
10
15
|
*/
|
|
11
|
-
constructor(editable, limit = 100) {
|
|
16
|
+
constructor(editable, limit = 100, maxBytes = 10 * 1024 * 1024) {
|
|
12
17
|
this.editable = editable;
|
|
13
18
|
this._limit = limit;
|
|
19
|
+
this._maxBytes = maxBytes;
|
|
20
|
+
this._bytes = 0;
|
|
14
21
|
/** @type {Array<{html: string, images?: Record<string,string>, sel: {start: number, end: number}|null}>} */
|
|
15
22
|
this.stack = [];
|
|
16
23
|
this.stackOffset = -1;
|
|
17
24
|
this._savePoint();
|
|
18
25
|
}
|
|
19
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
|
+
|
|
20
41
|
// ---------------------------------------------------------------------------
|
|
21
42
|
// Private helpers
|
|
22
43
|
// ---------------------------------------------------------------------------
|
|
@@ -115,16 +136,26 @@ export class History {
|
|
|
115
136
|
_savePoint() {
|
|
116
137
|
// Trim future history if we're mid-stack
|
|
117
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
|
+
}
|
|
118
142
|
this.stack = this.stack.slice(0, this.stackOffset + 1);
|
|
119
143
|
}
|
|
120
144
|
const raw = this._serialize();
|
|
121
145
|
const { html, images } = this._tokenizeImages(raw);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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());
|
|
127
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;
|
|
128
159
|
}
|
|
129
160
|
|
|
130
161
|
_restore(point) {
|
|
@@ -209,6 +240,7 @@ export class History {
|
|
|
209
240
|
reset() {
|
|
210
241
|
this.stack = [];
|
|
211
242
|
this.stackOffset = -1;
|
|
243
|
+
this._bytes = 0;
|
|
212
244
|
this._savePoint();
|
|
213
245
|
}
|
|
214
246
|
|
package/src/js/i18n/de.js
CHANGED
|
@@ -342,4 +342,19 @@ export const de = {
|
|
|
342
342
|
restore: 'Wiederherstellen',
|
|
343
343
|
discard: 'Verwerfen',
|
|
344
344
|
},
|
|
345
|
+
|
|
346
|
+
slashMenu: {
|
|
347
|
+
noResults: 'Keine passenden Befehle',
|
|
348
|
+
heading1: 'Überschrift 1',
|
|
349
|
+
heading2: 'Überschrift 2',
|
|
350
|
+
heading3: 'Überschrift 3',
|
|
351
|
+
bulletList: 'Aufzählungsliste',
|
|
352
|
+
numberedList: 'Nummerierte Liste',
|
|
353
|
+
checklist: 'Checkliste',
|
|
354
|
+
blockquote: 'Zitat',
|
|
355
|
+
codeBlock: 'Codeblock',
|
|
356
|
+
horizontalRule: 'Horizontale Linie',
|
|
357
|
+
table: 'Tabelle',
|
|
358
|
+
image: 'Bild',
|
|
359
|
+
},
|
|
345
360
|
};
|
package/src/js/i18n/en.js
CHANGED
|
@@ -350,4 +350,19 @@ export const en = {
|
|
|
350
350
|
imageSize: (maxSize) =>
|
|
351
351
|
`Image file is too large. Maximum allowed size is ${maxSize} MB.`,
|
|
352
352
|
},
|
|
353
|
+
|
|
354
|
+
slashMenu: {
|
|
355
|
+
noResults: 'No matching commands',
|
|
356
|
+
heading1: 'Heading 1',
|
|
357
|
+
heading2: 'Heading 2',
|
|
358
|
+
heading3: 'Heading 3',
|
|
359
|
+
bulletList: 'Bullet List',
|
|
360
|
+
numberedList: 'Numbered List',
|
|
361
|
+
checklist: 'Checklist',
|
|
362
|
+
blockquote: 'Blockquote',
|
|
363
|
+
codeBlock: 'Code Block',
|
|
364
|
+
horizontalRule: 'Horizontal Rule',
|
|
365
|
+
table: 'Table',
|
|
366
|
+
image: 'Image',
|
|
367
|
+
},
|
|
353
368
|
};
|
package/src/js/i18n/es.js
CHANGED
|
@@ -342,4 +342,19 @@ export const es = {
|
|
|
342
342
|
restore: 'Restaurar',
|
|
343
343
|
discard: 'Descartar',
|
|
344
344
|
},
|
|
345
|
+
|
|
346
|
+
slashMenu: {
|
|
347
|
+
noResults: 'No hay comandos coincidentes',
|
|
348
|
+
heading1: 'Encabezado 1',
|
|
349
|
+
heading2: 'Encabezado 2',
|
|
350
|
+
heading3: 'Encabezado 3',
|
|
351
|
+
bulletList: 'Lista con viñetas',
|
|
352
|
+
numberedList: 'Lista numerada',
|
|
353
|
+
checklist: 'Lista de verificación',
|
|
354
|
+
blockquote: 'Cita',
|
|
355
|
+
codeBlock: 'Bloque de código',
|
|
356
|
+
horizontalRule: 'Línea horizontal',
|
|
357
|
+
table: 'Tabla',
|
|
358
|
+
image: 'Imagen',
|
|
359
|
+
},
|
|
345
360
|
};
|
package/src/js/i18n/fr.js
CHANGED
|
@@ -343,4 +343,19 @@ export const fr = {
|
|
|
343
343
|
restore: 'Restaurer',
|
|
344
344
|
discard: 'Ignorer',
|
|
345
345
|
},
|
|
346
|
+
|
|
347
|
+
slashMenu: {
|
|
348
|
+
noResults: 'Aucune commande correspondante',
|
|
349
|
+
heading1: 'Titre 1',
|
|
350
|
+
heading2: 'Titre 2',
|
|
351
|
+
heading3: 'Titre 3',
|
|
352
|
+
bulletList: 'Liste à puces',
|
|
353
|
+
numberedList: 'Liste numérotée',
|
|
354
|
+
checklist: 'Liste de contrôle',
|
|
355
|
+
blockquote: 'Citation',
|
|
356
|
+
codeBlock: 'Bloc de code',
|
|
357
|
+
horizontalRule: 'Ligne horizontale',
|
|
358
|
+
table: 'Tableau',
|
|
359
|
+
image: 'Image',
|
|
360
|
+
},
|
|
346
361
|
};
|
package/src/js/i18n/ja.js
CHANGED
|
@@ -343,4 +343,19 @@ export const ja = {
|
|
|
343
343
|
restore: '復元',
|
|
344
344
|
discard: '破棄',
|
|
345
345
|
},
|
|
346
|
+
|
|
347
|
+
slashMenu: {
|
|
348
|
+
noResults: '一致するコマンドがありません',
|
|
349
|
+
heading1: '見出し1',
|
|
350
|
+
heading2: '見出し2',
|
|
351
|
+
heading3: '見出し3',
|
|
352
|
+
bulletList: '箇条書きリスト',
|
|
353
|
+
numberedList: '番号付きリスト',
|
|
354
|
+
checklist: 'チェックリスト',
|
|
355
|
+
blockquote: '引用',
|
|
356
|
+
codeBlock: 'コードブロック',
|
|
357
|
+
horizontalRule: '水平線',
|
|
358
|
+
table: '表',
|
|
359
|
+
image: '画像',
|
|
360
|
+
},
|
|
346
361
|
};
|
package/src/js/i18n/ko.js
CHANGED
|
@@ -342,4 +342,19 @@ export const ko = {
|
|
|
342
342
|
restore: '복원',
|
|
343
343
|
discard: '삭제',
|
|
344
344
|
},
|
|
345
|
+
|
|
346
|
+
slashMenu: {
|
|
347
|
+
noResults: '일치하는 명령이 없습니다',
|
|
348
|
+
heading1: '제목 1',
|
|
349
|
+
heading2: '제목 2',
|
|
350
|
+
heading3: '제목 3',
|
|
351
|
+
bulletList: '글머리 기호 목록',
|
|
352
|
+
numberedList: '번호 매기기 목록',
|
|
353
|
+
checklist: '체크리스트',
|
|
354
|
+
blockquote: '인용문',
|
|
355
|
+
codeBlock: '코드 블록',
|
|
356
|
+
horizontalRule: '가로선',
|
|
357
|
+
table: '표',
|
|
358
|
+
image: '이미지',
|
|
359
|
+
},
|
|
345
360
|
};
|
package/src/js/i18n/vi.js
CHANGED
|
@@ -343,4 +343,19 @@ export const vi = {
|
|
|
343
343
|
restore: 'Khôi phục',
|
|
344
344
|
discard: 'Bỏ qua',
|
|
345
345
|
},
|
|
346
|
+
|
|
347
|
+
slashMenu: {
|
|
348
|
+
noResults: 'Không có lệnh phù hợp',
|
|
349
|
+
heading1: 'Tiêu đề 1',
|
|
350
|
+
heading2: 'Tiêu đề 2',
|
|
351
|
+
heading3: 'Tiêu đề 3',
|
|
352
|
+
bulletList: 'Danh sách không thứ tự',
|
|
353
|
+
numberedList: 'Danh sách có thứ tự',
|
|
354
|
+
checklist: 'Danh sách công việc',
|
|
355
|
+
blockquote: 'Trích dẫn',
|
|
356
|
+
codeBlock: 'Khối mã',
|
|
357
|
+
horizontalRule: 'Đường kẻ ngang',
|
|
358
|
+
table: 'Bảng',
|
|
359
|
+
image: 'Hình ảnh',
|
|
360
|
+
},
|
|
346
361
|
};
|
package/src/js/i18n/zh.js
CHANGED
|
@@ -343,4 +343,19 @@ export const zh = {
|
|
|
343
343
|
restore: '恢复',
|
|
344
344
|
discard: '放弃',
|
|
345
345
|
},
|
|
346
|
+
|
|
347
|
+
slashMenu: {
|
|
348
|
+
noResults: '没有匹配的命令',
|
|
349
|
+
heading1: '标题 1',
|
|
350
|
+
heading2: '标题 2',
|
|
351
|
+
heading3: '标题 3',
|
|
352
|
+
bulletList: '项目符号列表',
|
|
353
|
+
numberedList: '编号列表',
|
|
354
|
+
checklist: '检查清单',
|
|
355
|
+
blockquote: '引用',
|
|
356
|
+
codeBlock: '代码块',
|
|
357
|
+
horizontalRule: '水平线',
|
|
358
|
+
table: '表格',
|
|
359
|
+
image: '图片',
|
|
360
|
+
},
|
|
346
361
|
};
|