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/README.md +253 -122
- package/dist/jotter.iife.min.js +3 -3
- package/dist/jotter.js +648 -329
- package/dist/jotter.min.css +1 -1
- package/dist/jotter.min.js +3 -3
- package/package.json +1 -1
package/dist/jotter.js
CHANGED
|
@@ -1,53 +1,133 @@
|
|
|
1
1
|
// src/jotter.js
|
|
2
|
-
var
|
|
3
|
-
{ custom: "toggleSource", label: "Source", title: "Edit HTML Source" },
|
|
4
|
-
{ type: "sep" },
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
{ type: "
|
|
8
|
-
|
|
9
|
-
{ cmd: "
|
|
10
|
-
{ cmd: "
|
|
11
|
-
{
|
|
12
|
-
{ cmd: "
|
|
13
|
-
{
|
|
14
|
-
{
|
|
15
|
-
{
|
|
16
|
-
{
|
|
17
|
-
{
|
|
18
|
-
{ cmd: "
|
|
19
|
-
{ cmd: "
|
|
20
|
-
{ cmd: "
|
|
21
|
-
{ cmd: "
|
|
22
|
-
{ cmd: "
|
|
23
|
-
{ cmd: "
|
|
24
|
-
{
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
{ type: "
|
|
28
|
-
{
|
|
29
|
-
{ cmd: "
|
|
30
|
-
{ cmd: "
|
|
31
|
-
{
|
|
32
|
-
{ type: "
|
|
33
|
-
{
|
|
34
|
-
{
|
|
35
|
-
{ type: "
|
|
36
|
-
{ type: "popup", id: "
|
|
37
|
-
{
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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
|
-
//
|
|
276
|
-
// 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().
|
|
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
|
-
/**
|
|
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
|
-
|
|
383
|
-
action.onClick
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
542
|
-
this.
|
|
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.
|
|
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
|
-
|
|
574
|
-
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) {
|
|
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
|
-
|
|
703
|
-
|
|
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
|
|
818
|
+
const text = textInput.value.trim() || selectedText || href;
|
|
747
819
|
const title = titleInput.value.trim();
|
|
748
820
|
const target = targetSel.value;
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
790
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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, """).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;
|
|
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.
|
|
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("
|
|
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("
|
|
1027
|
-
this.
|
|
1028
|
-
|
|
1029
|
-
if (this.
|
|
1030
|
-
|
|
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, " ");
|
|
1036
1226
|
}
|
|
1037
1227
|
});
|
|
1038
|
-
|
|
1039
|
-
if (this.
|
|
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("
|
|
1044
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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 =
|
|
1325
|
-
JotterJS.actions =
|
|
1326
|
-
|
|
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,
|