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/README.md +110 -1
- package/dist/jotter.iife.min.js +3 -3
- package/dist/jotter.js +522 -209
- package/dist/jotter.min.css +1 -1
- package/dist/jotter.min.js +3 -3
- package/package.json +45 -45
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.
|
|
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
|
-
//
|
|
350
|
-
// so popup submit handlers can
|
|
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
|
-
/**
|
|
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
|
-
|
|
457
|
-
action.onClick
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
616
|
-
this.
|
|
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.
|
|
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
|
-
|
|
648
|
-
if (
|
|
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
|
-
|
|
777
|
-
|
|
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
|
|
818
|
+
const text = textInput.value.trim() || selectedText || href;
|
|
821
819
|
const title = titleInput.value.trim();
|
|
822
820
|
const target = targetSel.value;
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
|
864
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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, """).replace(/</g, "<").replace(/>/g, ">");
|
|
990
|
+
return String(s).replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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.
|
|
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("
|
|
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("
|
|
1101
|
-
this.
|
|
1102
|
-
|
|
1103
|
-
if (this.
|
|
1104
|
-
|
|
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, " ");
|
|
1110
1226
|
}
|
|
1111
1227
|
});
|
|
1112
|
-
|
|
1113
|
-
if (this.
|
|
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("
|
|
1118
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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");
|