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
@@ -1,747 +0,0 @@
1
- /**
2
- * Editor.js - Core editing command module
3
- * Wraps all execCommand calls, undo/redo, and fires events via the context.
4
- * Inspired by Summernote's Editor module.
5
- */
6
-
7
- import { History } from '../editing/History.js';
8
- import * as Style from '../editing/Style.js';
9
- import { insertTable } from '../editing/Table.js';
10
- import { isModifier } from '../core/key.js';
11
- import { handleKeydown } from '../editing/Typing.js';
12
- import { on } from '../core/dom.js';
13
- import { sanitiseHTML, sanitiseUrl } from '../core/sanitise.js';
14
- import { markdownToHTML, htmlToMarkdown } from '../core/markdown.js';
15
- import { detectLang } from '../core/detectLang.js';
16
-
17
- export class Editor {
18
- /**
19
- * @param {import('../Context.js').Context} context
20
- */
21
- constructor(context) {
22
- this.context = context;
23
- this.options = context.options;
24
- /** @type {History|null} */
25
- this._history = null;
26
- this._disposers = [];
27
- /** @type {number|null} Timer handle for debounced undo snapshot */
28
- this._snapshotTimer = null;
29
- }
30
-
31
- // ---------------------------------------------------------------------------
32
- // Lifecycle
33
- // ---------------------------------------------------------------------------
34
-
35
- initialize() {
36
- const editable = this.context.layoutInfo.editable;
37
- this._history = new History(
38
- editable,
39
- this.options.historyLimit || 100,
40
- this.options.historyMaxBytes || 10 * 1024 * 1024,
41
- );
42
- this._bindEvents(editable);
43
- return this;
44
- }
45
-
46
- destroy() {
47
- this._disposers.forEach((d) => d());
48
- this._disposers = [];
49
- this._history = null;
50
- clearTimeout(this._snapshotTimer);
51
- this._snapshotTimer = null;
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // Event binding
56
- // ---------------------------------------------------------------------------
57
-
58
- _bindEvents(editable) {
59
- // Keyboard shortcuts
60
- const onKeydown = (event) => this._onKeydown(event);
61
- // Catch ALL content mutations: typing, IME, spellcheck, voice, drag-drop text.
62
- const onInput = () => this.afterCommand();
63
- // Hard-enforce maxChars / maxWords before content is mutated
64
- const onBeforeInput = (event) => this._enforceLimit(event);
65
- // Refresh toolbar on selection change, scoped to this editor
66
- const onSelChange = () => {
67
- if (!this.context._alive) return;
68
- const sel = globalThis.getSelection();
69
- if (sel?.rangeCount > 0 && editable.contains(sel.anchorNode)) {
70
- this.context.invoke('toolbar.refresh');
71
- if (typeof this.options.onSelectionChange === 'function') {
72
- this.options.onSelectionChange(this.context);
73
- }
74
- }
75
- };
76
-
77
- // Checklist checkboxes are contenteditable=false so native clicks work;
78
- // hook afterCommand so the checked state is preserved in undo history.
79
- const onCheckboxClick = (e) => {
80
- if (e.target.type === 'checkbox' && e.target.closest('.an-checklist')) {
81
- this.afterCommand();
82
- }
83
- };
84
-
85
- // Guard: when cursor lands at the <li> element node of a checklist item
86
- // (before the checkbox), nudge it to the correct text position.
87
- //
88
- // mouseup: use caretRangeFromPoint / caretPositionFromPoint so the cursor
89
- // lands WHERE the user actually clicked (middle, end of text…).
90
- // keyup : arrow-key navigation may land at <li>[0]; move to start-of-text.
91
- const fixChecklistCursor = (event) => {
92
- const sel = globalThis.getSelection();
93
- if (!sel?.rangeCount) return;
94
- const r = sel.getRangeAt(0);
95
- if (!r.collapsed) return;
96
- const sc = r.startContainer;
97
-
98
- // Only act when cursor is at the <li> element node itself (not inside
99
- // a text node — the browser already placed it correctly in that case).
100
- if (sc.nodeType !== Node.ELEMENT_NODE) return;
101
- const scEl = /** @type {Element} */ (sc);
102
- const li = scEl.matches('.an-checklist li') ? scEl : null;
103
- if (!li) return;
104
- const cb = li.querySelector('input[type="checkbox"]');
105
- if (!cb) return;
106
-
107
- // For mouse events: ask the browser where the pointer landed so the
108
- // cursor respects the actual click position inside the text.
109
- if (event?.type === 'mouseup') {
110
- let caret = null;
111
- if (document.caretRangeFromPoint) {
112
- caret = document.caretRangeFromPoint(event.clientX, event.clientY);
113
- } else if (document.caretPositionFromPoint) {
114
- const cp = document.caretPositionFromPoint(event.clientX, event.clientY);
115
- if (cp) {
116
- caret = document.createRange();
117
- caret.setStart(cp.offsetNode, cp.offset);
118
- }
119
- }
120
- // If the caret from point landed inside a text node of this li, use it
121
- if (caret && editable.contains(caret.startContainer) &&
122
- caret.startContainer !== li) {
123
- caret.collapse(true);
124
- sel.removeAllRanges();
125
- sel.addRange(caret);
126
- return;
127
- }
128
- }
129
-
130
- // Fallback (keyboard nav, or caretRangeFromPoint not available / landed
131
- // at li again): prefer the first text node after the checkbox so the
132
- // cursor renders at the padding-left edge (after the visual checkbox)
133
- // rather than at element-level where the browser may place it at x=0.
134
- const nr = document.createRange();
135
- let anchorNode = null;
136
- for (const child of li.childNodes) {
137
- if (child !== cb && child.nodeType === Node.TEXT_NODE) {
138
- anchorNode = child;
139
- break;
140
- }
141
- }
142
- if (anchorNode) {
143
- nr.setStart(anchorNode, 0);
144
- } else {
145
- nr.setStartAfter(cb);
146
- }
147
- nr.collapse(true);
148
- sel.removeAllRanges();
149
- sel.addRange(nr);
150
- };
151
-
152
- const isReadOnly = () => this.context.layoutInfo.container.classList.contains('an-disabled');
153
-
154
- this._disposers.push(
155
- on(editable, 'keydown', onKeydown),
156
- on(editable, 'beforeinput', onBeforeInput),
157
- on(editable, 'input', onInput),
158
- on(document, 'selectionchange', onSelChange),
159
- on(editable, 'click', onCheckboxClick),
160
- on(editable, 'mouseup', fixChecklistCursor),
161
- on(editable, 'keyup', fixChecklistCursor),
162
- // Block drag-out and external drops in read-only mode.
163
- // D-1: Also block dragging of iframes and .an-video-wrapper elements in
164
- // edit mode — a user can inadvertently drag the iframe out of its wrapper
165
- // (making it playable/removable from contenteditable protection) by holding
166
- // the mouse and moving outside the wrapper before releasing.
167
- on(editable, 'dragstart', (e) => {
168
- if (isReadOnly()) { e.preventDefault(); return; }
169
- const target = /** @type {Element} */ (e.target);
170
- if (target && (target.nodeName === 'IFRAME' ||
171
- target.closest('.an-video-wrapper'))) {
172
- e.preventDefault();
173
- }
174
- }),
175
- on(editable, 'drop', (e) => { if (isReadOnly()) e.preventDefault(); }),
176
- );
177
-
178
- // B-V: Re-apply superscript / subscript after IME composition ends.
179
- // Vietnamese and other IME-based inputs fire compositionstart/end around
180
- // the inserted characters. During composition the browser may place the
181
- // provisional text outside the current <sup>/<sub> element. When
182
- // compositionend fires we detect whether the cursor escaped the sup/sub
183
- // context and re-apply the command so the composed character stays inside.
184
- /** @type {string|null} 'superscript' | 'subscript' | null */
185
- let _compositionSupSub = null;
186
- const onCompositionStart = () => {
187
- const sel = globalThis.getSelection();
188
- if (!sel?.rangeCount) { _compositionSupSub = null; return; }
189
- let node = sel.getRangeAt(0).startContainer;
190
- if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
191
- if (node) {
192
- const el = /** @type {Element} */ (node);
193
- if (el.closest('sup')) _compositionSupSub = 'superscript';
194
- else if (el.closest('sub')) _compositionSupSub = 'subscript';
195
- else _compositionSupSub = null;
196
- }
197
- };
198
- const onCompositionEnd = () => {
199
- const tag = _compositionSupSub;
200
- _compositionSupSub = null;
201
- if (!tag) return;
202
- const sel = globalThis.getSelection();
203
- if (!sel?.rangeCount) return;
204
- let node = sel.getRangeAt(0).startContainer;
205
- if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
206
- const el = /** @type {Element} */ (node);
207
- const inContext = tag === 'superscript' ? el?.closest('sup') : el?.closest('sub');
208
- if (!inContext) {
209
- // The composed character escaped the sup/sub — re-apply the format.
210
- document.execCommand(tag);
211
- }
212
- };
213
- this._disposers.push(
214
- on(editable, 'compositionstart', onCompositionStart),
215
- on(editable, 'compositionend', onCompositionEnd),
216
- );
217
- }
218
-
219
- _onKeydown(event) {
220
- const editable = this.context.layoutInfo.editable;
221
-
222
- // Let Typing module handle special keys (Tab, Enter etc.)
223
- if (handleKeydown(event, editable, this.options)) return;
224
-
225
- // Built-in shortcuts
226
- if (isModifier(event, 'z') && !event.shiftKey) {
227
- event.preventDefault();
228
- this.undo();
229
- return;
230
- }
231
- if ((isModifier(event, 'z') && event.shiftKey) || isModifier(event, 'y')) {
232
- event.preventDefault();
233
- this.redo();
234
- return;
235
- }
236
- if (isModifier(event, 'b')) { event.preventDefault(); this.bold(); return; }
237
- if (isModifier(event, 'i')) { event.preventDefault(); this.italic(); return; }
238
- if (isModifier(event, 'u')) { event.preventDefault(); this.underline(); return; }
239
- if (isModifier(event, 'k')) { event.preventDefault(); this.context.invoke('linkDialog.show'); return; }
240
-
241
- // Ctrl+Shift+V — paste as plain text (signals Clipboard module)
242
- if (isModifier(event, 'v') && event.shiftKey) {
243
- this.context.invoke('clipboard.setForcePlain', true);
244
- return; // let the native paste event fire
245
- }
246
-
247
- // Show keyboard shortcuts dialog: Ctrl+Shift+/
248
- if (event.key === '/' && event.shiftKey && event.ctrlKey && !event.metaKey) {
249
- event.preventDefault();
250
- this.context.invoke('shortcutsDialog.show');
251
- return;
252
- }
253
- // Find: Ctrl+F
254
- if (isModifier(event, 'f')) {
255
- event.preventDefault();
256
- this.context.invoke('findReplace.show', 'find');
257
- return;
258
- }
259
- // Ctrl+H — Find & Replace
260
- if (isModifier(event, 'h')) {
261
- event.preventDefault();
262
- this.context.invoke('findReplace.show', 'replace');
263
- }
264
- // Ctrl+` — Inline Code
265
- if (isModifier(event, '`')) {
266
- event.preventDefault();
267
- this.inlineCode();
268
- }
269
- }
270
-
271
- // ---------------------------------------------------------------------------
272
- // Limit enforcement
273
- // ---------------------------------------------------------------------------
274
-
275
- /**
276
- * Called from beforeinput to block typing when char/word limits are reached.
277
- * Deletions and non-typing input types are always allowed.
278
- * @param {InputEvent} event
279
- */
280
- _enforceLimit(event) {
281
- const maxChars = this.options.maxChars || 0;
282
- const maxWords = this.options.maxWords || 0;
283
- if (!maxChars && !maxWords) return;
284
-
285
- const type = event.inputType || '';
286
- // Allow deletions, undo, redo and non-insert operations
287
- if (type.startsWith('delete') || type === 'historyUndo' || type === 'historyRedo') return;
288
- // Allow paste/drop — handled after the fact by Clipboard
289
- if (type === 'insertFromPaste' || type === 'insertFromDrop') return;
290
- // Only enforce for keyboard/IME/composition insertions
291
- if (!type.startsWith('insert')) return;
292
-
293
- const text = this.context.layoutInfo.editable.innerText || '';
294
- const chars = text.replaceAll('\n', '').length;
295
-
296
- if (maxChars && chars >= maxChars) {
297
- event.preventDefault();
298
- if (typeof this.options.onCharLimitReached === 'function') {
299
- this.options.onCharLimitReached(this.context);
300
- }
301
- return;
302
- }
303
-
304
- // Word limit: block space / newline insertion when already at the limit
305
- if (maxWords && (event.data === ' ' || type === 'insertParagraph' || type === 'insertLineBreak')) {
306
- const words = text.trim() ? text.trim().split(/\s+/).length : 0;
307
- if (words >= maxWords) {
308
- event.preventDefault();
309
- if (typeof this.options.onWordLimitReached === 'function') {
310
- this.options.onWordLimitReached(this.context);
311
- }
312
- }
313
- }
314
- }
315
-
316
- // ---------------------------------------------------------------------------
317
- // Post-command hook — records undo, fires change event
318
- // ---------------------------------------------------------------------------
319
-
320
- afterCommand() {
321
- // C4: Remove figure.an-figure elements whose <img> has been deleted so
322
- // orphaned figcaptions do not accumulate in the DOM.
323
- this._cleanOrphanedFigures();
324
- // Ensure the editable always ends with a paragraph so the user can click
325
- // and type after block elements that trap the cursor (pre, table, etc.).
326
- this._ensureTrailingParagraph();
327
- // Immediate: keep toolbar and statusbar in sync on every mutation.
328
- this.context.invoke('toolbar.refresh');
329
- this.context.invoke('statusbar.update');
330
- // Debounced: recording an undo snapshot and firing the change event require
331
- // a full innerHTML serialization. Batching rapid keystrokes prevents the
332
- // browser from re-serializing large content (e.g. embedded images) on every
333
- // single key press.
334
- this._scheduleSnapshot();
335
- }
336
-
337
- /**
338
- * Schedules a debounced undo snapshot + change event.
339
- * Resets the timer on each call so rapid typing produces one snapshot.
340
- */
341
- _scheduleSnapshot() {
342
- clearTimeout(this._snapshotTimer);
343
- this._snapshotTimer = setTimeout(() => {
344
- this._snapshotTimer = null;
345
- if (this._history) this._history.recordUndo();
346
- this.context.triggerEvent('change', this.getHTML());
347
- }, 400);
348
- }
349
-
350
- /**
351
- * C4: Removes figure.an-figure elements that no longer contain an <img>.
352
- * This happens when a user selects only the image (not the whole figure)
353
- * and deletes or replaces it, leaving a dangling figcaption.
354
- */
355
- _cleanOrphanedFigures() {
356
- const editable = this.context.layoutInfo.editable;
357
- editable.querySelectorAll('figure.an-figure').forEach((fig) => {
358
- if (!fig.querySelector('img')) {
359
- fig.remove();
360
- }
361
- });
362
- }
363
-
364
- /**
365
- * Ensures the editable always ends with a plain paragraph so the cursor can
366
- * be placed after block elements that do not naturally allow it
367
- * (pre, blockquote, table, figure, ul, ol, hr).
368
- * Without this, clicking below the last such element does nothing.
369
- */
370
- _ensureTrailingParagraph() {
371
- const editable = this.context.layoutInfo.editable;
372
- if (!editable) return;
373
- const last = editable.lastElementChild;
374
- if (!last) return;
375
- const TRAPPING = new Set(['PRE', 'BLOCKQUOTE', 'TABLE', 'FIGURE', 'UL', 'OL', 'HR']);
376
- if (TRAPPING.has(last.nodeName)) {
377
- const p = document.createElement('p');
378
- p.innerHTML = '<br>';
379
- editable.appendChild(p);
380
- }
381
- }
382
-
383
- // ---------------------------------------------------------------------------
384
- // Focus management
385
- // ---------------------------------------------------------------------------
386
-
387
- focus() {
388
- const editable = this.context.layoutInfo.editable;
389
- editable.focus();
390
- }
391
-
392
- // ---------------------------------------------------------------------------
393
- // Content API
394
- // ---------------------------------------------------------------------------
395
-
396
- /**
397
- * Returns the editor HTML content.
398
- * @returns {string}
399
- */
400
- getHTML() {
401
- // Strip zero-width spaces inserted after icons to allow caret placement.
402
- const raw = this.context.layoutInfo.editable.innerHTML.replaceAll('\u200B', '');
403
- // Replace any blob: URLs (lightweight DOM references to pasted/dropped images)
404
- // with their original data URLs so the returned HTML is fully self-contained.
405
- return this.context.invoke('clipboard.resolveImages', raw) ?? raw;
406
- }
407
-
408
- /**
409
- * Sets the editor HTML content.
410
- * @param {string} html - HTML string (will be sanitised)
411
- */
412
- setHTML(html) {
413
- this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html, { allowIframes: true });
414
- if (this._history) this._history.reset();
415
- this.afterCommand();
416
- }
417
-
418
- /**
419
- * Returns the editor plain text content.
420
- * @returns {string}
421
- */
422
- getText() {
423
- return this.context.layoutInfo.editable.innerText || '';
424
- }
425
-
426
- /**
427
- * Sets the editor content as plain text.
428
- * @param {string} text
429
- */
430
- setText(text) {
431
- this.context.layoutInfo.editable.textContent = text;
432
- if (this._history) this._history.reset();
433
- this.afterCommand();
434
- }
435
-
436
- /**
437
- * Clears the editor content.
438
- */
439
- clear() {
440
- this.setHTML('');
441
- }
442
-
443
- /**
444
- * Resets the undo/redo history stack.
445
- */
446
- clearHistory() {
447
- if (this._history) this._history.reset();
448
- }
449
-
450
- /**
451
- * Returns true when the editor has no meaningful content.
452
- * @returns {boolean}
453
- */
454
- isEmpty() {
455
- const text = (this.context.layoutInfo.editable.innerText || '')
456
- .trim()
457
- .replaceAll('\u00a0', '');
458
- const hasMedia = !!this.context.layoutInfo.editable.querySelector('img, video, iframe, table');
459
- return !text && !hasMedia;
460
- }
461
-
462
- /**
463
- * Inserts HTML at the current cursor position.
464
- * @param {string} html
465
- */
466
- insertHTML(html) {
467
- if (!html) return;
468
- Style.execCommand('insertHTML', sanitiseHTML(html));
469
- this.afterCommand();
470
- }
471
-
472
- /**
473
- * Inserts plain text at the current cursor position.
474
- * @param {string} text
475
- */
476
- insertText(text) {
477
- if (!text) return;
478
- Style.execCommand('insertText', text);
479
- this.afterCommand();
480
- }
481
-
482
- /**
483
- * Sets editor content from a Markdown string.
484
- * @param {string} md
485
- */
486
- setMarkdown(md) {
487
- this.setHTML(markdownToHTML(md || ''));
488
- }
489
-
490
- /**
491
- * Returns the editor content as Markdown.
492
- * @returns {string}
493
- */
494
- getMarkdown() {
495
- return htmlToMarkdown(this.getHTML());
496
- }
497
-
498
- // ---------------------------------------------------------------------------
499
- // Undo / redo
500
- // ---------------------------------------------------------------------------
501
-
502
- undo() {
503
- if (this._history) {
504
- this._flushPendingSnapshot();
505
- this._history.undo();
506
- this.context.invoke('toolbar.refresh');
507
- this.context.invoke('statusbar.update');
508
- this.context.triggerEvent('change', this.getHTML());
509
- }
510
- }
511
-
512
- redo() {
513
- if (this._history) {
514
- this._flushPendingSnapshot();
515
- this._history.redo();
516
- this.context.invoke('toolbar.refresh');
517
- this.context.invoke('statusbar.update');
518
- this.context.triggerEvent('change', this.getHTML());
519
- }
520
- }
521
-
522
- /** Records a debounced change before an immediate undo/redo command. */
523
- _flushPendingSnapshot() {
524
- if (this._snapshotTimer === null) return;
525
- clearTimeout(this._snapshotTimer);
526
- this._snapshotTimer = null;
527
- this._history?.recordUndo();
528
- }
529
-
530
- canUndo() {
531
- return this._history ? this._history.canUndo() : false;
532
- }
533
-
534
- canRedo() {
535
- return this._history ? this._history.canRedo() : false;
536
- }
537
-
538
- getUndoCount() {
539
- return this._history ? this._history.getUndoCount() : 0;
540
- }
541
-
542
- getRedoCount() {
543
- return this._history ? this._history.getRedoCount() : 0;
544
- }
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
-
556
- // ---------------------------------------------------------------------------
557
- // Style commands (delegated to Style module)
558
- // ---------------------------------------------------------------------------
559
-
560
- bold() { Style.bold(); this.afterCommand(); }
561
- italic() { Style.italic(); this.afterCommand(); }
562
- underline() { Style.underline(); this.afterCommand(); }
563
- strikethrough() { Style.strikethrough(); this.afterCommand(); }
564
- superscript() { Style.superscript(); this.afterCommand(); }
565
- subscript() { Style.subscript(); this.afterCommand(); }
566
- justifyLeft() { Style.justifyLeft(); this.afterCommand(); }
567
- justifyCenter() { Style.justifyCenter(); this.afterCommand(); }
568
- justifyRight() { Style.justifyRight(); this.afterCommand(); }
569
- justifyFull() { Style.justifyFull(); this.afterCommand(); }
570
- indent() { Style.indent(); this.afterCommand(); }
571
- outdent() { Style.outdent(); this.afterCommand(); }
572
- insertUL() { Style.insertUnorderedList(); this.afterCommand(); }
573
- insertOL() { Style.insertOrderedList(); this.afterCommand(); }
574
- inlineCode() { Style.toggleInlineCode(this.context.layoutInfo.editable); this.afterCommand(); }
575
- toggleChecklist() { Style.toggleChecklist(); this.afterCommand(); }
576
- print() { this.context.print(); }
577
-
578
- /**
579
- * @param {string} tagName - e.g. 'h1', 'p', 'blockquote', 'pre'
580
- */
581
- formatBlock(tagName) {
582
- Style.formatBlock(tagName);
583
-
584
- // Auto-detect the programming language when the user formats a code block.
585
- // Only runs when converting TO <pre> and the block has no language yet.
586
- if (tagName === 'pre') {
587
- const sel = globalThis.getSelection();
588
- if (sel?.rangeCount > 0) {
589
- const container = sel.getRangeAt(0).commonAncestorContainer;
590
- const pre = /** @type {Element|null} */ (
591
- container.nodeType === 1
592
- ? /** @type {Element} */ (container).closest('pre')
593
- : (/** @type {Element|null} */ (container.parentElement))?.closest('pre')
594
- );
595
- if (pre && !/** @type {HTMLElement} */ (pre).dataset.language) {
596
- const code = pre.textContent || '';
597
- const lang = detectLang(code);
598
- if (lang) {
599
- this.context.invoke('codeTooltip.applyLanguage', pre, lang);
600
- return; // applyLanguage already calls afterCommand internally
601
- }
602
- }
603
- }
604
- }
605
-
606
- this.afterCommand();
607
- }
608
-
609
- /**
610
- * @param {string} color
611
- */
612
- foreColor(color) { Style.foreColor(color); this.afterCommand(); }
613
-
614
- /**
615
- * @param {string} color
616
- */
617
- backColor(color) { Style.backColor(color); this.afterCommand(); }
618
-
619
- /**
620
- * @param {string} name
621
- */
622
- fontName(name) { Style.fontName(name); this.afterCommand(); }
623
-
624
- /**
625
- * @param {string} size - e.g. '14px'
626
- */
627
- fontSize(size) { Style.fontSize(size, this.context.layoutInfo.editable); this.afterCommand(); }
628
-
629
- // ---------------------------------------------------------------------------
630
- // Insert helpers
631
- // ---------------------------------------------------------------------------
632
-
633
- /**
634
- * Inserts a horizontal rule at the cursor.
635
- */
636
- insertHr() {
637
- Style.execCommand('insertHorizontalRule');
638
- this.afterCommand();
639
- }
640
-
641
- /**
642
- * Creates a link at the current selection.
643
- * @param {string} url
644
- * @param {string} text
645
- * @param {boolean} [openInNewTab=false]
646
- */
647
- insertLink(url, text, openInNewTab = false) {
648
- const sel = globalThis.getSelection();
649
- if (!sel || sel.rangeCount === 0) return;
650
- const safeUrl = sanitiseUrl(url);
651
- if (!safeUrl) return;
652
-
653
- const hasText = sel.toString().trim().length > 0;
654
- if (hasText) {
655
- Style.execCommand('createLink', safeUrl);
656
- if (openInNewTab) {
657
- const link = this._getClosestAnchor();
658
- if (link) {
659
- /** @type {Element} */ (link).setAttribute('target', '_blank');
660
- /** @type {Element} */ (link).setAttribute('rel', 'noopener noreferrer');
661
- }
662
- }
663
- } else {
664
- const displayText = this._escapeAttr(text || safeUrl);
665
- Style.execCommand('insertHTML', `<a href="${this._escapeAttr(safeUrl)}"${openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''}>${displayText}</a>`);
666
- }
667
- this.afterCommand();
668
- }
669
-
670
- /**
671
- * Removes the link from the selected anchor.
672
- */
673
- unlink() {
674
- Style.execCommand('unlink');
675
- this.afterCommand();
676
- }
677
-
678
- /**
679
- * Inserts an image.
680
- * @param {string} src - URL or data-URI
681
- * @param {string} [alt]
682
- */
683
- insertImage(src, alt = '', align = '') {
684
- const safeSrc = sanitiseUrl(src, { allowData: true });
685
- if (!safeSrc) return;
686
- const styleMap = {
687
- left: 'float:left;margin:0 1em 1em 0',
688
- center: 'display:block;margin:0 auto',
689
- right: 'float:right;margin:0 0 1em 1em',
690
- };
691
- const style = styleMap[align] || '';
692
- const styleAttr = style ? ` style="${style}"` : '';
693
- Style.execCommand('insertHTML', `<img src="${this._escapeAttr(safeSrc)}" alt="${this._escapeAttr(alt)}" class="an-image"${styleAttr}>`);
694
- this.afterCommand();
695
- }
696
-
697
- /**
698
- * Inserts a video embed (iframe or <video> element).
699
- * The html string is already validated/built by VideoDialog.
700
- * @param {string} html
701
- */
702
- insertVideo(html) {
703
- if (!html) return;
704
- Style.execCommand('insertHTML', html);
705
- this.afterCommand();
706
- }
707
-
708
- /**
709
- * Inserts a table.
710
- * @param {number} cols
711
- * @param {number} rows
712
- */
713
- insertTable(cols, rows) {
714
- insertTable(cols, rows, { headerRow: this.context.options.tableHeaderRow });
715
- this.afterCommand();
716
- }
717
-
718
- // ---------------------------------------------------------------------------
719
- // Helpers
720
- // ---------------------------------------------------------------------------
721
-
722
- _getClosestAnchor() {
723
- const sel = globalThis.getSelection();
724
- if (!sel || sel.rangeCount === 0) return null;
725
- let node = sel.getRangeAt(0).startContainer;
726
- while (node) {
727
- if (node.nodeName === 'A') return node;
728
- node = node.parentNode;
729
- }
730
- return null;
731
- }
732
-
733
- /**
734
- * Escapes a string for safe use inside an HTML attribute value.
735
- * @param {string} str
736
- * @returns {string}
737
- */
738
- _escapeAttr(str) {
739
- return String(str ?? '')
740
- .replaceAll('&', '&amp;')
741
- .replaceAll('"', '&quot;')
742
- .replaceAll('<', '&lt;')
743
- .replaceAll('>', '&gt;');
744
- }
745
-
746
- // --- delegated to shared sanitise.js ---
747
- }