autumnnote 2.0.0 → 2.2.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 (64) hide show
  1. package/README.md +72 -5
  2. package/dist/autumnnote.cjs +20 -20
  3. package/dist/autumnnote.css +1 -1
  4. package/dist/autumnnote.es.js +661 -798
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +20 -20
  7. package/dist/autumnnote.umd.js +20 -20
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/dist/icon-data-V0Xqv-wX.js +255 -0
  10. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  11. package/package.json +13 -4
  12. package/types/index.d.ts +8 -0
  13. package/src/js/Context.js +0 -854
  14. package/src/js/core/detectLang.js +0 -98
  15. package/src/js/core/dom.js +0 -372
  16. package/src/js/core/env.js +0 -25
  17. package/src/js/core/key.js +0 -66
  18. package/src/js/core/lists.js +0 -121
  19. package/src/js/core/markdown.js +0 -695
  20. package/src/js/core/range.js +0 -194
  21. package/src/js/core/sanitise.js +0 -231
  22. package/src/js/editing/History.js +0 -266
  23. package/src/js/editing/Style.js +0 -812
  24. package/src/js/editing/Table.js +0 -105
  25. package/src/js/editing/Typing.js +0 -397
  26. package/src/js/index.js +0 -193
  27. package/src/js/index.umd.js +0 -17
  28. package/src/js/module/AutoSaveRestore.js +0 -125
  29. package/src/js/module/BaseDialog.js +0 -133
  30. package/src/js/module/BaseMediaTooltip.js +0 -142
  31. package/src/js/module/BaseResizer.js +0 -312
  32. package/src/js/module/BubbleToolbar.js +0 -483
  33. package/src/js/module/Buttons.js +0 -399
  34. package/src/js/module/Clipboard.js +0 -579
  35. package/src/js/module/CodeTooltip.js +0 -493
  36. package/src/js/module/Codeview.js +0 -125
  37. package/src/js/module/ContextMenu.js +0 -621
  38. package/src/js/module/Editor.js +0 -747
  39. package/src/js/module/EmojiDialog.js +0 -254
  40. package/src/js/module/FindReplace.js +0 -512
  41. package/src/js/module/Fullscreen.js +0 -80
  42. package/src/js/module/IconDialog.js +0 -618
  43. package/src/js/module/ImageCropOverlay.js +0 -586
  44. package/src/js/module/ImageDialog.js +0 -193
  45. package/src/js/module/ImageResizer.js +0 -42
  46. package/src/js/module/ImageTooltip.js +0 -285
  47. package/src/js/module/LinkDialog.js +0 -145
  48. package/src/js/module/LinkTooltip.js +0 -250
  49. package/src/js/module/MarkdownShortcuts.js +0 -250
  50. package/src/js/module/Mention.js +0 -365
  51. package/src/js/module/Placeholder.js +0 -51
  52. package/src/js/module/ShortcutsDialog.js +0 -111
  53. package/src/js/module/SlashMenu.js +0 -376
  54. package/src/js/module/Statusbar.js +0 -246
  55. package/src/js/module/TableTooltip.js +0 -1521
  56. package/src/js/module/Toolbar.js +0 -750
  57. package/src/js/module/VideoDialog.js +0 -193
  58. package/src/js/module/VideoResizer.js +0 -66
  59. package/src/js/module/VideoTooltip.js +0 -248
  60. package/src/js/module/emoji-data.js +0 -496
  61. package/src/js/renderer.js +0 -120
  62. package/src/js/settings.js +0 -214
  63. package/src/styles/_variables.scss +0 -48
  64. package/src/styles/autumnnote.scss +0 -2866
package/src/js/Context.js DELETED
@@ -1,854 +0,0 @@
1
- /**
2
- * Context.js - Central hub for the editor instance
3
- * Holds references to all sub-modules and manages inter-module communication.
4
- * Inspired by Summernote's Context.js
5
- */
6
-
7
- import { mergeDeep } from './core/func.js';
8
- import { registerButton } from './module/Buttons.js';
9
- import { defaultOptions } from './settings.js';
10
- import { resolveLocale } from './i18n/index.js';
11
- import { renderLayout } from './renderer.js';
12
- import { on } from './core/dom.js';
13
-
14
- // Modules
15
- import { Editor } from './module/Editor.js';
16
- import { Toolbar } from './module/Toolbar.js';
17
- import { Statusbar } from './module/Statusbar.js';
18
- import { Clipboard } from './module/Clipboard.js';
19
- import { Placeholder } from './module/Placeholder.js';
20
- import { Codeview } from './module/Codeview.js';
21
- import { Fullscreen } from './module/Fullscreen.js';
22
- import { LinkDialog } from './module/LinkDialog.js';
23
- import { ImageDialog } from './module/ImageDialog.js';
24
- import { VideoDialog } from './module/VideoDialog.js';
25
- import { ImageResizer } from './module/ImageResizer.js';
26
- import { VideoResizer } from './module/VideoResizer.js';
27
- import { LinkTooltip } from './module/LinkTooltip.js';
28
- import { ImageTooltip } from './module/ImageTooltip.js';
29
- import { VideoTooltip } from './module/VideoTooltip.js';
30
- import { TableTooltip } from './module/TableTooltip.js';
31
- import { CodeTooltip } from './module/CodeTooltip.js';
32
- import { EmojiDialog } from './module/EmojiDialog.js';
33
- import { IconDialog } from './module/IconDialog.js';
34
- import { ContextMenu } from './module/ContextMenu.js';
35
- import { ShortcutsDialog } from './module/ShortcutsDialog.js';
36
- import { FindReplace } from './module/FindReplace.js';
37
- import { ImageCropOverlay } from './module/ImageCropOverlay.js';
38
- import { AutoSaveRestore } from './module/AutoSaveRestore.js';
39
- import { MarkdownShortcuts } from './module/MarkdownShortcuts.js';
40
- import { BubbleToolbar } from './module/BubbleToolbar.js';
41
- import { Mention } from './module/Mention.js';
42
- import { SlashMenu } from './module/SlashMenu.js';
43
-
44
- /** Module registry shared across all Context instances (populated via AutumnNote.registerModule). */
45
- export const _customModules = new Map();
46
-
47
- /**
48
- * Modules whose registration depends on an option value. Declared once so the
49
- * initial mount (`_registerModules`) and runtime toggles (`updateOptions`) can
50
- * never drift apart — previously `updateOptions({ bubbleToolbar: true })`
51
- * changed the option but silently left the module unregistered.
52
- * @type {Array<{ name: string, Class: new (ctx: Context) => { initialize: () => any, destroy?: () => void }, enabled: (options: any) => boolean }>}
53
- */
54
- const OPTIONAL_MODULES = [
55
- { name: 'autoSaveRestore', Class: AutoSaveRestore, enabled: (o) => !!o.autoSaveRestore },
56
- { name: 'markdownShortcuts', Class: MarkdownShortcuts, enabled: (o) => o.markdownShortcuts !== false },
57
- { name: 'bubbleToolbar', Class: BubbleToolbar, enabled: (o) => !!o.bubbleToolbar },
58
- { name: 'mention', Class: Mention, enabled: (o) => !!o.mention },
59
- { name: 'slashMenu', Class: SlashMenu, enabled: (o) => o.slashMenu !== false },
60
- ];
61
-
62
- /** Global plugin registry (populated via AutumnNote.use()). Applied to every new Context. */
63
- export const _globalPlugins = new Map();
64
-
65
- export class Context {
66
- /**
67
- * @param {HTMLElement} targetEl - The element to replace with the editor
68
- * @param {import('./settings.js').AsnOptions} [userOptions]
69
- */
70
- constructor(targetEl, userOptions = {}) {
71
- this.targetEl = targetEl;
72
- this.options = mergeDeep(defaultOptions, userOptions);
73
-
74
- /** @type {import('./i18n/index.js').AsnLocale} */
75
- this.locale = resolveLocale(this.options.lang);
76
-
77
- /** @type {{ container: HTMLElement, editable: HTMLElement, toolbar?: HTMLElement, statusbar?: HTMLElement }} */
78
- this.layoutInfo = /** @type {any} */ ({});
79
-
80
- /** @type {Map<string, Function[]>} */
81
- this._listeners = new Map();
82
-
83
- /** @type {Map<string, object>} */
84
- this._modules = new Map();
85
-
86
- /** @type {Map<string, { plugin: object, publicApi: * }>} */
87
- this._plugins = new Map();
88
-
89
- this._disposers = [];
90
- this._alive = false;
91
- this._autoSaveTimer = null;
92
- this._pendingAutoSave = null;
93
- this._suppressedRemoteHTML = null;
94
- /** @type {Promise<void>|null} Settles when destroy()'s closing auto-save finishes */
95
- this._destroyPromise = null;
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // Initialisation
100
- // ---------------------------------------------------------------------------
101
-
102
- initialize() {
103
- // 1. Render the DOM skeleton
104
- const { container, editable } = renderLayout(this.targetEl, this.options);
105
- this.layoutInfo.container = container;
106
- this.layoutInfo.editable = editable;
107
-
108
- // 2. Register core modules
109
- this._registerModules();
110
-
111
- // 3. Attach toolbar/statusbar to container
112
- const toolbar = this._modules.get('toolbar');
113
- if (toolbar?.el) {
114
- container.insertBefore(toolbar.el, editable);
115
- this.layoutInfo.toolbar = toolbar.el;
116
- }
117
-
118
- const statusbar = this._modules.get('statusbar');
119
- if (statusbar?.el) {
120
- container.appendChild(statusbar.el);
121
- this.layoutInfo.statusbar = statusbar.el;
122
- }
123
-
124
- // 4. Bind editor-level events
125
- this._bindEditorEvents(editable);
126
-
127
- // 5. Auto-focus if requested
128
- if (this.options.focus) {
129
- editable.focus();
130
- }
131
-
132
- this._alive = true;
133
-
134
- // Initial toolbar sync so dropdowns show the correct font on load
135
- this.invoke('toolbar.refresh');
136
-
137
- // Apply plugins registered globally via AutumnNote.use()
138
- this._applyGlobalPlugins();
139
-
140
- if (typeof this.options.onInit === 'function') {
141
- this.options.onInit(this);
142
- }
143
-
144
- return this;
145
- }
146
-
147
- _registerModules() {
148
- const register = (name, ModuleClass) => {
149
- const instance = new ModuleClass(this);
150
- this._modules.set(name, instance);
151
- instance.initialize();
152
- };
153
-
154
- register('editor', Editor);
155
- register('toolbar', Toolbar);
156
- register('statusbar', Statusbar);
157
- register('clipboard', Clipboard);
158
- register('contextMenu', ContextMenu);
159
- register('placeholder', Placeholder);
160
- register('codeview', Codeview);
161
- register('fullscreen', Fullscreen);
162
- register('linkDialog', LinkDialog);
163
- register('imageDialog', ImageDialog);
164
- register('videoDialog', VideoDialog);
165
- register('imageResizer', ImageResizer);
166
- register('videoResizer', VideoResizer);
167
- register('linkTooltip', LinkTooltip);
168
- register('imageTooltip', ImageTooltip);
169
- register('videoTooltip', VideoTooltip);
170
- register('tableTooltip', TableTooltip);
171
- register('codeTooltip', CodeTooltip);
172
- register('emojiDialog', EmojiDialog);
173
- register('iconDialog', IconDialog);
174
- register('shortcutsDialog', ShortcutsDialog);
175
- register('findReplace', FindReplace);
176
- register('imageCropOverlay', ImageCropOverlay);
177
- for (const { name, Class, enabled } of OPTIONAL_MODULES) {
178
- if (enabled(this.options)) register(name, Class);
179
- }
180
-
181
- // Custom modules registered via AutumnNote.registerModule()
182
- if (_customModules.size > 0) {
183
- for (const [name, ModuleClass] of _customModules) {
184
- register(name, ModuleClass);
185
- }
186
- }
187
- }
188
-
189
- /**
190
- * Registers or tears down option-gated modules so they match the current
191
- * option values. Called after `updateOptions()` so toggling e.g.
192
- * `bubbleToolbar` at runtime actually takes effect.
193
- */
194
- _syncOptionalModules() {
195
- for (const { name, Class, enabled } of OPTIONAL_MODULES) {
196
- const shouldRun = enabled(this.options);
197
- const isRunning = this._modules.has(name);
198
- if (shouldRun === isRunning) continue;
199
-
200
- if (shouldRun) {
201
- const instance = new Class(this);
202
- this._modules.set(name, instance);
203
- instance.initialize();
204
- } else {
205
- const instance = this._modules.get(name);
206
- if (typeof instance?.destroy === 'function') instance.destroy();
207
- this._modules.delete(name);
208
- }
209
- }
210
- }
211
-
212
- /**
213
- * Registers and initialises a custom module on this instance.
214
- * @param {string} name
215
- * @param {new (ctx: this) => any} ModuleClass
216
- * @returns {this}
217
- */
218
- registerModule(name, ModuleClass) {
219
- if (this._modules.has(name)) return this;
220
- const instance = new ModuleClass(this);
221
- instance.initialize();
222
- this._modules.set(name, instance);
223
- return this;
224
- }
225
-
226
- registerSlashCommand(command) {
227
- if (!command?.id || typeof command.run !== 'function') {
228
- throw new TypeError('[AutumnNote] Slash command requires an id and run(context) function.');
229
- }
230
- const commands = this.options.slashCommands || (this.options.slashCommands = []);
231
- const index = commands.findIndex((item) => item.id === command.id);
232
- if (index >= 0) commands[index] = command;
233
- else commands.push(command);
234
- this.invoke('slashMenu.refresh');
235
- return this;
236
- }
237
-
238
- /**
239
- * Installs a plugin on this editor instance.
240
- * If called after create(), buttons are registered immediately but the toolbar
241
- * must be rebuilt via ctx.invoke('toolbar.rebuild') to render new buttons.
242
- * @param {object} plugin - { name, version?, buttons?, install?, uninstall? }
243
- * @param {object} [options] - Forwarded to plugin.install(context, options)
244
- * @returns {this}
245
- */
246
- use(plugin, options = {}) {
247
- if (Array.isArray(plugin.buttons)) {
248
- plugin.buttons.forEach((b) => registerButton(b));
249
- }
250
- this._installPlugin(plugin, options);
251
- return this;
252
- }
253
-
254
- /**
255
- * Returns the public API returned by plugin.install(), or null.
256
- * @param {string} name
257
- * @returns {*}
258
- */
259
- getPlugin(name) {
260
- return this._plugins.get(name)?.publicApi ?? null;
261
- }
262
-
263
- _installPlugin(plugin, pluginOptions = {}) {
264
- const { name } = plugin;
265
- if (!name || typeof name !== 'string') {
266
- console.warn('[AutumnNote] Plugin must have a string `name` property.');
267
- return;
268
- }
269
- if (this._plugins.has(name)) {
270
- console.warn(`[AutumnNote] Plugin "${name}" already installed on this instance. Skipping.`);
271
- return;
272
- }
273
- const publicApi = (typeof plugin.install === 'function')
274
- ? plugin.install(this, pluginOptions) ?? null
275
- : null;
276
- this._plugins.set(name, { plugin, publicApi });
277
- }
278
-
279
- _applyGlobalPlugins() {
280
- if (_globalPlugins.size === 0) return;
281
- for (const { plugin, options } of _globalPlugins.values()) {
282
- this._installPlugin(plugin, options);
283
- }
284
- }
285
-
286
- _bindEditorEvents(editable) {
287
- // Keep the original textarea/input value in sync immediately on every input.
288
- // This guarantees form.submit() sees fresh data even before debounced change.
289
- const d0 = on(editable, 'input', () => this._syncToTarget());
290
- const d1 = on(editable, 'focus', () => {
291
- this.layoutInfo.container.classList.add('an-focused');
292
- if (typeof this.options.onFocus === 'function') {
293
- this.options.onFocus(this);
294
- }
295
- });
296
- const d2 = on(editable, 'blur', () => {
297
- this.layoutInfo.container.classList.remove('an-focused');
298
- this._syncToTarget();
299
- if (typeof this.options.onBlur === 'function') {
300
- this.options.onBlur(this);
301
- }
302
- });
303
- // Sync textarea/input value on every change so form.submit() always gets fresh content
304
- const d3 = this.on('change', (html) => this._syncToTarget(html));
305
- const dRemote = this.on('change', (html) => {
306
- if (this.options.blockIds) { this.ensureBlockIds(); html = this.getHTML(); }
307
- if (html === this._suppressedRemoteHTML) { this._suppressedRemoteHTML = null; return; }
308
- this.options.collaborationAdapter?.onLocalChange?.(html, this);
309
- });
310
- this._disposers.push(d0, d1, d2, d3, dRemote);
311
-
312
- // Auto-save to localStorage on every change (also writes :asrmeta for restore banner)
313
- if (this.options.autoSave && this.options.autoSaveKey) {
314
- const d4 = this.on('change', (html) => this._scheduleAutoSave(html));
315
- this._disposers.push(d4);
316
- }
317
- }
318
-
319
- _scheduleAutoSave(html) {
320
- this._pendingAutoSave = html;
321
- clearTimeout(this._autoSaveTimer);
322
- this._autoSaveTimer = setTimeout(() => this.flushAutoSave(), this.options.autoSaveDelay ?? 400);
323
- }
324
-
325
- async flushAutoSave() {
326
- clearTimeout(this._autoSaveTimer);
327
- this._autoSaveTimer = null;
328
- if (this._pendingAutoSave == null) return;
329
- const html = this._pendingAutoSave;
330
- this._pendingAutoSave = null;
331
- const key = this.options.autoSaveKey;
332
- const savedAt = Date.now();
333
- try {
334
- const adapter = this.options.autoSaveAdapter;
335
- if (typeof adapter?.save === 'function') {
336
- await adapter.save({ key, html, savedAt, context: this });
337
- } else {
338
- localStorage.setItem(key, html);
339
- localStorage.setItem(key + ':asrmeta', JSON.stringify({ savedAt }));
340
- }
341
- this.triggerEvent('autoSave', { key, html, savedAt });
342
- } catch (error) {
343
- this.triggerEvent('autoSaveError', { key, error });
344
- }
345
- }
346
-
347
- async loadAutoSave() {
348
- const adapter = this.options.autoSaveAdapter;
349
- if (typeof adapter?.load === 'function') {
350
- return adapter.load({ key: this.options.autoSaveKey, context: this });
351
- }
352
- try { return localStorage.getItem(this.options.autoSaveKey); } catch (_) { return null; }
353
- }
354
-
355
- // ---------------------------------------------------------------------------
356
- // Module invocation
357
- // ---------------------------------------------------------------------------
358
-
359
- /**
360
- * Invokes a method on a registered module.
361
- * Format: 'moduleName.methodName'
362
- * @param {string} path - e.g. 'editor.bold'
363
- * @param {...*} args
364
- * @returns {*}
365
- */
366
- invoke(path, ...args) {
367
- const [moduleName, methodName] = path.split('.');
368
- const module = this._modules.get(moduleName);
369
- if (!module) {
370
- console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
371
- return undefined;
372
- }
373
- if (typeof module[methodName] !== 'function') {
374
- console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
375
- return undefined;
376
- }
377
- return module[methodName](...args);
378
- }
379
-
380
- // ---------------------------------------------------------------------------
381
- // Event system
382
- // ---------------------------------------------------------------------------
383
-
384
- /**
385
- * Subscribes to an editor event.
386
- * @param {string} eventName
387
- * @param {Function} handler
388
- * @returns {() => void} unsubscribe
389
- */
390
- on(eventName, handler) {
391
- if (!this._listeners.has(eventName)) {
392
- this._listeners.set(eventName, []);
393
- }
394
- this._listeners.get(eventName).push(handler);
395
- return () => this.off(eventName, handler);
396
- }
397
-
398
- /**
399
- * Unsubscribes from an editor event.
400
- * @param {string} eventName
401
- * @param {Function} handler
402
- */
403
- off(eventName, handler) {
404
- const handlers = this._listeners.get(eventName);
405
- if (!handlers) return;
406
- const idx = handlers.indexOf(handler);
407
- if (idx !== -1) handlers.splice(idx, 1);
408
- }
409
-
410
- /**
411
- * Triggers an editor event.
412
- * @param {string} eventName
413
- * @param {...*} args
414
- */
415
- triggerEvent(eventName, ...args) {
416
- const handlers = this._listeners.get(eventName) || [];
417
- handlers.forEach((h) => h(...args));
418
-
419
- // Also call options callback if present (e.g. onChange)
420
- const cbName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
421
- if (typeof this.options[cbName] === 'function') {
422
- this.options[cbName](...args);
423
- }
424
- }
425
-
426
- // ---------------------------------------------------------------------------
427
- // Public editor API
428
- // ---------------------------------------------------------------------------
429
-
430
- /** Updates runtime-safe options without recreating the editor. */
431
- updateOptions(overrides = {}) {
432
- const next = mergeDeep(this.options, overrides);
433
- Object.keys(this.options).forEach((key) => delete this.options[key]);
434
- Object.assign(this.options, next);
435
-
436
- const { container, editable } = this.layoutInfo;
437
- if (Object.hasOwn(overrides, 'readOnly')) this.setDisabled(Boolean(this.options.readOnly));
438
- if (Object.hasOwn(overrides, 'spellcheck')) editable.spellcheck = this.options.spellcheck !== false;
439
- if (Object.hasOwn(overrides, 'placeholder')) editable.dataset.placeholder = this.options.placeholder || '';
440
- if (Object.hasOwn(overrides, 'direction')) {
441
- const rtl = this.options.direction === 'rtl';
442
- editable.setAttribute('dir', rtl ? 'rtl' : 'ltr');
443
- container.classList.toggle('an-dir-rtl', rtl);
444
- }
445
- if (Object.hasOwn(overrides, 'height') || Object.hasOwn(overrides, 'minHeight')) {
446
- const height = this.options.height || this.options.minHeight || 0;
447
- editable.style.minHeight = height ? `${height}px` : '';
448
- }
449
- if (Object.hasOwn(overrides, 'maxHeight')) {
450
- editable.style.maxHeight = this.options.maxHeight ? `${this.options.maxHeight}px` : '';
451
- }
452
- if (Object.hasOwn(overrides, 'toolbar')) this.invoke('toolbar.rebuild');
453
- // Start/stop option-gated modules (bubbleToolbar, mention, slashMenu, ...)
454
- // so toggling them here behaves the same as passing them to create().
455
- this._syncOptionalModules();
456
- this.invoke('statusbar.update');
457
- this.triggerEvent('optionsChange', { ...overrides });
458
- return this;
459
- }
460
-
461
- /**
462
- * Returns the current HTML content of the editor.
463
- * Zero-width spaces (U+200B) inserted by inline editing helpers are stripped
464
- * from the output so they don't leak into the consumer's HTML.
465
- * @returns {string}
466
- */
467
- getHTML() {
468
- const html = this.invoke('editor.getHTML');
469
- return typeof html === 'string' ? html.replace(/​/g, '') : html;
470
- }
471
-
472
- /**
473
- * Sets the HTML content of the editor.
474
- * @param {string} html
475
- */
476
- setHTML(html) {
477
- this.invoke('editor.setHTML', html);
478
- }
479
-
480
- /**
481
- * Returns the plain text content of the editor.
482
- * @returns {string}
483
- */
484
- getText() {
485
- return this.invoke('editor.getText');
486
- }
487
-
488
- /**
489
- * Sets the editor content as plain text (HTML-escaped).
490
- * @param {string} text
491
- */
492
- setText(text) {
493
- this.invoke('editor.setText', text);
494
- }
495
-
496
- /**
497
- * Clears the editor content.
498
- */
499
- clear() {
500
- this.invoke('editor.clear');
501
- }
502
-
503
- /**
504
- * Resets the undo/redo history stack.
505
- * Useful after programmatically loading a new document via setHTML() / setMarkdown()
506
- * so that Ctrl+Z cannot undo back to the previous document.
507
- */
508
- clearHistory() {
509
- this.invoke('editor.clearHistory');
510
- }
511
-
512
- /**
513
- * Returns the number of available undo steps.
514
- * @returns {number}
515
- */
516
- getUndoCount() {
517
- return this.invoke('editor.getUndoCount') ?? 0;
518
- }
519
-
520
- /**
521
- * Returns the number of available redo steps.
522
- * @returns {number}
523
- */
524
- getRedoCount() {
525
- return this.invoke('editor.getRedoCount') ?? 0;
526
- }
527
-
528
- /**
529
- * Returns true when the editor has no meaningful content.
530
- * @returns {boolean}
531
- */
532
- isEmpty() {
533
- return this.invoke('editor.isEmpty');
534
- }
535
-
536
- /**
537
- * Inserts HTML at the current cursor position.
538
- * @param {string} html
539
- */
540
- insertHTML(html) {
541
- this.invoke('editor.insertHTML', html);
542
- }
543
-
544
- /**
545
- * Inserts plain text at the current cursor position.
546
- * @param {string} text
547
- */
548
- insertText(text) {
549
- this.invoke('editor.insertText', text);
550
- }
551
-
552
- /**
553
- * Sets editor content from a Markdown string.
554
- * @param {string} md
555
- */
556
- setMarkdown(md) {
557
- this.invoke('editor.setMarkdown', md);
558
- }
559
-
560
- /**
561
- * Returns the editor content as Markdown.
562
- * @returns {string}
563
- */
564
- getMarkdown() {
565
- return this.invoke('editor.getMarkdown');
566
- }
567
-
568
- getSelectionBookmark() {
569
- return this.invoke('editor.getSelectionBookmark') ?? null;
570
- }
571
-
572
- restoreSelectionBookmark(bookmark) {
573
- return this.invoke('editor.restoreSelectionBookmark', bookmark);
574
- }
575
-
576
- async importDocument(format, data) {
577
- const adapter = this.options.documentAdapters?.[format];
578
- let html;
579
- if (typeof adapter?.import === 'function') html = await adapter.import(data, this);
580
- else if (format === 'html') html = String(data ?? '');
581
- else if (format === 'markdown') { this.setMarkdown(String(data ?? '')); return this; }
582
- else if (format === 'text') { this.setText(String(data ?? '')); return this; }
583
- else throw new Error(`[AutumnNote] No importer registered for "${format}".`);
584
- this.setHTML(html);
585
- return this;
586
- }
587
-
588
- async exportDocument(format) {
589
- const adapter = this.options.documentAdapters?.[format];
590
- if (typeof adapter?.export === 'function') return adapter.export(this, this.getHTML());
591
- if (format === 'html') return this.getHTML();
592
- if (format === 'markdown') return this.getMarkdown();
593
- if (format === 'text') return this.getText();
594
- throw new Error(`[AutumnNote] No exporter registered for "${format}".`);
595
- }
596
-
597
- ensureBlockIds() {
598
- const blocks = this.layoutInfo.editable.children;
599
- for (const block of blocks) {
600
- if (!block.hasAttribute('data-an-block-id')) {
601
- const id = globalThis.crypto?.randomUUID?.() || `an-${Date.now()}-${Math.random().toString(36).slice(2)}`;
602
- block.setAttribute('data-an-block-id', id);
603
- }
604
- }
605
- return this;
606
- }
607
-
608
- getDocument() {
609
- if (this.options.blockIds) this.ensureBlockIds();
610
- return { version: 1, html: this.getHTML(), markdown: this.getMarkdown() };
611
- }
612
-
613
- loadDocument(documentData) {
614
- this.setHTML(documentData?.html || '');
615
- this.clearHistory();
616
- return this;
617
- }
618
-
619
- applyRemoteHTML(html) {
620
- this.setHTML(html);
621
- this._suppressedRemoteHTML = this.getHTML();
622
- this.clearHistory();
623
- return this;
624
- }
625
-
626
- /**
627
- * Returns the current word count of the editor content.
628
- * @returns {number}
629
- */
630
- getWordCount() {
631
- return this.invoke('statusbar.getWordCount') ?? 0;
632
- }
633
-
634
- /**
635
- * Returns the current character count of the editor content.
636
- * @returns {number}
637
- */
638
- getCharCount() {
639
- return this.invoke('statusbar.getCharCount') ?? 0;
640
- }
641
-
642
- /**
643
- * Downloads the editor content as an HTML file.
644
- * @param {string} [filename='document.html']
645
- */
646
- downloadHTML(filename = 'document.html') {
647
- this._download(this.getHTML(), filename, 'text/html');
648
- }
649
-
650
- /**
651
- * Downloads the editor content as a plain-text file.
652
- * @param {string} [filename='document.txt']
653
- */
654
- downloadText(filename = 'document.txt') {
655
- this._download(this.getText(), filename, 'text/plain');
656
- }
657
-
658
- /**
659
- * Downloads the editor content as a Markdown file.
660
- * @param {string} [filename='document.md']
661
- */
662
- downloadMarkdown(filename = 'document.md') {
663
- this._download(this.getMarkdown(), filename, 'text/markdown');
664
- }
665
-
666
- /**
667
- * Creates a temporary Blob URL and triggers a browser file download.
668
- * @param {string} content
669
- * @param {string} filename
670
- * @param {string} mimeType
671
- */
672
- _download(content, filename, mimeType) {
673
- const blob = new Blob([content], { type: mimeType });
674
- const url = URL.createObjectURL(blob);
675
- const a = document.createElement('a');
676
- a.href = url;
677
- a.download = filename;
678
- a.style.display = 'none';
679
- document.body.appendChild(a);
680
- a.click();
681
- a.remove();
682
- URL.revokeObjectURL(url);
683
- }
684
-
685
- /**
686
- * Opens the editor content in a new globalThis and triggers the browser print dialog.
687
- * @param {string} [title='']
688
- */
689
- print(title = '') {
690
- const content = this.getHTML();
691
- const safeTitle = (title || '').replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
692
- const markup = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">' +
693
- `<title>${safeTitle}</title>` +
694
- '<style>' +
695
- 'body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;font-size:14px;line-height:1.6;padding:20mm;color:#111827;}' +
696
- 'ul.an-checklist{list-style:none;padding-left:0;}' +
697
- 'ul.an-checklist li{padding-left:24px;position:relative;margin:2px 0;}' +
698
- 'ul.an-checklist li input[type="checkbox"]{position:absolute;left:0;top:3px;}' +
699
- 'code{background:#f3f4f6;border-radius:3px;padding:.1em .35em;font-family:monospace;}' +
700
- 'pre{background:#f3f4f6;padding:.75em 1em;border-radius:4px;overflow-x:auto;}' +
701
- 'table{border-collapse:collapse;}td,th{border:1px solid #d1d5db;padding:4px 8px;}' +
702
- '</style>' +
703
- `</head><body>${content}</body></html>`;
704
- const blob = new Blob([markup], { type: 'text/html' });
705
- const url = URL.createObjectURL(blob);
706
- const w = globalThis.open(url, '_blank');
707
- if (!w) { URL.revokeObjectURL(url); return; } // popup blocked by browser
708
- w.addEventListener('load', () => {
709
- w.print();
710
- URL.revokeObjectURL(url);
711
- });
712
- }
713
-
714
- /**
715
- * Returns an array of heading objects representing the table of contents.
716
- * Each entry has: level (1-6), text (heading text), element (DOM element).
717
- * @returns {{ level: number, text: string, element: HTMLElement }[]}
718
- */
719
- getTableOfContents() {
720
- const headings = Array.from(
721
- this.layoutInfo.editable.querySelectorAll('h1,h2,h3,h4,h5,h6')
722
- );
723
- return headings.map((el) => ({
724
- level: parseInt(el.tagName[1], 10),
725
- text: el.textContent?.trim() ?? '',
726
- element: /** @type {HTMLElement} */ (el),
727
- }));
728
- }
729
-
730
- /**
731
- * Moves focus into the editable area.
732
- */
733
- focus() {
734
- this.layoutInfo.editable.focus();
735
- }
736
-
737
- /**
738
- * Removes focus from the editable area.
739
- */
740
- blur() {
741
- this.layoutInfo.editable.blur();
742
- }
743
-
744
- /**
745
- * Returns true when the editor is currently in fullscreen mode.
746
- * @returns {boolean}
747
- */
748
- isFullscreen() {
749
- return this.invoke('fullscreen.isActive') === true;
750
- }
751
-
752
- /**
753
- * Sets whether the editor is disabled (readonly).
754
- * @param {boolean} disabled
755
- */
756
- setDisabled(disabled) {
757
- const editable = this.layoutInfo.editable;
758
- if (disabled) {
759
- editable.setAttribute('contenteditable', 'false');
760
- this.layoutInfo.container.classList.add('an-disabled');
761
- editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
762
- cb.setAttribute('disabled', '');
763
- });
764
- } else {
765
- editable.setAttribute('contenteditable', 'true');
766
- this.layoutInfo.container.classList.remove('an-disabled');
767
- editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
768
- cb.removeAttribute('disabled');
769
- });
770
- }
771
- }
772
-
773
- // ---------------------------------------------------------------------------
774
- // Destroy
775
- // ---------------------------------------------------------------------------
776
-
777
- /**
778
- * Completely removes the editor and restores the original element.
779
- *
780
- * Teardown itself is synchronous; the returned promise only settles once the
781
- * closing auto-save has finished, so `await editor.destroy()` is worth doing
782
- * when using an async `autoSaveAdapter`. Ignoring the return value is safe.
783
- * @returns {Promise<void>}
784
- */
785
- destroy() {
786
- if (!this._alive) return this._destroyPromise ?? Promise.resolve();
787
-
788
- // Start the final auto-save before tearing anything down. `_listeners` is
789
- // deliberately kept alive until this settles, otherwise the closing
790
- // autoSave/autoSaveError event fires into an already-cleared listener map
791
- // and an async adapter's last write completes silently.
792
- const pendingFlush = this._pendingAutoSave != null
793
- ? this.flushAutoSave().catch(() => {})
794
- : null;
795
-
796
- this._modules.forEach((module) => {
797
- if (typeof module.destroy === 'function') module.destroy();
798
- });
799
- this._modules.clear();
800
-
801
- for (const { plugin } of this._plugins.values()) {
802
- if (typeof plugin.uninstall === 'function') {
803
- try { plugin.uninstall(this); } catch (_) { void _; }
804
- }
805
- }
806
- this._plugins.clear();
807
-
808
- this._disposers.forEach((d) => d());
809
- this._disposers = [];
810
-
811
- const container = this.layoutInfo.container;
812
- const wasDark = container?.classList.contains('an-theme-dark');
813
- const wasAuto = container?.classList.contains('an-theme-auto');
814
- if (container?.parentNode) {
815
- // Restore original element
816
- this.targetEl.style.display = '';
817
- container.remove();
818
- }
819
- // Clean up body theme classes if no other editors of that type remain
820
- if (wasDark && !document.querySelector('.an-container.an-theme-dark')) {
821
- document.body.classList.remove('an-theme-dark');
822
- }
823
- if (wasAuto && !document.querySelector('.an-container.an-theme-auto')) {
824
- document.body.classList.remove('an-theme-auto');
825
- }
826
-
827
- if (typeof this.options.onDestroy === 'function') {
828
- this.options.onDestroy(this);
829
- }
830
-
831
- this._alive = false;
832
-
833
- // Resolves once the closing auto-save (if any) has finished. Callers using
834
- // an async autoSaveAdapter can `await editor.destroy()` to be sure the last
835
- // write landed before unloading.
836
- this._destroyPromise = Promise.resolve(pendingFlush).then(() => {
837
- this._listeners.clear();
838
- });
839
- return this._destroyPromise;
840
- }
841
-
842
- // ---------------------------------------------------------------------------
843
- // Helpers
844
- // ---------------------------------------------------------------------------
845
-
846
- /**
847
- * Syncs editor HTML back into the original textarea/input for form submission.
848
- */
849
- _syncToTarget(html) {
850
- if (this.targetEl.tagName === 'TEXTAREA' || this.targetEl.tagName === 'INPUT') {
851
- /** @type {HTMLInputElement} */ (this.targetEl).value = typeof html === 'string' ? html : this.getHTML();
852
- }
853
- }
854
- }