autumnnote 1.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.15.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
5
  "type": "module",
6
6
  "main": "dist/autumnnote.cjs",
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "dev": "vite",
25
- "build": "vite build && vite build --config vite.umd.config.js && node scripts/create-cjs-alias.mjs",
25
+ "build": "vite build && vite build --config vite.umd.config.js && vite build --config vite.cdn.config.js && node scripts/create-cjs-alias.mjs",
26
26
  "build:demo": "vite build --config vite.demo.config.js",
27
27
  "preview": "vite preview",
28
28
  "prepublishOnly": "npm run build",
@@ -100,13 +100,13 @@
100
100
  "@vitest/browser-playwright": "^4.1.10",
101
101
  "@vitest/coverage-v8": "^4.1.8",
102
102
  "cross-env": "^10.1.0",
103
- "eslint": "10.3.0",
103
+ "eslint": "10.7.0",
104
104
  "jsdom": "^25.0.1",
105
105
  "playwright": "^1.61.1",
106
106
  "rollup-plugin-visualizer": "^7.0.1",
107
- "sass": "^1.99.0",
107
+ "sass": "^1.101.0",
108
108
  "typescript": "^6.0.2",
109
- "vite": "^8.0.16",
109
+ "vite": "^8.1.5",
110
110
  "vitest": "^4.1.8"
111
111
  },
112
112
  "packageManager": "pnpm@11.1.3",
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
  }
@@ -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
  };
package/src/js/index.js CHANGED
@@ -140,11 +140,23 @@ const AutumnNote = {
140
140
  */
141
141
  registerButton(btnDef) { registerButton(btnDef); return this; },
142
142
 
143
+ /** Registers a slash-menu command for future editor instances. */
144
+ registerSlashCommand(command) {
145
+ if (!command?.id || typeof command.run !== 'function') {
146
+ throw new TypeError('[AutumnNote] Slash command requires an id and run(context) function.');
147
+ }
148
+ const commands = defaultOptions.slashCommands;
149
+ const index = commands.findIndex((item) => item.id === command.id);
150
+ if (index >= 0) commands[index] = command;
151
+ else commands.push(command);
152
+ return this;
153
+ },
154
+
143
155
  /** All pre-built button definitions — accessible in every module format including UMD/CJS. */
144
156
  buttons,
145
157
 
146
158
  /** Library version */
147
- version: '1.15.0',
159
+ version: '1.16.0',
148
160
  };
149
161
 
150
162
  // ---------------------------------------------------------------------------
@@ -416,12 +416,7 @@ export class Clipboard {
416
416
  }
417
417
 
418
418
  const alt = file.name.replace(/\.[^.]+$/, '');
419
- this._compressImage(file).then((dataUrl) => {
420
- // Keep the large data URL in a JS Map; insert a lightweight blob: URL
421
- // into the DOM so editable.innerHTML never contains the big base64 string.
422
- const blob = this._dataUrlToBlob(dataUrl);
423
- const blobUrl = URL.createObjectURL(blob);
424
- this._blobRegistry.set(blobUrl, dataUrl);
419
+ this.compressAndRegister(file).then((blobUrl) => {
425
420
  this.context.invoke('editor.insertImage', blobUrl, alt);
426
421
  }).catch((err) => {
427
422
  const message = `Image "${file.name}" could not be processed.`;
@@ -431,6 +426,25 @@ export class Clipboard {
431
426
  });
432
427
  }
433
428
 
429
+ /**
430
+ * Compresses an image File via canvas and registers the result behind a
431
+ * lightweight blob: URL (see `resolveImages`), so callers never have to hold
432
+ * the full base64 string in the DOM. Shared by paste/drop and ImageDialog's
433
+ * file picker so every image-insertion path gets the same compression.
434
+ * @param {File} file
435
+ * @returns {Promise<string>} blob: URL usable as an <img src>
436
+ */
437
+ async compressAndRegister(file) {
438
+ const processor = this.options.imageProcessor;
439
+ const dataUrl = typeof processor === 'function'
440
+ ? await processor(file, { context: this.context })
441
+ : await this._compressImage(file);
442
+ const blob = this._dataUrlToBlob(dataUrl);
443
+ const blobUrl = URL.createObjectURL(blob);
444
+ this._blobRegistry.set(blobUrl, dataUrl);
445
+ return blobUrl;
446
+ }
447
+
434
448
  /**
435
449
  * Replaces any blob: URLs created by this module with their original data URLs.
436
450
  * Called by Editor.getHTML() so the returned HTML is fully self-contained.
@@ -34,7 +34,11 @@ export class Editor {
34
34
 
35
35
  initialize() {
36
36
  const editable = this.context.layoutInfo.editable;
37
- this._history = new History(editable, this.options.historyLimit || 100);
37
+ this._history = new History(
38
+ editable,
39
+ this.options.historyLimit || 100,
40
+ this.options.historyMaxBytes || 10 * 1024 * 1024,
41
+ );
38
42
  this._bindEvents(editable);
39
43
  return this;
40
44
  }
@@ -539,6 +543,16 @@ export class Editor {
539
543
  return this._history ? this._history.getRedoCount() : 0;
540
544
  }
541
545
 
546
+ getSelectionBookmark() {
547
+ return this._history?._serializeSelection() ?? null;
548
+ }
549
+
550
+ restoreSelectionBookmark(bookmark) {
551
+ if (!bookmark || !this._history) return false;
552
+ this._history._restoreSelection(bookmark);
553
+ return true;
554
+ }
555
+
542
556
  // ---------------------------------------------------------------------------
543
557
  // Style commands (delegated to Style module)
544
558
  // ---------------------------------------------------------------------------
@@ -13,7 +13,11 @@ export class ImageDialog extends BaseDialog {
13
13
  // Public API
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- show() {
16
+ /**
17
+ * @param {{ beforeInsert?: () => void }} [hooks]
18
+ */
19
+ show({ beforeInsert } = {}) {
20
+ this._beforeInsert = typeof beforeInsert === 'function' ? beforeInsert : null;
17
21
  this._saveRange();
18
22
  this._urlInput.value = '';
19
23
  this._altInput.value = '';
@@ -137,11 +141,23 @@ export class ImageDialog extends BaseDialog {
137
141
  return;
138
142
  }
139
143
 
140
- const reader = new FileReader();
141
- reader.onload = (e) => {
142
- this._urlInput.value = /** @type {string} */ (e.target.result);
143
- };
144
- reader.readAsDataURL(file);
144
+ // Resize/compress via Clipboard's shared canvas pipeline so a file chosen
145
+ // through this dialog gets the same treatment as a pasted/dropped image
146
+ // instead of embedding the original (potentially multi-MB) file untouched.
147
+ this.context.invoke('clipboard.compressAndRegister', file).then((blobUrl) => {
148
+ if (this.context._alive === false) return;
149
+ this._urlInput.value = blobUrl;
150
+ }).catch((err) => {
151
+ if (this.context._alive === false) return;
152
+ // Compression failed (e.g. canvas unavailable) — fall back to embedding
153
+ // the original file directly.
154
+ console.warn('[AutumnNote] ImageDialog: image compression failed, using original file.', err);
155
+ const reader = new FileReader();
156
+ reader.onload = (e) => {
157
+ this._urlInput.value = /** @type {string} */ (e.target.result);
158
+ };
159
+ reader.readAsDataURL(file);
160
+ });
145
161
  }
146
162
 
147
163
  _onInsert() {
@@ -159,7 +175,19 @@ export class ImageDialog extends BaseDialog {
159
175
  this._savedRange.select();
160
176
  }
161
177
 
178
+ // Let callers make a deferred DOM change only after the user commits the
179
+ // dialog. SlashMenu uses this to keep "/image" intact when the dialog is
180
+ // cancelled and remove it atomically immediately before image insertion.
181
+ const beforeInsert = this._beforeInsert;
182
+ this._beforeInsert = null;
183
+ beforeInsert?.();
184
+
162
185
  this.context.invoke('editor.insertImage', src, alt, align);
163
186
  this._close();
164
187
  }
188
+
189
+ _close() {
190
+ this._beforeInsert = null;
191
+ super._close();
192
+ }
165
193
  }