jotterjs 0.2.0 → 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
@@ -122,6 +122,12 @@ var PRESETS = {
122
122
  Object.values(PRESETS).forEach(Object.freeze);
123
123
  Object.freeze(PRESETS);
124
124
  var TOOLBAR_ACTIONS = PRESETS.full;
125
+ var RESOLVER_OPTIONS = {
126
+ image: "onRequestImage",
127
+ link: "onRequestLink",
128
+ video: "onRequestVideo",
129
+ embed: "onRequestEmbed"
130
+ };
125
131
  var HEADING_OPTIONS = [
126
132
  { label: "Paragraph", tag: "p" },
127
133
  { label: "Heading 1", tag: "h1" },
@@ -336,18 +342,28 @@ var JotterJS = class {
336
342
  theme: "default",
337
343
  onChange: null,
338
344
  onFocus: null,
339
- onBlur: null
345
+ onBlur: null,
346
+ onRequestImage: null,
347
+ onRequestLink: null,
348
+ onRequestVideo: null,
349
+ onRequestEmbed: null
340
350
  }, options);
341
351
  this._listeners = {};
342
- 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;
343
359
  this._lastForeColor = "#e8e4d8";
344
360
  this._lastHiliteColor = "#c8a96e";
345
361
  this._init();
346
362
  }
347
363
  // ─── Init ─────────────────────────────────────────────────────────────────
348
364
  // Popup is appended to document.body (not the root) to escape overflow:hidden.
349
- // _savedRange stores the selection before any toolbar interaction steals focus,
350
- // 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().
351
367
  _init() {
352
368
  const initialHTML = this._target.innerHTML || "";
353
369
  this._target.innerHTML = "";
@@ -398,8 +414,14 @@ var JotterJS = class {
398
414
  });
399
415
  return bar;
400
416
  }
401
- /** 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
+ */
402
422
  _buildAction(action) {
423
+ if (typeof action.onClick === "function")
424
+ return this._buildBtn(action);
403
425
  switch (action.type) {
404
426
  case "sep":
405
427
  return this._makeSep();
@@ -453,30 +475,28 @@ var JotterJS = class {
453
475
  btn.addEventListener("mousedown", (e) => {
454
476
  e.preventDefault();
455
477
  this._editor.focus();
456
- if (action.onClick) {
457
- action.onClick(this);
458
- } else if (action.custom === "toggleSource") {
459
- this._toggleSourceMode();
460
- } else if (action.custom === "code") {
461
- this._toggleInlineCode();
462
- } else if (action.cmd === "copy") {
463
- document.execCommand("copy");
464
- } else if (action.cmd === "cut") {
465
- document.execCommand("cut");
466
- } else if (action.cmd === "paste") {
467
- this._pasteFromClipboard();
468
- } else if (action.prompt) {
469
- const val = window.prompt(action.prompt);
470
- if (val)
471
- document.execCommand(action.cmd, false, val);
472
- } else {
473
- document.execCommand(action.cmd, false, null);
474
- }
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
+ });
475
499
  this._updateToolbarState();
476
- this._updateStatus();
477
- this._emit("change", this.getHTML());
478
- if (this._options.onChange)
479
- this._options.onChange(this.getHTML());
480
500
  });
481
501
  return btn;
482
502
  }
@@ -491,17 +511,10 @@ var JotterJS = class {
491
511
  opt.textContent = label;
492
512
  sel.appendChild(opt);
493
513
  });
494
- sel.addEventListener("mousedown", () => {
495
- this._savedRange = this._saveRange();
496
- });
514
+ sel.addEventListener("mousedown", () => this._saveBookmark());
497
515
  sel.addEventListener("change", () => {
498
- this._restoreRange(this._savedRange);
499
- document.execCommand("formatBlock", false, sel.value);
500
- this._editor.focus();
501
- this._updateStatus();
502
- this._emit("change", this.getHTML());
503
- if (this._options.onChange)
504
- this._options.onChange(this.getHTML());
516
+ this._restoreSavedBookmark();
517
+ this._applyEdit(() => document.execCommand("formatBlock", false, sel.value));
505
518
  });
506
519
  return sel;
507
520
  }
@@ -521,18 +534,12 @@ var JotterJS = class {
521
534
  opt.style.fontFamily = f;
522
535
  sel.appendChild(opt);
523
536
  });
524
- sel.addEventListener("mousedown", () => {
525
- this._savedRange = this._saveRange();
526
- });
537
+ sel.addEventListener("mousedown", () => this._saveBookmark());
527
538
  sel.addEventListener("change", () => {
528
539
  if (!sel.value)
529
540
  return;
530
- this._restoreRange(this._savedRange);
531
- document.execCommand("fontName", false, sel.value);
532
- this._editor.focus();
533
- this._emit("change", this.getHTML());
534
- if (this._options.onChange)
535
- this._options.onChange(this.getHTML());
541
+ this._restoreSavedBookmark();
542
+ this._applyEdit(() => document.execCommand("fontName", false, sel.value));
536
543
  });
537
544
  return sel;
538
545
  }
@@ -551,18 +558,12 @@ var JotterJS = class {
551
558
  opt.textContent = `${s}px`;
552
559
  sel.appendChild(opt);
553
560
  });
554
- sel.addEventListener("mousedown", () => {
555
- this._savedRange = this._saveRange();
556
- });
561
+ sel.addEventListener("mousedown", () => this._saveBookmark());
557
562
  sel.addEventListener("change", () => {
558
563
  if (!sel.value)
559
564
  return;
560
- this._restoreRange(this._savedRange);
561
- this._applyFontSize(sel.value);
562
- this._editor.focus();
563
- this._emit("change", this.getHTML());
564
- if (this._options.onChange)
565
- this._options.onChange(this.getHTML());
565
+ this._restoreSavedBookmark();
566
+ this._applyEdit(() => this._applyFontSize(sel.value));
566
567
  });
567
568
  return sel;
568
569
  }
@@ -612,16 +613,12 @@ var JotterJS = class {
612
613
  this._lastForeColor = color;
613
614
  else
614
615
  this._lastHiliteColor = color;
615
- this._restoreRange(this._savedRange);
616
- this._editor.focus();
617
- document.execCommand(action.cmd, false, color);
618
- this._emit("change", this.getHTML());
619
- if (this._options.onChange)
620
- this._options.onChange(this.getHTML());
616
+ this._restoreSavedBookmark();
617
+ this._applyEdit(() => document.execCommand(action.cmd, false, color));
621
618
  });
622
619
  btn.addEventListener("mousedown", (e) => {
623
620
  e.preventDefault();
624
- this._savedRange = this._saveRange();
621
+ this._saveBookmark();
625
622
  input.click();
626
623
  });
627
624
  wrap.appendChild(btn);
@@ -632,6 +629,8 @@ var JotterJS = class {
632
629
  // Single _popup element on document.body; toggled via _showPopup / _hidePopup.
633
630
  // Clicking the same button again dismisses the popup (toggle).
634
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).
635
634
  _buildPopupBtn(action) {
636
635
  const btn = document.createElement("button");
637
636
  btn.type = "button";
@@ -644,8 +643,14 @@ var JotterJS = class {
644
643
  btn.appendChild(icon);
645
644
  btn.addEventListener("mousedown", (e) => {
646
645
  e.preventDefault();
647
- this._savedRange = this._saveRange();
648
- 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) {
649
654
  this._hidePopup();
650
655
  return;
651
656
  }
@@ -675,9 +680,14 @@ var JotterJS = class {
675
680
  }
676
681
  });
677
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). */
678
687
  _hidePopup() {
679
688
  this._popup.classList.remove("jotter-popup--visible");
680
689
  this._popup.dataset.popupId = "";
690
+ this._releaseSavedBookmark();
681
691
  }
682
692
  /** Returns the DOM subtree for the popup identified by id. */
683
693
  _buildPopupContent(id) {
@@ -735,10 +745,7 @@ var JotterJS = class {
735
745
  );
736
746
  });
737
747
  });
738
- cell.addEventListener("click", () => {
739
- this._insertTable(r + 1, c + 1);
740
- this._hidePopup();
741
- });
748
+ cell.addEventListener("click", () => this._insertTable(r + 1, c + 1));
742
749
  cells.push(cell);
743
750
  grid.appendChild(cell);
744
751
  }
@@ -747,9 +754,17 @@ var JotterJS = class {
747
754
  wrap.appendChild(hint);
748
755
  return wrap;
749
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
+ }
750
767
  _insertTable(rows, cols) {
751
- this._restoreRange(this._savedRange);
752
- this._editor.focus();
753
768
  let html = "<table><tbody>";
754
769
  for (let r = 0; r < rows; r++) {
755
770
  html += "<tr>";
@@ -759,10 +774,7 @@ var JotterJS = class {
759
774
  html += "</tr>";
760
775
  }
761
776
  html += "</tbody></table><p><br></p>";
762
- document.execCommand("insertHTML", false, html);
763
- this._emit("change", this.getHTML());
764
- if (this._options.onChange)
765
- this._options.onChange(this.getHTML());
777
+ this._commitPopup(() => document.execCommand("insertHTML", false, html));
766
778
  }
767
779
  /**
768
780
  * Fields: URL, link text, title/tooltip, target.
@@ -773,22 +785,8 @@ var JotterJS = class {
773
785
  const wrap = document.createElement("div");
774
786
  wrap.className = "jotter-popup-inner jotter-popup-form";
775
787
  wrap.appendChild(this._popupTitle("Insert Link"));
776
- let existingAnchor = null;
777
- let selectedText = "";
778
- if (this._savedRange) {
779
- const sel = window.getSelection();
780
- if (sel && sel.rangeCount) {
781
- selectedText = sel.toString();
782
- let node = sel.anchorNode;
783
- while (node && node !== this._editor) {
784
- if (node.nodeName === "A") {
785
- existingAnchor = node;
786
- break;
787
- }
788
- node = node.parentNode;
789
- }
790
- }
791
- }
788
+ const existingAnchor = this._anchorInSelection();
789
+ const selectedText = this._selectedText();
792
790
  const urlInput = this._makeField(wrap, "URL", "url", "https://");
793
791
  const textInput = this._makeField(wrap, "Link text (leave blank to keep selection)", "text", "");
794
792
  const titleInput = this._makeField(wrap, "Title / tooltip", "text", "");
@@ -817,34 +815,25 @@ var JotterJS = class {
817
815
  const href = urlInput.value.trim();
818
816
  if (!href)
819
817
  return;
820
- const linkText = textInput.value.trim() || selectedText || href;
818
+ const text = textInput.value.trim() || selectedText || href;
821
819
  const title = titleInput.value.trim();
822
820
  const target = targetSel.value;
823
- let attrs = `href="${this._esc(href)}"`;
824
- if (target)
825
- attrs += ` target="${this._esc(target)}"`;
826
- if (title)
827
- attrs += ` title="${this._esc(title)}"`;
828
- this._restoreRange(this._savedRange);
829
- this._editor.focus();
830
- if (existingAnchor) {
831
- existingAnchor.href = href;
832
- if (target)
833
- existingAnchor.target = target;
834
- else
835
- existingAnchor.removeAttribute("target");
836
- if (title)
837
- existingAnchor.title = title;
838
- else
839
- existingAnchor.removeAttribute("title");
840
- existingAnchor.textContent = linkText;
841
- } else {
842
- document.execCommand("insertHTML", false, `<a ${attrs}>${this._esc(linkText)}</a>`);
843
- }
844
- this._hidePopup();
845
- this._emit("change", this.getHTML());
846
- if (this._options.onChange)
847
- 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
+ });
848
837
  }));
849
838
  return wrap;
850
839
  }
@@ -860,20 +849,8 @@ var JotterJS = class {
860
849
  const src = urlInput.value.trim();
861
850
  if (!src)
862
851
  return;
863
- const alt = altInput.value.trim();
864
- const w = widthInput.value.trim();
865
- const style = w ? `max-width:${w}` : "max-width:100%";
866
- this._restoreRange(this._savedRange);
867
- this._editor.focus();
868
- document.execCommand(
869
- "insertHTML",
870
- false,
871
- `<img src="${this._esc(src)}" alt="${this._esc(alt)}" style="${style}">`
872
- );
873
- this._hidePopup();
874
- this._emit("change", this.getHTML());
875
- if (this._options.onChange)
876
- 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));
877
854
  }));
878
855
  return wrap;
879
856
  }
@@ -890,14 +867,7 @@ var JotterJS = class {
890
867
  return;
891
868
  }
892
869
  urlInput.classList.remove("jotter-input--error");
893
- 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>`;
894
- this._restoreRange(this._savedRange);
895
- this._editor.focus();
896
- document.execCommand("insertHTML", false, html);
897
- this._hidePopup();
898
- this._emit("change", this.getHTML());
899
- if (this._options.onChange)
900
- this._options.onChange(this.getHTML());
870
+ this._commitPopup(() => document.execCommand("insertHTML", false, this._videoHTML(id)));
901
871
  }));
902
872
  return wrap;
903
873
  }
@@ -927,13 +897,7 @@ var JotterJS = class {
927
897
  const html = ta.value.trim();
928
898
  if (!html)
929
899
  return;
930
- this._restoreRange(this._savedRange);
931
- this._editor.focus();
932
- document.execCommand("insertHTML", false, html + "<p><br></p>");
933
- this._hidePopup();
934
- this._emit("change", this.getHTML());
935
- if (this._options.onChange)
936
- this._options.onChange(this.getHTML());
900
+ this._commitPopup(() => document.execCommand("insertHTML", false, this._embedHTML(html)));
937
901
  }));
938
902
  return wrap;
939
903
  }
@@ -964,13 +928,7 @@ var JotterJS = class {
964
928
  btn.title = `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
965
929
  btn.addEventListener("mousedown", (e) => {
966
930
  e.preventDefault();
967
- this._restoreRange(this._savedRange);
968
- this._editor.focus();
969
- document.execCommand("insertText", false, ch);
970
- this._hidePopup();
971
- this._emit("change", this.getHTML());
972
- if (this._options.onChange)
973
- this._options.onChange(this.getHTML());
931
+ this._commitPopup(() => document.execCommand("insertText", false, ch));
974
932
  });
975
933
  grid.appendChild(btn);
976
934
  });
@@ -988,17 +946,11 @@ var JotterJS = class {
988
946
  btn.textContent = v.label;
989
947
  btn.addEventListener("mousedown", (e) => {
990
948
  e.preventDefault();
991
- this._restoreRange(this._savedRange);
992
- this._editor.focus();
993
- if (v.isHTML) {
994
- document.execCommand("insertHTML", false, v.text);
995
- } else {
996
- document.execCommand("insertText", false, v.text);
997
- }
998
- this._hidePopup();
999
- this._emit("change", this.getHTML());
1000
- if (this._options.onChange)
1001
- this._options.onChange(this.getHTML());
949
+ this._commitPopup(() => document.execCommand(
950
+ v.isHTML ? "insertHTML" : "insertText",
951
+ false,
952
+ v.text
953
+ ));
1002
954
  });
1003
955
  wrap.appendChild(btn);
1004
956
  });
@@ -1035,7 +987,157 @@ var JotterJS = class {
1035
987
  }
1036
988
  /** Escapes ", <, > for safe insertion into HTML attribute values and text. */
1037
989
  _esc(s) {
1038
- 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;
1039
1141
  }
1040
1142
  // ─── Status bar ───────────────────────────────────────────────────────────
1041
1143
  _buildStatusBar() {
@@ -1085,23 +1187,37 @@ var JotterJS = class {
1085
1187
  }
1086
1188
  });
1087
1189
  this._updateStatus();
1088
- this._emit("change", this.getHTML());
1089
- if (this._options.onChange)
1090
- this._options.onChange(this.getHTML());
1190
+ this._emitChange();
1091
1191
  });
1092
1192
  this._editor.addEventListener("keyup", () => this._updateToolbarState());
1093
1193
  this._editor.addEventListener("mouseup", () => this._updateToolbarState());
1094
- this._editor.addEventListener("focus", () => {
1194
+ this._editor.addEventListener("focusin", (e) => {
1095
1195
  this._root.classList.add("jotter--focused");
1196
+ if (this._externalDepth > 0)
1197
+ return;
1198
+ if (this._isInternalTarget(e.relatedTarget))
1199
+ return;
1096
1200
  this._emit("focus");
1097
1201
  if (this._options.onFocus)
1098
1202
  this._options.onFocus();
1099
1203
  });
1100
- this._editor.addEventListener("blur", () => {
1101
- this._root.classList.remove("jotter--focused");
1102
- this._emit("blur");
1103
- if (this._options.onBlur)
1104
- 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);
1105
1221
  });
1106
1222
  this._editor.addEventListener("keydown", (e) => {
1107
1223
  if (e.key === "Tab") {
@@ -1109,17 +1225,23 @@ var JotterJS = class {
1109
1225
  document.execCommand("insertHTML", false, "&nbsp;&nbsp;&nbsp;&nbsp;");
1110
1226
  }
1111
1227
  });
1112
- document.addEventListener("mousedown", (e) => {
1113
- 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)) {
1114
1230
  this._hidePopup();
1115
1231
  }
1116
- });
1117
- document.addEventListener("keydown", (e) => {
1118
- 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();
1119
1237
  this._hidePopup();
1120
- this._editor.focus();
1121
1238
  }
1122
- });
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));
1123
1245
  }
1124
1246
  // ─── Custom commands ──────────────────────────────────────────────────────
1125
1247
  /**
@@ -1130,7 +1252,8 @@ var JotterJS = class {
1130
1252
  _toggleSourceMode() {
1131
1253
  this._sourceMode = !this._sourceMode;
1132
1254
  if (this._sourceMode) {
1133
- this._source.value = this._prettyHTML(this._editor.innerHTML);
1255
+ this._dropBookmarks();
1256
+ this._source.value = this._prettyHTML(this._richHTML());
1134
1257
  this._editor.style.display = "none";
1135
1258
  this._source.style.display = "block";
1136
1259
  } else {
@@ -1138,9 +1261,7 @@ var JotterJS = class {
1138
1261
  this._source.style.display = "none";
1139
1262
  this._editor.style.display = "";
1140
1263
  this._updateStatus();
1141
- this._emit("change", this.getHTML());
1142
- if (this._options.onChange)
1143
- this._options.onChange(this.getHTML());
1264
+ this._emitChange();
1144
1265
  }
1145
1266
  this._root.classList.toggle("jotter--source-mode", this._sourceMode);
1146
1267
  const btn = this._toolbarEl.querySelector('[data-custom="toggleSource"]');
@@ -1179,6 +1300,7 @@ var JotterJS = class {
1179
1300
  _sanitize(html) {
1180
1301
  const doc = new DOMParser().parseFromString(html, "text/html");
1181
1302
  doc.querySelectorAll("script").forEach((el) => el.remove());
1303
+ doc.querySelectorAll("[data-jotter-bookmark]").forEach((el) => el.remove());
1182
1304
  doc.querySelectorAll("*").forEach((el) => {
1183
1305
  Array.from(el.attributes).forEach((attr) => {
1184
1306
  if (attr.name.startsWith("on")) {
@@ -1295,26 +1417,113 @@ var JotterJS = class {
1295
1417
  this._wordCountEl.textContent = `${words} word${words !== 1 ? "s" : ""}`;
1296
1418
  this._charCountEl.textContent = `${chars} char${chars !== 1 ? "s" : ""}`;
1297
1419
  }
1298
- /** Clones current selection range before a toolbar interaction steals focus. */
1299
- _saveRange() {
1300
- const sel = window.getSelection();
1301
- 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;
1302
1434
  }
1303
- /** Restores a previously saved range so execCommand targets the original selection. */
1304
- _restoreRange(range) {
1305
- if (!range)
1306
- return;
1307
- const sel = window.getSelection();
1308
- sel.removeAllRanges();
1309
- 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;
1310
1493
  }
1311
1494
  _emit(event, data) {
1312
1495
  (this._listeners[event] || []).forEach((fn) => fn(data));
1313
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
+ }
1314
1523
  // ─── Public API ─────────────────────────────────────────────────────────
1315
1524
  // See class-level JSDoc for full method signatures.
1316
1525
  getHTML() {
1317
- return this._sourceMode ? this._source.value : this._editor.innerHTML;
1526
+ return this._sourceMode ? this._source.value : this._richHTML();
1318
1527
  }
1319
1528
  getText() {
1320
1529
  return this._editor.innerText;
@@ -1327,6 +1536,7 @@ var JotterJS = class {
1327
1536
  return this;
1328
1537
  }
1329
1538
  clear() {
1539
+ this._dropBookmarks();
1330
1540
  this._editor.innerHTML = "";
1331
1541
  this._updateStatus();
1332
1542
  return this;
@@ -1335,25 +1545,122 @@ var JotterJS = class {
1335
1545
  this._editor.focus();
1336
1546
  return this;
1337
1547
  }
1338
- 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);
1339
1557
  this._editor.focus();
1340
- document.execCommand("insertHTML", false, this._sanitize(html));
1341
- this._updateStatus();
1342
- this._emit("change", this.getHTML());
1343
- if (this._options.onChange)
1344
- this._options.onChange(this.getHTML());
1558
+ this._applyEdit(() => document.execCommand("insertHTML", false, this._sanitize(html)));
1345
1559
  return this;
1346
1560
  }
1347
- 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);
1348
1565
  this._editor.focus();
1349
- document.execCommand("insertText", false, text);
1350
- this._updateStatus();
1351
- this._emit("change", this.getHTML());
1352
- if (this._options.onChange)
1353
- 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--;
1354
1660
  return this;
1355
1661
  }
1356
1662
  setHTML(html) {
1663
+ this._dropBookmarks();
1357
1664
  this._editor.innerHTML = this._sanitize(html);
1358
1665
  this._updateStatus();
1359
1666
  return this;
@@ -1386,7 +1693,13 @@ var JotterJS = class {
1386
1693
  /** Unmounts editor, restores original element innerHTML, removes body popup, clears listeners. */
1387
1694
  destroy() {
1388
1695
  const html = this.getHTML();
1696
+ this._destroyed = true;
1389
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);
1390
1703
  if (this._popup.parentNode)
1391
1704
  this._popup.parentNode.removeChild(this._popup);
1392
1705
  this._target.classList.remove("jotter-host");