@richscripts/richtexteditor 2.14.0 → 2.15.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 +173 -10
- package/angular/build-manifest.json +23 -0
- package/angular/index.d.ts +71 -76
- package/angular/index.mjs +2 -0
- package/angular/package.json +4 -0
- package/headless/index.d.ts +3 -1
- package/headless/index.js +72 -16
- package/index.d.ts +33 -54
- package/integrations/initialization-scope.js +27 -0
- package/integrations/native-mount.js +30 -0
- package/integrations/shared.d.ts +23 -1
- package/integrations/shared.js +58 -22
- package/package.json +25 -3
- package/react/index.js +118 -37
- package/react/index.mjs +29 -0
- package/richtexteditor/all_plugins.js +7113 -1111
- package/richtexteditor/all_plugins.min.js +62 -59
- package/richtexteditor/lang/emoji/LICENSE-CLDR.txt +41 -0
- package/richtexteditor/lang/emoji/rte-emoji-de.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-es.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-fr.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-it.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-ja.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-ko.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-nl.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-pt-pt.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-pt.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-ru.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-zh-Hans.js +3 -0
- package/richtexteditor/lang/emoji/rte-emoji-zh-Hant.js +3 -0
- package/richtexteditor/lang/rte-lang-af.js +1 -0
- package/richtexteditor/lang/rte-lang-ar.js +1 -0
- package/richtexteditor/lang/rte-lang-bg.js +1 -0
- package/richtexteditor/lang/rte-lang-bn.js +1 -0
- package/richtexteditor/lang/rte-lang-bs.js +1 -0
- package/richtexteditor/lang/rte-lang-ca.js +1 -0
- package/richtexteditor/lang/rte-lang-cs.js +1 -0
- package/richtexteditor/lang/rte-lang-cy.js +1 -0
- package/richtexteditor/lang/rte-lang-da.js +1 -0
- package/richtexteditor/lang/rte-lang-de.js +1017 -2
- package/richtexteditor/lang/rte-lang-el.js +1 -0
- package/richtexteditor/lang/rte-lang-en.js +1 -0
- package/richtexteditor/lang/rte-lang-es.js +1023 -2
- package/richtexteditor/lang/rte-lang-et.js +1 -0
- package/richtexteditor/lang/rte-lang-fa.js +1 -0
- package/richtexteditor/lang/rte-lang-fi.js +1 -0
- package/richtexteditor/lang/rte-lang-fil.js +1 -0
- package/richtexteditor/lang/rte-lang-fj.js +1 -0
- package/richtexteditor/lang/rte-lang-fr.js +1023 -2
- package/richtexteditor/lang/rte-lang-ga.js +1 -0
- package/richtexteditor/lang/rte-lang-gu.js +1 -0
- package/richtexteditor/lang/rte-lang-he.js +1 -0
- package/richtexteditor/lang/rte-lang-hi.js +1 -0
- package/richtexteditor/lang/rte-lang-hr.js +1 -0
- package/richtexteditor/lang/rte-lang-ht.js +1 -0
- package/richtexteditor/lang/rte-lang-hu.js +1 -0
- package/richtexteditor/lang/rte-lang-id.js +1 -0
- package/richtexteditor/lang/rte-lang-is.js +1 -0
- package/richtexteditor/lang/rte-lang-it.js +1023 -2
- package/richtexteditor/lang/rte-lang-ja.js +1023 -2
- package/richtexteditor/lang/rte-lang-kn.js +1 -0
- package/richtexteditor/lang/rte-lang-ko.js +1023 -2
- package/richtexteditor/lang/rte-lang-lt.js +1 -0
- package/richtexteditor/lang/rte-lang-lv.js +1 -0
- package/richtexteditor/lang/rte-lang-mg.js +1 -0
- package/richtexteditor/lang/rte-lang-mi.js +1 -0
- package/richtexteditor/lang/rte-lang-ml.js +1 -0
- package/richtexteditor/lang/rte-lang-mr.js +1 -0
- package/richtexteditor/lang/rte-lang-ms.js +1 -0
- package/richtexteditor/lang/rte-lang-mt.js +1 -0
- package/richtexteditor/lang/rte-lang-mww.js +1 -0
- package/richtexteditor/lang/rte-lang-nb.js +1 -0
- package/richtexteditor/lang/rte-lang-nl.js +1023 -2
- package/richtexteditor/lang/rte-lang-otq.js +1 -0
- package/richtexteditor/lang/rte-lang-pa.js +1 -0
- package/richtexteditor/lang/rte-lang-pl.js +1 -0
- package/richtexteditor/lang/rte-lang-pt-pt.js +1023 -2
- package/richtexteditor/lang/rte-lang-pt.js +1023 -2
- package/richtexteditor/lang/rte-lang-ro.js +1 -0
- package/richtexteditor/lang/rte-lang-ru.js +1023 -2
- package/richtexteditor/lang/rte-lang-sk.js +1 -0
- package/richtexteditor/lang/rte-lang-sl.js +1 -0
- package/richtexteditor/lang/rte-lang-sm.js +1 -0
- package/richtexteditor/lang/rte-lang-sr-Cyrl.js +1 -0
- package/richtexteditor/lang/rte-lang-sr-Latn.js +1 -0
- package/richtexteditor/lang/rte-lang-sv.js +1 -0
- package/richtexteditor/lang/rte-lang-sw.js +1 -0
- package/richtexteditor/lang/rte-lang-ta.js +1 -0
- package/richtexteditor/lang/rte-lang-te.js +1 -0
- package/richtexteditor/lang/rte-lang-th.js +1 -0
- package/richtexteditor/lang/rte-lang-tlh-Latn.js +1 -0
- package/richtexteditor/lang/rte-lang-tlh-Piqd.js +1 -0
- package/richtexteditor/lang/rte-lang-to.js +1 -0
- package/richtexteditor/lang/rte-lang-tr.js +1 -0
- package/richtexteditor/lang/rte-lang-ty.js +1 -0
- package/richtexteditor/lang/rte-lang-uk.js +1 -0
- package/richtexteditor/lang/rte-lang-ur.js +1 -0
- package/richtexteditor/lang/rte-lang-vi.js +1 -0
- package/richtexteditor/lang/rte-lang-yua.js +1 -0
- package/richtexteditor/lang/rte-lang-zh-Hans.js +1017 -2
- package/richtexteditor/lang/rte-lang-zh-Hant.js +1017 -2
- package/richtexteditor/plugins/a11yenhance.js +2 -1
- package/richtexteditor/plugins/a11yenhance.min.js +1 -1
- package/richtexteditor/plugins/accessibilitychecker.js +252 -60
- package/richtexteditor/plugins/accessibilitychecker.min.js +1 -1
- package/richtexteditor/plugins/aitoolkit.js +966 -303
- package/richtexteditor/plugins/aitoolkit.min.js +7 -7
- package/richtexteditor/plugins/all_plugins.js +7113 -1111
- package/richtexteditor/plugins/all_plugins.min.js +62 -59
- package/richtexteditor/plugins/autocorrect.js +66 -31
- package/richtexteditor/plugins/autocorrect.min.js +1 -1
- package/richtexteditor/plugins/autoembed.js +17 -0
- package/richtexteditor/plugins/autoembed.min.js +1 -1
- package/richtexteditor/plugins/blocktypes.js +18 -9
- package/richtexteditor/plugins/blocktypes.min.js +1 -1
- package/richtexteditor/plugins/bookmarkcard.js +12 -5
- package/richtexteditor/plugins/bookmarkcard.min.js +2 -2
- package/richtexteditor/plugins/changecase.js +72 -19
- package/richtexteditor/plugins/changecase.min.js +1 -1
- package/richtexteditor/plugins/charlimit.js +62 -2
- package/richtexteditor/plugins/charlimit.min.js +2 -2
- package/richtexteditor/plugins/comments.js +458 -59
- package/richtexteditor/plugins/comments.min.js +4 -3
- package/richtexteditor/plugins/contentminimap.js +25 -6
- package/richtexteditor/plugins/contentminimap.min.js +1 -1
- package/richtexteditor/plugins/crossreference.js +1 -1
- package/richtexteditor/plugins/crossreference.min.js +1 -1
- package/richtexteditor/plugins/dialoga11y.js +26 -4
- package/richtexteditor/plugins/dialoga11y.min.js +1 -1
- package/richtexteditor/plugins/dictation.js +1 -1
- package/richtexteditor/plugins/documentimport.js +220 -51
- package/richtexteditor/plugins/documentimport.min.js +4 -4
- package/richtexteditor/plugins/documentoutline.js +88 -38
- package/richtexteditor/plugins/documentoutline.min.js +1 -1
- package/richtexteditor/plugins/docxexport.js +204 -32
- package/richtexteditor/plugins/docxexport.min.js +2 -2
- package/richtexteditor/plugins/draghandle.js +267 -3
- package/richtexteditor/plugins/draghandle.min.js +1 -1
- package/richtexteditor/plugins/emailtoolkit.js +28 -14
- package/richtexteditor/plugins/emailtoolkit.min.js +1 -1
- package/richtexteditor/plugins/emojiautocomplete.js +110 -56
- package/richtexteditor/plugins/emojiautocomplete.min.js +1 -1
- package/richtexteditor/plugins/footnotes.js +65 -11
- package/richtexteditor/plugins/footnotes.min.js +1 -1
- package/richtexteditor/plugins/formatpainter.js +10 -0
- package/richtexteditor/plugins/formatpainter.min.js +1 -1
- package/richtexteditor/plugins/formattingmarks.js +53 -8
- package/richtexteditor/plugins/formattingmarks.min.js +1 -1
- package/richtexteditor/plugins/gapcursor.js +60 -0
- package/richtexteditor/plugins/gapcursor.min.js +1 -1
- package/richtexteditor/plugins/ghostcomplete.js +1 -1
- package/richtexteditor/plugins/html2pdf.js +1 -1
- package/richtexteditor/plugins/html2pdf.min.js +1 -1
- package/richtexteditor/plugins/imageeditor.js +12 -6
- package/richtexteditor/plugins/imageeditor.min.js +1 -1
- package/richtexteditor/plugins/importstyles.js +1 -1
- package/richtexteditor/plugins/inlinecode.js +25 -0
- package/richtexteditor/plugins/inlinecode.min.js +1 -1
- package/richtexteditor/plugins/insertcode.js +21 -3
- package/richtexteditor/plugins/insertcode.min.js +4 -4
- package/richtexteditor/plugins/insertemoji.js +112 -15
- package/richtexteditor/plugins/insertemoji.min.js +1 -1
- package/richtexteditor/plugins/insertgallery.js +42 -36
- package/richtexteditor/plugins/insertgallery.min.js +1 -1
- package/richtexteditor/plugins/inserttemplate.js +33 -17
- package/richtexteditor/plugins/inserttemplate.min.js +1 -1
- package/richtexteditor/plugins/keyboarda11y.js +66 -3
- package/richtexteditor/plugins/keyboarda11y.min.js +1 -1
- package/richtexteditor/plugins/layouttable.js +20 -0
- package/richtexteditor/plugins/layouttable.min.js +1 -1
- package/richtexteditor/plugins/linkchecker.js +29 -9
- package/richtexteditor/plugins/linkchecker.min.js +1 -1
- package/richtexteditor/plugins/localdraft.js +51 -7
- package/richtexteditor/plugins/localdraft.min.js +1 -1
- package/richtexteditor/plugins/markdownexport.js +159 -0
- package/richtexteditor/plugins/markdownexport.min.js +2 -2
- package/richtexteditor/plugins/mathdialog.js +11 -5
- package/richtexteditor/plugins/mathdialog.min.js +1 -1
- package/richtexteditor/plugins/mentionplugin.js +88 -25
- package/richtexteditor/plugins/mentionplugin.min.js +1 -1
- package/richtexteditor/plugins/mermaiddiagram.js +9 -3
- package/richtexteditor/plugins/mermaiddiagram.min.js +2 -2
- package/richtexteditor/plugins/multilevellist.js +4 -0
- package/richtexteditor/plugins/multilevellist.min.js +1 -1
- package/richtexteditor/plugins/pdfexport.js +235 -35
- package/richtexteditor/plugins/pdfexport.min.js +12 -12
- package/richtexteditor/plugins/permanentpen.js +3 -3
- package/richtexteditor/plugins/permanentpen.min.js +1 -1
- package/richtexteditor/plugins/readabilitystats.js +25 -11
- package/richtexteditor/plugins/readabilitystats.min.js +1 -1
- package/richtexteditor/plugins/readaloud.js +29 -15
- package/richtexteditor/plugins/readaloud.min.js +1 -1
- package/richtexteditor/plugins/restrictedediting.js +166 -0
- package/richtexteditor/plugins/restrictedediting.min.js +2 -2
- package/richtexteditor/plugins/revisionhistory.js +169 -37
- package/richtexteditor/plugins/revisionhistory.min.js +3 -3
- package/richtexteditor/plugins/rubyannotation.js +19 -9
- package/richtexteditor/plugins/rubyannotation.min.js +1 -1
- package/richtexteditor/plugins/sanitizer.js +86 -4
- package/richtexteditor/plugins/sanitizer.min.js +1 -1
- package/richtexteditor/plugins/slashcommand.js +30 -16
- package/richtexteditor/plugins/slashcommand.min.js +2 -2
- package/richtexteditor/plugins/smartchips.js +36 -22
- package/richtexteditor/plugins/smartchips.min.js +2 -2
- package/richtexteditor/plugins/spellcheck.js +17 -8
- package/richtexteditor/plugins/spellcheck.min.js +2 -2
- package/richtexteditor/plugins/stateinspector.js +19 -9
- package/richtexteditor/plugins/stateinspector.min.js +2 -2
- package/richtexteditor/plugins/structured-content-bridge.js +56 -24
- package/richtexteditor/plugins/structured-content-bridge.min.js +7 -7
- package/richtexteditor/plugins/tableofcontents.js +5 -2
- package/richtexteditor/plugins/tableofcontents.min.js +1 -1
- package/richtexteditor/plugins/tabletools.js +388 -3
- package/richtexteditor/plugins/tabletools.min.js +1 -1
- package/richtexteditor/plugins/textdirection.js +18 -5
- package/richtexteditor/plugins/textdirection.min.js +1 -1
- package/richtexteditor/plugins/textpartlanguage.js +161 -32
- package/richtexteditor/plugins/textpartlanguage.min.js +1 -1
- package/richtexteditor/plugins/todolist.js +90 -6
- package/richtexteditor/plugins/todolist.min.js +1 -1
- package/richtexteditor/plugins/trackedchanges.js +1812 -25
- package/richtexteditor/plugins/trackedchanges.min.js +5 -3
- package/richtexteditor/plugins/wordexport.js +2 -2
- package/richtexteditor/plugins/yjscollab.js +9 -4
- package/richtexteditor/plugins/yjscollab.min.js +1 -1
- package/richtexteditor/plugins/zz_richtextbox_dialog_style.js +22 -13
- package/richtexteditor/plugins/zz_richtextbox_dialog_style.min.js +1 -1
- package/richtexteditor/rte-config.js +131 -50
- package/richtexteditor/rte-headless.js +19 -4
- package/richtexteditor/rte-webcomponent.js +89 -16
- package/richtexteditor/rte.js +133 -52
- package/richtexteditor/runtime/colorpicker_more_ns.htm +93 -16
- package/scripts/build-angular.mjs +97 -0
- package/svelte/index.d.ts +2 -1
- package/svelte/index.js +80 -9
- package/vue/index.js +130 -34
- package/angular/component.ts +0 -297
- package/angular/index.ts +0 -24
|
@@ -45,25 +45,32 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
45
45
|
enable: function (user) { enable(user); },
|
|
46
46
|
disable: function () { disable(); },
|
|
47
47
|
isEnabled: function () { return enabled; },
|
|
48
|
-
acceptAll: function (filter) { return acceptAll(filter); },
|
|
49
|
-
rejectAll: function (filter) { return rejectAll(filter); },
|
|
50
|
-
accept: function (id) { return acceptEntry(id); },
|
|
51
|
-
reject: function (id) { return rejectEntry(id); },
|
|
48
|
+
acceptAll: function (filter) { syncFromDocument(); return acceptAll(filter); },
|
|
49
|
+
rejectAll: function (filter) { syncFromDocument(); return rejectAll(filter); },
|
|
50
|
+
accept: function (id) { syncFromDocument(); return acceptEntry(id); },
|
|
51
|
+
reject: function (id) { syncFromDocument(); return rejectEntry(id); },
|
|
52
52
|
canDecide: function (id, action) {
|
|
53
|
+
syncFromDocument();
|
|
53
54
|
var entry = editor.reviewLedger && editor.reviewLedger.get(id);
|
|
54
55
|
return !!entry && canDecide(entry, action || "accept");
|
|
55
56
|
},
|
|
56
57
|
list: function (opts) {
|
|
57
58
|
if (!editor.reviewLedger) return [];
|
|
59
|
+
syncFromDocument();
|
|
58
60
|
// Default to the PENDING review queue (matches the internal
|
|
59
61
|
// accept/reject semantics). Pass {all:true} for full history
|
|
60
62
|
// including accepted/rejected entries (also via reviewLedger.list()).
|
|
61
63
|
var includeAll = opts && opts.all === true;
|
|
62
64
|
return editor.reviewLedger.list().filter(function (e) {
|
|
63
|
-
if (e.changeType !== "insert" && e.changeType !== "delete") return false;
|
|
65
|
+
if (e.changeType !== "insert" && e.changeType !== "delete" && e.changeType !== "format") return false;
|
|
64
66
|
return includeAll || e.status === "pending";
|
|
65
67
|
});
|
|
66
|
-
}
|
|
68
|
+
},
|
|
69
|
+
// 2026-10-17 step through the pending suggestions and report the one at the caret.
|
|
70
|
+
// tools/review/suggestion-card.mjs
|
|
71
|
+
next: function () { return stepSuggestion(1); },
|
|
72
|
+
previous: function () { return stepSuggestion(-1); },
|
|
73
|
+
current: function () { var el = suggestionAtCaret(); return el ? el.getAttribute("data-tc-id") : null; }
|
|
67
74
|
};
|
|
68
75
|
|
|
69
76
|
injectStyles();
|
|
@@ -93,7 +100,99 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
93
100
|
else enable();
|
|
94
101
|
});
|
|
95
102
|
|
|
96
|
-
|
|
103
|
+
// 2026-10-17 bound through the core so they follow the live editing document (these worked only because this
|
|
104
|
+
// plugin initialises after the last plugin that moves the iframe). tools/keyboard/plugin-listeners-live.mjs
|
|
105
|
+
var bindEd = editor.bindEditableEvent ? function (type, fn) { editor.bindEditableEvent(type, fn, true); } : function (type, fn) { editor.getEditable().addEventListener(type, fn, true); };
|
|
106
|
+
bindEd("beforeinput", onBeforeInput);
|
|
107
|
+
// the suggestion card follows the caret (tools/review/suggestion-card.mjs)
|
|
108
|
+
if (editor.bindDocumentEvent) editor.bindDocumentEvent("selectionchange", function () { scheduleCard(); });
|
|
109
|
+
// 2026-10-17 Alt+F10 moves focus to the suggestion card while it shows (else the core takes it to a toolbar).
|
|
110
|
+
// tools/review/suggestion-card-keyboard.mjs
|
|
111
|
+
if (editor.bindDocumentEvent) editor.bindDocumentEvent("keydown", function (e) {
|
|
112
|
+
if (!(e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && (e.key === "F10" || e.keyCode === 121))) return;
|
|
113
|
+
if (!sugCard || !sugCard.isConnected) return;
|
|
114
|
+
var first = sugCard.querySelector("button:not([disabled])");
|
|
115
|
+
if (!first) return;
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
try { var ks = editor.getDocument().getSelection(); sugCard.__returnRange = ks && ks.rangeCount ? ks.getRangeAt(0).cloneRange() : null; } catch (x) { sugCard.__returnRange = null; }
|
|
119
|
+
try { (sugCard.ownerDocument.defaultView || window).focus(); } catch (x) { /* ignore */ }
|
|
120
|
+
first.focus();
|
|
121
|
+
}, true);
|
|
122
|
+
else { try { editor.getDocument().addEventListener("selectionchange", scheduleCard); } catch (x) { /* no card */ } }
|
|
123
|
+
bindEd("compositionstart", onCompositionStart);
|
|
124
|
+
bindEd("compositionend", onCompositionEnd);
|
|
125
|
+
bindEd("keydown", function (e) { shiftEnter = e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey; });
|
|
126
|
+
|
|
127
|
+
// The document is loaded after this runs (the textarea's value, then any setHTMLCode),
|
|
128
|
+
// and an undo or a collaborator can bring tracked spans back: catch the ledger up each time.
|
|
129
|
+
["setHTMLCode", "setHTML"].forEach(function (name) {
|
|
130
|
+
var original = editor[name];
|
|
131
|
+
if (typeof original !== "function") return;
|
|
132
|
+
editor[name] = function () {
|
|
133
|
+
var result = original.apply(this, arguments);
|
|
134
|
+
syncFromDocument();
|
|
135
|
+
return result;
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
editor.attachEvent("change", function () { syncFromDocument(); });
|
|
139
|
+
setTimeout(syncFromDocument, 0);
|
|
140
|
+
|
|
141
|
+
// 2026-10-03 Paragraph structure. The core makes a paragraph split (Enter) and a join
|
|
142
|
+
// (Backspace at the start of a block, Delete at its end) before beforeinput reaches this
|
|
143
|
+
// plugin, so both went in for real in suggesting mode. The core asks here first: a split is
|
|
144
|
+
// made by the core (info.split) and marked here; a join is only marked.
|
|
145
|
+
// tools/review/tracked-structure.mjs
|
|
146
|
+
editor.__trackHook = function (kind, info) {
|
|
147
|
+
if (!enabled || !editor.reviewLedger || !config.currentUser) return null;
|
|
148
|
+
if (kind === "enter") return trackSplit(info);
|
|
149
|
+
if (kind === "join") return trackJoin(info);
|
|
150
|
+
if (kind === "paste") return trackPaste(info);
|
|
151
|
+
if (kind === "delete") return trackDeleteHook(info);
|
|
152
|
+
if (kind === "command") return trackCommand(info);
|
|
153
|
+
if (kind === "replace") return trackReplace(info);
|
|
154
|
+
if (kind === "insert") return trackInsert(info);
|
|
155
|
+
if (kind === "insert-element") return trackElementInsert(info);
|
|
156
|
+
// 2026-10-14 An edit made while this plugin is recording a command (trackFormat, trackBlockFormat and
|
|
157
|
+
// trackInsertCommand run the command inside the suggestion) is already tracked, so the refusals below,
|
|
158
|
+
// which are for edits that would go in untracked, step aside. Without this, change case run as a
|
|
159
|
+
// command was refused instead of suggested. tools/review/tracked-plugin-formatting.mjs
|
|
160
|
+
if ((kind === "refuse" || kind === "remove" || kind === "insert-block" || kind === "duplicate" || kind === "move") &&
|
|
161
|
+
(formatRunning || blockRunning || commandRunning)) return null;
|
|
162
|
+
// 2026-10-14 A move (the core's Alt+Arrow, the grip's drop, Move up / Move down) is not tracked yet, so
|
|
163
|
+
// suggesting mode refuses it and says so. tools/tables/table-row-move.mjs
|
|
164
|
+
// 2026-10-14 A copy of a block or row (Ctrl+Shift+D, the block menu's Duplicate) is not tracked yet, so
|
|
165
|
+
// suggesting mode refuses it and says so. tools/blocks/duplicate-block-suggesting.mjs
|
|
166
|
+
if (kind === "duplicate") {
|
|
167
|
+
if (typeof editor.announce === "function") editor.announce("Suggesting is on, so this copy was not made. Turn suggesting off to copy the block.");
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
// 2026-10-14 An empty paragraph added by the API (insertParagraphAt / After / Before) is not tracked yet, so
|
|
171
|
+
// suggesting mode refuses it and says so. tools/review/suggesting-structure-audit.mjs
|
|
172
|
+
if (kind === "insert-block") {
|
|
173
|
+
if (typeof editor.announce === "function") editor.announce("Suggesting is on, so no paragraph was added. Turn suggesting off to add one.");
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// 2026-10-14 Removing a table of contents or a text language is not tracked yet, so suggesting mode refuses it.
|
|
177
|
+
// tools/review/suggesting-structure-audit.mjs
|
|
178
|
+
if (kind === "remove") {
|
|
179
|
+
if (typeof editor.announce === "function") editor.announce("Suggesting is on, so nothing was removed. Turn suggesting off to remove it.");
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
// 2026-10-14 A command that cannot be tracked yet (a case or direction change) says why it was refused.
|
|
183
|
+
if (kind === "refuse") {
|
|
184
|
+
if (typeof editor.announce === "function" && info && info.message) editor.announce(info.message);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (kind === "move") {
|
|
188
|
+
if (typeof editor.announce === "function") editor.announce("Suggesting is on, so this move was not made. Turn suggesting off to move it.");
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Inline formatting in suggesting mode is a suggestion (trackFormat): the core asks the hook
|
|
195
|
+
// ("command") before any command handler - its own or a plugin's - runs.
|
|
97
196
|
};
|
|
98
197
|
|
|
99
198
|
function appendToolbarCommand(toolbar, item) {
|
|
@@ -133,6 +232,11 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
133
232
|
|
|
134
233
|
var type = e.inputType;
|
|
135
234
|
if (!type) return;
|
|
235
|
+
// a command this plugin is carrying out itself (trackInsertCommand) is left to the browser;
|
|
236
|
+
// and an insertText with no text is not typing - WebKit fires one for a native command such as
|
|
237
|
+
// insertHorizontalRule, and cancelling it made the command do nothing
|
|
238
|
+
if (commandRunning) return;
|
|
239
|
+
if ((type === "insertText" || type === "insertReplacementText") && !e.data && !(e.dataTransfer && e.dataTransfer.getData && e.dataTransfer.getData("text/plain"))) return;
|
|
136
240
|
|
|
137
241
|
if (type === "insertText" || type === "insertReplacementText") {
|
|
138
242
|
e.preventDefault();
|
|
@@ -144,7 +248,15 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
144
248
|
handleDelete(type);
|
|
145
249
|
return;
|
|
146
250
|
}
|
|
147
|
-
//
|
|
251
|
+
// Shift+Enter is a line break; a WebKit build that reports it as insertParagraph too
|
|
252
|
+
if (type === "insertLineBreak" || (type === "insertParagraph" && shiftEnter)) {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
handleLineBreak();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// insertParagraph (the core's Enter, tracked through editor.__trackHook), insertCompositionText
|
|
258
|
+
// (wrapped when the composition ends, onCompositionEnd), insertFromPaste (the core's paste,
|
|
259
|
+
// tracked through the hook), historyUndo/Redo: pass through.
|
|
148
260
|
}
|
|
149
261
|
|
|
150
262
|
// --- insert tracking ---
|
|
@@ -155,10 +267,11 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
155
267
|
if (!sel || sel.rangeCount === 0) return;
|
|
156
268
|
var range = sel.getRangeAt(0);
|
|
157
269
|
if (!range.collapsed) {
|
|
158
|
-
// Selection replace:
|
|
159
|
-
|
|
160
|
-
|
|
270
|
+
// Selection replace: the selection is a suggested deletion (across blocks too), then the
|
|
271
|
+
// new text goes in after it.
|
|
272
|
+
trackSelectionDelete(range, true);
|
|
161
273
|
range = sel.getRangeAt(0);
|
|
274
|
+
if (!range.collapsed) { range.collapse(false); placeRange(range); }
|
|
162
275
|
createInsertSpan(text, range);
|
|
163
276
|
return;
|
|
164
277
|
}
|
|
@@ -232,6 +345,168 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
232
345
|
updateEntryForSpan(span);
|
|
233
346
|
}
|
|
234
347
|
|
|
348
|
+
// --- find & replace ---
|
|
349
|
+
|
|
350
|
+
// 2026-10-03 A Find & Replace replacement in suggesting mode: the match is a suggested deletion and
|
|
351
|
+
// the new text a suggested insertion right after it. A match inside text that is already a
|
|
352
|
+
// suggested deletion is left alone. tools/review/tracked-find-replace.mjs
|
|
353
|
+
function trackReplace(info) {
|
|
354
|
+
var range = info && info.range;
|
|
355
|
+
if (!range || typeof info.text !== "string") return null;
|
|
356
|
+
if (isInsideDeleteSpan(range.startContainer) || isInsideDeleteSpan(range.endContainer)) return true;
|
|
357
|
+
var sel = editor.getSelection();
|
|
358
|
+
if (!sel) return null;
|
|
359
|
+
if (!range.collapsed) {
|
|
360
|
+
trackSelectionDelete(range.cloneRange(), true);
|
|
361
|
+
} else {
|
|
362
|
+
placeRange(range.cloneRange());
|
|
363
|
+
}
|
|
364
|
+
if (info.text) {
|
|
365
|
+
var at = sel.rangeCount ? sel.getRangeAt(0) : null;
|
|
366
|
+
if (!at) return true;
|
|
367
|
+
if (!at.collapsed) { at = at.cloneRange(); at.collapse(false); placeRange(at); }
|
|
368
|
+
createInsertSpan(info.text, at);
|
|
369
|
+
}
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- IME and line breaks ---
|
|
374
|
+
|
|
375
|
+
// 2026-10-03 Text typed through an IME (Chinese, Japanese, Korean) and a Shift+Enter line break
|
|
376
|
+
// were let through untracked, so everything a CJK reviewer typed went into the document for
|
|
377
|
+
// real. A composition cannot be cancelled while it runs: it is left to the browser, and when it
|
|
378
|
+
// ends the text it committed - just before the caret - is wrapped as an insertion (or joins
|
|
379
|
+
// the reviewer's insertion it was typed into or next to). A composition over a selection makes
|
|
380
|
+
// the selection a suggested deletion first. tools/review/tracked-ime-linebreak.mjs
|
|
381
|
+
var composing = false, shiftEnter = false;
|
|
382
|
+
|
|
383
|
+
function onCompositionStart() {
|
|
384
|
+
composing = false;
|
|
385
|
+
if (!enabled || !editor.reviewLedger) return;
|
|
386
|
+
var sel = editor.getSelection();
|
|
387
|
+
if (!sel || !sel.rangeCount) return;
|
|
388
|
+
var range = sel.getRangeAt(0);
|
|
389
|
+
if (!range.collapsed) {
|
|
390
|
+
trackSelectionDelete(range, true);
|
|
391
|
+
var after = sel.rangeCount ? sel.getRangeAt(0) : null;
|
|
392
|
+
if (after && !after.collapsed) { after.collapse(false); placeRange(after); }
|
|
393
|
+
}
|
|
394
|
+
composing = true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function onCompositionEnd(e) {
|
|
398
|
+
if (!composing) return;
|
|
399
|
+
composing = false;
|
|
400
|
+
var text = e && e.data ? e.data : "";
|
|
401
|
+
if (!text || !enabled || !editor.reviewLedger) return;
|
|
402
|
+
// the committed text is normally in place already; an engine that fires compositionend first
|
|
403
|
+
// has it a moment later
|
|
404
|
+
if (!wrapComposed(text)) setTimeout(function () { wrapComposed(text); }, 0);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function wrapComposed(text) {
|
|
408
|
+
var sel = editor.getSelection();
|
|
409
|
+
if (!sel || !sel.rangeCount) return false;
|
|
410
|
+
var r = sel.getRangeAt(0);
|
|
411
|
+
if (!r.collapsed) return false;
|
|
412
|
+
var node = r.startContainer, off = r.startOffset;
|
|
413
|
+
if (node.nodeType !== 3) {
|
|
414
|
+
var prev = node.childNodes[off - 1];
|
|
415
|
+
if (!prev || prev.nodeType !== 3) return false;
|
|
416
|
+
node = prev;
|
|
417
|
+
off = prev.data.length;
|
|
418
|
+
}
|
|
419
|
+
if (off < text.length || node.data.slice(off - text.length, off) !== text) return false;
|
|
420
|
+
if (isInsideDeleteSpan(node)) {
|
|
421
|
+
var del = node.parentNode;
|
|
422
|
+
while (del && !(del.classList && del.classList.contains("rte-tc-delete"))) del = del.parentNode;
|
|
423
|
+
// only the text at the very end of a deletion is the composition that followed it
|
|
424
|
+
if (!del || off !== node.data.length || lastTextLeaf(del) !== node) return false;
|
|
425
|
+
var moved = off - text.length > 0 ? node.splitText(off - text.length) : node;
|
|
426
|
+
del.parentNode.insertBefore(moved, del.nextSibling);
|
|
427
|
+
node = moved;
|
|
428
|
+
off = moved.data.length;
|
|
429
|
+
}
|
|
430
|
+
if (isMyInsert(node.parentNode)) { updateEntryForSpan(node.parentNode); return true; }
|
|
431
|
+
if (off < node.data.length) node.splitText(off);
|
|
432
|
+
var piece = off - text.length > 0 ? node.splitText(off - text.length) : node;
|
|
433
|
+
var before = piece.previousSibling;
|
|
434
|
+
while (before && before.nodeType === 3 && !before.data.length) before = before.previousSibling;
|
|
435
|
+
var span;
|
|
436
|
+
if (before && isMyInsert(before)) {
|
|
437
|
+
span = before;
|
|
438
|
+
span.appendChild(piece);
|
|
439
|
+
updateEntryForSpan(span);
|
|
440
|
+
} else {
|
|
441
|
+
span = newInsertSpan();
|
|
442
|
+
piece.parentNode.insertBefore(span, piece);
|
|
443
|
+
span.appendChild(piece);
|
|
444
|
+
addInsertEntry(span, text);
|
|
445
|
+
}
|
|
446
|
+
placeCaretAtEnd(piece);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Shift+Enter: a <br> in an insertion. At the very end of a block a lone <br> shows no new line;
|
|
451
|
+
// the browser adds a second, and so does this (inside the insertion, so a reject takes both).
|
|
452
|
+
function handleLineBreak() {
|
|
453
|
+
var sel = editor.getSelection();
|
|
454
|
+
if (!sel || !sel.rangeCount) return;
|
|
455
|
+
var range = sel.getRangeAt(0);
|
|
456
|
+
if (!range.collapsed) {
|
|
457
|
+
trackSelectionDelete(range, true);
|
|
458
|
+
range = sel.getRangeAt(0);
|
|
459
|
+
if (!range.collapsed) { range.collapse(false); placeRange(range); }
|
|
460
|
+
}
|
|
461
|
+
var doc = editor.getDocument();
|
|
462
|
+
var block = containerOf(range.startContainer);
|
|
463
|
+
var tail = doc.createRange();
|
|
464
|
+
tail.setStart(range.startContainer, range.startOffset);
|
|
465
|
+
tail.setEnd(block, block.childNodes.length);
|
|
466
|
+
var atEnd = !tail.toString() && !tail.cloneContents().querySelector("img,br,iframe,video,audio,svg,input,table,hr,[contenteditable='false']");
|
|
467
|
+
var span = adjacentInsertSpan(range), fresh = !span;
|
|
468
|
+
var br = doc.createElement("br");
|
|
469
|
+
if (fresh) {
|
|
470
|
+
span = newInsertSpan();
|
|
471
|
+
range.insertNode(span);
|
|
472
|
+
span.appendChild(br);
|
|
473
|
+
} else {
|
|
474
|
+
// inside or right after your own insertion: at the caret
|
|
475
|
+
range.insertNode(br);
|
|
476
|
+
}
|
|
477
|
+
if (atEnd) br.parentNode.insertBefore(doc.createElement("br"), br.nextSibling);
|
|
478
|
+
if (fresh) addInsertEntry(span, "\n");
|
|
479
|
+
else updateEntryForSpan(span);
|
|
480
|
+
var cr = doc.createRange();
|
|
481
|
+
cr.setStartAfter(br);
|
|
482
|
+
cr.collapse(true);
|
|
483
|
+
placeRange(cr);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function newInsertSpan() {
|
|
487
|
+
var span = editor.getDocument().createElement("span");
|
|
488
|
+
var user = config.currentUser;
|
|
489
|
+
span.className = config.trackChangesInsertClass;
|
|
490
|
+
span.setAttribute("data-tc-id", newId());
|
|
491
|
+
span.setAttribute("data-tc-author", user.id);
|
|
492
|
+
span.setAttribute("data-tc-at", new Date().toISOString());
|
|
493
|
+
span.style.color = user.color;
|
|
494
|
+
span.style.textDecoration = "underline";
|
|
495
|
+
return span;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function addInsertEntry(span, text) {
|
|
499
|
+
editor.reviewLedger.add({
|
|
500
|
+
id: span.getAttribute("data-tc-id"),
|
|
501
|
+
changeType: "insert",
|
|
502
|
+
author: config.currentUser,
|
|
503
|
+
text: text,
|
|
504
|
+
status: "pending",
|
|
505
|
+
sourceLabel: "Suggested insert",
|
|
506
|
+
createdAt: Date.now()
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
235
510
|
function createInsertSpan(text, range) {
|
|
236
511
|
var editdoc = editor.getDocument();
|
|
237
512
|
var span = editdoc.createElement("span");
|
|
@@ -271,9 +546,23 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
271
546
|
var sel = editor.getSelection();
|
|
272
547
|
if (!sel || sel.rangeCount === 0) return;
|
|
273
548
|
var range = sel.getRangeAt(0);
|
|
549
|
+
if (mode === "deleteByCut") {
|
|
550
|
+
var cells = editor.getDocument().querySelectorAll("[__rte_selected_cell]");
|
|
551
|
+
if (cells.length > 1) {
|
|
552
|
+
var cellRanges = [];
|
|
553
|
+
for (var ci = 0; ci < cells.length; ci++) {
|
|
554
|
+
var cr = editor.getDocument().createRange();
|
|
555
|
+
cr.selectNodeContents(cells[ci]);
|
|
556
|
+
cellRanges.push(cr);
|
|
557
|
+
}
|
|
558
|
+
markLeavesDeleted(cellRanges, null);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (range.collapsed) return;
|
|
562
|
+
}
|
|
274
563
|
|
|
275
564
|
if (!range.collapsed) {
|
|
276
|
-
|
|
565
|
+
trackSelectionDelete(range, false);
|
|
277
566
|
return;
|
|
278
567
|
}
|
|
279
568
|
|
|
@@ -595,6 +884,1263 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
595
884
|
sel.addRange(range);
|
|
596
885
|
}
|
|
597
886
|
|
|
887
|
+
// --- the document is the record ---
|
|
888
|
+
|
|
889
|
+
// 2026-10-03 A suggestion lives in two places: its span in the document and its entry in the
|
|
890
|
+
// review ledger, which list(), accept(), acceptAll(), rejectAll() and the Review drawer read.
|
|
891
|
+
// Only the span is saved with the document; the ledger is kept in memory (or in THIS browser's
|
|
892
|
+
// localStorage, with aiToolkitPersistenceKey). So the review workflow - suggest and save, a
|
|
893
|
+
// reviewer opens it later and decides - never worked: after a reload list() was empty,
|
|
894
|
+
// acceptAll() and rejectAll() returned 0, and the marked-up text stayed in the document for
|
|
895
|
+
// good. Every tracked span in the document is a PENDING suggestion: add the ones the ledger
|
|
896
|
+
// lacks (the span carries id, author and time), and reopen a decided one whose span is back
|
|
897
|
+
// (an older version reopened, a decision undone). tools/review/tracked-changes.mjs
|
|
898
|
+
function syncFromDocument() {
|
|
899
|
+
var ledger = editor.reviewLedger;
|
|
900
|
+
var editable = editor.getEditable && editor.getEditable();
|
|
901
|
+
if (!ledger || !editable || !editable.querySelectorAll) return;
|
|
902
|
+
var spans = editable.querySelectorAll(".rte-tc-insert[data-tc-id], .rte-tc-delete[data-tc-id], [data-tc-break][data-tc-id], [data-tc-block][data-tc-id]");
|
|
903
|
+
var seen = {};
|
|
904
|
+
for (var i = 0; i < spans.length; i++) {
|
|
905
|
+
var span = spans[i];
|
|
906
|
+
var id = span.getAttribute("data-tc-id");
|
|
907
|
+
if (!id) continue;
|
|
908
|
+
seen[id] = true;
|
|
909
|
+
var entry = ledger.get(id);
|
|
910
|
+
if (entry) {
|
|
911
|
+
if (entry.status !== "pending" && (entry.changeType === "insert" || entry.changeType === "delete" || entry.changeType === "format")) {
|
|
912
|
+
ledger.update(id, { status: "pending", decidedAt: 0, decidedBy: null });
|
|
913
|
+
}
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if (span.hasAttribute("data-tc-break")) {
|
|
917
|
+
ledger.add(breakEntry(span, spanAuthor(span)));
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (span.hasAttribute("data-tc-block")) {
|
|
921
|
+
var blockFmt = span.hasAttribute("data-tc-format");
|
|
922
|
+
ledger.add({
|
|
923
|
+
id: id, changeType: blockFmt ? "format" : span.getAttribute("data-tc-block") === "delete" ? "delete" : "insert", author: spanAuthor(span), text: span.textContent, isBlock: true,
|
|
924
|
+
status: "pending", sourceLabel: blockFmt ? "Suggested formatting" : span.getAttribute("data-tc-block") === "delete" ? "Suggested delete" : "Suggested insert",
|
|
925
|
+
createdAt: Date.parse(span.getAttribute("data-tc-at") || "") || Date.now()
|
|
926
|
+
});
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
var isInsert = span.classList.contains("rte-tc-insert");
|
|
930
|
+
ledger.add({
|
|
931
|
+
id: id,
|
|
932
|
+
changeType: span.hasAttribute("data-tc-format") ? "format" : isInsert ? "insert" : "delete",
|
|
933
|
+
author: spanAuthor(span),
|
|
934
|
+
text: span.textContent,
|
|
935
|
+
originalHtml: isInsert ? "" : span.innerHTML,
|
|
936
|
+
status: "pending",
|
|
937
|
+
sourceLabel: isInsert ? "Suggested insert" : "Suggested delete",
|
|
938
|
+
createdAt: Date.parse(span.getAttribute("data-tc-at") || "") || Date.now()
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
// and the other way: a pending suggestion of ours whose mark has left the document (undone,
|
|
942
|
+
// taken back, another document loaded) is no longer a suggestion - it was listed for good
|
|
943
|
+
var entries = ledger.list();
|
|
944
|
+
for (var j = 0; j < entries.length; j++) {
|
|
945
|
+
var en = entries[j];
|
|
946
|
+
if (en.status !== "pending" || (en.changeType !== "insert" && en.changeType !== "delete" && en.changeType !== "format")) continue;
|
|
947
|
+
if (String(en.id).indexOf("tc-") !== 0 || seen[en.id]) continue;
|
|
948
|
+
ledger.remove(en.id);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// The span stores only the author's id: the current user's own suggestions get their name back,
|
|
953
|
+
// anyone else's show the id.
|
|
954
|
+
function spanAuthor(span) {
|
|
955
|
+
var id = span.getAttribute("data-tc-author") || "user";
|
|
956
|
+
var me = config.currentUser;
|
|
957
|
+
if (me && me.id === id) return me;
|
|
958
|
+
return { id: id, name: id, color: (span.style && span.style.color) || "" };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// --- paragraph breaks ---
|
|
962
|
+
|
|
963
|
+
var PILCROW = String.fromCharCode(182);
|
|
964
|
+
var BREAK_ATTRS = ["data-tc-id", "data-tc-author", "data-tc-at", "data-tc-break", "data-tc-code-first-line"];
|
|
965
|
+
var TEXT_BLOCK = /^(P|DIV|H[1-6]|LI)$/;
|
|
966
|
+
|
|
967
|
+
function trackSplit(info) {
|
|
968
|
+
var res = info && typeof info.split === "function" ? info.split() : null;
|
|
969
|
+
if (!res || !res.created) return false;
|
|
970
|
+
markBreak(res.created, "insert");
|
|
971
|
+
return true;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function trackJoin(info) {
|
|
975
|
+
var a = info && info.first, b = info && info.second;
|
|
976
|
+
if (!a || !b) return null;
|
|
977
|
+
var brk = b.getAttribute("data-tc-break");
|
|
978
|
+
if (typeof info.begin === "function") info.begin();
|
|
979
|
+
if (brk === "insert" && b.getAttribute("data-tc-author") === config.currentUser.id) {
|
|
980
|
+
// taking back your own suggested break: the paragraphs go back together, as on reject
|
|
981
|
+
var id = b.getAttribute("data-tc-id");
|
|
982
|
+
var caret = undoSplit(b);
|
|
983
|
+
if (id && !trackedElements(id).length) editor.reviewLedger.remove(id);
|
|
984
|
+
if (caret) placeRange(caret);
|
|
985
|
+
return true;
|
|
986
|
+
}
|
|
987
|
+
if (!brk) markBreak(b, "delete");
|
|
988
|
+
if (info.codeFirstLine) b.setAttribute("data-tc-code-first-line", "true");
|
|
989
|
+
// a join already suggested, or someone else's suggested break: only the caret moves
|
|
990
|
+
if (info.backward) caretToEnd(a);
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function markBreak(block, kind) {
|
|
995
|
+
var user = config.currentUser;
|
|
996
|
+
block.setAttribute("data-tc-id", "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000));
|
|
997
|
+
block.setAttribute("data-tc-author", user.id);
|
|
998
|
+
block.setAttribute("data-tc-at", new Date().toISOString());
|
|
999
|
+
block.setAttribute("data-tc-break", kind);
|
|
1000
|
+
editor.reviewLedger.add(breakEntry(block, user));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function breakEntry(block, author) {
|
|
1004
|
+
var insert = block.getAttribute("data-tc-break") === "insert";
|
|
1005
|
+
return {
|
|
1006
|
+
id: block.getAttribute("data-tc-id"),
|
|
1007
|
+
changeType: insert ? "insert" : "delete",
|
|
1008
|
+
author: author,
|
|
1009
|
+
text: PILCROW,
|
|
1010
|
+
isBlock: true,
|
|
1011
|
+
status: "pending",
|
|
1012
|
+
sourceLabel: insert ? "Suggested paragraph break" : "Suggested paragraph join",
|
|
1013
|
+
createdAt: Date.parse(block.getAttribute("data-tc-at") || "") || Date.now()
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function clearBreak(block) {
|
|
1018
|
+
for (var i = 0; i < BREAK_ATTRS.length; i++) block.removeAttribute(BREAK_ATTRS[i]);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Reject a suggested break: the new block goes back into the one it was split from, which keeps
|
|
1022
|
+
// its own attributes and id; an inline wrapper the split cut in two is one again.
|
|
1023
|
+
function undoSplit(block) {
|
|
1024
|
+
var a = block.previousElementSibling;
|
|
1025
|
+
clearBreak(block);
|
|
1026
|
+
if (!a || !TEXT_BLOCK.test(a.nodeName) || typeof editor.__joinTextBlocks !== "function") return null;
|
|
1027
|
+
// the layout whitespace between the two blocks (a reloaded document has it) goes with the break
|
|
1028
|
+
for (var ws = block.previousSibling; ws && ws !== a && ws.nodeType === 3 && !/\S/.test(ws.data); ws = block.previousSibling) ws.parentNode.removeChild(ws);
|
|
1029
|
+
return mergeAtJoin(editor.__joinTextBlocks(a, block, true));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Accept a suggested join: exactly what an untracked Backspace does.
|
|
1033
|
+
function joinBreak(block, keepFirst) {
|
|
1034
|
+
var a = block.previousElementSibling;
|
|
1035
|
+
var codeFirstLine = block.getAttribute("data-tc-code-first-line") === "true";
|
|
1036
|
+
clearBreak(block);
|
|
1037
|
+
if (codeFirstLine && a && TEXT_BLOCK.test(a.nodeName) && block.nodeName === "PRE" && typeof editor.__joinFirstCodeLine === "function") {
|
|
1038
|
+
editor.__joinFirstCodeLine(a, block);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (!a || !TEXT_BLOCK.test(a.nodeName) || !TEXT_BLOCK.test(block.nodeName) || typeof editor.__joinTextBlocks !== "function") return;
|
|
1042
|
+
editor.__joinTextBlocks(a, block, !!keepFirst);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function sameShell(x, y) {
|
|
1046
|
+
if (x.nodeName !== y.nodeName || /^(BR|IMG|HR|INPUT|IFRAME|VIDEO|AUDIO|SVG|OBJECT|EMBED|TABLE)$/i.test(x.nodeName)) return false;
|
|
1047
|
+
if (x.getAttribute("contenteditable") === "false") return false;
|
|
1048
|
+
if (x.attributes.length !== y.attributes.length) return false;
|
|
1049
|
+
for (var i = 0; i < x.attributes.length; i++) {
|
|
1050
|
+
if (y.getAttribute(x.attributes[i].name) !== x.attributes[i].value) return false;
|
|
1051
|
+
}
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function mergeAtJoin(caret) {
|
|
1056
|
+
if (!caret) return caret;
|
|
1057
|
+
var c = caret.startContainer, o = caret.startOffset;
|
|
1058
|
+
if (c.nodeType !== 1) return caret;
|
|
1059
|
+
var left = c.childNodes[o - 1], right = left ? left.nextSibling : null, parent = null, merged = false;
|
|
1060
|
+
while (left && right && left.nodeType === 1 && right.nodeType === 1 && sameShell(left, right)) {
|
|
1061
|
+
var inner = left.lastChild;
|
|
1062
|
+
while (right.firstChild) left.appendChild(right.firstChild);
|
|
1063
|
+
right.parentNode.removeChild(right);
|
|
1064
|
+
parent = left;
|
|
1065
|
+
left = inner;
|
|
1066
|
+
right = inner ? inner.nextSibling : null;
|
|
1067
|
+
merged = true;
|
|
1068
|
+
}
|
|
1069
|
+
if (!merged) return caret;
|
|
1070
|
+
var r = editor.getDocument().createRange();
|
|
1071
|
+
if (left && left.nodeType === 3) r.setStart(left, left.data.length);
|
|
1072
|
+
else if (left) r.setStartAfter(left);
|
|
1073
|
+
else r.setStart(parent, 0);
|
|
1074
|
+
r.collapse(true);
|
|
1075
|
+
return r;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function caretToEnd(block) {
|
|
1079
|
+
var doc = editor.getDocument();
|
|
1080
|
+
var r = doc.createRange();
|
|
1081
|
+
var w = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT), t, last = null;
|
|
1082
|
+
while ((t = w.nextNode())) if (t.data.length) last = t;
|
|
1083
|
+
var del = last && isInsideDeleteSpan(last) ? last.parentNode : null;
|
|
1084
|
+
while (del && !(del.classList && del.classList.contains("rte-tc-delete"))) del = del.parentNode;
|
|
1085
|
+
if (del) r.setStartAfter(del);
|
|
1086
|
+
else if (last) r.setStart(last, last.data.length);
|
|
1087
|
+
else r.setStart(block, 0);
|
|
1088
|
+
r.collapse(true);
|
|
1089
|
+
placeRange(r);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function placeRange(r) {
|
|
1093
|
+
var sel = editor.getSelection();
|
|
1094
|
+
if (!sel) return;
|
|
1095
|
+
sel.removeAllRanges();
|
|
1096
|
+
sel.addRange(r);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// --- paste ---
|
|
1100
|
+
|
|
1101
|
+
// 2026-10-03 A paste in suggesting mode. The core runs its whole pipeline and hands the RESULT
|
|
1102
|
+
// here (info.html) with its own insert (info.insert, its __InsertHTML): the inline runs go in as
|
|
1103
|
+
// one insertion, a table / list / rule as a wholly inserted block, and every paragraph the paste
|
|
1104
|
+
// started is a suggested break - so a reject gives the document back exactly and an accept is the
|
|
1105
|
+
// untracked paste. Pasted over a selection, the selection is a suggested deletion first.
|
|
1106
|
+
// tools/review/tracked-paste.mjs
|
|
1107
|
+
var PASTE_BLOCK = /^(P|DIV|H[1-6]|UL|OL|DL|LI|DT|DD|TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|CAPTION|BLOCKQUOTE|PRE|HR|FIGURE|FIGCAPTION|SECTION|ARTICLE|ASIDE|HEADER|FOOTER|NAV|ADDRESS|DETAILS|SUMMARY|FIELDSET|FORM)$/;
|
|
1108
|
+
var PASTE_BLOCK_SELECTOR = "p,div,h1,h2,h3,h4,h5,h6,ul,ol,dl,li,table,blockquote,pre,hr,figure,section,article,aside,header,footer,nav,address,details,fieldset,form";
|
|
1109
|
+
var PASTE_ATOMS = "img,br,iframe,video,audio,svg,canvas,object,embed,input,table,hr";
|
|
1110
|
+
var ZWSP = String.fromCharCode(8203);
|
|
1111
|
+
|
|
1112
|
+
function newId() {
|
|
1113
|
+
return "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// 2026-10-03 Inserted ELEMENTS (an image, a table, a picture pasted or dropped as a file) and native
|
|
1117
|
+
// insert commands (a horizontal rule, which splits the paragraph) are suggested insertions: an
|
|
1118
|
+
// inline element goes in an insertion, a block is a wholly inserted block, and a paragraph the
|
|
1119
|
+
// command split off is a suggested break - so reject gives the document back and accept is the
|
|
1120
|
+
// untracked insert. tools/review/tracked-insert-elements.mjs
|
|
1121
|
+
function caretInMyInsert() {
|
|
1122
|
+
var sel = editor.getSelection();
|
|
1123
|
+
if (!sel || !sel.rangeCount) return false;
|
|
1124
|
+
var r = sel.getRangeAt(0);
|
|
1125
|
+
return r.collapsed ? isMyInsertAncestor(r.startContainer.nodeType === 3 ? r.startContainer : (r.startContainer.childNodes[r.startOffset] || r.startContainer)) : rangeAllMine(r);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function deleteSelectionFirst() {
|
|
1129
|
+
var sel = editor.getSelection();
|
|
1130
|
+
if (!sel || !sel.rangeCount || sel.isCollapsed) return;
|
|
1131
|
+
trackSelectionDelete(sel.getRangeAt(0).cloneRange(), true);
|
|
1132
|
+
var r = sel.rangeCount ? sel.getRangeAt(0) : null;
|
|
1133
|
+
if (r && !r.collapsed) { r = r.cloneRange(); r.collapse(false); placeRange(r); }
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function markInserted(node, st, fromCommand) {
|
|
1137
|
+
if (!node || !node.parentNode) return;
|
|
1138
|
+
if (PASTE_BLOCK.test(node.nodeName) || /^(IFRAME|VIDEO|AUDIO|FIGURE)$/.test(node.nodeName) && node.parentNode === editor.getEditable()) {
|
|
1139
|
+
if (fromCommand && TEXT_BLOCK.test(node.nodeName)) node.setAttribute("data-tc-break", "insert");
|
|
1140
|
+
else node.setAttribute("data-tc-block", "insert");
|
|
1141
|
+
st.mark(node);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
var span = editor.getDocument().createElement("span");
|
|
1145
|
+
span.className = config.trackChangesInsertClass;
|
|
1146
|
+
st.mark(span);
|
|
1147
|
+
node.parentNode.insertBefore(span, node);
|
|
1148
|
+
span.appendChild(node);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function addElementEntry(st, text, what) {
|
|
1152
|
+
editor.reviewLedger.add({
|
|
1153
|
+
id: st.id, changeType: "insert", author: st.user, text: text, isBlock: true,
|
|
1154
|
+
status: "pending", sourceLabel: "Suggested insert" + (what ? " (" + what + ")" : ""), createdAt: Date.now()
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function trackElementInsert(info) {
|
|
1159
|
+
// an element a format command makes (a link around text) is part of that formatting suggestion
|
|
1160
|
+
if (formatRunning) return null;
|
|
1161
|
+
var el = info && info.element;
|
|
1162
|
+
if (!el || typeof info.run !== "function") return null;
|
|
1163
|
+
if (caretInMyInsert()) return null;
|
|
1164
|
+
deleteSelectionFirst();
|
|
1165
|
+
info.run();
|
|
1166
|
+
if (!el.parentNode || !editor.getEditable().contains(el)) return true;
|
|
1167
|
+
var st = deletionStamp();
|
|
1168
|
+
markInserted(el, st);
|
|
1169
|
+
addElementEntry(st, el.textContent || "", el.nodeName.toLowerCase());
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// 2026-10-10 A PAGE BREAK inserted in suggesting mode is a suggested insertion: the command runs, the new
|
|
1174
|
+
// break element is marked as a whole block (data-tc-block="insert"; a text-block mark would read as a
|
|
1175
|
+
// paragraph split and reject would merge it back) and shows until decided. tools/review/tracked-page-break.mjs
|
|
1176
|
+
function trackPageBreak(info) {
|
|
1177
|
+
var editable = editor.getEditable();
|
|
1178
|
+
var mo = typeof MutationObserver === "function" ? new MutationObserver(function () { }) : null;
|
|
1179
|
+
if (mo) mo.observe(editable, { childList: true, subtree: true });
|
|
1180
|
+
var recs = [];
|
|
1181
|
+
try { info.run(); } finally { if (mo) { recs = mo.takeRecords(); mo.disconnect(); } }
|
|
1182
|
+
var added = [];
|
|
1183
|
+
for (var i = 0; i < recs.length; i++) {
|
|
1184
|
+
for (var j = 0; j < recs[i].addedNodes.length; j++) {
|
|
1185
|
+
var n = recs[i].addedNodes[j];
|
|
1186
|
+
if (n.nodeType === 1 && editable.contains(n) && added.indexOf(n) < 0) added.push(n);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
var top = added.filter(function (n) { return !added.some(function (o) { return o !== n && o.contains(n); }); });
|
|
1190
|
+
if (!top.length) return true;
|
|
1191
|
+
var st = deletionStamp();
|
|
1192
|
+
for (var k = 0; k < top.length; k++) {
|
|
1193
|
+
top[k].setAttribute("data-tc-block", "insert");
|
|
1194
|
+
st.mark(top[k]);
|
|
1195
|
+
}
|
|
1196
|
+
editor.reviewLedger.add({
|
|
1197
|
+
id: st.id, changeType: "insert", author: st.user, text: "", isBlock: true,
|
|
1198
|
+
status: "pending", sourceLabel: "Suggested insert (page break)", createdAt: Date.now()
|
|
1199
|
+
});
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function trackInsertCommand(info) {
|
|
1204
|
+
var editable = editor.getEditable();
|
|
1205
|
+
deleteSelectionFirst();
|
|
1206
|
+
var recs = [];
|
|
1207
|
+
var mo = typeof MutationObserver === "function" ? new MutationObserver(function () { }) : null;
|
|
1208
|
+
if (mo) mo.observe(editable, { childList: true, subtree: true });
|
|
1209
|
+
commandRunning = true;
|
|
1210
|
+
try { info.run(); } finally { commandRunning = false; if (mo) { recs = mo.takeRecords(); mo.disconnect(); } }
|
|
1211
|
+
var added = [];
|
|
1212
|
+
for (var i = 0; i < recs.length; i++) {
|
|
1213
|
+
for (var j = 0; j < recs[i].addedNodes.length; j++) {
|
|
1214
|
+
var n = recs[i].addedNodes[j];
|
|
1215
|
+
if (n.nodeType === 1 && editable.contains(n) && added.indexOf(n) < 0) added.push(n);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
var top = added.filter(function (n) { return !added.some(function (o) { return o !== n && o.contains(n); }); });
|
|
1219
|
+
if (!top.length) return true;
|
|
1220
|
+
var st = deletionStamp();
|
|
1221
|
+
for (var k = 0; k < top.length; k++) markInserted(top[k], st, true);
|
|
1222
|
+
addElementEntry(st, "", info.name);
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// 2026-10-03 BLOCK formatting in suggesting mode - a heading or other paragraph style, a list, a block
|
|
1227
|
+
// quote, alignment, indent, line height - changed the blocks for real. Now the command runs, the
|
|
1228
|
+
// blocks it changed are marked as a formatting suggestion, and the blocks as they were are kept,
|
|
1229
|
+
// hidden, just before them (their ids parked in data-tc-was-id, so no id is in the document twice):
|
|
1230
|
+
// accept drops the originals - the untracked result; reject drops the new blocks and shows the
|
|
1231
|
+
// originals again, exactly. Reformatting your own suggested block just applies.
|
|
1232
|
+
// tools/review/tracked-block-format.mjs
|
|
1233
|
+
// 2026-10-10 table structure and appearance changes (a column inserted, a header, cell alignment, columns distributed,
|
|
1234
|
+
// a caption) changed the table with no suggestion: now a formatting suggestion of the whole table. tools/review/tracked-table-format.mjs
|
|
1235
|
+
var BLOCK_FORMAT = /^(formatblock|insertunorderedlist|insertorderedlist|insertblockquote|justifyleft|justifycenter|justifyright|justifyfull|indent|outdent|lineheight|linkedit|tablecolumninsertleft|tablecolumninsertright|tablecellsplitver|tablecellsplithor|tablecellmerge|tableheader|tablecellvalign|tablecellhalign|tabledistributecolumns|tablecaption|liststart|tablecellbackcolor|tablecellforecolor|tabledistributerows|tablecelltype)$/;
|
|
1236
|
+
// containers the comparison goes down into when only their children changed
|
|
1237
|
+
var BLOCK_SCOPE = /^(UL|OL|LI|BLOCKQUOTE|DIV|SECTION|ARTICLE|TABLE|TBODY|THEAD|TFOOT|TR|TD|TH)$/;
|
|
1238
|
+
var TABLE_PART = /^(TBODY|THEAD|TFOOT|TR|TD|TH|CAPTION|COLGROUP|COL)$/;
|
|
1239
|
+
var TC_BLOCK_ATTRS = ["data-tc-id", "data-tc-author", "data-tc-at", "data-tc-block", "data-tc-break", "data-tc-format"];
|
|
1240
|
+
var blockRunning = false;
|
|
1241
|
+
|
|
1242
|
+
function trackBlockFormat(info) {
|
|
1243
|
+
var sel = editor.getSelection(), editable = editor.getEditable();
|
|
1244
|
+
if (!sel || !sel.rangeCount) return null;
|
|
1245
|
+
var r = sel.getRangeAt(0);
|
|
1246
|
+
if (!editable.contains(r.startContainer) || !editable.contains(r.endContainer)) return null;
|
|
1247
|
+
// the blocks the selection is in, and one more on each side (a list joins its neighbour)
|
|
1248
|
+
var kids = elementKids(editable);
|
|
1249
|
+
if (!kids || !kids.length) return null;
|
|
1250
|
+
var a = topChildAt(editable, r.startContainer, r.startOffset), b = topChildAt(editable, r.endContainer, r.endOffset);
|
|
1251
|
+
var lo = Math.max(0, (a ? kids.indexOf(a) : 0) - 1);
|
|
1252
|
+
var hi = b ? kids.indexOf(b) : kids.length - 1;
|
|
1253
|
+
hi = Math.min(kids.length - 1, (hi < 0 ? kids.length - 1 : hi) + 1);
|
|
1254
|
+
var copies = kids.slice(lo, hi + 1).map(function (k) { return k.cloneNode(true); });
|
|
1255
|
+
var prevAnchor = kids[lo - 1] || null, nextAnchor = kids[hi + 1] || null;
|
|
1256
|
+
// the hidden originals of earlier block suggestions step out while the command runs: a new
|
|
1257
|
+
// list joined the hidden list beside it, and its items were hidden with it
|
|
1258
|
+
var hidden = editable.querySelectorAll('[data-tc-block="delete"][data-tc-format]'), parked = [], i;
|
|
1259
|
+
for (i = 0; i < hidden.length; i++) {
|
|
1260
|
+
var ph = editor.getDocument().createComment("rte-tc");
|
|
1261
|
+
hidden[i].parentNode.replaceChild(ph, hidden[i]);
|
|
1262
|
+
parked.push([ph, hidden[i]]);
|
|
1263
|
+
}
|
|
1264
|
+
blockRunning = commandRunning = true;
|
|
1265
|
+
try { info.run(); } finally {
|
|
1266
|
+
blockRunning = commandRunning = false;
|
|
1267
|
+
for (i = parked.length - 1; i >= 0; i--) if (parked[i][0].parentNode) parked[i][0].parentNode.replaceChild(parked[i][1], parked[i][0]);
|
|
1268
|
+
}
|
|
1269
|
+
if ((prevAnchor && prevAnchor.parentNode !== editable) || (nextAnchor && nextAnchor.parentNode !== editable)) return true;
|
|
1270
|
+
var now = [];
|
|
1271
|
+
for (var n = prevAnchor ? prevAnchor.nextSibling : editable.firstChild; n && n !== nextAnchor; n = n.nextSibling) {
|
|
1272
|
+
if (n.nodeType === 1) now.push(n);
|
|
1273
|
+
else if (n.nodeType === 3 && /\S/.test(n.data)) return true;
|
|
1274
|
+
}
|
|
1275
|
+
var d = diffBlocks(copies, now, editable, nextAnchor);
|
|
1276
|
+
var removed = d.removed, added = d.added;
|
|
1277
|
+
if (!removed.length && !added.length) return true;
|
|
1278
|
+
// your own suggested blocks: the formatting just applies, and the new blocks stay yours
|
|
1279
|
+
if (removed.length && removed.every(isMyBlock)) {
|
|
1280
|
+
for (i = 0; i < added.length; i++) if (!added[i].hasAttribute("data-tc-id")) copyBlockMarks(removed[Math.min(i, removed.length - 1)], added[i]);
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
// blocks that are already part of another suggestion (a suggested break or block) are left
|
|
1284
|
+
// as the command made them, as before
|
|
1285
|
+
if (removed.some(hasBlockMark) || added.some(hasBlockMark)) return true;
|
|
1286
|
+
var st = deletionStamp(), text = [];
|
|
1287
|
+
for (i = 0; i < removed.length; i++) {
|
|
1288
|
+
var c = removed[i];
|
|
1289
|
+
parkIds(c);
|
|
1290
|
+
c.setAttribute("data-tc-block", "delete");
|
|
1291
|
+
c.setAttribute("data-tc-format", "1");
|
|
1292
|
+
st.mark(c);
|
|
1293
|
+
d.parent.insertBefore(c, d.before);
|
|
1294
|
+
}
|
|
1295
|
+
for (i = 0; i < added.length; i++) {
|
|
1296
|
+
added[i].setAttribute("data-tc-block", "insert");
|
|
1297
|
+
added[i].setAttribute("data-tc-format", "1");
|
|
1298
|
+
st.mark(added[i]);
|
|
1299
|
+
text.push(added[i].textContent);
|
|
1300
|
+
}
|
|
1301
|
+
editor.reviewLedger.add({
|
|
1302
|
+
id: st.id, changeType: "format", author: st.user, text: text.join("\n"), isBlock: true,
|
|
1303
|
+
status: "pending", sourceLabel: "Suggested formatting (" + info.name + ")", createdAt: Date.now()
|
|
1304
|
+
});
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// the element children of a container, or null when it holds text of its own
|
|
1309
|
+
function elementKids(el) {
|
|
1310
|
+
var out = [];
|
|
1311
|
+
for (var n = el.firstChild; n; n = n.nextSibling) {
|
|
1312
|
+
if (n.nodeType === 1) out.push(n);
|
|
1313
|
+
else if (n.nodeType === 3 && /\S/.test(n.data)) return null;
|
|
1314
|
+
}
|
|
1315
|
+
return out;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// the child of the editable a boundary point is in
|
|
1319
|
+
function topChildAt(editable, node, offset) {
|
|
1320
|
+
if (node === editable) return editable.childNodes[Math.min(offset, editable.childNodes.length - 1)] || null;
|
|
1321
|
+
while (node && node.parentNode !== editable) node = node.parentNode;
|
|
1322
|
+
return node;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// what changed, as the blocks it was (copies) and the blocks it is (live), at the deepest level
|
|
1326
|
+
// where only one container's children changed
|
|
1327
|
+
function diffBlocks(olds, news, parent, tail) {
|
|
1328
|
+
var p = 0, s = 0;
|
|
1329
|
+
while (p < olds.length && p < news.length && olds[p].outerHTML === news[p].outerHTML) p++;
|
|
1330
|
+
while (s < olds.length - p && s < news.length - p && olds[olds.length - 1 - s].outerHTML === news[news.length - 1 - s].outerHTML) s++;
|
|
1331
|
+
var removed = olds.slice(p, olds.length - s), added = news.slice(p, news.length - s);
|
|
1332
|
+
if (removed.length === 1 && added.length === 1 && BLOCK_SCOPE.test(added[0].nodeName) && sameAttrs(removed[0], added[0])) {
|
|
1333
|
+
var ok = elementKids(removed[0]), nk = elementKids(added[0]);
|
|
1334
|
+
if (ok && nk && ok.length && nk.length) {
|
|
1335
|
+
var inner = diffBlocks(ok, nk, added[0], null);
|
|
1336
|
+
// a cell, row or row group is never a unit: its hidden original would be one more cell
|
|
1337
|
+
// or row in the live table - the part above it is, up to the table itself
|
|
1338
|
+
if (!inner.removed.concat(inner.added).some(function (x) { return TABLE_PART.test(x.nodeName); })) return inner;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return { removed: removed, added: added, parent: parent, before: added[0] || news[news.length - s] || tail };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function sameAttrs(x, y) {
|
|
1345
|
+
if (x.nodeName !== y.nodeName || x.attributes.length !== y.attributes.length) return false;
|
|
1346
|
+
for (var i = 0; i < x.attributes.length; i++) if (y.getAttribute(x.attributes[i].name) !== x.attributes[i].value) return false;
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function hasBlockMark(el) {
|
|
1351
|
+
return el.hasAttribute("data-tc-block") || el.hasAttribute("data-tc-break");
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function isMyBlock(el) {
|
|
1355
|
+
return el.getAttribute("data-tc-block") === "insert" && el.getAttribute("data-tc-author") === config.currentUser.id;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function copyBlockMarks(from, to) {
|
|
1359
|
+
for (var i = 0; i < TC_BLOCK_ATTRS.length; i++) {
|
|
1360
|
+
var v = from.getAttribute(TC_BLOCK_ATTRS[i]);
|
|
1361
|
+
if (v != null) to.setAttribute(TC_BLOCK_ATTRS[i], v);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function parkIds(el) {
|
|
1366
|
+
var list = [el].concat(Array.prototype.slice.call(el.querySelectorAll("[id]")));
|
|
1367
|
+
for (var i = 0; i < list.length; i++) {
|
|
1368
|
+
if (!list[i].hasAttribute("id")) continue;
|
|
1369
|
+
list[i].setAttribute("data-tc-was-id", list[i].getAttribute("id"));
|
|
1370
|
+
list[i].removeAttribute("id");
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function unparkIds(el) {
|
|
1375
|
+
var list = [el].concat(Array.prototype.slice.call(el.querySelectorAll("[data-tc-was-id]")));
|
|
1376
|
+
for (var i = 0; i < list.length; i++) {
|
|
1377
|
+
if (!list[i].hasAttribute("data-tc-was-id")) continue;
|
|
1378
|
+
list[i].setAttribute("id", list[i].getAttribute("data-tc-was-id"));
|
|
1379
|
+
list[i].removeAttribute("data-tc-was-id");
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// 2026-10-03 The public insert API (editor.insertHTML / insertText) - plugins, dialogs, integrators -
|
|
1384
|
+
// marks its content like a paste. Inside the reviewer's own suggested insertion it just goes in:
|
|
1385
|
+
// it is part of that insertion already. tools/review/tracked-insert.mjs
|
|
1386
|
+
function trackInsert(info) {
|
|
1387
|
+
var sel = editor.getSelection();
|
|
1388
|
+
if (sel && sel.rangeCount) {
|
|
1389
|
+
var r = sel.getRangeAt(0);
|
|
1390
|
+
if (r.collapsed ? isMyInsertAncestor(r.startContainer.nodeType === 3 ? r.startContainer : (r.startContainer.childNodes[r.startOffset] || r.startContainer)) : rangeAllMine(r)) return null;
|
|
1391
|
+
}
|
|
1392
|
+
return trackPaste(info, "Suggested insert");
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function trackPaste(info, label) {
|
|
1396
|
+
if (!info || typeof info.insert !== "function" || typeof info.html !== "string" || !info.html) return null;
|
|
1397
|
+
var sel = editor.getSelection();
|
|
1398
|
+
if (!sel || !sel.rangeCount) return null;
|
|
1399
|
+
var doc = editor.getDocument(), editable = editor.getEditable();
|
|
1400
|
+
var user = config.currentUser, id = newId(), at = new Date().toISOString();
|
|
1401
|
+
var stamp = function (el) {
|
|
1402
|
+
el.setAttribute("data-tc-id", id);
|
|
1403
|
+
el.setAttribute("data-tc-author", user.id);
|
|
1404
|
+
el.setAttribute("data-tc-at", at);
|
|
1405
|
+
};
|
|
1406
|
+
// marked in an inert document: nothing in the pasted HTML loads or runs while it is wrapped
|
|
1407
|
+
var inert = doc.implementation.createHTMLDocument("");
|
|
1408
|
+
var box = inert.createElement("div");
|
|
1409
|
+
box.innerHTML = info.html;
|
|
1410
|
+
var text = box.textContent;
|
|
1411
|
+
var hasBlock = markPasted(box, inert, stamp);
|
|
1412
|
+
|
|
1413
|
+
if (typeof info.begin === "function") info.begin();
|
|
1414
|
+
var range = sel.getRangeAt(0);
|
|
1415
|
+
if (!range.collapsed) {
|
|
1416
|
+
// the text it replaces is a suggested deletion, not a silent removal
|
|
1417
|
+
if (!wrapRangeAsDelete(range)) { range.collapse(false); placeRange(range); }
|
|
1418
|
+
range = sel.getRangeAt(0);
|
|
1419
|
+
}
|
|
1420
|
+
// the block the core splits for a block paste, and what followed it
|
|
1421
|
+
var host = null;
|
|
1422
|
+
for (var n = range.startContainer; n && n !== editable; n = n.parentNode) {
|
|
1423
|
+
if (n.nodeType === 1 && /^(P|H[1-6])$/.test(n.nodeName)) { host = n; break; }
|
|
1424
|
+
}
|
|
1425
|
+
var hostNext = host ? host.nextElementSibling : null;
|
|
1426
|
+
// The core drops a host whose first half is EMPTY (a paste at its start, or into an empty
|
|
1427
|
+
// paragraph) - with its id and attributes. A zero-width space at the caret keeps it for the
|
|
1428
|
+
// length of the insert; it is always taken out again.
|
|
1429
|
+
var guard = null;
|
|
1430
|
+
if (hasBlock && host && firstHalfEmpty(host, range)) {
|
|
1431
|
+
guard = doc.createTextNode(ZWSP);
|
|
1432
|
+
range.insertNode(guard);
|
|
1433
|
+
range.setStartAfter(guard);
|
|
1434
|
+
range.collapse(true);
|
|
1435
|
+
placeRange(range);
|
|
1436
|
+
}
|
|
1437
|
+
try {
|
|
1438
|
+
info.insert(box.innerHTML);
|
|
1439
|
+
} finally {
|
|
1440
|
+
if (guard && guard.parentNode) guard.parentNode.removeChild(guard);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (host && host.parentNode && editable.contains(host)) {
|
|
1444
|
+
// every block after the host up to what followed it is one the paste started
|
|
1445
|
+
for (var el = host.nextElementSibling; el && el !== hostNext; el = el.nextElementSibling) {
|
|
1446
|
+
if (el.hasAttribute("data-tc-block") || el.hasAttribute("data-tc-break")) continue;
|
|
1447
|
+
if (TEXT_BLOCK.test(el.nodeName)) el.setAttribute("data-tc-break", "insert");
|
|
1448
|
+
else el.setAttribute("data-tc-block", "insert");
|
|
1449
|
+
stamp(el);
|
|
1450
|
+
}
|
|
1451
|
+
if (guard) {
|
|
1452
|
+
// the host is left empty; accepting the paste lets it go, as an untracked paste does
|
|
1453
|
+
if (!host.firstChild) host.appendChild(doc.createElement("br"));
|
|
1454
|
+
if (!host.hasAttribute("data-tc-break")) {
|
|
1455
|
+
host.setAttribute("data-tc-break", "delete");
|
|
1456
|
+
stamp(host);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
// pasted paragraphs that went in INSIDE another block (a cell, an item, a div): wholly inserted
|
|
1461
|
+
var landed = editable.querySelectorAll("[data-tc-new]");
|
|
1462
|
+
for (var k = 0; k < landed.length; k++) {
|
|
1463
|
+
landed[k].removeAttribute("data-tc-new");
|
|
1464
|
+
if (!landed[k].hasAttribute("data-tc-break") && !landed[k].hasAttribute("data-tc-block")) {
|
|
1465
|
+
landed[k].setAttribute("data-tc-block", "insert");
|
|
1466
|
+
stamp(landed[k]);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
editor.reviewLedger.add({
|
|
1470
|
+
id: id,
|
|
1471
|
+
changeType: "insert",
|
|
1472
|
+
author: user,
|
|
1473
|
+
text: text,
|
|
1474
|
+
isBlock: hasBlock,
|
|
1475
|
+
status: "pending",
|
|
1476
|
+
sourceLabel: label || "Suggested paste",
|
|
1477
|
+
createdAt: Date.now()
|
|
1478
|
+
});
|
|
1479
|
+
// 2026-10-03 the caret goes after what went in, as after an untracked paste or insert: left
|
|
1480
|
+
// selected, the next keystroke (or a second insertHTML) replaced the whole paste, and with your
|
|
1481
|
+
// own insertion that took its suggestion too. At the end of the last inserted text - the end
|
|
1482
|
+
// of the range after pasted paragraphs is the start of the paragraph that followed.
|
|
1483
|
+
// tools/review/tracked-paste.mjs, tracked-insert.mjs
|
|
1484
|
+
var ins = editable.querySelectorAll('.rte-tc-insert[data-tc-id="' + cssEscape(id) + '"]');
|
|
1485
|
+
var endSel = editor.getSelection(), endR = null;
|
|
1486
|
+
if (ins.length) {
|
|
1487
|
+
endR = doc.createRange();
|
|
1488
|
+
endR.selectNodeContents(ins[ins.length - 1]);
|
|
1489
|
+
} else if (endSel && endSel.rangeCount && !endSel.isCollapsed) {
|
|
1490
|
+
endR = endSel.getRangeAt(0).cloneRange();
|
|
1491
|
+
}
|
|
1492
|
+
if (endR) {
|
|
1493
|
+
endR.collapse(false);
|
|
1494
|
+
placeRange(endR);
|
|
1495
|
+
}
|
|
1496
|
+
return true;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Wrap the pasted fragment: inline runs in insert spans; a plain text block keeps its tag and gets
|
|
1500
|
+
// its runs wrapped (and a mark, to find it once it is in); anything else is one inserted block.
|
|
1501
|
+
function markPasted(box, inert, stamp) {
|
|
1502
|
+
var hasBlock = false, c;
|
|
1503
|
+
for (c = box.firstChild; c; c = c.nextSibling) {
|
|
1504
|
+
if (c.nodeType === 1 && PASTE_BLOCK.test(c.nodeName)) { hasBlock = true; break; }
|
|
1505
|
+
}
|
|
1506
|
+
var child = box.firstChild;
|
|
1507
|
+
while (child) {
|
|
1508
|
+
if (child.nodeType === 1 && PASTE_BLOCK.test(child.nodeName)) {
|
|
1509
|
+
var next = child.nextSibling;
|
|
1510
|
+
if (/^(P|DIV|H[1-6])$/.test(child.nodeName) && !child.querySelector(PASTE_BLOCK_SELECTOR)) {
|
|
1511
|
+
var inner = [];
|
|
1512
|
+
for (var ic = child.firstChild; ic; ic = ic.nextSibling) inner.push(ic);
|
|
1513
|
+
wrapPastedRun(child, inner, inert, stamp, false);
|
|
1514
|
+
child.setAttribute("data-tc-new", "1");
|
|
1515
|
+
} else {
|
|
1516
|
+
child.setAttribute("data-tc-block", "insert");
|
|
1517
|
+
stamp(child);
|
|
1518
|
+
}
|
|
1519
|
+
child = next;
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
var run = [];
|
|
1523
|
+
while (child && !(child.nodeType === 1 && PASTE_BLOCK.test(child.nodeName))) {
|
|
1524
|
+
run.push(child);
|
|
1525
|
+
child = child.nextSibling;
|
|
1526
|
+
}
|
|
1527
|
+
// whitespace between pasted blocks is layout, not content
|
|
1528
|
+
wrapPastedRun(box, run, inert, stamp, hasBlock);
|
|
1529
|
+
}
|
|
1530
|
+
return hasBlock;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function wrapPastedRun(parent, nodes, inert, stamp, skipBlank) {
|
|
1534
|
+
if (!nodes.length) return;
|
|
1535
|
+
if (skipBlank) {
|
|
1536
|
+
var blank = true;
|
|
1537
|
+
for (var i = 0; i < nodes.length && blank; i++) {
|
|
1538
|
+
var nd = nodes[i];
|
|
1539
|
+
if (nd.nodeType === 3) blank = !nd.data.replace(/[\s\xa0]/g, "");
|
|
1540
|
+
else if (nd.nodeType === 1) blank = !(nd.textContent || "").replace(/[\s\xa0]/g, "") && !(nd.matches && nd.matches(PASTE_ATOMS)) && !(nd.querySelector && nd.querySelector(PASTE_ATOMS));
|
|
1541
|
+
}
|
|
1542
|
+
if (blank) return;
|
|
1543
|
+
}
|
|
1544
|
+
var span = inert.createElement("span");
|
|
1545
|
+
span.className = config.trackChangesInsertClass;
|
|
1546
|
+
stamp(span);
|
|
1547
|
+
parent.insertBefore(span, nodes[0]);
|
|
1548
|
+
for (var j = 0; j < nodes.length; j++) span.appendChild(nodes[j]);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function firstHalfEmpty(host, range) {
|
|
1552
|
+
var r = editor.getDocument().createRange();
|
|
1553
|
+
r.setStart(host, 0);
|
|
1554
|
+
r.setEnd(range.startContainer, range.startOffset);
|
|
1555
|
+
var f = r.cloneContents();
|
|
1556
|
+
return !(f.textContent || "").split(ZWSP).join("").replace(/[\s\xa0]/g, "") && !f.querySelector("img,iframe,video,audio,table,hr,input,svg,canvas,object,embed");
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function clearBlock(el) {
|
|
1560
|
+
el.removeAttribute("data-tc-id");
|
|
1561
|
+
el.removeAttribute("data-tc-author");
|
|
1562
|
+
el.removeAttribute("data-tc-at");
|
|
1563
|
+
el.removeAttribute("data-tc-block");
|
|
1564
|
+
el.removeAttribute("data-tc-format");
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// --- selection deletes ---
|
|
1568
|
+
|
|
1569
|
+
// 2026-10-03 Deleting a SELECTION in suggesting mode - Backspace/Delete, a selected image, typing,
|
|
1570
|
+
// Enter or a paste over it, a cut. The core deletes most of these itself and asks first
|
|
1571
|
+
// (editor.__trackHook "delete"); cut and the browser's replace arrive as beforeinput. Inside one
|
|
1572
|
+
// block: wrapRangeAsDelete as before. Across sibling text blocks: the selected text of the first
|
|
1573
|
+
// and last block is wrapped (text node by text node, so formatting is never split), a wholly
|
|
1574
|
+
// covered block in between is a deleted block, and the last block is a suggested join - accept is
|
|
1575
|
+
// exactly the untracked delete, reject is the document as it was. Any other shape (list items,
|
|
1576
|
+
// cells): the text is wrapped and the structure left alone. Nothing is removed.
|
|
1577
|
+
// tools/review/tracked-selection-delete.mjs
|
|
1578
|
+
var DELETE_ATOMS = "img,iframe,video,audio,svg,canvas,object,embed,input,[contenteditable='false']";
|
|
1579
|
+
var CONTAINER_BLOCK = /^(P|DIV|H[1-6]|LI|TD|TH|DT|DD|BLOCKQUOTE|PRE|FIGCAPTION|CAPTION)$/;
|
|
1580
|
+
var LAYOUT_PARENT = /^(UL|OL|DL|TABLE|THEAD|TBODY|TFOOT|TR|BODY|BLOCKQUOTE|SECTION|ARTICLE|FIGURE)$/;
|
|
1581
|
+
|
|
1582
|
+
function trackDeleteHook(info) {
|
|
1583
|
+
var cells = info && info.ranges && info.ranges.length ? info.ranges : null;
|
|
1584
|
+
if (!info || (!cells && (!info.range || info.range.collapsed))) return null;
|
|
1585
|
+
if (typeof info.begin === "function") info.begin();
|
|
1586
|
+
if (cells) markLeavesDeleted(cells, null);
|
|
1587
|
+
else trackSelectionDelete(info.range.cloneRange(), !!info.typing);
|
|
1588
|
+
// suggesting mode never deletes for real, even when there is nothing to mark
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function containerOf(node) {
|
|
1593
|
+
var editable = editor.getEditable();
|
|
1594
|
+
for (var n = node; n && n !== editable; n = n.parentNode) if (n.nodeType === 1 && CONTAINER_BLOCK.test(n.nodeName)) return n;
|
|
1595
|
+
return editable;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// the leaf text block (P/DIV/H) a node is in, or null inside a list item, cell, quote...
|
|
1599
|
+
function textBlockOf(node) {
|
|
1600
|
+
var editable = editor.getEditable();
|
|
1601
|
+
for (var n = node; n && n !== editable; n = n.parentNode) {
|
|
1602
|
+
if (n.nodeType !== 1) continue;
|
|
1603
|
+
if (/^(P|DIV|H[1-6])$/.test(n.nodeName)) return n;
|
|
1604
|
+
if (/^(LI|TD|TH|UL|OL|TABLE|BLOCKQUOTE|FIGURE|PRE)$/.test(n.nodeName)) return null;
|
|
1605
|
+
}
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function blankRange(r) {
|
|
1610
|
+
return !(r.toString() || "").replace(/[\s\xa0]/g, "") && !r.cloneContents().querySelector(DELETE_ATOMS);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function trackSelectionDelete(range, keepFirst) {
|
|
1614
|
+
if (!range || range.collapsed) return false;
|
|
1615
|
+
var doc = editor.getDocument();
|
|
1616
|
+
// a triple-click ends at the very start of the next block: that block is not part of it
|
|
1617
|
+
var endBlock = textBlockOf(range.endContainer);
|
|
1618
|
+
if (endBlock && endBlock !== textBlockOf(range.startContainer) && endBlock.previousElementSibling) {
|
|
1619
|
+
var head = doc.createRange();
|
|
1620
|
+
head.setStart(endBlock, 0);
|
|
1621
|
+
head.setEnd(range.endContainer, range.endOffset);
|
|
1622
|
+
if (head.toString() === "" && !head.cloneContents().querySelector(DELETE_ATOMS)) range.setEnd(endBlock.previousElementSibling, endBlock.previousElementSibling.childNodes.length);
|
|
1623
|
+
}
|
|
1624
|
+
if (range.collapsed) return false;
|
|
1625
|
+
var c1 = containerOf(range.startContainer), c2 = containerOf(range.endContainer);
|
|
1626
|
+
if (c1 === c2 && !range.cloneContents().querySelector(PASTE_BLOCK_SELECTOR)) {
|
|
1627
|
+
if (wrapRangeAsDelete(range)) return true;
|
|
1628
|
+
return markLeavesDeleted([range], null);
|
|
1629
|
+
}
|
|
1630
|
+
var a = textBlockOf(range.startContainer), b = textBlockOf(range.endContainer);
|
|
1631
|
+
if (a && b && a !== b && a.parentNode === b.parentNode && (a.compareDocumentPosition(b) & 4)) return markBlocksDeleted(range, a, b, keepFirst);
|
|
1632
|
+
return markLeavesDeleted([range], null);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function deletionStamp() {
|
|
1636
|
+
var user = config.currentUser, id = newId(), at = new Date().toISOString();
|
|
1637
|
+
return {
|
|
1638
|
+
id: id, user: user,
|
|
1639
|
+
mark: function (el) {
|
|
1640
|
+
el.setAttribute("data-tc-id", id);
|
|
1641
|
+
el.setAttribute("data-tc-author", user.id);
|
|
1642
|
+
el.setAttribute("data-tc-at", at);
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function markBlocksDeleted(range, a, b, keepFirst) {
|
|
1648
|
+
var doc = editor.getDocument();
|
|
1649
|
+
var blocks = [];
|
|
1650
|
+
for (var el = a; el; el = el.nextElementSibling) { blocks.push(el); if (el === b) break; }
|
|
1651
|
+
if (blocks[blocks.length - 1] !== b) return markLeavesDeleted([range], null);
|
|
1652
|
+
var text = range.toString();
|
|
1653
|
+
var head = doc.createRange(); head.setStart(a, 0); head.setEnd(range.startContainer, range.startOffset);
|
|
1654
|
+
var tail = doc.createRange(); tail.setStart(range.endContainer, range.endOffset); tail.setEnd(b, b.childNodes.length);
|
|
1655
|
+
var next = b.nextElementSibling;
|
|
1656
|
+
// whole blocks, nothing typed in their place, and somewhere for the caret: they go whole
|
|
1657
|
+
var whole = blankRange(head) && blankRange(tail) && !keepFirst && !!next;
|
|
1658
|
+
var st = deletionStamp(), parts = [];
|
|
1659
|
+
for (var i = 0; i < blocks.length; i++) {
|
|
1660
|
+
var blk = blocks[i], first = i === 0, last = i === blocks.length - 1;
|
|
1661
|
+
if (whole || (!first && !last)) {
|
|
1662
|
+
if (!blk.hasAttribute("data-tc-block") && !blk.hasAttribute("data-tc-break")) {
|
|
1663
|
+
blk.setAttribute("data-tc-block", "delete");
|
|
1664
|
+
st.mark(blk);
|
|
1665
|
+
}
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
var r = doc.createRange();
|
|
1669
|
+
if (first) { r.setStart(range.startContainer, range.startOffset); r.setEnd(blk, blk.childNodes.length); }
|
|
1670
|
+
else { r.setStart(blk, 0); r.setEnd(range.endContainer, range.endOffset); }
|
|
1671
|
+
parts.push(r);
|
|
1672
|
+
if (last && !blk.hasAttribute("data-tc-break")) {
|
|
1673
|
+
blk.setAttribute("data-tc-break", "delete");
|
|
1674
|
+
st.mark(blk);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
markLeavesDeleted(parts, st, true);
|
|
1678
|
+
editor.reviewLedger.add({
|
|
1679
|
+
id: st.id, changeType: "delete", author: st.user, text: text, isBlock: true,
|
|
1680
|
+
status: "pending", sourceLabel: "Suggested delete", createdAt: Date.now()
|
|
1681
|
+
});
|
|
1682
|
+
if (whole) {
|
|
1683
|
+
var cr = doc.createRange();
|
|
1684
|
+
cr.setStart(next, 0);
|
|
1685
|
+
cr.collapse(true);
|
|
1686
|
+
placeRange(cr);
|
|
1687
|
+
}
|
|
1688
|
+
else caretToEnd(a);
|
|
1689
|
+
return true;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// Wrap every piece of text (and every atom) the ranges cover in a delete span of its own, without
|
|
1693
|
+
// splitting any element. With no stamp given this is a deletion of its own, listed here.
|
|
1694
|
+
function markLeavesDeleted(ranges, st, noEntry) {
|
|
1695
|
+
var doc = editor.getDocument();
|
|
1696
|
+
var own = !st;
|
|
1697
|
+
if (own) st = deletionStamp();
|
|
1698
|
+
var targets = [], text = "";
|
|
1699
|
+
for (var k = 0; k < ranges.length; k++) {
|
|
1700
|
+
var range = ranges[k];
|
|
1701
|
+
if (range.collapsed) continue;
|
|
1702
|
+
text += range.toString();
|
|
1703
|
+
var root = range.commonAncestorContainer;
|
|
1704
|
+
if (root.nodeType !== 1) root = root.parentNode;
|
|
1705
|
+
var w = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null, false), n;
|
|
1706
|
+
while ((n = w.nextNode())) {
|
|
1707
|
+
if (!range.intersectsNode(n) || isInsideDeleteSpan(n)) continue;
|
|
1708
|
+
if (n.nodeType === 1) {
|
|
1709
|
+
if (n.matches(DELETE_ATOMS) && !(n.parentNode && n.parentNode.closest && n.parentNode.closest("[contenteditable='false']"))) {
|
|
1710
|
+
var nr = doc.createRange(); nr.selectNode(n);
|
|
1711
|
+
if (range.compareBoundaryPoints(Range.START_TO_START, nr) <= 0 && range.compareBoundaryPoints(Range.END_TO_END, nr) >= 0) targets.push({ atom: n });
|
|
1712
|
+
}
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
if (n.parentNode && n.parentNode.closest && n.parentNode.closest("[contenteditable='false']")) continue;
|
|
1716
|
+
var s0 = n === range.startContainer ? range.startOffset : 0;
|
|
1717
|
+
var e0 = n === range.endContainer ? range.endOffset : n.data.length;
|
|
1718
|
+
if (e0 <= s0) continue;
|
|
1719
|
+
// whitespace between blocks is layout, not text
|
|
1720
|
+
if (!/\S/.test(n.data) && n.parentNode && LAYOUT_PARENT.test(n.parentNode.nodeName)) continue;
|
|
1721
|
+
targets.push({ node: n, s: s0, e: e0 });
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
var lastSpan = null;
|
|
1725
|
+
for (var t = targets.length - 1; t >= 0; t--) {
|
|
1726
|
+
var tg = targets[t], piece = tg.atom;
|
|
1727
|
+
if (!piece) {
|
|
1728
|
+
piece = tg.node;
|
|
1729
|
+
if (tg.e < piece.data.length) piece.splitText(tg.e);
|
|
1730
|
+
if (tg.s > 0) piece = piece.splitText(tg.s);
|
|
1731
|
+
}
|
|
1732
|
+
var span = doc.createElement("span");
|
|
1733
|
+
span.className = config.trackChangesDeleteClass;
|
|
1734
|
+
st.mark(span);
|
|
1735
|
+
span.style.color = st.user.color;
|
|
1736
|
+
span.style.textDecoration = "line-through";
|
|
1737
|
+
piece.parentNode.insertBefore(span, piece);
|
|
1738
|
+
span.appendChild(piece);
|
|
1739
|
+
if (!lastSpan) lastSpan = span;
|
|
1740
|
+
}
|
|
1741
|
+
if (own) {
|
|
1742
|
+
if (!targets.length) return false;
|
|
1743
|
+
editor.reviewLedger.add({
|
|
1744
|
+
id: st.id, changeType: "delete", author: st.user, text: text,
|
|
1745
|
+
status: "pending", sourceLabel: "Suggested delete", createdAt: Date.now()
|
|
1746
|
+
});
|
|
1747
|
+
var cr = doc.createRange();
|
|
1748
|
+
cr.setStartAfter(lastSpan);
|
|
1749
|
+
cr.collapse(true);
|
|
1750
|
+
placeRange(cr);
|
|
1751
|
+
}
|
|
1752
|
+
return targets.length > 0;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// --- formatting ---
|
|
1756
|
+
|
|
1757
|
+
// 2026-10-03 Inline formatting in suggesting mode - bold, italic, underline, strikethrough,
|
|
1758
|
+
// sub/superscript, colours, font, size, remove formatting - went in for real. Now the command
|
|
1759
|
+
// runs, and the formatted text is marked as a formatting suggestion while the text as it was
|
|
1760
|
+
// (with its formatting) is kept beside it as a hidden deletion: accept drops the original - the
|
|
1761
|
+
// untracked result; reject drops the formatted copy and brings the original back, exactly.
|
|
1762
|
+
// Formatting your own suggested insertion just applies. tools/review/tracked-formatting.mjs
|
|
1763
|
+
// 2026-10-09 highlight and unlink went in untracked in suggesting mode (no suggestion to decide)
|
|
1764
|
+
var INLINE_FORMAT = /^(bold|italic|underline|strikethrough|superscript|subscript|forecolor|backcolor|fontname|fontsize|removeformat|inlinecode|changecase|createlink|highlight|unlink)$/;
|
|
1765
|
+
// commands that act on the word under a caret: the word is the selection they format
|
|
1766
|
+
var WORD_COMMANDS = /^(inlinecode|changecase)$/;
|
|
1767
|
+
var WORD_CHAR = /[^\s.,;:!?()"'\[\]{}<>]/;
|
|
1768
|
+
|
|
1769
|
+
// native insert commands the browser carries out (a horizontal rule splits the paragraph)
|
|
1770
|
+
var INSERT_COMMANDS = /^(inserthorizontalrule)$/;
|
|
1771
|
+
var commandRunning = false;
|
|
1772
|
+
|
|
1773
|
+
// 2026-10-09 Deleting a table ROW, COLUMN or whole TABLE in suggesting mode is a suggested deletion: the
|
|
1774
|
+
// row, cells or table stay visible (struck through) until decided; accept removes them, reject clears the
|
|
1775
|
+
// mark - the table as it was. The command is not run, so the table keeps its structure meanwhile.
|
|
1776
|
+
// Merged cells and a multi-cell selection stay untracked. tools/review/tracked-table-delete.mjs
|
|
1777
|
+
var TABLE_DELETE = /^(tablerowdelete|tablecolumndelete|tabledelete)$/;
|
|
1778
|
+
|
|
1779
|
+
function trackTableDelete(info) {
|
|
1780
|
+
var sel = editor.getSelection();
|
|
1781
|
+
if (!sel || !sel.rangeCount) return null;
|
|
1782
|
+
var doc = editor.getDocument(), editable = editor.getEditable();
|
|
1783
|
+
if (doc.querySelectorAll("[__rte_selected_cell]").length > 1) return null;
|
|
1784
|
+
var node = sel.getRangeAt(0).startContainer;
|
|
1785
|
+
var cell = node.nodeType === 1 ? node : node.parentNode;
|
|
1786
|
+
while (cell && !/^(TD|TH)$/.test(cell.nodeName)) cell = cell.parentNode;
|
|
1787
|
+
if (!cell || !editable.contains(cell)) return null;
|
|
1788
|
+
var row = cell.parentNode, table = cell.parentNode && cell.parentNode.parentNode && cell.parentNode.parentNode.parentNode;
|
|
1789
|
+
while (table && table.nodeName !== "TABLE") table = table.parentNode;
|
|
1790
|
+
if (!table) return null;
|
|
1791
|
+
var targets = [], i, r;
|
|
1792
|
+
if (info.name === "tabledelete" || (info.name === "tablerowdelete" && table.rows.length <= 1) || (info.name === "tablecolumndelete" && row.cells.length <= 1)) {
|
|
1793
|
+
targets = [table];
|
|
1794
|
+
} else if (info.name === "tablerowdelete") {
|
|
1795
|
+
targets = [row];
|
|
1796
|
+
} else {
|
|
1797
|
+
var idx = cell.cellIndex;
|
|
1798
|
+
for (r = 0; r < table.rows.length; r++) {
|
|
1799
|
+
var c = table.rows[r].cells[idx];
|
|
1800
|
+
if (!c) continue;
|
|
1801
|
+
if (c.colSpan > 1 || c.rowSpan > 1) return null;
|
|
1802
|
+
targets.push(c);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
for (i = 0; i < targets.length; i++) {
|
|
1806
|
+
var tg = targets[i];
|
|
1807
|
+
if (tg.hasAttribute("data-tc-block") || tg.hasAttribute("data-tc-break") || tg.querySelector("[data-tc-block], [data-tc-break]")) return null;
|
|
1808
|
+
}
|
|
1809
|
+
if (!targets.length) return null;
|
|
1810
|
+
var st = deletionStamp(), text = [];
|
|
1811
|
+
for (i = 0; i < targets.length; i++) {
|
|
1812
|
+
targets[i].setAttribute("data-tc-block", "delete");
|
|
1813
|
+
st.mark(targets[i]);
|
|
1814
|
+
text.push(targets[i].textContent);
|
|
1815
|
+
}
|
|
1816
|
+
editor.reviewLedger.add({
|
|
1817
|
+
id: st.id, changeType: "delete", author: st.user, text: text.join(" "), isBlock: true,
|
|
1818
|
+
status: "pending",
|
|
1819
|
+
sourceLabel: "Suggested delete (" + (targets[0].nodeName === "TABLE" ? "table" : targets[0].nodeName === "TR" ? "table row" : "table column") + ")",
|
|
1820
|
+
createdAt: Date.now()
|
|
1821
|
+
});
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// 2026-10-09 Inserting a table ROW above or below in suggesting mode is a suggested insertion: the command
|
|
1826
|
+
// runs, the new row is marked (data-tc-block="insert") and shows until decided; accept keeps it, reject
|
|
1827
|
+
// removes it - the table as it was. tools/review/tracked-table-insert.mjs
|
|
1828
|
+
var TABLE_ROW_INSERT = /^(tablerowinsertabove|tablerowinsertbelow)$/;
|
|
1829
|
+
|
|
1830
|
+
function trackTableRowInsert(info) {
|
|
1831
|
+
var editable = editor.getEditable();
|
|
1832
|
+
var mo = typeof MutationObserver === "function" ? new MutationObserver(function () { }) : null;
|
|
1833
|
+
if (mo) mo.observe(editable, { childList: true, subtree: true });
|
|
1834
|
+
var recs = [];
|
|
1835
|
+
try { info.run(); } finally { if (mo) { recs = mo.takeRecords(); mo.disconnect(); } }
|
|
1836
|
+
var rows = [];
|
|
1837
|
+
for (var i = 0; i < recs.length; i++) {
|
|
1838
|
+
for (var j = 0; j < recs[i].addedNodes.length; j++) {
|
|
1839
|
+
var n = recs[i].addedNodes[j];
|
|
1840
|
+
if (n.nodeName === "TR" && editable.contains(n) && rows.indexOf(n) < 0) rows.push(n);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (!rows.length) return true;
|
|
1844
|
+
var st = deletionStamp();
|
|
1845
|
+
for (var k = 0; k < rows.length; k++) {
|
|
1846
|
+
rows[k].setAttribute("data-tc-block", "insert");
|
|
1847
|
+
st.mark(rows[k]);
|
|
1848
|
+
}
|
|
1849
|
+
editor.reviewLedger.add({
|
|
1850
|
+
id: st.id, changeType: "insert", author: st.user, text: "", isBlock: true,
|
|
1851
|
+
status: "pending", sourceLabel: "Suggested insert (table row)", createdAt: Date.now()
|
|
1852
|
+
});
|
|
1853
|
+
return true;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function trackCommand(info) {
|
|
1857
|
+
if (!formatRunning && !blockRunning && info && TABLE_ROW_INSERT.test(info.name || "") && typeof info.run === "function") return trackTableRowInsert(info);
|
|
1858
|
+
if (!formatRunning && !blockRunning && info && TABLE_DELETE.test(info.name || "") && typeof info.run === "function") {
|
|
1859
|
+
var tableDelete = trackTableDelete(info);
|
|
1860
|
+
if (tableDelete === true) return true;
|
|
1861
|
+
}
|
|
1862
|
+
if (!formatRunning && !blockRunning && info && BLOCK_FORMAT.test(info.name || "") && typeof info.run === "function") return trackBlockFormat(info);
|
|
1863
|
+
if (!formatRunning && info && info.name === "insertpagebreak" && typeof info.run === "function") return trackPageBreak(info);
|
|
1864
|
+
if (!formatRunning && info && INSERT_COMMANDS.test(info.name || "") && typeof info.run === "function") return trackInsertCommand(info);
|
|
1865
|
+
if (formatRunning || !info || !INLINE_FORMAT.test(info.name || "")) return null;
|
|
1866
|
+
var sel = editor.getSelection();
|
|
1867
|
+
if (sel && sel.rangeCount && sel.isCollapsed && WORD_COMMANDS.test(info.name)) selectWordAtCaret(sel);
|
|
1868
|
+
return trackFormat(info.name, info.value, info.run) ? true : null;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function selectWordAtCaret(sel) {
|
|
1872
|
+
var r = sel.getRangeAt(0), n = r.startContainer;
|
|
1873
|
+
if (n.nodeType !== 3) return false;
|
|
1874
|
+
var s0 = r.startOffset, e0 = s0;
|
|
1875
|
+
while (s0 > 0 && WORD_CHAR.test(n.data.charAt(s0 - 1))) s0--;
|
|
1876
|
+
while (e0 < n.data.length && WORD_CHAR.test(n.data.charAt(e0))) e0++;
|
|
1877
|
+
if (e0 <= s0) return false;
|
|
1878
|
+
var w = editor.getDocument().createRange();
|
|
1879
|
+
w.setStart(n, s0);
|
|
1880
|
+
w.setEnd(n, e0);
|
|
1881
|
+
placeRange(w);
|
|
1882
|
+
return true;
|
|
1883
|
+
}
|
|
1884
|
+
var CONTAINER_SELECTOR = "p,div,h1,h2,h3,h4,h5,h6,li,td,th,dt,dd,blockquote,pre,figcaption,caption";
|
|
1885
|
+
var INLINE_WRAPPER = /^(B|STRONG|I|EM|U|S|STRIKE|DEL|INS|SPAN|FONT|SUB|SUP|MARK|CODE|SMALL|BIG|A)$/;
|
|
1886
|
+
var formatRunning = false;
|
|
1887
|
+
|
|
1888
|
+
function trackFormat(cmd, value, run) {
|
|
1889
|
+
var sel = editor.getSelection();
|
|
1890
|
+
if (!sel || !sel.rangeCount) return false;
|
|
1891
|
+
var doc = editor.getDocument();
|
|
1892
|
+
// a dragged cell selection: the caret sits collapsed in the first cell and the core formats
|
|
1893
|
+
// every marked cell's content itself - each cell is a part of the one suggestion
|
|
1894
|
+
var cells = doc.querySelectorAll("[__rte_selected_cell]"), ranges = [], i;
|
|
1895
|
+
if (cells.length > 1) {
|
|
1896
|
+
for (i = 0; i < cells.length; i++) { var cr = doc.createRange(); cr.selectNodeContents(cells[i]); ranges.push(cr); }
|
|
1897
|
+
} else {
|
|
1898
|
+
if (sel.isCollapsed) return false;
|
|
1899
|
+
ranges.push(sel.getRangeAt(0));
|
|
1900
|
+
if (rangeAllMine(ranges[0])) return false;
|
|
1901
|
+
}
|
|
1902
|
+
var parts = [];
|
|
1903
|
+
for (var k = 0; k < ranges.length; k++) {
|
|
1904
|
+
var blocks = blocksInRange(ranges[k]);
|
|
1905
|
+
for (i = 0; i < blocks.length; i++) {
|
|
1906
|
+
var r = clipToBlock(ranges[k], blocks[i]);
|
|
1907
|
+
if (r.collapsed || blankRange(r)) continue;
|
|
1908
|
+
parts.push({ block: blocks[i], frag: cloneWithContext(r, blocks[i]), before: blocks[i].innerHTML });
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
if (!parts.length) return false;
|
|
1912
|
+
// where the selection is, as text offsets in its blocks: a command that leaves the selection
|
|
1913
|
+
// collapsed or elsewhere (inline code does) still tells us what it formatted
|
|
1914
|
+
var anchor = null;
|
|
1915
|
+
if (cells.length < 2) {
|
|
1916
|
+
var r0 = ranges[0], sb = containerOf(r0.startContainer), eb = containerOf(r0.endContainer);
|
|
1917
|
+
anchor = { sb: sb, so: textOffsetIn(sb, r0.startContainer, r0.startOffset), eb: eb, eo: textOffsetIn(eb, r0.endContainer, r0.endOffset) };
|
|
1918
|
+
}
|
|
1919
|
+
formatRunning = true;
|
|
1920
|
+
try { if (typeof run === "function") run(); else editor.execCommand(cmd, value); } finally { formatRunning = false; }
|
|
1921
|
+
var after = cells.length > 1 ? null : sel.rangeCount ? sel.getRangeAt(0) : null;
|
|
1922
|
+
if (cells.length < 2) {
|
|
1923
|
+
var usable = !!after && !after.collapsed;
|
|
1924
|
+
// usable only if it covers some of what was selected in EVERY block
|
|
1925
|
+
for (var pi = 0; usable && pi < parts.length; pi++) if (!after.intersectsNode(parts[pi].block) || blankRange(clipToBlock(after, parts[pi].block))) usable = false;
|
|
1926
|
+
if (!usable && anchor && anchor.sb.parentNode && anchor.eb.parentNode) {
|
|
1927
|
+
var sp = pointAtTextOffset(anchor.sb, anchor.so), ep = pointAtTextOffset(anchor.eb, anchor.eo);
|
|
1928
|
+
after = doc.createRange();
|
|
1929
|
+
after.setStart(sp.node, sp.offset);
|
|
1930
|
+
after.setEnd(ep.node, ep.offset);
|
|
1931
|
+
}
|
|
1932
|
+
if (!after || after.collapsed) return true;
|
|
1933
|
+
}
|
|
1934
|
+
var st = deletionStamp(), text = "", first = null, last = null;
|
|
1935
|
+
for (i = 0; i < parts.length; i++) {
|
|
1936
|
+
var part = parts[i];
|
|
1937
|
+
if (!part.block.parentNode) continue;
|
|
1938
|
+
// a block the command left as it was is not part of the suggestion (a plugin command on a
|
|
1939
|
+
// cell selection changes only the caret's word, not every selected cell)
|
|
1940
|
+
if (part.block.innerHTML === part.before) continue;
|
|
1941
|
+
var whole = null;
|
|
1942
|
+
if (!after) { whole = doc.createRange(); whole.selectNodeContents(part.block); }
|
|
1943
|
+
var pr = whole || clipToBlock(after, part.block);
|
|
1944
|
+
if (pr.collapsed) continue;
|
|
1945
|
+
text += pr.toString();
|
|
1946
|
+
var at = outerStart(pr, part.block);
|
|
1947
|
+
var spans = wrapFormatted(pr, st);
|
|
1948
|
+
if (!spans.length) continue;
|
|
1949
|
+
if (!first) first = spans[0];
|
|
1950
|
+
last = spans[spans.length - 1];
|
|
1951
|
+
var del = doc.createElement("span");
|
|
1952
|
+
del.className = config.trackChangesDeleteClass;
|
|
1953
|
+
st.mark(del);
|
|
1954
|
+
del.setAttribute("data-tc-format", "1");
|
|
1955
|
+
del.appendChild(stripContext(part.frag, at.startContainer, part.block));
|
|
1956
|
+
at.insertNode(del);
|
|
1957
|
+
}
|
|
1958
|
+
if (!first) return true;
|
|
1959
|
+
editor.reviewLedger.add({
|
|
1960
|
+
id: st.id, changeType: "format", author: st.user, text: text,
|
|
1961
|
+
status: "pending", sourceLabel: "Suggested formatting (" + cmd + ")", createdAt: Date.now()
|
|
1962
|
+
});
|
|
1963
|
+
var nr = doc.createRange();
|
|
1964
|
+
nr.setStart(first, 0);
|
|
1965
|
+
nr.setEnd(last, last.childNodes.length);
|
|
1966
|
+
placeRange(nr);
|
|
1967
|
+
return true;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// every character the range covers is in the reviewer's own suggested insertion
|
|
1971
|
+
function rangeAllMine(range) {
|
|
1972
|
+
var root = range.commonAncestorContainer;
|
|
1973
|
+
if (root.nodeType === 3) return isMyInsertAncestor(root);
|
|
1974
|
+
var w = editor.getDocument().createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false), n, any = false;
|
|
1975
|
+
while ((n = w.nextNode())) {
|
|
1976
|
+
if (!range.intersectsNode(n)) continue;
|
|
1977
|
+
var s0 = n === range.startContainer ? range.startOffset : 0;
|
|
1978
|
+
var e0 = n === range.endContainer ? range.endOffset : n.data.length;
|
|
1979
|
+
if (e0 <= s0) continue;
|
|
1980
|
+
if (!isMyInsertAncestor(n)) return false;
|
|
1981
|
+
any = true;
|
|
1982
|
+
}
|
|
1983
|
+
return any;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function isMyInsertAncestor(node) {
|
|
1987
|
+
var editable = editor.getEditable();
|
|
1988
|
+
for (var a = node.parentNode; a && a !== editable; a = a.parentNode) if (isMyInsert(a)) return true;
|
|
1989
|
+
return false;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function textOffsetIn(block, node, offset) {
|
|
1993
|
+
var r = editor.getDocument().createRange();
|
|
1994
|
+
r.setStart(block, 0);
|
|
1995
|
+
r.setEnd(node, offset);
|
|
1996
|
+
return r.toString().length;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function pointAtTextOffset(block, n) {
|
|
2000
|
+
var w = editor.getDocument().createTreeWalker(block, NodeFilter.SHOW_TEXT, null, false), tn, left = n, last = null;
|
|
2001
|
+
while ((tn = w.nextNode())) {
|
|
2002
|
+
last = tn;
|
|
2003
|
+
if (left <= tn.data.length) return { node: tn, offset: left };
|
|
2004
|
+
left -= tn.data.length;
|
|
2005
|
+
}
|
|
2006
|
+
return last ? { node: last, offset: last.data.length } : { node: block, offset: block.childNodes.length };
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// the leaf container blocks a range runs through, in document order
|
|
2010
|
+
function blocksInRange(range) {
|
|
2011
|
+
var c1 = containerOf(range.startContainer), c2 = containerOf(range.endContainer);
|
|
2012
|
+
if (c1 === c2 && !c1.querySelector(CONTAINER_SELECTOR)) return [c1];
|
|
2013
|
+
var root = range.commonAncestorContainer;
|
|
2014
|
+
if (root.nodeType !== 1) root = root.parentNode;
|
|
2015
|
+
var out = [], list = root.querySelectorAll(CONTAINER_SELECTOR);
|
|
2016
|
+
for (var i = 0; i < list.length; i++) {
|
|
2017
|
+
if (range.intersectsNode(list[i]) && !list[i].querySelector(CONTAINER_SELECTOR)) out.push(list[i]);
|
|
2018
|
+
}
|
|
2019
|
+
return out.length ? out : [c1];
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function clipToBlock(range, block) {
|
|
2023
|
+
var r = range.cloneRange();
|
|
2024
|
+
if (!block.contains(range.startContainer) && block !== range.startContainer) r.setStart(block, 0);
|
|
2025
|
+
if (!block.contains(range.endContainer) && block !== range.endContainer) r.setEnd(block, block.childNodes.length);
|
|
2026
|
+
return r;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// the selected content with every inline element around it, up to the block
|
|
2030
|
+
function cloneWithContext(r, block) {
|
|
2031
|
+
var doc = editor.getDocument();
|
|
2032
|
+
var frag = r.cloneContents();
|
|
2033
|
+
var n = r.commonAncestorContainer;
|
|
2034
|
+
if (n.nodeType !== 1) n = n.parentNode;
|
|
2035
|
+
for (; n && n !== block && n.nodeType === 1; n = n.parentNode) {
|
|
2036
|
+
var w = n.cloneNode(false);
|
|
2037
|
+
w.removeAttribute("id");
|
|
2038
|
+
w.appendChild(frag);
|
|
2039
|
+
frag = doc.createDocumentFragment();
|
|
2040
|
+
frag.appendChild(w);
|
|
2041
|
+
}
|
|
2042
|
+
return frag;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// the start of a range moved out of every element it starts at the very beginning of: the spot
|
|
2046
|
+
// just before what the command wrapped around the selection
|
|
2047
|
+
function outerStart(range, block) {
|
|
2048
|
+
var r = range.cloneRange();
|
|
2049
|
+
r.collapse(true);
|
|
2050
|
+
var c = r.startContainer, o = r.startOffset;
|
|
2051
|
+
while (c !== block && o === 0 && c.parentNode) {
|
|
2052
|
+
var parent = c.parentNode, idx = 0;
|
|
2053
|
+
for (var x = c; x.previousSibling; x = x.previousSibling) idx++;
|
|
2054
|
+
c = parent;
|
|
2055
|
+
o = idx;
|
|
2056
|
+
}
|
|
2057
|
+
r.setStart(c, o);
|
|
2058
|
+
r.collapse(true);
|
|
2059
|
+
return r;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// drop the outer context the insertion point already has (the original goes back inside it)
|
|
2063
|
+
function stripContext(frag, container, block) {
|
|
2064
|
+
var outer = [];
|
|
2065
|
+
for (var n = container.nodeType === 1 ? container : container.parentNode; n && n !== block; n = n.parentNode) outer.unshift(n);
|
|
2066
|
+
var k = 0;
|
|
2067
|
+
while (k < outer.length && frag.childNodes.length === 1 && frag.firstChild.nodeType === 1 && sameShell(frag.firstChild, outer[k])) {
|
|
2068
|
+
var only = frag.firstChild, inner = editor.getDocument().createDocumentFragment();
|
|
2069
|
+
while (only.firstChild) inner.appendChild(only.firstChild);
|
|
2070
|
+
frag = inner;
|
|
2071
|
+
k++;
|
|
2072
|
+
}
|
|
2073
|
+
return frag;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function wrapFormatted(range, st) {
|
|
2077
|
+
var doc = editor.getDocument();
|
|
2078
|
+
var root = range.commonAncestorContainer;
|
|
2079
|
+
if (root.nodeType !== 1) root = root.parentNode;
|
|
2080
|
+
var targets = [], w = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null, false), n;
|
|
2081
|
+
while ((n = w.nextNode())) {
|
|
2082
|
+
if (!range.intersectsNode(n) || isInsideDeleteSpan(n)) continue;
|
|
2083
|
+
if (n.nodeType === 1) {
|
|
2084
|
+
if (n.matches(DELETE_ATOMS)) {
|
|
2085
|
+
var nr = doc.createRange(); nr.selectNode(n);
|
|
2086
|
+
if (range.compareBoundaryPoints(Range.START_TO_START, nr) <= 0 && range.compareBoundaryPoints(Range.END_TO_END, nr) >= 0) targets.push({ atom: n });
|
|
2087
|
+
}
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
if (n.parentNode && n.parentNode.closest && n.parentNode.closest("[contenteditable='false']")) continue;
|
|
2091
|
+
var s0 = n === range.startContainer ? range.startOffset : 0;
|
|
2092
|
+
var e0 = n === range.endContainer ? range.endOffset : n.data.length;
|
|
2093
|
+
if (e0 <= s0) continue;
|
|
2094
|
+
if (!/\S/.test(n.data) && n.parentNode && LAYOUT_PARENT.test(n.parentNode.nodeName)) continue;
|
|
2095
|
+
targets.push({ node: n, s: s0, e: e0 });
|
|
2096
|
+
}
|
|
2097
|
+
var spans = [];
|
|
2098
|
+
for (var t = targets.length - 1; t >= 0; t--) {
|
|
2099
|
+
var tg = targets[t], piece = tg.atom;
|
|
2100
|
+
if (!piece) {
|
|
2101
|
+
piece = tg.node;
|
|
2102
|
+
if (tg.e < piece.data.length) piece.splitText(tg.e);
|
|
2103
|
+
if (tg.s > 0) piece = piece.splitText(tg.s);
|
|
2104
|
+
}
|
|
2105
|
+
var span = doc.createElement("span");
|
|
2106
|
+
span.className = config.trackChangesInsertClass;
|
|
2107
|
+
st.mark(span);
|
|
2108
|
+
span.setAttribute("data-tc-format", "1");
|
|
2109
|
+
piece.parentNode.insertBefore(span, piece);
|
|
2110
|
+
span.appendChild(piece);
|
|
2111
|
+
spans.unshift(span);
|
|
2112
|
+
}
|
|
2113
|
+
return spans;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// a rejected formatted piece goes with the wrappers the command made around it, now empty
|
|
2117
|
+
function removeFormattedPiece(el, seams) {
|
|
2118
|
+
var editable = editor.getEditable(), top = el, parent = el.parentNode;
|
|
2119
|
+
while (parent && parent !== editable && INLINE_WRAPPER.test(parent.nodeName) && parent.childNodes.length === 1) { top = parent; parent = parent.parentNode; }
|
|
2120
|
+
if (top.previousSibling) seams.push(top.previousSibling);
|
|
2121
|
+
if (top.parentNode) top.parentNode.removeChild(top);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// two neighbours with the same tag and attributes (one wrapper a command split) become one again
|
|
2125
|
+
function mergePair(left) {
|
|
2126
|
+
while (left && left.nodeType === 1) {
|
|
2127
|
+
var right = left.nextSibling;
|
|
2128
|
+
if (!right || right.nodeType !== 1 || !sameShell(left, right)) break;
|
|
2129
|
+
var inner = left.lastChild;
|
|
2130
|
+
while (right.firstChild) left.appendChild(right.firstChild);
|
|
2131
|
+
right.parentNode.removeChild(right);
|
|
2132
|
+
// the seam inside, then this level again: the next neighbour may be a third piece
|
|
2133
|
+
mergePair(inner);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function trackedElements(id) {
|
|
2138
|
+
var list = editor.getEditable().querySelectorAll('[data-tc-id="' + cssEscape(id) + '"]');
|
|
2139
|
+
var out = [];
|
|
2140
|
+
for (var i = 0; i < list.length; i++) out.push(list[i]);
|
|
2141
|
+
return out;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
598
2144
|
// --- accept / reject ---
|
|
599
2145
|
|
|
600
2146
|
function decisionUser() {
|
|
@@ -616,16 +2162,210 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
616
2162
|
return false;
|
|
617
2163
|
}
|
|
618
2164
|
|
|
2165
|
+
// 2026-10-17 Accepting or rejecting one suggestion on screen. Nothing in the editor could - only accept(id) from
|
|
2166
|
+
// code; the AI review drawer shows AI suggestions only - while CKEditor, TinyMCE's suggested edits and Google Docs
|
|
2167
|
+
// put Accept / Reject on each one. With the caret in a pending suggestion a card shows what it is and who made it,
|
|
2168
|
+
// with Accept, Reject, Previous and Next. tools/review/suggestion-card.mjs
|
|
2169
|
+
var sugCard = null, sugCardId = null, sugCardTimer = 0;
|
|
2170
|
+
function suggestionAtCaret() {
|
|
2171
|
+
var doc = editor.getDocument(), ed = editor.getEditable();
|
|
2172
|
+
var sel = doc && doc.getSelection();
|
|
2173
|
+
if (!sel || !sel.rangeCount || !ed) return null;
|
|
2174
|
+
for (var n = sel.getRangeAt(0).startContainer; n && n !== ed; n = n.parentNode)
|
|
2175
|
+
if (n.nodeType === 1 && n.getAttribute("data-tc-id")) return n;
|
|
2176
|
+
return null;
|
|
2177
|
+
}
|
|
2178
|
+
function pendingEntry(id) {
|
|
2179
|
+
if (!id || !editor.reviewLedger) return null;
|
|
2180
|
+
var entry = editor.reviewLedger.get(id);
|
|
2181
|
+
if (!entry) { syncFromDocument(); entry = editor.reviewLedger.get(id); }
|
|
2182
|
+
return entry && entry.status === "pending" ? entry : null;
|
|
2183
|
+
}
|
|
2184
|
+
function scheduleCard() {
|
|
2185
|
+
clearTimeout(sugCardTimer);
|
|
2186
|
+
sugCardTimer = setTimeout(updateSuggestionCard, 60);
|
|
2187
|
+
}
|
|
2188
|
+
function hideSuggestionCard() {
|
|
2189
|
+
if (sugCard && sugCard.parentNode) sugCard.parentNode.removeChild(sugCard);
|
|
2190
|
+
sugCard = null;
|
|
2191
|
+
sugCardId = null;
|
|
2192
|
+
}
|
|
2193
|
+
// 2026-10-17 the card is appended to the page body, outside the editor's element, so the editor's rte-dark class
|
|
2194
|
+
// cannot reach it: mirror it (as the comment sidebar does), and rte-auto-dark under a dark system setting.
|
|
2195
|
+
// tools/review/suggestion-card-dark.mjs
|
|
2196
|
+
function editorIsDark() {
|
|
2197
|
+
try {
|
|
2198
|
+
var hostEl = editor.container || (editor.iframe && editor.iframe.closest && editor.iframe.closest(".richtexteditor"));
|
|
2199
|
+
if (!hostEl || !hostEl.classList) return false;
|
|
2200
|
+
if (hostEl.classList.contains("rte-dark")) return true;
|
|
2201
|
+
var w = hostEl.ownerDocument.defaultView || window;
|
|
2202
|
+
return hostEl.classList.contains("rte-auto-dark") && !hostEl.classList.contains("rte-light") && !!(w.matchMedia && w.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
2203
|
+
} catch (x) { return false; }
|
|
2204
|
+
}
|
|
2205
|
+
// 2026-10-18 the card's words come from config.text_ keys (language packs), English as the fallback.
|
|
2206
|
+
// tools/i18n/suggestion-card-language.mjs
|
|
2207
|
+
function tcText(key, fallback) { return (config && config["text_" + key]) || fallback; }
|
|
2208
|
+
function suggestionLabel(el, entry) {
|
|
2209
|
+
var brk = el.getAttribute("data-tc-break");
|
|
2210
|
+
if (brk) return brk === "insert" ? tcText("tc_break", "Suggested paragraph break") : tcText("tc_join", "Suggested paragraph join");
|
|
2211
|
+
if (entry.changeType === "insert") return tcText("tc_insert", "Suggested insertion");
|
|
2212
|
+
if (entry.changeType === "delete") return tcText("tc_delete", "Suggested deletion");
|
|
2213
|
+
return tcText("tc_format", "Suggested formatting");
|
|
2214
|
+
}
|
|
2215
|
+
function updateSuggestionCard() {
|
|
2216
|
+
var el = suggestionAtCaret();
|
|
2217
|
+
var id = el && el.getAttribute("data-tc-id");
|
|
2218
|
+
var entry = pendingEntry(id);
|
|
2219
|
+
if (!entry) { hideSuggestionCard(); return; }
|
|
2220
|
+
if (!sugCard || sugCardId !== id || !sugCard.isConnected) {
|
|
2221
|
+
hideSuggestionCard();
|
|
2222
|
+
var host = (editor.iframe && editor.iframe.ownerDocument) || document;
|
|
2223
|
+
sugCard = host.createElement("div");
|
|
2224
|
+
sugCard.className = "rte-suggestion-card" + (editorIsDark() ? " rte-suggestion-card-dark" : "");
|
|
2225
|
+
sugCard.setAttribute("role", "group");
|
|
2226
|
+
sugCard.setAttribute("aria-label", tcText("tc_suggestion", "Suggestion"));
|
|
2227
|
+
var head = host.createElement("div");
|
|
2228
|
+
head.className = "rte-suggestion-card-title";
|
|
2229
|
+
var who = entry.author && (entry.author.name || entry.author.id);
|
|
2230
|
+
head.textContent = suggestionLabel(el, entry) + (who ? " " + tcText("tc_by", "by") + " " + who : "");
|
|
2231
|
+
sugCard.appendChild(head);
|
|
2232
|
+
var excerpt = String(entry.text || "").replace(/\s+/g, " ").trim();
|
|
2233
|
+
if (excerpt && excerpt.length > 1) {
|
|
2234
|
+
var ex = host.createElement("div");
|
|
2235
|
+
ex.className = "rte-suggestion-card-text";
|
|
2236
|
+
ex.textContent = excerpt.length > 60 ? excerpt.slice(0, 57) + "..." : excerpt;
|
|
2237
|
+
sugCard.appendChild(ex);
|
|
2238
|
+
}
|
|
2239
|
+
var row = host.createElement("div");
|
|
2240
|
+
row.className = "rte-suggestion-card-actions";
|
|
2241
|
+
var mk = function (label, cls, enabled, run) {
|
|
2242
|
+
var b = host.createElement("button");
|
|
2243
|
+
b.type = "button";
|
|
2244
|
+
b.className = "rte-suggestion-card-button " + cls;
|
|
2245
|
+
b.textContent = label;
|
|
2246
|
+
if (!enabled) b.disabled = true;
|
|
2247
|
+
b.addEventListener("mousedown", function (e) { e.preventDefault(); });
|
|
2248
|
+
b.addEventListener("click", function (e) { e.preventDefault(); run(); });
|
|
2249
|
+
row.appendChild(b);
|
|
2250
|
+
};
|
|
2251
|
+
var cid = id;
|
|
2252
|
+
mk(tcText("tc_accept", "Accept"), "rte-suggestion-card-accept", canDecide(entry, "accept"), function () { hideSuggestionCard(); acceptEntry(cid); try { editor.focus(); } catch (x) { /* best effort */ } });
|
|
2253
|
+
mk(tcText("tc_reject", "Reject"), "rte-suggestion-card-reject", canDecide(entry, "reject"), function () { hideSuggestionCard(); rejectEntry(cid); try { editor.focus(); } catch (x) { /* best effort */ } });
|
|
2254
|
+
mk(tcText("findprevious", "Previous"), "rte-suggestion-card-prev", true, function () { stepSuggestion(-1); });
|
|
2255
|
+
mk(tcText("findnext", "Next"), "rte-suggestion-card-next", true, function () { stepSuggestion(1); });
|
|
2256
|
+
sugCard.appendChild(row);
|
|
2257
|
+
// 2026-10-17 Accept all / Reject all when more than one suggestion is pending (Google Docs, CKEditor):
|
|
2258
|
+
// acceptAll() / rejectAll() had no button. tools/review/suggestion-card-all.mjs
|
|
2259
|
+
var pendingCount = 0;
|
|
2260
|
+
try { pendingCount = editor.trackedChanges.list().length; } catch (x) { pendingCount = 0; }
|
|
2261
|
+
if (pendingCount > 1) {
|
|
2262
|
+
var allRow = host.createElement("div");
|
|
2263
|
+
allRow.className = "rte-suggestion-card-actions rte-suggestion-card-all";
|
|
2264
|
+
var keepRow = row;
|
|
2265
|
+
row = allRow;
|
|
2266
|
+
mk(tcText("tc_acceptall", "Accept all") + " (" + pendingCount + ")", "rte-suggestion-card-accept", true, function () { hideSuggestionCard(); acceptAll(); try { editor.focus(); } catch (x) { /* best effort */ } });
|
|
2267
|
+
mk(tcText("tc_rejectall", "Reject all"), "rte-suggestion-card-reject", true, function () { hideSuggestionCard(); rejectAll(); try { editor.focus(); } catch (x) { /* best effort */ } });
|
|
2268
|
+
row = keepRow;
|
|
2269
|
+
sugCard.appendChild(allRow);
|
|
2270
|
+
}
|
|
2271
|
+
// Left / Right / Home / End move between the buttons; Escape returns to the editor with the caret where it
|
|
2272
|
+
// was (the card stays: the caret is still in the suggestion). tools/review/suggestion-card-keyboard.mjs
|
|
2273
|
+
sugCard.addEventListener("keydown", function (e) {
|
|
2274
|
+
var btns = [].slice.call(sugCard.querySelectorAll("button:not([disabled])"));
|
|
2275
|
+
var at = btns.indexOf(e.target), next = -1;
|
|
2276
|
+
if (e.key === "ArrowRight") next = (at + 1) % btns.length;
|
|
2277
|
+
else if (e.key === "ArrowLeft") next = (at - 1 + btns.length) % btns.length;
|
|
2278
|
+
else if (e.key === "Home") next = 0;
|
|
2279
|
+
else if (e.key === "End") next = btns.length - 1;
|
|
2280
|
+
else if (e.key === "Escape") {
|
|
2281
|
+
e.preventDefault();
|
|
2282
|
+
e.stopPropagation();
|
|
2283
|
+
var back = sugCard.__returnRange;
|
|
2284
|
+
try { editor.focus(); } catch (x) { /* best effort */ }
|
|
2285
|
+
if (back) { try { var bs = editor.getDocument().getSelection(); bs.removeAllRanges(); bs.addRange(back); } catch (x) { /* best effort */ } }
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
if (next < 0 || at < 0) return;
|
|
2289
|
+
e.preventDefault();
|
|
2290
|
+
btns[next].focus();
|
|
2291
|
+
});
|
|
2292
|
+
host.body.appendChild(sugCard);
|
|
2293
|
+
sugCardId = id;
|
|
2294
|
+
}
|
|
2295
|
+
// under the suggestion (the iframe's offset added)
|
|
2296
|
+
var rect = el.getBoundingClientRect();
|
|
2297
|
+
var fx = 0, fy = 0;
|
|
2298
|
+
try { if (editor.iframe) { var fr = editor.iframe.getBoundingClientRect(); fx = fr.left; fy = fr.top; } } catch (x) { /* no frame */ }
|
|
2299
|
+
var top = fy + rect.bottom + 6, left = Math.max(8, fx + rect.left);
|
|
2300
|
+
var vw = (sugCard.ownerDocument.defaultView || window).innerWidth;
|
|
2301
|
+
if (left + 300 > vw) left = Math.max(8, vw - 308);
|
|
2302
|
+
sugCard.style.top = top + "px";
|
|
2303
|
+
sugCard.style.left = left + "px";
|
|
2304
|
+
}
|
|
2305
|
+
// the next / previous pending suggestion after / before the caret (wrapping), selected
|
|
2306
|
+
function stepSuggestion(dir) {
|
|
2307
|
+
var doc = editor.getDocument(), ed = editor.getEditable();
|
|
2308
|
+
if (!doc || !ed) return null;
|
|
2309
|
+
var els = ed.querySelectorAll("[data-tc-id]"), firsts = [], seen = {};
|
|
2310
|
+
for (var i = 0; i < els.length; i++) {
|
|
2311
|
+
var sid = els[i].getAttribute("data-tc-id");
|
|
2312
|
+
if (seen[sid] || !pendingEntry(sid)) continue;
|
|
2313
|
+
seen[sid] = true;
|
|
2314
|
+
firsts.push(els[i]);
|
|
2315
|
+
}
|
|
2316
|
+
if (!firsts.length) return null;
|
|
2317
|
+
var sel = doc.getSelection(), cur = sel && sel.rangeCount ? sel.getRangeAt(0) : null, pick = null;
|
|
2318
|
+
if (cur) {
|
|
2319
|
+
if (dir > 0) {
|
|
2320
|
+
for (var a = 0; a < firsts.length && !pick; a++) {
|
|
2321
|
+
var ra = doc.createRange(); ra.selectNodeContents(firsts[a]);
|
|
2322
|
+
if (ra.compareBoundaryPoints(Range.START_TO_START, cur) > 0) pick = firsts[a];
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
else {
|
|
2326
|
+
for (var b = firsts.length - 1; b >= 0 && !pick; b--) {
|
|
2327
|
+
var rb = doc.createRange(); rb.selectNodeContents(firsts[b]);
|
|
2328
|
+
if (rb.compareBoundaryPoints(Range.START_TO_START, cur) < 0) pick = firsts[b];
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
if (!pick) pick = dir > 0 ? firsts[0] : firsts[firsts.length - 1];
|
|
2333
|
+
var pid = pick.getAttribute("data-tc-id");
|
|
2334
|
+
var all = trackedElements(pid);
|
|
2335
|
+
var r = doc.createRange();
|
|
2336
|
+
r.setStart(all[0], 0);
|
|
2337
|
+
r.setEnd(all[all.length - 1], all[all.length - 1].childNodes.length);
|
|
2338
|
+
try { editor.focus(); } catch (x) { /* best effort */ }
|
|
2339
|
+
sel.removeAllRanges();
|
|
2340
|
+
sel.addRange(r);
|
|
2341
|
+
try { pick.scrollIntoView({ block: "nearest" }); } catch (x) { /* best effort */ }
|
|
2342
|
+
updateSuggestionCard();
|
|
2343
|
+
return pid;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
619
2346
|
function acceptEntry(id) {
|
|
620
2347
|
var entry = editor.reviewLedger.get(id);
|
|
621
2348
|
if (!entry) return false;
|
|
622
2349
|
if (!canDecide(entry, "accept")) return false;
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
2350
|
+
// every element of it (a split cuts an insert span in two), and paragraph breaks
|
|
2351
|
+
var els = trackedElements(id), joins = [], i;
|
|
2352
|
+
// a join that is part of a selection deletion keeps the block before it, as the untracked
|
|
2353
|
+
// delete does (__JoinTextBlocks keepFirst); a lone Backspace join does not
|
|
2354
|
+
for (i = 0; i < els.length; i++) {
|
|
2355
|
+
if (els[i].getAttribute("data-tc-break") !== "delete") continue;
|
|
2356
|
+
var prev = els[i].previousElementSibling;
|
|
2357
|
+
joins.push({ el: els[i], keep: !!prev && (prev.getAttribute("data-tc-id") === id || !!prev.querySelector('[data-tc-id="' + cssEscape(id) + '"]')) });
|
|
2358
|
+
}
|
|
2359
|
+
for (i = 0; i < els.length; i++) {
|
|
2360
|
+
var el = els[i], brk = el.getAttribute("data-tc-break"), blk = el.getAttribute("data-tc-block");
|
|
2361
|
+
if (brk === "insert") clearBreak(el);
|
|
2362
|
+
else if (brk === "delete") continue;
|
|
2363
|
+
else if (blk === "delete") { if (el.parentNode) el.parentNode.removeChild(el); }
|
|
2364
|
+
else if (blk) clearBlock(el);
|
|
2365
|
+
else if (el.classList.contains("rte-tc-delete")) { if (el.parentNode) el.parentNode.removeChild(el); }
|
|
2366
|
+
else unwrapKeepChildren(el);
|
|
628
2367
|
}
|
|
2368
|
+
for (i = 0; i < joins.length; i++) joinBreak(joins[i].el, joins[i].keep);
|
|
629
2369
|
editor.reviewLedger.update(id, { status: "accepted", decidedAt: Date.now(), decidedBy: decisionUser() });
|
|
630
2370
|
return true;
|
|
631
2371
|
}
|
|
@@ -634,19 +2374,35 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
634
2374
|
var entry = editor.reviewLedger.get(id);
|
|
635
2375
|
if (!entry) return false;
|
|
636
2376
|
if (!canDecide(entry, "reject")) return false;
|
|
637
|
-
var
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
2377
|
+
var els = trackedElements(id), splits = [], seams = [];
|
|
2378
|
+
for (var i = 0; i < els.length; i++) {
|
|
2379
|
+
var el = els[i], brk = el.getAttribute("data-tc-break");
|
|
2380
|
+
var blk = el.getAttribute("data-tc-block");
|
|
2381
|
+
var fmt = el.hasAttribute("data-tc-format");
|
|
2382
|
+
if (brk === "insert") splits.push(el);
|
|
2383
|
+
else if (brk === "delete") clearBreak(el);
|
|
2384
|
+
else if (blk === "delete") { clearBlock(el); if (fmt) unparkIds(el); }
|
|
2385
|
+
else if (blk) { if (el.parentNode) el.parentNode.removeChild(el); }
|
|
2386
|
+
else if (el.classList.contains("rte-tc-delete")) {
|
|
2387
|
+
if (fmt) { if (el.previousSibling) seams.push(el.previousSibling); if (el.lastChild) seams.push(el.lastChild); }
|
|
2388
|
+
unwrapKeepChildren(el);
|
|
2389
|
+
}
|
|
2390
|
+
else if (fmt) removeFormattedPiece(el, seams);
|
|
2391
|
+
else if (el.parentNode) el.parentNode.removeChild(el);
|
|
642
2392
|
}
|
|
2393
|
+
for (var j = splits.length - 1; j >= 0; j--) undoSplit(splits[j]);
|
|
2394
|
+
for (var k = 0; k < seams.length; k++) if (seams[k].parentNode && editor.getEditable().contains(seams[k])) mergePair(seams[k]);
|
|
643
2395
|
editor.reviewLedger.update(id, { status: "rejected", decidedAt: Date.now(), decidedBy: decisionUser() });
|
|
2396
|
+
// 2026-10-09 A rejected footnote drops its reference AND its note: the footnotes plugin removes a note
|
|
2397
|
+
// whose marker is gone only in its own renumber pass, which a reject never ran (the note and an empty
|
|
2398
|
+
// Footnotes section stayed). tools/review/tracked-footnote-reject.mjs
|
|
2399
|
+
try { if (typeof editor.syncFootnotes === "function") editor.syncFootnotes(); } catch (er) { /* ignore */ }
|
|
644
2400
|
return true;
|
|
645
2401
|
}
|
|
646
2402
|
|
|
647
2403
|
function acceptAll(filter) {
|
|
648
2404
|
var entries = editor.reviewLedger.list(filter).filter(function (e) {
|
|
649
|
-
return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
|
|
2405
|
+
return (e.changeType === "insert" || e.changeType === "delete" || e.changeType === "format") && e.status === "pending";
|
|
650
2406
|
});
|
|
651
2407
|
var accepted = 0;
|
|
652
2408
|
for (var i = 0; i < entries.length; i++) if (acceptEntry(entries[i].id)) accepted++;
|
|
@@ -655,7 +2411,7 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
655
2411
|
|
|
656
2412
|
function rejectAll(filter) {
|
|
657
2413
|
var entries = editor.reviewLedger.list(filter).filter(function (e) {
|
|
658
|
-
return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
|
|
2414
|
+
return (e.changeType === "insert" || e.changeType === "delete" || e.changeType === "format") && e.status === "pending";
|
|
659
2415
|
});
|
|
660
2416
|
var rejected = 0;
|
|
661
2417
|
for (var i = 0; i < entries.length; i++) if (rejectEntry(entries[i].id)) rejected++;
|
|
@@ -695,6 +2451,23 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
695
2451
|
".richtexteditor rte-toolbar-button.rte_command_trackchanges.rte-ui-active{background:linear-gradient(135deg,#e9f7ef,#eef5ff);color:#17633f;box-shadow:inset 0 0 0 1px rgba(34,132,82,.28),0 6px 14px rgba(34,132,82,.12)}",
|
|
696
2452
|
".richtexteditor rte-toolbar-button.rte_command_trackchanges.rte-ui-active:after{content:'';position:absolute;right:5px;top:5px;width:6px;height:6px;border-radius:50%;background:#22a66a;box-shadow:0 0 0 2px #fff}",
|
|
697
2453
|
".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
|
|
2454
|
+
".rte-suggestion-card{position:fixed;z-index:100000;max-width:300px;padding:8px 10px;border:1px solid rgba(100,116,139,.28);border-radius:8px;background:#fff;color:#1e293b;box-shadow:0 10px 26px rgba(15,23,42,.16);font:13px/1.4 system-ui,-apple-system,Segoe UI,sans-serif}",
|
|
2455
|
+
".rte-suggestion-card-title{font-weight:600;margin-bottom:2px}",
|
|
2456
|
+
".rte-suggestion-card-text{color:#475569;margin-bottom:6px;word-break:break-word}",
|
|
2457
|
+
".rte-suggestion-card-actions{display:flex;gap:6px;flex-wrap:wrap}",
|
|
2458
|
+
".rte-suggestion-card-all{margin-top:6px;padding-top:6px;border-top:1px solid rgba(100,116,139,.18)}",
|
|
2459
|
+
".rte-suggestion-card-button{font:inherit;padding:3px 9px;border:1px solid rgba(100,116,139,.35);border-radius:6px;background:#fff;color:#1e293b;cursor:pointer}",
|
|
2460
|
+
".rte-suggestion-card-button:focus-visible{outline:2px solid #2563eb;outline-offset:1px}",
|
|
2461
|
+
".rte-suggestion-card-button:disabled{opacity:.5;cursor:default}",
|
|
2462
|
+
".rte-suggestion-card-accept{background:#e9f7ef;border-color:#86c8a2;color:#125b39}",
|
|
2463
|
+
".rte-suggestion-card-reject{background:#fff1f1;border-color:#e3a3a3;color:#9f1d1d}",
|
|
2464
|
+
".rte-suggestion-card-dark{background:#1e293b;color:#e2e8f0;border-color:#334155;box-shadow:0 10px 26px rgba(0,0,0,.45)}",
|
|
2465
|
+
".rte-suggestion-card-dark .rte-suggestion-card-text{color:#cbd5e1}",
|
|
2466
|
+
".rte-suggestion-card-dark .rte-suggestion-card-all{border-top-color:#334155}",
|
|
2467
|
+
".rte-suggestion-card-dark .rte-suggestion-card-button{background:#0f172a;color:#e2e8f0;border-color:#475569}",
|
|
2468
|
+
".rte-suggestion-card-dark .rte-suggestion-card-accept{background:#14532d;border-color:#22c55e;color:#dcfce7}",
|
|
2469
|
+
".rte-suggestion-card-dark .rte-suggestion-card-reject{background:#7f1d1d;border-color:#f87171;color:#fee2e2}",
|
|
2470
|
+
".rte-suggestion-card-dark .rte-suggestion-card-button:focus-visible{outline-color:#93c5fd}",
|
|
698
2471
|
".rte-tc-insert{background:linear-gradient(180deg,rgba(232,248,239,.78),rgba(232,248,239,.38));color:#125b39;text-decoration:underline;text-decoration-color:#22a66a;text-decoration-thickness:2px;text-underline-offset:3px}",
|
|
699
2472
|
".rte-tc-delete{background:linear-gradient(180deg,rgba(255,240,240,.82),rgba(255,240,240,.42));color:#9f1d1d;text-decoration:line-through;text-decoration-color:#dc4c4c;text-decoration-thickness:2px;opacity:.92}"
|
|
700
2473
|
].join("\n");
|
|
@@ -715,7 +2488,21 @@ function RTE_Plugin_TrackedChanges() {
|
|
|
715
2488
|
iStyle.textContent = [
|
|
716
2489
|
".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
|
|
717
2490
|
".rte-tc-insert{background:linear-gradient(180deg,rgba(232,248,239,.78),rgba(232,248,239,.38));color:#125b39;text-decoration:underline;text-decoration-color:#22a66a;text-decoration-thickness:2px;text-underline-offset:3px}",
|
|
718
|
-
".rte-tc-delete{background:linear-gradient(180deg,rgba(255,240,240,.82),rgba(255,240,240,.42));color:#9f1d1d;text-decoration:line-through;text-decoration-color:#dc4c4c;text-decoration-thickness:2px;opacity:.92}"
|
|
2491
|
+
".rte-tc-delete{background:linear-gradient(180deg,rgba(255,240,240,.82),rgba(255,240,240,.42));color:#9f1d1d;text-decoration:line-through;text-decoration-color:#dc4c4c;text-decoration-thickness:2px;opacity:.92}",
|
|
2492
|
+
// a suggested paragraph break / join: a bar on the block after it, a pilcrow where it is
|
|
2493
|
+
"[data-tc-break=insert]{box-shadow:inset 3px 0 0 #22a66a}",
|
|
2494
|
+
"[data-tc-break=delete]{box-shadow:inset 3px 0 0 #dc4c4c}",
|
|
2495
|
+
"[data-tc-block=insert]{outline:2px solid rgba(34,166,106,.6);outline-offset:1px;background:rgba(232,248,239,.45)}",
|
|
2496
|
+
// a suggested deletion of a table row, column or table: struck through until decided
|
|
2497
|
+
"tr[data-tc-block=delete], td[data-tc-block=delete], th[data-tc-block=delete], table[data-tc-block=delete]{text-decoration:line-through;color:#9f1d1d;opacity:.85}",
|
|
2498
|
+
// a formatting suggestion shows the text with its NEW formatting (colours included)
|
|
2499
|
+
".rte-tc-delete[data-tc-format]{display:none}",
|
|
2500
|
+
".rte-tc-insert[data-tc-format]{color:inherit;background:none;text-decoration:underline dotted #b45309;text-decoration-thickness:2px;text-underline-offset:3px}",
|
|
2501
|
+
// a block formatting suggestion: the blocks as they will be; the originals wait hidden
|
|
2502
|
+
"[data-tc-block=delete][data-tc-format]{display:none}",
|
|
2503
|
+
"[data-tc-block=insert][data-tc-format]{outline:2px dotted #b45309;outline-offset:2px;background:none}",
|
|
2504
|
+
":has(+ [data-tc-break=insert])::after{content:'" + PILCROW + "';color:#22a66a;font-weight:600;padding-left:2px}",
|
|
2505
|
+
":has(+ [data-tc-break=delete])::after{content:'" + PILCROW + "';color:#dc4c4c;font-weight:600;padding-left:2px;text-decoration:line-through}"
|
|
719
2506
|
].join("\n");
|
|
720
2507
|
editdoc.head.appendChild(iStyle);
|
|
721
2508
|
// A <style> ELEMENT is governed by style-src: under a strict policy the browser
|