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.
Files changed (46) hide show
  1. package/README.md +37 -10
  2. package/dist/autumnnote.cjs +61 -0
  3. package/dist/autumnnote.css +2 -2579
  4. package/dist/autumnnote.es.js +4934 -9981
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +61 -0
  7. package/dist/autumnnote.umd.js +39 -17545
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/package.json +24 -15
  10. package/src/js/Context.js +154 -15
  11. package/src/js/core/sanitise.js +47 -11
  12. package/src/js/editing/History.js +38 -6
  13. package/src/js/i18n/de.js +15 -0
  14. package/src/js/i18n/en.js +15 -0
  15. package/src/js/i18n/es.js +15 -0
  16. package/src/js/i18n/fr.js +15 -0
  17. package/src/js/i18n/ja.js +15 -0
  18. package/src/js/i18n/ko.js +15 -0
  19. package/src/js/i18n/vi.js +15 -0
  20. package/src/js/i18n/zh.js +15 -0
  21. package/src/js/index.js +13 -1
  22. package/src/js/module/Clipboard.js +25 -7
  23. package/src/js/module/Editor.js +25 -5
  24. package/src/js/module/ImageCropOverlay.js +5 -0
  25. package/src/js/module/ImageDialog.js +34 -6
  26. package/src/js/module/MarkdownShortcuts.js +2 -2
  27. package/src/js/module/SlashMenu.js +376 -0
  28. package/src/js/module/Statusbar.js +3 -9
  29. package/src/js/module/TableTooltip.js +2 -1
  30. package/src/js/module/VideoDialog.js +3 -13
  31. package/src/js/settings.js +30 -0
  32. package/src/styles/autumnnote.scss +49 -0
  33. package/types/core/dom.d.ts +32 -0
  34. package/types/core/env.d.ts +10 -0
  35. package/types/core/func.d.ts +11 -0
  36. package/types/core/key.d.ts +3 -0
  37. package/types/core/lists.d.ts +10 -0
  38. package/types/core/range.d.ts +22 -0
  39. package/types/core/sanitise.d.ts +9 -0
  40. package/types/index.d.ts +102 -8
  41. package/dist/_headers +0 -5
  42. package/dist/image/banner.png +0 -0
  43. package/dist/image/favicon.ico +0 -0
  44. package/dist/image/og-banner.png +0 -0
  45. package/dist/robots.txt +0 -4
  46. package/dist/sitemap.xml +0 -58
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.14.0",
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
- "main": "dist/autumnnote.umd.js",
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.umd.js",
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.2",
94
- "@vitest/coverage-v8": "^4.1.2",
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": "^10.2.0",
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.99.0",
107
+ "sass": "^1.101.0",
100
108
  "typescript": "^6.0.2",
101
- "vite": "^8.0.3",
102
- "vitest": "^4.1.2"
109
+ "vite": "^8.1.5",
110
+ "vitest": "^4.1.8"
103
111
  },
112
+ "packageManager": "pnpm@11.1.3",
104
113
  "engines": {
105
- "node": ">=18.0.0"
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._disposers.push(d0, d1, d2, d3);
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
  }
@@ -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
- if (/^(javascript|vbscript):/i.test(val)) {
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 (/^(javascript|vbscript):/i.test(trimmed)) return null;
193
- if (!allowData && /^data:/i.test(trimmed)) return null;
194
- return url;
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
- this.stack.push({ html, images, sel: this._serializeSelection() });
123
- if (this.stack.length > this._limit) {
124
- this.stack.shift();
125
- } else {
126
- this.stackOffset++;
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
  };