jotterjs 0.1.3 → 0.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.
package/dist/jotter.js CHANGED
@@ -1,53 +1,133 @@
1
1
  // src/jotter.js
2
- var TOOLBAR_ACTIONS = [
3
- { custom: "toggleSource", label: "Source", title: "Edit HTML Source" },
4
- { type: "sep" },
5
- { cmd: "undo", icon: "undo", title: "Undo (Ctrl+Z)" },
6
- { cmd: "redo", icon: "redo", title: "Redo (Ctrl+Y)" },
7
- { type: "sep" },
8
- { cmd: "copy", icon: "content_copy", title: "Copy" },
9
- { cmd: "cut", icon: "content_cut", title: "Cut" },
10
- { cmd: "paste", icon: "content_paste", title: "Paste" },
11
- { type: "sep" },
12
- { cmd: "removeFormat", icon: "format_clear", title: "Clear Formatting" },
13
- { type: "sep" },
14
- { type: "blockformat" },
15
- { type: "fontfamily" },
16
- { type: "fontsize" },
17
- { type: "sep" },
18
- { cmd: "bold", icon: "format_bold", title: "Bold (Ctrl+B)" },
19
- { cmd: "italic", icon: "format_italic", title: "Italic (Ctrl+I)" },
20
- { cmd: "underline", icon: "format_underlined", title: "Underline (Ctrl+U)" },
21
- { cmd: "strikeThrough", icon: "strikethrough_s", title: "Strikethrough" },
22
- { cmd: "subscript", icon: "subscript", title: "Subscript" },
23
- { cmd: "superscript", icon: "superscript", title: "Superscript" },
24
- { custom: "code", icon: "code", title: "Inline Code" },
25
- { type: "sep" },
26
- { type: "color", cmd: "foreColor", icon: "format_color_text", title: "Text Color" },
27
- { type: "color", cmd: "hiliteColor", icon: "format_color_fill", title: "Background Color" },
28
- { type: "sep" },
29
- { cmd: "justifyLeft", icon: "format_align_left", title: "Align Left" },
30
- { cmd: "justifyCenter", icon: "format_align_center", title: "Align Center" },
31
- { cmd: "justifyRight", icon: "format_align_right", title: "Align Right" },
32
- { type: "sep" },
33
- { cmd: "insertUnorderedList", icon: "format_list_bulleted", title: "Bullet List" },
34
- { cmd: "insertOrderedList", icon: "format_list_numbered", title: "Numbered List" },
35
- { type: "sep" },
36
- { type: "popup", id: "link", icon: "insert_link", title: "Insert Link" },
37
- { cmd: "unlink", icon: "link_off", title: "Remove Link" },
38
- { type: "sep" },
39
- { type: "popup", id: "image", icon: "image", title: "Insert Image" },
40
- { type: "popup", id: "video", icon: "smart_display", title: "Insert YouTube Video" },
41
- { type: "popup", id: "table", icon: "table_chart", title: "Insert Table" },
42
- { type: "popup", id: "embed", icon: "html", title: "Insert Embed" },
43
- { type: "popup", id: "symbol", icon: "emoji_symbols", title: "Insert Symbol" },
44
- { type: "popup", id: "specialchar", icon: "format_shapes", title: "Special Characters" },
45
- { type: "popup", id: "lorem", icon: "script", title: "Insert Lorem Ipsum" },
46
- { type: "sep" },
47
- { type: "theme" },
48
- { type: "sep" },
49
- { type: "theme" }
50
- ];
2
+ var ACTIONS = {
3
+ source: { custom: "toggleSource", label: "Source", title: "Edit HTML Source" },
4
+ sep: { type: "sep" },
5
+ blockformat: { type: "blockformat" },
6
+ fontfamily: { type: "fontfamily" },
7
+ fontsize: { type: "fontsize" },
8
+ theme: { type: "theme" },
9
+ undo: { cmd: "undo", icon: "undo", title: "Undo (Ctrl+Z)" },
10
+ redo: { cmd: "redo", icon: "redo", title: "Redo (Ctrl+Y)" },
11
+ bold: { cmd: "bold", icon: "format_bold", title: "Bold (Ctrl+B)" },
12
+ italic: { cmd: "italic", icon: "format_italic", title: "Italic (Ctrl+I)" },
13
+ underline: { cmd: "underline", icon: "format_underlined", title: "Underline (Ctrl+U)" },
14
+ strike: { cmd: "strikeThrough", icon: "strikethrough_s", title: "Strikethrough" },
15
+ subscript: { cmd: "subscript", icon: "subscript", title: "Subscript" },
16
+ superscript: { cmd: "superscript", icon: "superscript", title: "Superscript" },
17
+ code: { custom: "code", icon: "code", title: "Inline Code" },
18
+ copy: { cmd: "copy", icon: "content_copy", title: "Copy" },
19
+ cut: { cmd: "cut", icon: "content_cut", title: "Cut" },
20
+ paste: { cmd: "paste", icon: "content_paste", title: "Paste" },
21
+ clearFormat: { cmd: "removeFormat", icon: "format_clear", title: "Clear Formatting" },
22
+ alignLeft: { cmd: "justifyLeft", icon: "format_align_left", title: "Align Left" },
23
+ alignCenter: { cmd: "justifyCenter", icon: "format_align_center", title: "Align Center" },
24
+ alignRight: { cmd: "justifyRight", icon: "format_align_right", title: "Align Right" },
25
+ bullets: { cmd: "insertUnorderedList", icon: "format_list_bulleted", title: "Bullet List" },
26
+ numbered: { cmd: "insertOrderedList", icon: "format_list_numbered", title: "Numbered List" },
27
+ link: { type: "popup", id: "link", icon: "insert_link", title: "Insert Link" },
28
+ unlink: { cmd: "unlink", icon: "link_off", title: "Remove Link" },
29
+ foreColor: { type: "color", cmd: "foreColor", icon: "format_color_text", title: "Text Color" },
30
+ hiliteColor: { type: "color", cmd: "hiliteColor", icon: "format_color_fill", title: "Background Color" },
31
+ image: { type: "popup", id: "image", icon: "image", title: "Insert Image" },
32
+ video: { type: "popup", id: "video", icon: "smart_display", title: "Insert YouTube Video" },
33
+ table: { type: "popup", id: "table", icon: "table_chart", title: "Insert Table" },
34
+ embed: { type: "popup", id: "embed", icon: "html", title: "Insert Embed" },
35
+ symbol: { type: "popup", id: "symbol", icon: "emoji_symbols", title: "Insert Symbol" },
36
+ specialChar: { type: "popup", id: "specialchar", icon: "format_shapes", title: "Special Characters" },
37
+ lorem: { type: "popup", id: "lorem", icon: "history_edu", title: "Insert Lorem Ipsum" }
38
+ };
39
+ Object.values(ACTIONS).forEach(Object.freeze);
40
+ Object.freeze(ACTIONS);
41
+ var A = ACTIONS;
42
+ var PRESETS = {
43
+ minimal: [
44
+ A.source,
45
+ A.sep,
46
+ A.bold,
47
+ A.italic,
48
+ A.underline,
49
+ A.sep,
50
+ A.link,
51
+ A.unlink
52
+ ],
53
+ writing: [
54
+ A.source,
55
+ A.sep,
56
+ A.undo,
57
+ A.redo,
58
+ A.sep,
59
+ A.blockformat,
60
+ A.sep,
61
+ A.bold,
62
+ A.italic,
63
+ A.underline,
64
+ A.strike,
65
+ A.sep,
66
+ A.bullets,
67
+ A.numbered,
68
+ A.sep,
69
+ A.link,
70
+ A.unlink,
71
+ A.sep,
72
+ A.image
73
+ ],
74
+ full: [
75
+ A.source,
76
+ A.sep,
77
+ A.undo,
78
+ A.redo,
79
+ A.sep,
80
+ A.copy,
81
+ A.cut,
82
+ A.paste,
83
+ A.sep,
84
+ A.clearFormat,
85
+ A.sep,
86
+ A.blockformat,
87
+ A.fontfamily,
88
+ A.fontsize,
89
+ A.sep,
90
+ A.bold,
91
+ A.italic,
92
+ A.underline,
93
+ A.strike,
94
+ A.subscript,
95
+ A.superscript,
96
+ A.code,
97
+ A.sep,
98
+ A.foreColor,
99
+ A.hiliteColor,
100
+ A.sep,
101
+ A.alignLeft,
102
+ A.alignCenter,
103
+ A.alignRight,
104
+ A.sep,
105
+ A.bullets,
106
+ A.numbered,
107
+ A.sep,
108
+ A.link,
109
+ A.unlink,
110
+ A.sep,
111
+ A.image,
112
+ A.video,
113
+ A.table,
114
+ A.embed,
115
+ A.symbol,
116
+ A.specialChar,
117
+ A.lorem,
118
+ A.sep,
119
+ A.theme
120
+ ]
121
+ };
122
+ Object.values(PRESETS).forEach(Object.freeze);
123
+ Object.freeze(PRESETS);
124
+ var TOOLBAR_ACTIONS = PRESETS.full;
125
+ var RESOLVER_OPTIONS = {
126
+ image: "onRequestImage",
127
+ link: "onRequestLink",
128
+ video: "onRequestVideo",
129
+ embed: "onRequestEmbed"
130
+ };
51
131
  var HEADING_OPTIONS = [
52
132
  { label: "Paragraph", tag: "p" },
53
133
  { label: "Heading 1", tag: "h1" },
@@ -262,18 +342,28 @@ var JotterJS = class {
262
342
  theme: "default",
263
343
  onChange: null,
264
344
  onFocus: null,
265
- onBlur: null
345
+ onBlur: null,
346
+ onRequestImage: null,
347
+ onRequestLink: null,
348
+ onRequestVideo: null,
349
+ onRequestEmbed: null
266
350
  }, options);
267
351
  this._listeners = {};
268
- this._savedRange = null;
352
+ this._bookmarks = /* @__PURE__ */ new Map();
353
+ this._bmSeq = 0;
354
+ this._savedBookmark = null;
355
+ this._externalBookmark = null;
356
+ this._externalDepth = 0;
357
+ this._changeCount = 0;
358
+ this._destroyed = false;
269
359
  this._lastForeColor = "#e8e4d8";
270
360
  this._lastHiliteColor = "#c8a96e";
271
361
  this._init();
272
362
  }
273
363
  // ─── Init ─────────────────────────────────────────────────────────────────
274
364
  // Popup is appended to document.body (not the root) to escape overflow:hidden.
275
- // _savedRange stores the selection before any toolbar interaction steals focus,
276
- // so popup submit handlers can restore it via _restoreRange().
365
+ // _savedBookmark holds the selection before any toolbar interaction steals
366
+ // focus, so popup submit handlers can put it back via _restoreSavedBookmark().
277
367
  _init() {
278
368
  const initialHTML = this._target.innerHTML || "";
279
369
  this._target.innerHTML = "";
@@ -324,8 +414,14 @@ var JotterJS = class {
324
414
  });
325
415
  return bar;
326
416
  }
327
- /** Dispatches an action descriptor to the appropriate builder. */
417
+ /**
418
+ * Dispatches an action descriptor to the appropriate builder.
419
+ * `onClick` is checked before `type` so a host can override any built-in —
420
+ * popup actions included — with `{ ...JotterJS.actions.image, onClick: fn }`.
421
+ */
328
422
  _buildAction(action) {
423
+ if (typeof action.onClick === "function")
424
+ return this._buildBtn(action);
329
425
  switch (action.type) {
330
426
  case "sep":
331
427
  return this._makeSep();
@@ -379,30 +475,28 @@ var JotterJS = class {
379
475
  btn.addEventListener("mousedown", (e) => {
380
476
  e.preventDefault();
381
477
  this._editor.focus();
382
- if (action.onClick) {
383
- action.onClick(this);
384
- } else if (action.custom === "toggleSource") {
385
- this._toggleSourceMode();
386
- } else if (action.custom === "code") {
387
- this._toggleInlineCode();
388
- } else if (action.cmd === "copy") {
389
- document.execCommand("copy");
390
- } else if (action.cmd === "cut") {
391
- document.execCommand("cut");
392
- } else if (action.cmd === "paste") {
393
- this._pasteFromClipboard();
394
- } else if (action.prompt) {
395
- const val = window.prompt(action.prompt);
396
- if (val)
397
- document.execCommand(action.cmd, false, val);
398
- } else {
399
- document.execCommand(action.cmd, false, null);
400
- }
478
+ this._applyEdit(() => {
479
+ if (action.onClick) {
480
+ action.onClick(this);
481
+ } else if (action.custom === "toggleSource") {
482
+ this._toggleSourceMode();
483
+ } else if (action.custom === "code") {
484
+ this._toggleInlineCode();
485
+ } else if (action.cmd === "copy") {
486
+ document.execCommand("copy");
487
+ } else if (action.cmd === "cut") {
488
+ document.execCommand("cut");
489
+ } else if (action.cmd === "paste") {
490
+ this._pasteFromClipboard();
491
+ } else if (action.prompt) {
492
+ const val = window.prompt(action.prompt);
493
+ if (val)
494
+ document.execCommand(action.cmd, false, val);
495
+ } else {
496
+ document.execCommand(action.cmd, false, null);
497
+ }
498
+ });
401
499
  this._updateToolbarState();
402
- this._updateStatus();
403
- this._emit("change", this.getHTML());
404
- if (this._options.onChange)
405
- this._options.onChange(this.getHTML());
406
500
  });
407
501
  return btn;
408
502
  }
@@ -417,17 +511,10 @@ var JotterJS = class {
417
511
  opt.textContent = label;
418
512
  sel.appendChild(opt);
419
513
  });
420
- sel.addEventListener("mousedown", () => {
421
- this._savedRange = this._saveRange();
422
- });
514
+ sel.addEventListener("mousedown", () => this._saveBookmark());
423
515
  sel.addEventListener("change", () => {
424
- this._restoreRange(this._savedRange);
425
- document.execCommand("formatBlock", false, sel.value);
426
- this._editor.focus();
427
- this._updateStatus();
428
- this._emit("change", this.getHTML());
429
- if (this._options.onChange)
430
- this._options.onChange(this.getHTML());
516
+ this._restoreSavedBookmark();
517
+ this._applyEdit(() => document.execCommand("formatBlock", false, sel.value));
431
518
  });
432
519
  return sel;
433
520
  }
@@ -447,18 +534,12 @@ var JotterJS = class {
447
534
  opt.style.fontFamily = f;
448
535
  sel.appendChild(opt);
449
536
  });
450
- sel.addEventListener("mousedown", () => {
451
- this._savedRange = this._saveRange();
452
- });
537
+ sel.addEventListener("mousedown", () => this._saveBookmark());
453
538
  sel.addEventListener("change", () => {
454
539
  if (!sel.value)
455
540
  return;
456
- this._restoreRange(this._savedRange);
457
- document.execCommand("fontName", false, sel.value);
458
- this._editor.focus();
459
- this._emit("change", this.getHTML());
460
- if (this._options.onChange)
461
- this._options.onChange(this.getHTML());
541
+ this._restoreSavedBookmark();
542
+ this._applyEdit(() => document.execCommand("fontName", false, sel.value));
462
543
  });
463
544
  return sel;
464
545
  }
@@ -477,18 +558,12 @@ var JotterJS = class {
477
558
  opt.textContent = `${s}px`;
478
559
  sel.appendChild(opt);
479
560
  });
480
- sel.addEventListener("mousedown", () => {
481
- this._savedRange = this._saveRange();
482
- });
561
+ sel.addEventListener("mousedown", () => this._saveBookmark());
483
562
  sel.addEventListener("change", () => {
484
563
  if (!sel.value)
485
564
  return;
486
- this._restoreRange(this._savedRange);
487
- this._applyFontSize(sel.value);
488
- this._editor.focus();
489
- this._emit("change", this.getHTML());
490
- if (this._options.onChange)
491
- this._options.onChange(this.getHTML());
565
+ this._restoreSavedBookmark();
566
+ this._applyEdit(() => this._applyFontSize(sel.value));
492
567
  });
493
568
  return sel;
494
569
  }
@@ -538,16 +613,12 @@ var JotterJS = class {
538
613
  this._lastForeColor = color;
539
614
  else
540
615
  this._lastHiliteColor = color;
541
- this._restoreRange(this._savedRange);
542
- this._editor.focus();
543
- document.execCommand(action.cmd, false, color);
544
- this._emit("change", this.getHTML());
545
- if (this._options.onChange)
546
- this._options.onChange(this.getHTML());
616
+ this._restoreSavedBookmark();
617
+ this._applyEdit(() => document.execCommand(action.cmd, false, color));
547
618
  });
548
619
  btn.addEventListener("mousedown", (e) => {
549
620
  e.preventDefault();
550
- this._savedRange = this._saveRange();
621
+ this._saveBookmark();
551
622
  input.click();
552
623
  });
553
624
  wrap.appendChild(btn);
@@ -558,6 +629,8 @@ var JotterJS = class {
558
629
  // Single _popup element on document.body; toggled via _showPopup / _hidePopup.
559
630
  // Clicking the same button again dismisses the popup (toggle).
560
631
  // Outside-click and Escape both close it (see _bindEvents).
632
+ // A popup whose id has an onRequest* hook is never built: the button hands
633
+ // over to the host resolver instead (see _runResolver).
561
634
  _buildPopupBtn(action) {
562
635
  const btn = document.createElement("button");
563
636
  btn.type = "button";
@@ -570,8 +643,14 @@ var JotterJS = class {
570
643
  btn.appendChild(icon);
571
644
  btn.addEventListener("mousedown", (e) => {
572
645
  e.preventDefault();
573
- this._savedRange = this._saveRange();
574
- if (this._popup.classList.contains("jotter-popup--visible") && this._popup.dataset.popupId === action.id) {
646
+ const resolver = this._resolverFor(action.id);
647
+ if (resolver) {
648
+ this._hidePopup();
649
+ this._runResolver(action.id, resolver);
650
+ return;
651
+ }
652
+ this._saveBookmark();
653
+ if (this._popupVisible() && this._popup.dataset.popupId === action.id) {
575
654
  this._hidePopup();
576
655
  return;
577
656
  }
@@ -601,9 +680,14 @@ var JotterJS = class {
601
680
  }
602
681
  });
603
682
  }
683
+ _popupVisible() {
684
+ return this._popup.classList.contains("jotter-popup--visible");
685
+ }
686
+ /** Hides the popup and drops the caret bookmark it was holding (no-op if already consumed). */
604
687
  _hidePopup() {
605
688
  this._popup.classList.remove("jotter-popup--visible");
606
689
  this._popup.dataset.popupId = "";
690
+ this._releaseSavedBookmark();
607
691
  }
608
692
  /** Returns the DOM subtree for the popup identified by id. */
609
693
  _buildPopupContent(id) {
@@ -661,10 +745,7 @@ var JotterJS = class {
661
745
  );
662
746
  });
663
747
  });
664
- cell.addEventListener("click", () => {
665
- this._insertTable(r + 1, c + 1);
666
- this._hidePopup();
667
- });
748
+ cell.addEventListener("click", () => this._insertTable(r + 1, c + 1));
668
749
  cells.push(cell);
669
750
  grid.appendChild(cell);
670
751
  }
@@ -673,9 +754,17 @@ var JotterJS = class {
673
754
  wrap.appendChild(hint);
674
755
  return wrap;
675
756
  }
757
+ /**
758
+ * Completes a popup interaction: caret back where it was, popup closed, edit
759
+ * applied. The popup goes away before the change is announced, so a host that
760
+ * re-renders on change never remounts the editor with its popup still open.
761
+ */
762
+ _commitPopup(fn) {
763
+ this._restoreSavedBookmark();
764
+ this._hidePopup();
765
+ this._applyEdit(fn);
766
+ }
676
767
  _insertTable(rows, cols) {
677
- this._restoreRange(this._savedRange);
678
- this._editor.focus();
679
768
  let html = "<table><tbody>";
680
769
  for (let r = 0; r < rows; r++) {
681
770
  html += "<tr>";
@@ -685,10 +774,7 @@ var JotterJS = class {
685
774
  html += "</tr>";
686
775
  }
687
776
  html += "</tbody></table><p><br></p>";
688
- document.execCommand("insertHTML", false, html);
689
- this._emit("change", this.getHTML());
690
- if (this._options.onChange)
691
- this._options.onChange(this.getHTML());
777
+ this._commitPopup(() => document.execCommand("insertHTML", false, html));
692
778
  }
693
779
  /**
694
780
  * Fields: URL, link text, title/tooltip, target.
@@ -699,22 +785,8 @@ var JotterJS = class {
699
785
  const wrap = document.createElement("div");
700
786
  wrap.className = "jotter-popup-inner jotter-popup-form";
701
787
  wrap.appendChild(this._popupTitle("Insert Link"));
702
- let existingAnchor = null;
703
- let selectedText = "";
704
- if (this._savedRange) {
705
- const sel = window.getSelection();
706
- if (sel && sel.rangeCount) {
707
- selectedText = sel.toString();
708
- let node = sel.anchorNode;
709
- while (node && node !== this._editor) {
710
- if (node.nodeName === "A") {
711
- existingAnchor = node;
712
- break;
713
- }
714
- node = node.parentNode;
715
- }
716
- }
717
- }
788
+ const existingAnchor = this._anchorInSelection();
789
+ const selectedText = this._selectedText();
718
790
  const urlInput = this._makeField(wrap, "URL", "url", "https://");
719
791
  const textInput = this._makeField(wrap, "Link text (leave blank to keep selection)", "text", "");
720
792
  const titleInput = this._makeField(wrap, "Title / tooltip", "text", "");
@@ -743,34 +815,25 @@ var JotterJS = class {
743
815
  const href = urlInput.value.trim();
744
816
  if (!href)
745
817
  return;
746
- const linkText = textInput.value.trim() || selectedText || href;
818
+ const text = textInput.value.trim() || selectedText || href;
747
819
  const title = titleInput.value.trim();
748
820
  const target = targetSel.value;
749
- let attrs = `href="${this._esc(href)}"`;
750
- if (target)
751
- attrs += ` target="${this._esc(target)}"`;
752
- if (title)
753
- attrs += ` title="${this._esc(title)}"`;
754
- this._restoreRange(this._savedRange);
755
- this._editor.focus();
756
- if (existingAnchor) {
757
- existingAnchor.href = href;
758
- if (target)
759
- existingAnchor.target = target;
760
- else
761
- existingAnchor.removeAttribute("target");
762
- if (title)
763
- existingAnchor.title = title;
764
- else
765
- existingAnchor.removeAttribute("title");
766
- existingAnchor.textContent = linkText;
767
- } else {
768
- document.execCommand("insertHTML", false, `<a ${attrs}>${this._esc(linkText)}</a>`);
769
- }
770
- this._hidePopup();
771
- this._emit("change", this.getHTML());
772
- if (this._options.onChange)
773
- this._options.onChange(this.getHTML());
821
+ this._commitPopup(() => {
822
+ if (existingAnchor) {
823
+ existingAnchor.href = href;
824
+ if (target)
825
+ existingAnchor.target = target;
826
+ else
827
+ existingAnchor.removeAttribute("target");
828
+ if (title)
829
+ existingAnchor.title = title;
830
+ else
831
+ existingAnchor.removeAttribute("title");
832
+ existingAnchor.textContent = text;
833
+ } else {
834
+ document.execCommand("insertHTML", false, this._linkHTML({ href, text, title, target }));
835
+ }
836
+ });
774
837
  }));
775
838
  return wrap;
776
839
  }
@@ -786,20 +849,8 @@ var JotterJS = class {
786
849
  const src = urlInput.value.trim();
787
850
  if (!src)
788
851
  return;
789
- const alt = altInput.value.trim();
790
- const w = widthInput.value.trim();
791
- const style = w ? `max-width:${w}` : "max-width:100%";
792
- this._restoreRange(this._savedRange);
793
- this._editor.focus();
794
- document.execCommand(
795
- "insertHTML",
796
- false,
797
- `<img src="${this._esc(src)}" alt="${this._esc(alt)}" style="${style}">`
798
- );
799
- this._hidePopup();
800
- this._emit("change", this.getHTML());
801
- if (this._options.onChange)
802
- this._options.onChange(this.getHTML());
852
+ const html = this._imageHTML({ src, alt: altInput.value.trim(), width: widthInput.value.trim() });
853
+ this._commitPopup(() => document.execCommand("insertHTML", false, html));
803
854
  }));
804
855
  return wrap;
805
856
  }
@@ -816,14 +867,7 @@ var JotterJS = class {
816
867
  return;
817
868
  }
818
869
  urlInput.classList.remove("jotter-input--error");
819
- const html = `<div class="jotter-video-wrap"><iframe src="https://www.youtube.com/embed/${id}" frameborder="0" allowfullscreen loading="lazy" title="YouTube video"></iframe></div><p><br></p>`;
820
- this._restoreRange(this._savedRange);
821
- this._editor.focus();
822
- document.execCommand("insertHTML", false, html);
823
- this._hidePopup();
824
- this._emit("change", this.getHTML());
825
- if (this._options.onChange)
826
- this._options.onChange(this.getHTML());
870
+ this._commitPopup(() => document.execCommand("insertHTML", false, this._videoHTML(id)));
827
871
  }));
828
872
  return wrap;
829
873
  }
@@ -853,13 +897,7 @@ var JotterJS = class {
853
897
  const html = ta.value.trim();
854
898
  if (!html)
855
899
  return;
856
- this._restoreRange(this._savedRange);
857
- this._editor.focus();
858
- document.execCommand("insertHTML", false, html + "<p><br></p>");
859
- this._hidePopup();
860
- this._emit("change", this.getHTML());
861
- if (this._options.onChange)
862
- this._options.onChange(this.getHTML());
900
+ this._commitPopup(() => document.execCommand("insertHTML", false, this._embedHTML(html)));
863
901
  }));
864
902
  return wrap;
865
903
  }
@@ -890,13 +928,7 @@ var JotterJS = class {
890
928
  btn.title = `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
891
929
  btn.addEventListener("mousedown", (e) => {
892
930
  e.preventDefault();
893
- this._restoreRange(this._savedRange);
894
- this._editor.focus();
895
- document.execCommand("insertText", false, ch);
896
- this._hidePopup();
897
- this._emit("change", this.getHTML());
898
- if (this._options.onChange)
899
- this._options.onChange(this.getHTML());
931
+ this._commitPopup(() => document.execCommand("insertText", false, ch));
900
932
  });
901
933
  grid.appendChild(btn);
902
934
  });
@@ -914,17 +946,11 @@ var JotterJS = class {
914
946
  btn.textContent = v.label;
915
947
  btn.addEventListener("mousedown", (e) => {
916
948
  e.preventDefault();
917
- this._restoreRange(this._savedRange);
918
- this._editor.focus();
919
- if (v.isHTML) {
920
- document.execCommand("insertHTML", false, v.text);
921
- } else {
922
- document.execCommand("insertText", false, v.text);
923
- }
924
- this._hidePopup();
925
- this._emit("change", this.getHTML());
926
- if (this._options.onChange)
927
- this._options.onChange(this.getHTML());
949
+ this._commitPopup(() => document.execCommand(
950
+ v.isHTML ? "insertHTML" : "insertText",
951
+ false,
952
+ v.text
953
+ ));
928
954
  });
929
955
  wrap.appendChild(btn);
930
956
  });
@@ -961,7 +987,157 @@ var JotterJS = class {
961
987
  }
962
988
  /** Escapes ", <, > for safe insertion into HTML attribute values and text. */
963
989
  _esc(s) {
964
- return s.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
990
+ return String(s).replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
991
+ }
992
+ // ─── Insertion markup ─────────────────────────────────────────────────────
993
+ // Single source of truth for what each insertable looks like. The built-in
994
+ // popups and the onRequest* resolver hooks both go through these, so host UI
995
+ // produces byte-identical markup to the popup it replaced.
996
+ _imageHTML({ src, alt, width }) {
997
+ const style = width ? `max-width:${width}` : "max-width:100%";
998
+ return `<img src="${this._esc(src)}" alt="${this._esc(alt || "")}" style="${this._esc(style)}">`;
999
+ }
1000
+ _linkHTML({ href, text, title, target }) {
1001
+ let attrs = `href="${this._esc(href)}"`;
1002
+ if (target)
1003
+ attrs += ` target="${this._esc(target)}"`;
1004
+ if (title)
1005
+ attrs += ` title="${this._esc(title)}"`;
1006
+ return `<a ${attrs}>${this._esc(text || href)}</a>`;
1007
+ }
1008
+ /** @param {string} id An 11-char YouTube id — anything else yields null. */
1009
+ _videoHTML(id) {
1010
+ if (!/^[A-Za-z0-9_-]{11}$/.test(id))
1011
+ return null;
1012
+ return `<div class="jotter-video-wrap"><iframe src="https://www.youtube.com/embed/${id}" frameborder="0" allowfullscreen loading="lazy" title="YouTube video"></iframe></div><p><br></p>`;
1013
+ }
1014
+ /** Embeds are inserted verbatim — the host, not the end user, supplies them. */
1015
+ _embedHTML(html) {
1016
+ return html + "<p><br></p>";
1017
+ }
1018
+ // ─── Resolver hooks ───────────────────────────────────────────────────────
1019
+ // An onRequest* option replaces the built-in popup for that insertable. The
1020
+ // editor still owns the caret bookmark, the blur suppression, the markup and
1021
+ // the change emit; the host only answers "which image / link / video / embed?".
1022
+ _resolverFor(id) {
1023
+ const key = RESOLVER_OPTIONS[id];
1024
+ const fn = key ? this._options[key] : null;
1025
+ return typeof fn === "function" ? fn : null;
1026
+ }
1027
+ /**
1028
+ * Bookmarks the caret, awaits the host, then inserts at the bookmark.
1029
+ * A null/false/throwing result means cancelled — the caret still comes back.
1030
+ */
1031
+ async _runResolver(id, resolver) {
1032
+ const ctx = this._resolverContext(id);
1033
+ this.beginExternalUI();
1034
+ let result = null;
1035
+ try {
1036
+ result = await resolver(ctx);
1037
+ } catch (_) {
1038
+ result = null;
1039
+ }
1040
+ if (this._destroyed)
1041
+ return;
1042
+ const html = result === null || result === void 0 || result === false ? null : this._resolvedHTML(id, result, ctx);
1043
+ this.endExternalUI();
1044
+ if (!html)
1045
+ return;
1046
+ this._applyEdit(() => document.execCommand("insertHTML", false, html));
1047
+ this._updateToolbarState();
1048
+ }
1049
+ /** Current values handed to the resolver, mirroring the popup's pre-filled fields. */
1050
+ _resolverContext(id) {
1051
+ switch (id) {
1052
+ case "image":
1053
+ return { src: "", alt: "", width: "", selection: this._selectedText() };
1054
+ case "video":
1055
+ return { url: "", selection: this._selectedText() };
1056
+ case "embed":
1057
+ return { html: "", selection: this._selectedText() };
1058
+ case "link":
1059
+ return this._linkContext();
1060
+ default:
1061
+ return { selection: this._selectedText() };
1062
+ }
1063
+ }
1064
+ /**
1065
+ * Link context doubles as edit mode: when the caret sits in an <a>, the whole
1066
+ * anchor is selected first so whatever the host returns replaces it — the same
1067
+ * outcome as the popup's in-place mutation, but robust to the host re-rendering.
1068
+ */
1069
+ _linkContext() {
1070
+ const anchor = this._anchorInSelection();
1071
+ if (!anchor) {
1072
+ const text = this._selectedText();
1073
+ return { href: "", text, title: "", target: "", selection: text, isEdit: false };
1074
+ }
1075
+ const sel = window.getSelection();
1076
+ if (sel) {
1077
+ const r = document.createRange();
1078
+ r.selectNode(anchor);
1079
+ sel.removeAllRanges();
1080
+ sel.addRange(r);
1081
+ }
1082
+ return {
1083
+ href: anchor.getAttribute("href") || "",
1084
+ text: anchor.textContent || "",
1085
+ title: anchor.getAttribute("title") || "",
1086
+ target: anchor.getAttribute("target") || "",
1087
+ selection: anchor.textContent || "",
1088
+ isEdit: true
1089
+ };
1090
+ }
1091
+ /** Normalises a resolver result (object or bare string) into insertable HTML. */
1092
+ _resolvedHTML(id, result, ctx) {
1093
+ switch (id) {
1094
+ case "image": {
1095
+ const r = typeof result === "string" ? { src: result } : result;
1096
+ const src = String(r.src || r.url || "").trim();
1097
+ return src ? this._imageHTML({ ...r, src }) : null;
1098
+ }
1099
+ case "link": {
1100
+ const r = typeof result === "string" ? { href: result } : result;
1101
+ const href = String(r.href || r.url || "").trim();
1102
+ if (!href)
1103
+ return null;
1104
+ const text = String(r.text != null ? r.text : ctx.text || "").trim();
1105
+ return this._linkHTML({ ...r, href, text });
1106
+ }
1107
+ case "video": {
1108
+ const r = typeof result === "string" ? { url: result } : result;
1109
+ const id2 = r.id ? String(r.id).trim() : this._ytId(String(r.url || "").trim());
1110
+ return id2 ? this._videoHTML(id2) : null;
1111
+ }
1112
+ case "embed": {
1113
+ const html = String(typeof result === "string" ? result : result.html || "").trim();
1114
+ return html ? this._embedHTML(html) : null;
1115
+ }
1116
+ default:
1117
+ return null;
1118
+ }
1119
+ }
1120
+ /** Text of the current selection, '' when it is collapsed or outside the editor. */
1121
+ _selectedText() {
1122
+ const sel = window.getSelection();
1123
+ if (!sel || !sel.rangeCount)
1124
+ return "";
1125
+ return this._editor.contains(sel.getRangeAt(0).commonAncestorContainer) ? sel.toString() : "";
1126
+ }
1127
+ /** Nearest <a> ancestor of the caret, or null when the caret is elsewhere. */
1128
+ _anchorInSelection() {
1129
+ const sel = window.getSelection();
1130
+ if (!sel || !sel.rangeCount)
1131
+ return null;
1132
+ let node = sel.anchorNode;
1133
+ if (!node || !this._editor.contains(node))
1134
+ return null;
1135
+ while (node && node !== this._editor) {
1136
+ if (node.nodeName === "A")
1137
+ return node;
1138
+ node = node.parentNode;
1139
+ }
1140
+ return null;
965
1141
  }
966
1142
  // ─── Status bar ───────────────────────────────────────────────────────────
967
1143
  _buildStatusBar() {
@@ -1011,23 +1187,37 @@ var JotterJS = class {
1011
1187
  }
1012
1188
  });
1013
1189
  this._updateStatus();
1014
- this._emit("change", this.getHTML());
1015
- if (this._options.onChange)
1016
- this._options.onChange(this.getHTML());
1190
+ this._emitChange();
1017
1191
  });
1018
1192
  this._editor.addEventListener("keyup", () => this._updateToolbarState());
1019
1193
  this._editor.addEventListener("mouseup", () => this._updateToolbarState());
1020
- this._editor.addEventListener("focus", () => {
1194
+ this._editor.addEventListener("focusin", (e) => {
1021
1195
  this._root.classList.add("jotter--focused");
1196
+ if (this._externalDepth > 0)
1197
+ return;
1198
+ if (this._isInternalTarget(e.relatedTarget))
1199
+ return;
1022
1200
  this._emit("focus");
1023
1201
  if (this._options.onFocus)
1024
1202
  this._options.onFocus();
1025
1203
  });
1026
- this._editor.addEventListener("blur", () => {
1027
- this._root.classList.remove("jotter--focused");
1028
- this._emit("blur");
1029
- if (this._options.onBlur)
1030
- this._options.onBlur();
1204
+ this._editor.addEventListener("focusout", (e) => {
1205
+ if (this._externalDepth > 0)
1206
+ return;
1207
+ if (this._isInternalTarget(e.relatedTarget))
1208
+ return;
1209
+ setTimeout(() => {
1210
+ if (this._destroyed || this._externalDepth > 0)
1211
+ return;
1212
+ if (this._isInternalTarget(document.activeElement))
1213
+ return;
1214
+ if (this._popupVisible())
1215
+ return;
1216
+ this._root.classList.remove("jotter--focused");
1217
+ this._emit("blur");
1218
+ if (this._options.onBlur)
1219
+ this._options.onBlur();
1220
+ }, 0);
1031
1221
  });
1032
1222
  this._editor.addEventListener("keydown", (e) => {
1033
1223
  if (e.key === "Tab") {
@@ -1035,17 +1225,23 @@ var JotterJS = class {
1035
1225
  document.execCommand("insertHTML", false, "&nbsp;&nbsp;&nbsp;&nbsp;");
1036
1226
  }
1037
1227
  });
1038
- document.addEventListener("mousedown", (e) => {
1039
- if (this._popup.classList.contains("jotter-popup--visible") && !this._popup.contains(e.target) && !this._toolbarEl.contains(e.target)) {
1228
+ this._onDocMouseDown = (e) => {
1229
+ if (this._popupVisible() && !this._popup.contains(e.target) && !this._toolbarEl.contains(e.target)) {
1040
1230
  this._hidePopup();
1041
1231
  }
1042
- });
1043
- document.addEventListener("keydown", (e) => {
1044
- if (e.key === "Escape" && this._popup.classList.contains("jotter-popup--visible")) {
1232
+ };
1233
+ document.addEventListener("mousedown", this._onDocMouseDown);
1234
+ this._onDocKeyDown = (e) => {
1235
+ if (e.key === "Escape" && this._popupVisible()) {
1236
+ this._restoreSavedBookmark();
1045
1237
  this._hidePopup();
1046
- this._editor.focus();
1047
1238
  }
1048
- });
1239
+ };
1240
+ document.addEventListener("keydown", this._onDocKeyDown);
1241
+ }
1242
+ /** True when a node lives inside the editor's own chrome (root or body-level popup). */
1243
+ _isInternalTarget(node) {
1244
+ return !!node && (this._root.contains(node) || this._popup.contains(node));
1049
1245
  }
1050
1246
  // ─── Custom commands ──────────────────────────────────────────────────────
1051
1247
  /**
@@ -1056,7 +1252,8 @@ var JotterJS = class {
1056
1252
  _toggleSourceMode() {
1057
1253
  this._sourceMode = !this._sourceMode;
1058
1254
  if (this._sourceMode) {
1059
- this._source.value = this._prettyHTML(this._editor.innerHTML);
1255
+ this._dropBookmarks();
1256
+ this._source.value = this._prettyHTML(this._richHTML());
1060
1257
  this._editor.style.display = "none";
1061
1258
  this._source.style.display = "block";
1062
1259
  } else {
@@ -1064,9 +1261,7 @@ var JotterJS = class {
1064
1261
  this._source.style.display = "none";
1065
1262
  this._editor.style.display = "";
1066
1263
  this._updateStatus();
1067
- this._emit("change", this.getHTML());
1068
- if (this._options.onChange)
1069
- this._options.onChange(this.getHTML());
1264
+ this._emitChange();
1070
1265
  }
1071
1266
  this._root.classList.toggle("jotter--source-mode", this._sourceMode);
1072
1267
  const btn = this._toolbarEl.querySelector('[data-custom="toggleSource"]');
@@ -1105,6 +1300,7 @@ var JotterJS = class {
1105
1300
  _sanitize(html) {
1106
1301
  const doc = new DOMParser().parseFromString(html, "text/html");
1107
1302
  doc.querySelectorAll("script").forEach((el) => el.remove());
1303
+ doc.querySelectorAll("[data-jotter-bookmark]").forEach((el) => el.remove());
1108
1304
  doc.querySelectorAll("*").forEach((el) => {
1109
1305
  Array.from(el.attributes).forEach((attr) => {
1110
1306
  if (attr.name.startsWith("on")) {
@@ -1221,26 +1417,113 @@ var JotterJS = class {
1221
1417
  this._wordCountEl.textContent = `${words} word${words !== 1 ? "s" : ""}`;
1222
1418
  this._charCountEl.textContent = `${chars} char${chars !== 1 ? "s" : ""}`;
1223
1419
  }
1224
- /** Clones current selection range before a toolbar interaction steals focus. */
1225
- _saveRange() {
1226
- const sel = window.getSelection();
1227
- return sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
1420
+ // ─── Selection bookmarks ──────────────────────────────────────────────────
1421
+ // Toolbar interactions and host UI both need the caret to survive losing
1422
+ // focus. A cloned Range does not: it goes stale the moment the DOM around it
1423
+ // mutates, which is exactly what happens during an upload with a progress
1424
+ // indicator or a host re-render. Empty marker <span>s move with the DOM
1425
+ // instead, so they still point at the right spot afterwards. They are stripped
1426
+ // from getHTML() and from _sanitize(), so hosts never see them.
1427
+ /** Editable area's HTML, minus any live selection markers. */
1428
+ _richHTML() {
1429
+ if (this._bookmarks.size === 0)
1430
+ return this._editor.innerHTML;
1431
+ const clone = this._editor.cloneNode(true);
1432
+ clone.querySelectorAll("[data-jotter-bookmark]").forEach((el) => el.remove());
1433
+ return clone.innerHTML;
1228
1434
  }
1229
- /** Restores a previously saved range so execCommand targets the original selection. */
1230
- _restoreRange(range) {
1231
- if (!range)
1232
- return;
1233
- const sel = window.getSelection();
1234
- sel.removeAllRanges();
1235
- sel.addRange(range);
1435
+ _makeMarker(id) {
1436
+ const el = document.createElement("span");
1437
+ el.className = "jotter-bookmark";
1438
+ el.dataset.jotterBookmark = id;
1439
+ return el;
1440
+ }
1441
+ /**
1442
+ * Turns a bookmark back into a Range and takes it out of circulation:
1443
+ * markers are removed and the token is forgotten. Returns null when the token
1444
+ * is unknown or its markers were wiped out (setHTML, host re-render).
1445
+ */
1446
+ _takeRange(token) {
1447
+ const rec = token != null ? this._bookmarks.get(token) : null;
1448
+ if (!rec)
1449
+ return null;
1450
+ this._bookmarks.delete(token);
1451
+ if (rec.atStart) {
1452
+ const range2 = document.createRange();
1453
+ range2.setStart(this._editor, 0);
1454
+ range2.collapse(true);
1455
+ return range2;
1456
+ }
1457
+ const { start, end } = rec;
1458
+ const live = this._editor.contains(start) && (!end || this._editor.contains(end));
1459
+ let range = null;
1460
+ if (live) {
1461
+ range = document.createRange();
1462
+ range.setStartAfter(start);
1463
+ if (end)
1464
+ range.setEndBefore(end);
1465
+ else
1466
+ range.collapse(true);
1467
+ }
1468
+ if (start.parentNode)
1469
+ start.remove();
1470
+ if (end && end.parentNode)
1471
+ end.remove();
1472
+ return range;
1473
+ }
1474
+ /** Bookmarks the caret for the current toolbar interaction, dropping any previous one. */
1475
+ _saveBookmark() {
1476
+ this._releaseSavedBookmark();
1477
+ this._savedBookmark = this.saveSelection();
1478
+ }
1479
+ /** Puts the caret back where the toolbar interaction started and focuses the editor. */
1480
+ _restoreSavedBookmark() {
1481
+ this.restoreSelection(this._savedBookmark);
1482
+ this._savedBookmark = null;
1483
+ this._editor.focus();
1484
+ }
1485
+ _releaseSavedBookmark() {
1486
+ this.releaseSelection(this._savedBookmark);
1487
+ this._savedBookmark = null;
1488
+ }
1489
+ /** Drops every outstanding bookmark — for operations that replace the content wholesale. */
1490
+ _dropBookmarks() {
1491
+ Array.from(this._bookmarks.keys()).forEach((token) => this.releaseSelection(token));
1492
+ this._savedBookmark = null;
1236
1493
  }
1237
1494
  _emit(event, data) {
1238
1495
  (this._listeners[event] || []).forEach((fn) => fn(data));
1239
1496
  }
1497
+ /** Single funnel for content-change notification, so 'change' and onChange never drift. */
1498
+ _emitChange() {
1499
+ const html = this.getHTML();
1500
+ this._changeCount++;
1501
+ this._emit("change", html);
1502
+ if (this._options.onChange)
1503
+ this._options.onChange(html);
1504
+ }
1505
+ /**
1506
+ * Runs an edit and reports it exactly once.
1507
+ *
1508
+ * execCommand fires `input` on the editable, and that handler already emits —
1509
+ * so emitting again here would double-notify, while an edit made straight
1510
+ * through the DOM (the link popup's in-place update) emits nothing at all.
1511
+ * Both matter: hosts that save on change re-render, and a re-render mid-edit
1512
+ * remounts the editor. So: emit only if the edit changed something and
1513
+ * nothing else has spoken for it.
1514
+ */
1515
+ _applyEdit(fn) {
1516
+ const emits = this._changeCount;
1517
+ const before = this._richHTML();
1518
+ fn();
1519
+ this._updateStatus();
1520
+ if (this._changeCount === emits && this._richHTML() !== before)
1521
+ this._emitChange();
1522
+ }
1240
1523
  // ─── Public API ─────────────────────────────────────────────────────────
1241
1524
  // See class-level JSDoc for full method signatures.
1242
1525
  getHTML() {
1243
- return this._sourceMode ? this._source.value : this._editor.innerHTML;
1526
+ return this._sourceMode ? this._source.value : this._richHTML();
1244
1527
  }
1245
1528
  getText() {
1246
1529
  return this._editor.innerText;
@@ -1253,6 +1536,7 @@ var JotterJS = class {
1253
1536
  return this;
1254
1537
  }
1255
1538
  clear() {
1539
+ this._dropBookmarks();
1256
1540
  this._editor.innerHTML = "";
1257
1541
  this._updateStatus();
1258
1542
  return this;
@@ -1261,25 +1545,122 @@ var JotterJS = class {
1261
1545
  this._editor.focus();
1262
1546
  return this;
1263
1547
  }
1264
- insertHTML(html) {
1548
+ /**
1549
+ * @param {string} html
1550
+ * @param {object} [opts]
1551
+ * @param {*} [opts.at] Bookmark from saveSelection(); insert there instead
1552
+ * of at the current caret. The bookmark is consumed.
1553
+ */
1554
+ insertHTML(html, opts = {}) {
1555
+ if (opts.at != null)
1556
+ this.restoreSelection(opts.at);
1265
1557
  this._editor.focus();
1266
- document.execCommand("insertHTML", false, this._sanitize(html));
1267
- this._updateStatus();
1268
- this._emit("change", this.getHTML());
1269
- if (this._options.onChange)
1270
- this._options.onChange(this.getHTML());
1558
+ this._applyEdit(() => document.execCommand("insertHTML", false, this._sanitize(html)));
1271
1559
  return this;
1272
1560
  }
1273
- insertText(text) {
1561
+ /** @param {object} [opts] @param {*} [opts.at] Bookmark to insert at; consumed. */
1562
+ insertText(text, opts = {}) {
1563
+ if (opts.at != null)
1564
+ this.restoreSelection(opts.at);
1274
1565
  this._editor.focus();
1275
- document.execCommand("insertText", false, text);
1276
- this._updateStatus();
1277
- this._emit("change", this.getHTML());
1278
- if (this._options.onChange)
1279
- this._options.onChange(this.getHTML());
1566
+ this._applyEdit(() => document.execCommand("insertText", false, text));
1567
+ return this;
1568
+ }
1569
+ /**
1570
+ * Bookmarks the current selection and returns an opaque token, or null when
1571
+ * the selection is not inside this editor. The token survives DOM mutation
1572
+ * between save and restore — uploads, progress indicators, host re-renders.
1573
+ * Every token must be handed to restoreSelection() or releaseSelection()
1574
+ * exactly once; both consume it.
1575
+ *
1576
+ * @returns {string|null}
1577
+ */
1578
+ saveSelection() {
1579
+ const sel = window.getSelection();
1580
+ if (!sel || sel.rangeCount === 0)
1581
+ return null;
1582
+ const range = sel.getRangeAt(0);
1583
+ if (!this._editor.contains(range.startContainer) || !this._editor.contains(range.endContainer))
1584
+ return null;
1585
+ const token = "jbm" + ++this._bmSeq;
1586
+ if (this._editor.childNodes.length === 0) {
1587
+ this._bookmarks.set(token, { atStart: true });
1588
+ return token;
1589
+ }
1590
+ const start = this._makeMarker(token);
1591
+ const end = range.collapsed ? null : this._makeMarker(token);
1592
+ if (end) {
1593
+ const r = range.cloneRange();
1594
+ r.collapse(false);
1595
+ r.insertNode(end);
1596
+ }
1597
+ const r0 = range.cloneRange();
1598
+ r0.collapse(true);
1599
+ r0.insertNode(start);
1600
+ this._bookmarks.set(token, { start, end });
1601
+ const live = document.createRange();
1602
+ live.setStartAfter(start);
1603
+ if (end)
1604
+ live.setEndBefore(end);
1605
+ else
1606
+ live.collapse(true);
1607
+ sel.removeAllRanges();
1608
+ sel.addRange(live);
1609
+ return token;
1610
+ }
1611
+ /** Restores (and consumes) a saveSelection() token; no-op for null/stale tokens. */
1612
+ restoreSelection(token) {
1613
+ const range = this._takeRange(token);
1614
+ if (!range)
1615
+ return this;
1616
+ if (!this._sourceMode)
1617
+ this._editor.focus();
1618
+ const sel = window.getSelection();
1619
+ sel.removeAllRanges();
1620
+ sel.addRange(range);
1621
+ return this;
1622
+ }
1623
+ /** Consumes a saveSelection() token without moving the caret. */
1624
+ releaseSelection(token) {
1625
+ this._takeRange(token);
1626
+ return this;
1627
+ }
1628
+ /**
1629
+ * Declares that host UI (a modal, an asset picker) is taking over. Bookmarks
1630
+ * the caret and suppresses focus/blur emission until endExternalUI(), so a
1631
+ * save-on-blur host cannot re-render — and remount the editor — while its own
1632
+ * modal is still open. Calls nest.
1633
+ */
1634
+ beginExternalUI() {
1635
+ if (this._externalDepth === 0)
1636
+ this._externalBookmark = this.saveSelection();
1637
+ this._externalDepth++;
1638
+ return this;
1639
+ }
1640
+ /**
1641
+ * Hands control back: restores the caret bookmarked by beginExternalUI() and
1642
+ * resumes focus/blur emission. Call it after the host UI has closed, so the
1643
+ * focus it takes back is not stolen again.
1644
+ *
1645
+ * @param {object} [opts]
1646
+ * @param {boolean} [opts.restore=true] false to drop the caret instead.
1647
+ */
1648
+ endExternalUI(opts = {}) {
1649
+ if (this._externalDepth === 0)
1650
+ return this;
1651
+ if (this._externalDepth === 1) {
1652
+ const token = this._externalBookmark;
1653
+ this._externalBookmark = null;
1654
+ if (opts.restore === false)
1655
+ this.releaseSelection(token);
1656
+ else
1657
+ this.restoreSelection(token);
1658
+ }
1659
+ this._externalDepth--;
1280
1660
  return this;
1281
1661
  }
1282
1662
  setHTML(html) {
1663
+ this._dropBookmarks();
1283
1664
  this._editor.innerHTML = this._sanitize(html);
1284
1665
  this._updateStatus();
1285
1666
  return this;
@@ -1312,7 +1693,13 @@ var JotterJS = class {
1312
1693
  /** Unmounts editor, restores original element innerHTML, removes body popup, clears listeners. */
1313
1694
  destroy() {
1314
1695
  const html = this.getHTML();
1696
+ this._destroyed = true;
1315
1697
  this._hidePopup();
1698
+ this._dropBookmarks();
1699
+ this._externalBookmark = null;
1700
+ this._externalDepth = 0;
1701
+ document.removeEventListener("mousedown", this._onDocMouseDown);
1702
+ document.removeEventListener("keydown", this._onDocKeyDown);
1316
1703
  if (this._popup.parentNode)
1317
1704
  this._popup.parentNode.removeChild(this._popup);
1318
1705
  this._target.classList.remove("jotter-host");
@@ -1321,77 +1708,9 @@ var JotterJS = class {
1321
1708
  return html;
1322
1709
  }
1323
1710
  };
1324
- JotterJS.toolbar = TOOLBAR_ACTIONS;
1325
- JotterJS.actions = {
1326
- source: { custom: "toggleSource", label: "Source", title: "Edit HTML Source" },
1327
- sep: { type: "sep" },
1328
- blockformat: { type: "blockformat" },
1329
- fontfamily: { type: "fontfamily" },
1330
- fontsize: { type: "fontsize" },
1331
- theme: { type: "theme" },
1332
- undo: { cmd: "undo", icon: "undo", title: "Undo (Ctrl+Z)" },
1333
- redo: { cmd: "redo", icon: "redo", title: "Redo (Ctrl+Y)" },
1334
- bold: { cmd: "bold", icon: "format_bold", title: "Bold (Ctrl+B)" },
1335
- italic: { cmd: "italic", icon: "format_italic", title: "Italic (Ctrl+I)" },
1336
- underline: { cmd: "underline", icon: "format_underlined", title: "Underline (Ctrl+U)" },
1337
- strike: { cmd: "strikeThrough", icon: "strikethrough_s", title: "Strikethrough" },
1338
- subscript: { cmd: "subscript", icon: "subscript", title: "Subscript" },
1339
- superscript: { cmd: "superscript", icon: "superscript", title: "Superscript" },
1340
- code: { custom: "code", icon: "code", title: "Inline Code" },
1341
- copy: { cmd: "copy", icon: "content_copy", title: "Copy" },
1342
- cut: { cmd: "cut", icon: "content_cut", title: "Cut" },
1343
- paste: { cmd: "paste", icon: "content_paste", title: "Paste" },
1344
- clearFormat: { cmd: "removeFormat", icon: "format_clear", title: "Clear Formatting" },
1345
- alignLeft: { cmd: "justifyLeft", icon: "format_align_left", title: "Align Left" },
1346
- alignCenter: { cmd: "justifyCenter", icon: "format_align_center", title: "Align Center" },
1347
- alignRight: { cmd: "justifyRight", icon: "format_align_right", title: "Align Right" },
1348
- bullets: { cmd: "insertUnorderedList", icon: "format_list_bulleted", title: "Bullet List" },
1349
- numbered: { cmd: "insertOrderedList", icon: "format_list_numbered", title: "Numbered List" },
1350
- link: { type: "popup", id: "link", icon: "insert_link", title: "Insert Link" },
1351
- unlink: { cmd: "unlink", icon: "link_off", title: "Remove Link" },
1352
- foreColor: { type: "color", cmd: "foreColor", icon: "format_color_text", title: "Text Color" },
1353
- hiliteColor: { type: "color", cmd: "hiliteColor", icon: "format_color_fill", title: "Background Color" },
1354
- image: { type: "popup", id: "image", icon: "image", title: "Insert Image" },
1355
- video: { type: "popup", id: "video", icon: "smart_display", title: "Insert YouTube Video" },
1356
- table: { type: "popup", id: "table", icon: "table_chart", title: "Insert Table" },
1357
- embed: { type: "popup", id: "embed", icon: "html", title: "Insert Embed" },
1358
- symbol: { type: "popup", id: "symbol", icon: "emoji_symbols", title: "Insert Symbol" },
1359
- specialChar: { type: "popup", id: "specialchar", icon: "format_shapes", title: "Special Characters" },
1360
- lorem: { type: "popup", id: "lorem", icon: "article", title: "Insert Lorem Ipsum" }
1361
- };
1362
- JotterJS.presets = {
1363
- minimal: [
1364
- { custom: "toggleSource", label: "Source", title: "Edit HTML Source" },
1365
- { type: "sep" },
1366
- { cmd: "bold", icon: "format_bold", title: "Bold" },
1367
- { cmd: "italic", icon: "format_italic", title: "Italic" },
1368
- { cmd: "underline", icon: "format_underlined", title: "Underline" },
1369
- { type: "sep" },
1370
- { type: "popup", id: "link", icon: "insert_link", title: "Insert Link" },
1371
- { cmd: "unlink", icon: "link_off", title: "Remove Link" }
1372
- ],
1373
- writing: [
1374
- { custom: "toggleSource", label: "Source", title: "Edit HTML Source" },
1375
- { type: "sep" },
1376
- { cmd: "undo", icon: "undo", title: "Undo" },
1377
- { cmd: "redo", icon: "redo", title: "Redo" },
1378
- { type: "sep" },
1379
- { type: "blockformat" },
1380
- { type: "sep" },
1381
- { cmd: "bold", icon: "format_bold", title: "Bold" },
1382
- { cmd: "italic", icon: "format_italic", title: "Italic" },
1383
- { cmd: "underline", icon: "format_underlined", title: "Underline" },
1384
- { cmd: "strikeThrough", icon: "strikethrough_s", title: "Strikethrough" },
1385
- { type: "sep" },
1386
- { cmd: "insertUnorderedList", icon: "format_list_bulleted", title: "Bullet List" },
1387
- { cmd: "insertOrderedList", icon: "format_list_numbered", title: "Ordered List" },
1388
- { type: "sep" },
1389
- { type: "popup", id: "link", icon: "insert_link", title: "Insert Link" },
1390
- { cmd: "unlink", icon: "link_off", title: "Remove Link" },
1391
- { type: "sep" },
1392
- { type: "popup", id: "image", icon: "image", title: "Insert Image" }
1393
- ]
1394
- };
1711
+ JotterJS.toolbar = PRESETS.full;
1712
+ JotterJS.actions = ACTIONS;
1713
+ JotterJS.presets = PRESETS;
1395
1714
  var jotter_default = JotterJS;
1396
1715
  export {
1397
1716
  JotterJS,