autumnnote 2.1.0 → 2.3.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 (65) hide show
  1. package/README.md +74 -6
  2. package/dist/autumnnote.cjs +27 -22
  3. package/dist/autumnnote.es.js +474 -591
  4. package/dist/autumnnote.es.js.map +1 -1
  5. package/dist/autumnnote.min.js +27 -22
  6. package/dist/autumnnote.umd.js +27 -22
  7. package/dist/autumnnote.umd.js.map +1 -1
  8. package/dist/icon-data-V0Xqv-wX.js +255 -0
  9. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  10. package/package.json +12 -3
  11. package/types/index.d.ts +8 -0
  12. package/src/js/Context.js +0 -854
  13. package/src/js/core/detectLang.js +0 -98
  14. package/src/js/core/dom.js +0 -372
  15. package/src/js/core/env.js +0 -38
  16. package/src/js/core/key.js +0 -66
  17. package/src/js/core/lists.js +0 -121
  18. package/src/js/core/markdown.js +0 -695
  19. package/src/js/core/range.js +0 -194
  20. package/src/js/core/sanitise.js +0 -304
  21. package/src/js/editing/History.js +0 -266
  22. package/src/js/editing/Style.js +0 -812
  23. package/src/js/editing/Table.js +0 -105
  24. package/src/js/editing/Typing.js +0 -397
  25. package/src/js/index.js +0 -193
  26. package/src/js/index.umd.js +0 -17
  27. package/src/js/module/AutoSaveRestore.js +0 -125
  28. package/src/js/module/BaseDialog.js +0 -133
  29. package/src/js/module/BaseMediaTooltip.js +0 -142
  30. package/src/js/module/BaseResizer.js +0 -322
  31. package/src/js/module/BubbleToolbar.js +0 -483
  32. package/src/js/module/Buttons.js +0 -399
  33. package/src/js/module/Clipboard.js +0 -579
  34. package/src/js/module/CodeTooltip.js +0 -493
  35. package/src/js/module/Codeview.js +0 -125
  36. package/src/js/module/ContextMenu.js +0 -621
  37. package/src/js/module/Editor.js +0 -747
  38. package/src/js/module/EmojiDialog.js +0 -254
  39. package/src/js/module/FindReplace.js +0 -512
  40. package/src/js/module/Fullscreen.js +0 -80
  41. package/src/js/module/IconDialog.js +0 -618
  42. package/src/js/module/ImageCropOverlay.js +0 -586
  43. package/src/js/module/ImageDialog.js +0 -193
  44. package/src/js/module/ImageResizer.js +0 -42
  45. package/src/js/module/ImageTooltip.js +0 -285
  46. package/src/js/module/LinkDialog.js +0 -145
  47. package/src/js/module/LinkTooltip.js +0 -250
  48. package/src/js/module/MarkdownShortcuts.js +0 -250
  49. package/src/js/module/Mention.js +0 -365
  50. package/src/js/module/Placeholder.js +0 -51
  51. package/src/js/module/ShortcutsDialog.js +0 -111
  52. package/src/js/module/SlashMenu.js +0 -376
  53. package/src/js/module/Statusbar.js +0 -246
  54. package/src/js/module/TableTooltip.js +0 -1392
  55. package/src/js/module/Toolbar.js +0 -855
  56. package/src/js/module/VideoDialog.js +0 -193
  57. package/src/js/module/VideoResizer.js +0 -66
  58. package/src/js/module/VideoTooltip.js +0 -248
  59. package/src/js/module/emoji-data.js +0 -496
  60. package/src/js/module/table-grid.js +0 -102
  61. package/src/js/module/table-icons.js +0 -33
  62. package/src/js/renderer.js +0 -120
  63. package/src/js/settings.js +0 -214
  64. package/src/styles/_variables.scss +0 -48
  65. package/src/styles/autumnnote.scss +0 -2877
@@ -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
- }