@richscripts/richtexteditor 2.4.1 → 2.5.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.
@@ -1,689 +1,716 @@
1
- if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
-
3
- if (!RTE_DefaultConfig.svgCode_trackchanges) {
4
- RTE_DefaultConfig.svgCode_trackchanges = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 17l4-1 10-10-3-3L5 13l-1 4z"/><path d="M14 4l3 3"/><path d="M4 21h10"/></svg>';
5
- }
6
-
7
- RTE_DefaultConfig.plugin_trackedchanges = RTE_Plugin_TrackedChanges;
8
-
9
- function RTE_Plugin_TrackedChanges() {
10
- var obj = this;
11
- var config;
12
- var editor;
13
- var enabled = false;
14
-
15
- obj.PluginName = "TrackedChanges";
16
-
17
- obj.InitConfig = function (argconfig) {
18
- config = argconfig;
19
- if (config.trackChangesEnabled === false) return;
20
-
21
- config.currentUser = config.currentUser || null;
22
- config.trackChangesInsertClass = config.trackChangesInsertClass || "rte-tc rte-tc-insert";
23
- config.trackChangesDeleteClass = config.trackChangesDeleteClass || "rte-tc rte-tc-delete";
24
- // Optional application policy for accept/reject. Return false to keep a
25
- // suggestion pending. The default remains permissive for compatibility.
26
- config.reviewCanDecide = config.reviewCanDecide || null;
27
- config.onReviewDecisionDenied = config.onReviewDecisionDenied || null;
28
-
29
- // Toolbar command — only surfaces if the editor page adds it to a toolbar slot.
30
- config.text_trackchanges = config.text_trackchanges || "Suggesting mode";
31
- appendToolbarCommand("toolbar_default", "#{trackchanges}");
32
- appendToolbarCommand("toolbar_full", "#{trackchanges}");
33
- appendToolbarCommand("toolbar_mobile", "#{trackchanges}");
34
-
35
- if ((config.controltoolbar_TEXT || "").indexOf("trackchanges") === -1) {
36
- config.controltoolbar_TEXT = (config.controltoolbar_TEXT || "") + "|{trackchanges}";
37
- }
38
- };
39
-
40
- obj.InitEditor = function (argeditor) {
41
- editor = argeditor;
42
- if (config.trackChangesEnabled === false) return;
43
-
44
- editor.trackedChanges = {
45
- enable: function (user) { enable(user); },
46
- disable: function () { disable(); },
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); },
52
- canDecide: function (id, action) {
53
- var entry = editor.reviewLedger && editor.reviewLedger.get(id);
54
- return !!entry && canDecide(entry, action || "accept");
55
- },
56
- list: function (opts) {
57
- if (!editor.reviewLedger) return [];
58
- // Default to the PENDING review queue (matches the internal
59
- // accept/reject semantics). Pass {all:true} for full history
60
- // including accepted/rejected entries (also via reviewLedger.list()).
61
- var includeAll = opts && opts.all === true;
62
- return editor.reviewLedger.list().filter(function (e) {
63
- if (e.changeType !== "insert" && e.changeType !== "delete") return false;
64
- return includeAll || e.status === "pending";
65
- });
66
- }
67
- };
68
-
69
- injectStyles();
70
-
71
- editor.toolbarFactoryMap = editor.toolbarFactoryMap || {};
72
- editor.toolbarFactoryMap["trackchanges"] = function (cmd) {
73
- var btn = editor.createToolbarButton(cmd);
74
- btn.__tcSync = function () {
75
- if (enabled) btn.classList.add("rte-ui-active");
76
- else btn.classList.remove("rte-ui-active");
77
- btn.setAttribute("aria-pressed", enabled ? "true" : "false");
78
- btn.setAttribute("title", enabled ? "Suggesting mode is on" : "Turn on suggesting mode");
79
- };
80
- btn.__tcSync();
81
- return btn;
82
- };
83
-
84
- editor.attachEvent("exec_command_trackchanges", function (state) {
85
- state.returnValue = true;
86
- state.stopBubble = true;
87
- if (enabled) disable();
88
- else enable();
89
- });
90
-
91
- editor.getEditable().addEventListener("beforeinput", onBeforeInput, true);
92
- };
93
-
94
- function appendToolbarCommand(toolbar, item) {
95
- if (!config[toolbar]) return;
96
- if (config[toolbar].indexOf(item) !== -1) return;
97
- config[toolbar] = config[toolbar] + item;
98
- }
99
-
100
- function enable(user) {
101
- if (user) config.currentUser = user;
102
- if (!config.currentUser) {
103
- config.currentUser = { id: "user", name: "User", color: "#2563eb" };
104
- console.warn("trackedchanges: no config.currentUser configured; defaulting to generic user.");
105
- }
106
- enabled = true;
107
- syncToolbarButtons();
108
- }
109
-
110
- function disable() {
111
- enabled = false;
112
- syncToolbarButtons();
113
- }
114
-
115
- function syncToolbarButtons() {
116
- var doc = editor.iframe && editor.iframe.ownerDocument || document;
117
- var btns = doc.querySelectorAll(".rte_command_trackchanges");
118
- for (var i = 0; i < btns.length; i++) {
119
- if (btns[i].__tcSync) btns[i].__tcSync();
120
- }
121
- }
122
-
123
- // --- beforeinput intercept ---
124
-
125
- function onBeforeInput(e) {
126
- if (!enabled) return;
127
- if (!editor.reviewLedger) return; // No ledger => AI toolkit not loaded; do nothing.
128
-
129
- var type = e.inputType;
130
- if (!type) return;
131
-
132
- if (type === "insertText" || type === "insertReplacementText") {
133
- e.preventDefault();
134
- handleInsertText(e.data || "");
135
- return;
136
- }
137
- if (type === "deleteContentBackward" || type === "deleteContentForward" || type === "deleteByCut") {
138
- e.preventDefault();
139
- handleDelete(type);
140
- return;
141
- }
142
- // insertParagraph, insertCompositionText, insertFromPaste, historyUndo/Redo: pass through for v1.
143
- }
144
-
145
- // --- insert tracking ---
146
-
147
- function handleInsertText(text) {
148
- if (!text) return;
149
- var sel = editor.getSelection();
150
- if (!sel || sel.rangeCount === 0) return;
151
- var range = sel.getRangeAt(0);
152
- if (!range.collapsed) {
153
- // Selection replace: wrap selection as delete, then insert new text.
154
- if (!wrapRangeAsDelete(range)) return;
155
- // After wrapping, caret should be past the delete span; re-fetch selection.
156
- range = sel.getRangeAt(0);
157
- createInsertSpan(text, range);
158
- return;
159
- }
160
-
161
- var mergeTarget = adjacentInsertSpan(range);
162
- if (mergeTarget) {
163
- appendToInsertSpan(mergeTarget, text);
164
- } else {
165
- createInsertSpan(text, range);
166
- }
167
- }
168
-
169
- function adjacentInsertSpan(range) {
170
- // If caret sits inside an insert span by the current user, or immediately after one, merge.
171
- var node = range.startContainer;
172
- if (node.nodeType === 3 && node.parentNode && isMyInsert(node.parentNode)) {
173
- return node.parentNode;
174
- }
175
- // Caret just after an insert span (at an element boundary).
176
- if (node.nodeType === 1 && range.startOffset > 0) {
177
- var prev = node.childNodes[range.startOffset - 1];
178
- if (prev && prev.nodeType === 1 && isMyInsert(prev)) return prev;
179
- }
180
- // Caret at the very beginning just before our span.
181
- if (node.nodeType === 3 && node.previousSibling && isMyInsert(node.previousSibling) && range.startOffset === 0) {
182
- return node.previousSibling;
183
- }
184
- return null;
185
- }
186
-
187
- function isMyInsert(el) {
188
- if (!el || el.nodeType !== 1) return false;
189
- if (!el.classList || !el.classList.contains("rte-tc-insert")) return false;
190
- return el.getAttribute("data-tc-author") === config.currentUser.id;
191
- }
192
-
193
- function appendToInsertSpan(span, text) {
194
- var editdoc = editor.getDocument();
195
- // Append to the span's last text node (or create one).
196
- var last = span.lastChild;
197
- var node;
198
- if (last && last.nodeType === 3) {
199
- last.nodeValue = last.nodeValue + text;
200
- node = last;
201
- } else {
202
- node = editdoc.createTextNode(text);
203
- span.appendChild(node);
204
- }
205
- placeCaretAtEnd(node);
206
- updateEntryForSpan(span);
207
- }
208
-
209
- function createInsertSpan(text, range) {
210
- var editdoc = editor.getDocument();
211
- var span = editdoc.createElement("span");
212
- span.className = config.trackChangesInsertClass;
213
- var user = config.currentUser;
214
- var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
215
- span.setAttribute("data-tc-id", id);
216
- span.setAttribute("data-tc-author", user.id);
217
- span.setAttribute("data-tc-at", new Date().toISOString());
218
- span.style.color = user.color;
219
- span.style.textDecoration = "underline";
220
- span.appendChild(editdoc.createTextNode(text));
221
- range.insertNode(span);
222
- placeCaretAtEnd(span.firstChild);
223
-
224
- editor.reviewLedger.add({
225
- id: id,
226
- changeType: "insert",
227
- author: user,
228
- text: text,
229
- status: "pending",
230
- sourceLabel: "Suggested insert",
231
- createdAt: Date.now()
232
- });
233
- }
234
-
235
- function updateEntryForSpan(span) {
236
- var id = span.getAttribute("data-tc-id");
237
- if (!id) return;
238
- editor.reviewLedger.update(id, { text: span.textContent });
239
- }
240
-
241
- // --- delete tracking ---
242
-
243
- function handleDelete(mode) {
244
- var sel = editor.getSelection();
245
- if (!sel || sel.rangeCount === 0) return;
246
- var range = sel.getRangeAt(0);
247
-
248
- if (!range.collapsed) {
249
- wrapRangeAsDelete(range);
250
- return;
251
- }
252
-
253
- // Collapsed: extend by one char in the appropriate direction, then wrap.
254
- // The extended range covers only that one character — any delete spans between
255
- // it and the caret should be SKIPPED, not re-wrapped.
256
- var extended = editor.getDocument().createRange();
257
- if (mode === "deleteContentBackward") {
258
- var anchor = prevCharPoint(range.startContainer, range.startOffset);
259
- if (!anchor) return;
260
- extended.setStart(anchor.node, anchor.offset);
261
- extended.setEnd(anchor.node, anchor.offset + 1);
262
- } else {
263
- var forward = nextCharPoint(range.startContainer, range.startOffset);
264
- if (!forward) return;
265
- extended.setStart(forward.node, forward.offset - 1);
266
- extended.setEnd(forward.node, forward.offset);
267
- }
268
-
269
- // If that extended range sits entirely inside one of the current user's own
270
- // insert spans, treat this as undoing their own typing — actually delete.
271
- if (rangeInsideMyInsert(extended)) {
272
- extended.deleteContents();
273
- var sel2 = editor.getSelection();
274
- sel2.removeAllRanges();
275
- sel2.addRange(extended);
276
- // If the insert span is now empty, remove it and its ledger entry.
277
- cleanupEmptyInsertSpans();
278
- return;
279
- }
280
-
281
- wrapRangeAsDelete(extended);
282
- }
283
-
284
- function isInsideDeleteSpan(node) {
285
- var walk = node.nodeType === 1 ? node : node.parentNode;
286
- while (walk && walk !== editor.getEditable()) {
287
- if (walk.classList && walk.classList.contains("rte-tc-delete")) return true;
288
- walk = walk.parentNode;
289
- }
290
- return false;
291
- }
292
-
293
- // Walk text leaves in the editable, skipping any that live inside an existing delete
294
- // span (those characters have already been visually "deleted" — we shouldn't re-wrap them).
295
- function liveTextLeaves() {
296
- var editable = editor.getEditable();
297
- var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT, {
298
- acceptNode: function (n) { return isInsideDeleteSpan(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }
299
- });
300
- var out = [], n;
301
- while ((n = walker.nextNode())) out.push(n);
302
- return out;
303
- }
304
-
305
- function prevCharPoint(node, offset) {
306
- var leaves = liveTextLeaves();
307
- if (!leaves.length) return null;
308
- var idx = caretIndex(node, offset, leaves);
309
- if (idx <= 0) return null;
310
- return leafOffsetAt(idx - 1, leaves);
311
- }
312
-
313
- function nextCharPoint(node, offset) {
314
- var leaves = liveTextLeaves();
315
- if (!leaves.length) return null;
316
- var total = 0;
317
- for (var i = 0; i < leaves.length; i++) total += leaves[i].nodeValue.length;
318
- var idx = caretIndex(node, offset, leaves);
319
- if (idx >= total) return null;
320
- return leafOffsetAt(idx + 1, leaves);
321
- }
322
-
323
- // How many live characters appear before the given caret position?
324
- function caretIndex(node, offset, leaves) {
325
- var r = editor.getDocument().createRange();
326
- r.setStart(node, offset);
327
- r.collapse(true);
328
- var count = 0;
329
- for (var i = 0; i < leaves.length; i++) {
330
- var leaf = leaves[i];
331
- // If the entire leaf is before the caret, add its length.
332
- var cmpEnd = r.comparePoint(leaf, leaf.nodeValue.length);
333
- if (cmpEnd <= 0) { count += leaf.nodeValue.length; continue; }
334
- var cmpStart = r.comparePoint(leaf, 0);
335
- if (cmpStart >= 0) break; // leaf starts at or after caret
336
- // Caret falls inside this leaf — binary search the split point.
337
- var lo = 0, hi = leaf.nodeValue.length;
338
- while (lo < hi) {
339
- var mid = (lo + hi) >> 1;
340
- if (r.comparePoint(leaf, mid) < 0) lo = mid + 1;
341
- else hi = mid;
342
- }
343
- count += lo;
344
- break;
345
- }
346
- return count;
347
- }
348
-
349
- function leafOffsetAt(index, leaves) {
350
- var remaining = index;
351
- for (var i = 0; i < leaves.length; i++) {
352
- if (remaining <= leaves[i].nodeValue.length) return { node: leaves[i], offset: remaining };
353
- remaining -= leaves[i].nodeValue.length;
354
- }
355
- return null;
356
- }
357
-
358
- function firstTextLeaf(node) {
359
- if (!node) return null;
360
- if (node.nodeType === 3) return node;
361
- var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
362
- return walker.nextNode();
363
- }
364
-
365
- function lastTextLeaf(node) {
366
- if (!node) return null;
367
- if (node.nodeType === 3) return node;
368
- var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
369
- var last = null, n;
370
- while ((n = walker.nextNode())) last = n;
371
- return last;
372
- }
373
-
374
- function previousTextLeaf(node) {
375
- var editable = editor.getEditable();
376
- var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
377
- var prev = null, cur;
378
- while ((cur = walker.nextNode())) {
379
- if (cur === node) return prev;
380
- prev = cur;
381
- }
382
- return null;
383
- }
384
-
385
- function nextTextLeaf(node) {
386
- var editable = editor.getEditable();
387
- var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
388
- var seen = false, cur;
389
- while ((cur = walker.nextNode())) {
390
- if (seen) return cur;
391
- if (cur === node) seen = true;
392
- }
393
- return null;
394
- }
395
-
396
- function rangeInsideMyInsert(range) {
397
- var span = range.startContainer.parentNode;
398
- while (span && span !== editor.getEditable()) {
399
- if (isMyInsert(span)) return range.endContainer.parentNode === span || span.contains(range.endContainer);
400
- span = span.parentNode;
401
- }
402
- return false;
403
- }
404
-
405
- function wrapRangeAsDelete(range) {
406
- if (range.collapsed) return false;
407
- var editdoc = editor.getDocument();
408
-
409
- var fragment = range.cloneContents();
410
- if (!fragmentHasText(fragment)) return false;
411
-
412
- // Find existing same-author delete spans adjacent to the range so we can merge
413
- // sequential backspaces / forward deletes into one contiguous span.
414
- var user = config.currentUser;
415
- var mergeBefore = adjacentDeleteSpan(range, "before", user);
416
- var mergeAfter = adjacentDeleteSpan(range, "after", user);
417
-
418
- range.deleteContents();
419
-
420
- if (mergeBefore) {
421
- // Append the fragment's content to the existing "before" span.
422
- appendFragmentToSpan(mergeBefore, fragment);
423
- if (mergeAfter && mergeAfter !== mergeBefore) {
424
- // Collapse both sides: move "after" span's children into "before" and drop it.
425
- while (mergeAfter.firstChild) mergeBefore.appendChild(mergeAfter.firstChild);
426
- var afterId = mergeAfter.getAttribute("data-tc-id");
427
- mergeAfter.remove();
428
- if (afterId && editor.reviewLedger) editor.reviewLedger.remove(afterId);
429
- }
430
- var rangeAfter = editdoc.createRange();
431
- rangeAfter.setStartAfter(mergeBefore);
432
- rangeAfter.collapse(true);
433
- var sel = editor.getSelection();
434
- sel.removeAllRanges();
435
- sel.addRange(rangeAfter);
436
- // 2026-05-28 Defense: if a customer pasted a delete-class span from
437
- // outside this plugin, it may lack data-tc-id. Skip the ledger
438
- // update instead of writing under a `null` key (would silently
439
- // corrupt the ledger via the update("null", ...) coercion).
440
- var beforeId = mergeBefore.getAttribute("data-tc-id");
441
- if (beforeId && editor.reviewLedger) {
442
- editor.reviewLedger.update(beforeId, { text: mergeBefore.textContent });
443
- }
444
- return true;
445
- }
446
-
447
- if (mergeAfter) {
448
- prependFragmentToSpan(mergeAfter, fragment);
449
- var rangeBefore = editdoc.createRange();
450
- rangeBefore.setStartBefore(mergeAfter);
451
- rangeBefore.collapse(true);
452
- var sel2 = editor.getSelection();
453
- sel2.removeAllRanges();
454
- sel2.addRange(rangeBefore);
455
- // Same defense as above (mergeAfter may lack data-tc-id).
456
- var afterMergeId = mergeAfter.getAttribute("data-tc-id");
457
- if (afterMergeId && editor.reviewLedger) {
458
- editor.reviewLedger.update(afterMergeId, { text: mergeAfter.textContent });
459
- }
460
- return true;
461
- }
462
-
463
- var span = editdoc.createElement("span");
464
- span.className = config.trackChangesDeleteClass;
465
- var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
466
- span.setAttribute("data-tc-id", id);
467
- span.setAttribute("data-tc-author", user.id);
468
- span.setAttribute("data-tc-at", new Date().toISOString());
469
- span.style.color = user.color;
470
- span.style.textDecoration = "line-through";
471
- span.appendChild(fragment);
472
- range.insertNode(span);
473
-
474
- var after = editdoc.createRange();
475
- after.setStartAfter(span);
476
- after.collapse(true);
477
- var sel3 = editor.getSelection();
478
- sel3.removeAllRanges();
479
- sel3.addRange(after);
480
-
481
- editor.reviewLedger.add({
482
- id: id,
483
- changeType: "delete",
484
- author: user,
485
- text: span.textContent,
486
- originalHtml: span.innerHTML,
487
- status: "pending",
488
- sourceLabel: "Suggested delete",
489
- createdAt: Date.now()
490
- });
491
- return true;
492
- }
493
-
494
- function adjacentDeleteSpan(range, side, user) {
495
- // Find element immediately before or after the range boundaries at the container level.
496
- var node = side === "before" ? range.startContainer : range.endContainer;
497
- var offset = side === "before" ? range.startOffset : range.endOffset;
498
- if (node.nodeType === 3) {
499
- // Look at the sibling of the text node if we're at the text node's boundary.
500
- if (side === "before" && offset === 0) {
501
- var prev = node.previousSibling;
502
- if (prev && isDeleteSpanByAuthor(prev, user)) return prev;
503
- }
504
- if (side === "after" && offset === node.nodeValue.length) {
505
- var next = node.nextSibling;
506
- if (next && isDeleteSpanByAuthor(next, user)) return next;
507
- }
508
- return null;
509
- }
510
- // Element container: look at children on either side of the offset.
511
- if (side === "before" && offset > 0) {
512
- var childBefore = node.childNodes[offset - 1];
513
- if (childBefore && isDeleteSpanByAuthor(childBefore, user)) return childBefore;
514
- }
515
- if (side === "after" && offset < node.childNodes.length) {
516
- var childAfter = node.childNodes[offset];
517
- if (childAfter && isDeleteSpanByAuthor(childAfter, user)) return childAfter;
518
- }
519
- return null;
520
- }
521
-
522
- function isDeleteSpanByAuthor(el, user) {
523
- if (!el || el.nodeType !== 1) return false;
524
- if (!el.classList || !el.classList.contains("rte-tc-delete")) return false;
525
- return el.getAttribute("data-tc-author") === user.id;
526
- }
527
-
528
- function appendFragmentToSpan(span, fragment) {
529
- while (fragment.firstChild) span.appendChild(fragment.firstChild);
530
- }
531
-
532
- function prependFragmentToSpan(span, fragment) {
533
- var first = span.firstChild;
534
- while (fragment.firstChild) span.insertBefore(fragment.firstChild, first);
535
- }
536
-
537
- function fragmentHasText(fragment) {
538
- var t = (fragment.textContent || "").replace(/\s+/g, "");
539
- return !!t;
540
- }
541
-
542
- function cleanupEmptyInsertSpans() {
543
- var spans = editor.getEditable().querySelectorAll("." + "rte-tc-insert");
544
- for (var i = 0; i < spans.length; i++) {
545
- var s = spans[i];
546
- if (!s.textContent || !s.textContent.length) {
547
- var id = s.getAttribute("data-tc-id");
548
- if (id && editor.reviewLedger) editor.reviewLedger.remove(id);
549
- s.remove();
550
- } else {
551
- updateEntryForSpan(s);
552
- }
553
- }
554
- }
555
-
556
- function placeCaretAtEnd(node) {
557
- if (!node) return;
558
- var editdoc = editor.getDocument();
559
- var range = editdoc.createRange();
560
- if (node.nodeType === 3) {
561
- range.setStart(node, node.nodeValue.length);
562
- } else {
563
- range.setStart(node, node.childNodes.length);
564
- }
565
- range.collapse(true);
566
- var sel = editor.getSelection();
567
- sel.removeAllRanges();
568
- sel.addRange(range);
569
- }
570
-
571
- // --- accept / reject ---
572
-
573
- function decisionUser() {
574
- return config.currentUser || { id: "user", name: "User", color: "#2563eb" };
575
- }
576
-
577
- function canDecide(entry, action) {
578
- if (typeof config.reviewCanDecide !== "function") return true;
579
- var allowed = false;
580
- var user = decisionUser();
581
- try { allowed = config.reviewCanDecide(entry, action, user, editor) !== false; }
582
- catch (err) {
583
- console.warn("trackedchanges: reviewCanDecide threw; denying decision.", err);
584
- }
585
- if (allowed) return true;
586
- var detail = { entry: entry, action: action, user: user };
587
- try { typeof config.onReviewDecisionDenied === "function" && config.onReviewDecisionDenied(detail); } catch (ignore) { }
588
- try { typeof editor.fireEvent === "function" && editor.fireEvent("review_decision_denied", detail); } catch (ignore2) { }
589
- return false;
590
- }
591
-
592
- function acceptEntry(id) {
593
- var entry = editor.reviewLedger.get(id);
594
- if (!entry) return false;
595
- if (!canDecide(entry, "accept")) return false;
596
- var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
597
- if (entry.changeType === "insert") {
598
- if (span) unwrapKeepChildren(span);
599
- } else if (entry.changeType === "delete") {
600
- if (span) span.remove();
601
- }
602
- editor.reviewLedger.update(id, { status: "accepted", decidedAt: Date.now(), decidedBy: decisionUser() });
603
- return true;
604
- }
605
-
606
- function rejectEntry(id) {
607
- var entry = editor.reviewLedger.get(id);
608
- if (!entry) return false;
609
- if (!canDecide(entry, "reject")) return false;
610
- var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
611
- if (entry.changeType === "insert") {
612
- if (span) span.remove();
613
- } else if (entry.changeType === "delete") {
614
- if (span) unwrapKeepChildren(span);
615
- }
616
- editor.reviewLedger.update(id, { status: "rejected", decidedAt: Date.now(), decidedBy: decisionUser() });
617
- return true;
618
- }
619
-
620
- function acceptAll(filter) {
621
- var entries = editor.reviewLedger.list(filter).filter(function (e) {
622
- return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
623
- });
624
- var accepted = 0;
625
- for (var i = 0; i < entries.length; i++) if (acceptEntry(entries[i].id)) accepted++;
626
- return accepted;
627
- }
628
-
629
- function rejectAll(filter) {
630
- var entries = editor.reviewLedger.list(filter).filter(function (e) {
631
- return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
632
- });
633
- var rejected = 0;
634
- for (var i = 0; i < entries.length; i++) if (rejectEntry(entries[i].id)) rejected++;
635
- return rejected;
636
- }
637
-
638
- function unwrapKeepChildren(el) {
639
- var parent = el.parentNode;
640
- if (!parent) return;
641
- while (el.firstChild) parent.insertBefore(el.firstChild, el);
642
- parent.removeChild(el);
643
- }
644
-
645
- // 2026-05-28 Prefer native CSS.escape — same hardening as comments.js.
646
- // Defense-in-depth for customers who wire reviewLedger.add() with custom IDs
647
- // containing punctuation (e.g. UUIDs with dashes, OAuth subject claims with
648
- // colons, base64 IDs with `=` padding).
649
- function cssEscape(s) {
650
- var str = String(s);
651
- if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
652
- try { return CSS.escape(str); } catch (e) { /* fall through */ }
653
- }
654
- return str.replace(/["\\\]\[\(\)\{\}'#:;,<>=`~!@$%^&*+/?\s\x00-\x1F]/g, function (c) {
655
- return "\\" + c;
656
- });
657
- }
658
-
659
- // --- styles ---
660
-
661
- function injectStyles() {
662
- var host = (editor && editor.iframe && editor.iframe.ownerDocument) || document;
663
- if (!host.querySelector("style[data-rte-trackchanges]")) {
664
- var style = host.createElement("style");
665
- style.setAttribute("data-rte-trackchanges", "1");
666
- style.textContent = [
667
- ".richtexteditor rte-toolbar-button.rte_command_trackchanges{position:relative;border-radius:8px;transition:background .14s ease,color .14s ease,box-shadow .14s ease}",
668
- ".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)}",
669
- ".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}",
670
- ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
671
- ".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}",
672
- ".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}"
673
- ].join("\n");
674
- host.head.appendChild(style);
675
- }
676
-
677
- var editdoc = editor.getDocument();
678
- if (editdoc && editdoc.head && !editdoc.querySelector("style[data-rte-trackchanges-inline]")) {
679
- var iStyle = editdoc.createElement("style");
680
- iStyle.setAttribute("data-rte-trackchanges-inline", "1");
681
- iStyle.textContent = [
682
- ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
683
- ".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}",
684
- ".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}"
685
- ].join("\n");
686
- editdoc.head.appendChild(iStyle);
687
- }
688
- }
689
- }
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ if (!RTE_DefaultConfig.svgCode_trackchanges) {
4
+ RTE_DefaultConfig.svgCode_trackchanges = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 17l4-1 10-10-3-3L5 13l-1 4z"/><path d="M14 4l3 3"/><path d="M4 21h10"/></svg>';
5
+ }
6
+
7
+ RTE_DefaultConfig.plugin_trackedchanges = RTE_Plugin_TrackedChanges;
8
+
9
+ function RTE_Plugin_TrackedChanges() {
10
+ var obj = this;
11
+ var config;
12
+ var editor;
13
+ var enabled = false;
14
+
15
+ obj.PluginName = "TrackedChanges";
16
+
17
+ obj.InitConfig = function (argconfig) {
18
+ config = argconfig;
19
+ if (config.trackChangesEnabled === false) return;
20
+
21
+ config.currentUser = config.currentUser || null;
22
+ config.trackChangesInsertClass = config.trackChangesInsertClass || "rte-tc rte-tc-insert";
23
+ config.trackChangesDeleteClass = config.trackChangesDeleteClass || "rte-tc rte-tc-delete";
24
+ // Optional application policy for accept/reject. Return false to keep a
25
+ // suggestion pending. The default remains permissive for compatibility.
26
+ config.reviewCanDecide = config.reviewCanDecide || null;
27
+ config.onReviewDecisionDenied = config.onReviewDecisionDenied || null;
28
+
29
+ // Toolbar command — only surfaces if the editor page adds it to a toolbar slot.
30
+ config.text_trackchanges = config.text_trackchanges || "Suggesting mode";
31
+ appendToolbarCommand("toolbar_default", "#{trackchanges}");
32
+ appendToolbarCommand("toolbar_full", "#{trackchanges}");
33
+ appendToolbarCommand("toolbar_mobile", "#{trackchanges}");
34
+
35
+ if ((config.controltoolbar_TEXT || "").indexOf("trackchanges") === -1) {
36
+ config.controltoolbar_TEXT = (config.controltoolbar_TEXT || "") + "|{trackchanges}";
37
+ }
38
+ };
39
+
40
+ obj.InitEditor = function (argeditor) {
41
+ editor = argeditor;
42
+ if (config.trackChangesEnabled === false) return;
43
+
44
+ editor.trackedChanges = {
45
+ enable: function (user) { enable(user); },
46
+ disable: function () { disable(); },
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); },
52
+ canDecide: function (id, action) {
53
+ var entry = editor.reviewLedger && editor.reviewLedger.get(id);
54
+ return !!entry && canDecide(entry, action || "accept");
55
+ },
56
+ list: function (opts) {
57
+ if (!editor.reviewLedger) return [];
58
+ // Default to the PENDING review queue (matches the internal
59
+ // accept/reject semantics). Pass {all:true} for full history
60
+ // including accepted/rejected entries (also via reviewLedger.list()).
61
+ var includeAll = opts && opts.all === true;
62
+ return editor.reviewLedger.list().filter(function (e) {
63
+ if (e.changeType !== "insert" && e.changeType !== "delete") return false;
64
+ return includeAll || e.status === "pending";
65
+ });
66
+ }
67
+ };
68
+
69
+ injectStyles();
70
+
71
+ editor.toolbarFactoryMap = editor.toolbarFactoryMap || {};
72
+ editor.toolbarFactoryMap["trackchanges"] = function (cmd) {
73
+ var btn = editor.createToolbarButton(cmd);
74
+ btn.__tcSync = function () {
75
+ if (enabled) btn.classList.add("rte-ui-active");
76
+ else btn.classList.remove("rte-ui-active");
77
+ btn.setAttribute("aria-pressed", enabled ? "true" : "false");
78
+ // WCAG 2.5.3 Label in Name: the visible label (this tooltip) must be
79
+ // contained in the accessible name. "Turn on suggesting mode" was not
80
+ // — the accessible name is "Suggesting mode" — so a speech-input user
81
+ // saying what they see did not match the control. State belongs in
82
+ // aria-pressed and the active styling, not in the visible label.
83
+ btn.setAttribute("title", config.text_trackchanges || "Suggesting mode");
84
+ };
85
+ btn.__tcSync();
86
+ return btn;
87
+ };
88
+
89
+ editor.attachEvent("exec_command_trackchanges", function (state) {
90
+ state.returnValue = true;
91
+ state.stopBubble = true;
92
+ if (enabled) disable();
93
+ else enable();
94
+ });
95
+
96
+ editor.getEditable().addEventListener("beforeinput", onBeforeInput, true);
97
+ };
98
+
99
+ function appendToolbarCommand(toolbar, item) {
100
+ if (!config[toolbar]) return;
101
+ if (config[toolbar].indexOf(item) !== -1) return;
102
+ config[toolbar] = config[toolbar] + item;
103
+ }
104
+
105
+ function enable(user) {
106
+ if (user) config.currentUser = user;
107
+ if (!config.currentUser) {
108
+ config.currentUser = { id: "user", name: "User", color: "#2563eb" };
109
+ console.warn("trackedchanges: no config.currentUser configured; defaulting to generic user.");
110
+ }
111
+ enabled = true;
112
+ syncToolbarButtons();
113
+ }
114
+
115
+ function disable() {
116
+ enabled = false;
117
+ syncToolbarButtons();
118
+ }
119
+
120
+ function syncToolbarButtons() {
121
+ var doc = editor.iframe && editor.iframe.ownerDocument || document;
122
+ var btns = doc.querySelectorAll(".rte_command_trackchanges");
123
+ for (var i = 0; i < btns.length; i++) {
124
+ if (btns[i].__tcSync) btns[i].__tcSync();
125
+ }
126
+ }
127
+
128
+ // --- beforeinput intercept ---
129
+
130
+ function onBeforeInput(e) {
131
+ if (!enabled) return;
132
+ if (!editor.reviewLedger) return; // No ledger => AI toolkit not loaded; do nothing.
133
+
134
+ var type = e.inputType;
135
+ if (!type) return;
136
+
137
+ if (type === "insertText" || type === "insertReplacementText") {
138
+ e.preventDefault();
139
+ handleInsertText(e.data || "");
140
+ return;
141
+ }
142
+ if (type === "deleteContentBackward" || type === "deleteContentForward" || type === "deleteByCut") {
143
+ e.preventDefault();
144
+ handleDelete(type);
145
+ return;
146
+ }
147
+ // insertParagraph, insertCompositionText, insertFromPaste, historyUndo/Redo: pass through for v1.
148
+ }
149
+
150
+ // --- insert tracking ---
151
+
152
+ function handleInsertText(text) {
153
+ if (!text) return;
154
+ var sel = editor.getSelection();
155
+ if (!sel || sel.rangeCount === 0) return;
156
+ var range = sel.getRangeAt(0);
157
+ if (!range.collapsed) {
158
+ // Selection replace: wrap selection as delete, then insert new text.
159
+ if (!wrapRangeAsDelete(range)) return;
160
+ // After wrapping, caret should be past the delete span; re-fetch selection.
161
+ range = sel.getRangeAt(0);
162
+ createInsertSpan(text, range);
163
+ return;
164
+ }
165
+
166
+ var mergeTarget = adjacentInsertSpan(range);
167
+ if (mergeTarget) {
168
+ appendToInsertSpan(mergeTarget, text);
169
+ } else {
170
+ createInsertSpan(text, range);
171
+ }
172
+ }
173
+
174
+ function adjacentInsertSpan(range) {
175
+ // If caret sits inside an insert span by the current user, or immediately after one, merge.
176
+ var node = range.startContainer;
177
+ if (node.nodeType === 3 && node.parentNode && isMyInsert(node.parentNode)) {
178
+ return node.parentNode;
179
+ }
180
+ // Caret just after an insert span (at an element boundary).
181
+ if (node.nodeType === 1 && range.startOffset > 0) {
182
+ var prev = node.childNodes[range.startOffset - 1];
183
+ if (prev && prev.nodeType === 1 && isMyInsert(prev)) return prev;
184
+ }
185
+ // Caret at the very beginning just before our span.
186
+ if (node.nodeType === 3 && node.previousSibling && isMyInsert(node.previousSibling) && range.startOffset === 0) {
187
+ return node.previousSibling;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ function isMyInsert(el) {
193
+ if (!el || el.nodeType !== 1) return false;
194
+ if (!el.classList || !el.classList.contains("rte-tc-insert")) return false;
195
+ return el.getAttribute("data-tc-author") === config.currentUser.id;
196
+ }
197
+
198
+ // We preventDefault() the browser's own text insertion, which means we also
199
+ // lose the whitespace handling it does for free. A plain second space is
200
+ // collapsed by HTML rendering, so pressing space a second time moved nothing
201
+ // on screen and suggesting mode looked like it had stopped accepting input.
202
+ // Reproduce what contenteditable does: any space followed by another space,
203
+ // and any trailing space, becomes a non-breaking space. Recomputed from
204
+ // scratch each time so an NBSP reverts to a normal space once real text
205
+ // follows it (keeping word wrap and copied text clean). Only typed text
206
+ // reaches this path -- paste arrives as insertFromPaste and is passed
207
+ // through -- so this never rewrites an NBSP the user inserted deliberately.
208
+ function normalizeInsertWhitespace(node) {
209
+ if (!node || node.nodeType !== 3) return;
210
+ var v = node.nodeValue;
211
+ if (v.indexOf(" ") === -1 && v.indexOf("\u00a0") === -1) return;
212
+ v = v.replace(/\u00a0/g, " ");
213
+ v = v.replace(/ (?= )/g, "\u00a0");
214
+ v = v.replace(/ $/, "\u00a0");
215
+ if (v !== node.nodeValue) node.nodeValue = v;
216
+ }
217
+
218
+ function appendToInsertSpan(span, text) {
219
+ var editdoc = editor.getDocument();
220
+ // Append to the span's last text node (or create one).
221
+ var last = span.lastChild;
222
+ var node;
223
+ if (last && last.nodeType === 3) {
224
+ last.nodeValue = last.nodeValue + text;
225
+ node = last;
226
+ } else {
227
+ node = editdoc.createTextNode(text);
228
+ span.appendChild(node);
229
+ }
230
+ normalizeInsertWhitespace(node);
231
+ placeCaretAtEnd(node);
232
+ updateEntryForSpan(span);
233
+ }
234
+
235
+ function createInsertSpan(text, range) {
236
+ var editdoc = editor.getDocument();
237
+ var span = editdoc.createElement("span");
238
+ span.className = config.trackChangesInsertClass;
239
+ var user = config.currentUser;
240
+ var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
241
+ span.setAttribute("data-tc-id", id);
242
+ span.setAttribute("data-tc-author", user.id);
243
+ span.setAttribute("data-tc-at", new Date().toISOString());
244
+ span.style.color = user.color;
245
+ span.style.textDecoration = "underline";
246
+ span.appendChild(editdoc.createTextNode(text));
247
+ range.insertNode(span);
248
+ normalizeInsertWhitespace(span.firstChild);
249
+ placeCaretAtEnd(span.firstChild);
250
+
251
+ editor.reviewLedger.add({
252
+ id: id,
253
+ changeType: "insert",
254
+ author: user,
255
+ text: text,
256
+ status: "pending",
257
+ sourceLabel: "Suggested insert",
258
+ createdAt: Date.now()
259
+ });
260
+ }
261
+
262
+ function updateEntryForSpan(span) {
263
+ var id = span.getAttribute("data-tc-id");
264
+ if (!id) return;
265
+ editor.reviewLedger.update(id, { text: span.textContent });
266
+ }
267
+
268
+ // --- delete tracking ---
269
+
270
+ function handleDelete(mode) {
271
+ var sel = editor.getSelection();
272
+ if (!sel || sel.rangeCount === 0) return;
273
+ var range = sel.getRangeAt(0);
274
+
275
+ if (!range.collapsed) {
276
+ wrapRangeAsDelete(range);
277
+ return;
278
+ }
279
+
280
+ // Collapsed: extend by one char in the appropriate direction, then wrap.
281
+ // The extended range covers only that one character — any delete spans between
282
+ // it and the caret should be SKIPPED, not re-wrapped.
283
+ var extended = editor.getDocument().createRange();
284
+ if (mode === "deleteContentBackward") {
285
+ var anchor = prevCharPoint(range.startContainer, range.startOffset);
286
+ if (!anchor) return;
287
+ extended.setStart(anchor.node, anchor.offset);
288
+ extended.setEnd(anchor.node, anchor.offset + 1);
289
+ } else {
290
+ var forward = nextCharPoint(range.startContainer, range.startOffset);
291
+ if (!forward) return;
292
+ extended.setStart(forward.node, forward.offset - 1);
293
+ extended.setEnd(forward.node, forward.offset);
294
+ }
295
+
296
+ // If that extended range sits entirely inside one of the current user's own
297
+ // insert spans, treat this as undoing their own typing — actually delete.
298
+ if (rangeInsideMyInsert(extended)) {
299
+ extended.deleteContents();
300
+ var sel2 = editor.getSelection();
301
+ sel2.removeAllRanges();
302
+ sel2.addRange(extended);
303
+ // If the insert span is now empty, remove it and its ledger entry.
304
+ cleanupEmptyInsertSpans();
305
+ return;
306
+ }
307
+
308
+ wrapRangeAsDelete(extended);
309
+ }
310
+
311
+ function isInsideDeleteSpan(node) {
312
+ var walk = node.nodeType === 1 ? node : node.parentNode;
313
+ while (walk && walk !== editor.getEditable()) {
314
+ if (walk.classList && walk.classList.contains("rte-tc-delete")) return true;
315
+ walk = walk.parentNode;
316
+ }
317
+ return false;
318
+ }
319
+
320
+ // Walk text leaves in the editable, skipping any that live inside an existing delete
321
+ // span (those characters have already been visually "deleted" — we shouldn't re-wrap them).
322
+ function liveTextLeaves() {
323
+ var editable = editor.getEditable();
324
+ var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT, {
325
+ acceptNode: function (n) { return isInsideDeleteSpan(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }
326
+ });
327
+ var out = [], n;
328
+ while ((n = walker.nextNode())) out.push(n);
329
+ return out;
330
+ }
331
+
332
+ function prevCharPoint(node, offset) {
333
+ var leaves = liveTextLeaves();
334
+ if (!leaves.length) return null;
335
+ var idx = caretIndex(node, offset, leaves);
336
+ if (idx <= 0) return null;
337
+ return leafOffsetAt(idx - 1, leaves);
338
+ }
339
+
340
+ function nextCharPoint(node, offset) {
341
+ var leaves = liveTextLeaves();
342
+ if (!leaves.length) return null;
343
+ var total = 0;
344
+ for (var i = 0; i < leaves.length; i++) total += leaves[i].nodeValue.length;
345
+ var idx = caretIndex(node, offset, leaves);
346
+ if (idx >= total) return null;
347
+ return leafOffsetAt(idx + 1, leaves);
348
+ }
349
+
350
+ // How many live characters appear before the given caret position?
351
+ function caretIndex(node, offset, leaves) {
352
+ var r = editor.getDocument().createRange();
353
+ r.setStart(node, offset);
354
+ r.collapse(true);
355
+ var count = 0;
356
+ for (var i = 0; i < leaves.length; i++) {
357
+ var leaf = leaves[i];
358
+ // If the entire leaf is before the caret, add its length.
359
+ var cmpEnd = r.comparePoint(leaf, leaf.nodeValue.length);
360
+ if (cmpEnd <= 0) { count += leaf.nodeValue.length; continue; }
361
+ var cmpStart = r.comparePoint(leaf, 0);
362
+ if (cmpStart >= 0) break; // leaf starts at or after caret
363
+ // Caret falls inside this leaf — binary search the split point.
364
+ var lo = 0, hi = leaf.nodeValue.length;
365
+ while (lo < hi) {
366
+ var mid = (lo + hi) >> 1;
367
+ if (r.comparePoint(leaf, mid) < 0) lo = mid + 1;
368
+ else hi = mid;
369
+ }
370
+ count += lo;
371
+ break;
372
+ }
373
+ return count;
374
+ }
375
+
376
+ function leafOffsetAt(index, leaves) {
377
+ var remaining = index;
378
+ for (var i = 0; i < leaves.length; i++) {
379
+ if (remaining <= leaves[i].nodeValue.length) return { node: leaves[i], offset: remaining };
380
+ remaining -= leaves[i].nodeValue.length;
381
+ }
382
+ return null;
383
+ }
384
+
385
+ function firstTextLeaf(node) {
386
+ if (!node) return null;
387
+ if (node.nodeType === 3) return node;
388
+ var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
389
+ return walker.nextNode();
390
+ }
391
+
392
+ function lastTextLeaf(node) {
393
+ if (!node) return null;
394
+ if (node.nodeType === 3) return node;
395
+ var walker = editor.getDocument().createTreeWalker(node, NodeFilter.SHOW_TEXT);
396
+ var last = null, n;
397
+ while ((n = walker.nextNode())) last = n;
398
+ return last;
399
+ }
400
+
401
+ function previousTextLeaf(node) {
402
+ var editable = editor.getEditable();
403
+ var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
404
+ var prev = null, cur;
405
+ while ((cur = walker.nextNode())) {
406
+ if (cur === node) return prev;
407
+ prev = cur;
408
+ }
409
+ return null;
410
+ }
411
+
412
+ function nextTextLeaf(node) {
413
+ var editable = editor.getEditable();
414
+ var walker = editor.getDocument().createTreeWalker(editable, NodeFilter.SHOW_TEXT);
415
+ var seen = false, cur;
416
+ while ((cur = walker.nextNode())) {
417
+ if (seen) return cur;
418
+ if (cur === node) seen = true;
419
+ }
420
+ return null;
421
+ }
422
+
423
+ function rangeInsideMyInsert(range) {
424
+ var span = range.startContainer.parentNode;
425
+ while (span && span !== editor.getEditable()) {
426
+ if (isMyInsert(span)) return range.endContainer.parentNode === span || span.contains(range.endContainer);
427
+ span = span.parentNode;
428
+ }
429
+ return false;
430
+ }
431
+
432
+ function wrapRangeAsDelete(range) {
433
+ if (range.collapsed) return false;
434
+ var editdoc = editor.getDocument();
435
+
436
+ var fragment = range.cloneContents();
437
+ if (!fragmentHasText(fragment)) return false;
438
+
439
+ // Find existing same-author delete spans adjacent to the range so we can merge
440
+ // sequential backspaces / forward deletes into one contiguous span.
441
+ var user = config.currentUser;
442
+ var mergeBefore = adjacentDeleteSpan(range, "before", user);
443
+ var mergeAfter = adjacentDeleteSpan(range, "after", user);
444
+
445
+ range.deleteContents();
446
+
447
+ if (mergeBefore) {
448
+ // Append the fragment's content to the existing "before" span.
449
+ appendFragmentToSpan(mergeBefore, fragment);
450
+ if (mergeAfter && mergeAfter !== mergeBefore) {
451
+ // Collapse both sides: move "after" span's children into "before" and drop it.
452
+ while (mergeAfter.firstChild) mergeBefore.appendChild(mergeAfter.firstChild);
453
+ var afterId = mergeAfter.getAttribute("data-tc-id");
454
+ mergeAfter.remove();
455
+ if (afterId && editor.reviewLedger) editor.reviewLedger.remove(afterId);
456
+ }
457
+ var rangeAfter = editdoc.createRange();
458
+ rangeAfter.setStartAfter(mergeBefore);
459
+ rangeAfter.collapse(true);
460
+ var sel = editor.getSelection();
461
+ sel.removeAllRanges();
462
+ sel.addRange(rangeAfter);
463
+ // 2026-05-28 Defense: if a customer pasted a delete-class span from
464
+ // outside this plugin, it may lack data-tc-id. Skip the ledger
465
+ // update instead of writing under a `null` key (would silently
466
+ // corrupt the ledger via the update("null", ...) coercion).
467
+ var beforeId = mergeBefore.getAttribute("data-tc-id");
468
+ if (beforeId && editor.reviewLedger) {
469
+ editor.reviewLedger.update(beforeId, { text: mergeBefore.textContent });
470
+ }
471
+ return true;
472
+ }
473
+
474
+ if (mergeAfter) {
475
+ prependFragmentToSpan(mergeAfter, fragment);
476
+ var rangeBefore = editdoc.createRange();
477
+ rangeBefore.setStartBefore(mergeAfter);
478
+ rangeBefore.collapse(true);
479
+ var sel2 = editor.getSelection();
480
+ sel2.removeAllRanges();
481
+ sel2.addRange(rangeBefore);
482
+ // Same defense as above (mergeAfter may lack data-tc-id).
483
+ var afterMergeId = mergeAfter.getAttribute("data-tc-id");
484
+ if (afterMergeId && editor.reviewLedger) {
485
+ editor.reviewLedger.update(afterMergeId, { text: mergeAfter.textContent });
486
+ }
487
+ return true;
488
+ }
489
+
490
+ var span = editdoc.createElement("span");
491
+ span.className = config.trackChangesDeleteClass;
492
+ var id = "tc-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
493
+ span.setAttribute("data-tc-id", id);
494
+ span.setAttribute("data-tc-author", user.id);
495
+ span.setAttribute("data-tc-at", new Date().toISOString());
496
+ span.style.color = user.color;
497
+ span.style.textDecoration = "line-through";
498
+ span.appendChild(fragment);
499
+ range.insertNode(span);
500
+
501
+ var after = editdoc.createRange();
502
+ after.setStartAfter(span);
503
+ after.collapse(true);
504
+ var sel3 = editor.getSelection();
505
+ sel3.removeAllRanges();
506
+ sel3.addRange(after);
507
+
508
+ editor.reviewLedger.add({
509
+ id: id,
510
+ changeType: "delete",
511
+ author: user,
512
+ text: span.textContent,
513
+ originalHtml: span.innerHTML,
514
+ status: "pending",
515
+ sourceLabel: "Suggested delete",
516
+ createdAt: Date.now()
517
+ });
518
+ return true;
519
+ }
520
+
521
+ function adjacentDeleteSpan(range, side, user) {
522
+ // Find element immediately before or after the range boundaries at the container level.
523
+ var node = side === "before" ? range.startContainer : range.endContainer;
524
+ var offset = side === "before" ? range.startOffset : range.endOffset;
525
+ if (node.nodeType === 3) {
526
+ // Look at the sibling of the text node if we're at the text node's boundary.
527
+ if (side === "before" && offset === 0) {
528
+ var prev = node.previousSibling;
529
+ if (prev && isDeleteSpanByAuthor(prev, user)) return prev;
530
+ }
531
+ if (side === "after" && offset === node.nodeValue.length) {
532
+ var next = node.nextSibling;
533
+ if (next && isDeleteSpanByAuthor(next, user)) return next;
534
+ }
535
+ return null;
536
+ }
537
+ // Element container: look at children on either side of the offset.
538
+ if (side === "before" && offset > 0) {
539
+ var childBefore = node.childNodes[offset - 1];
540
+ if (childBefore && isDeleteSpanByAuthor(childBefore, user)) return childBefore;
541
+ }
542
+ if (side === "after" && offset < node.childNodes.length) {
543
+ var childAfter = node.childNodes[offset];
544
+ if (childAfter && isDeleteSpanByAuthor(childAfter, user)) return childAfter;
545
+ }
546
+ return null;
547
+ }
548
+
549
+ function isDeleteSpanByAuthor(el, user) {
550
+ if (!el || el.nodeType !== 1) return false;
551
+ if (!el.classList || !el.classList.contains("rte-tc-delete")) return false;
552
+ return el.getAttribute("data-tc-author") === user.id;
553
+ }
554
+
555
+ function appendFragmentToSpan(span, fragment) {
556
+ while (fragment.firstChild) span.appendChild(fragment.firstChild);
557
+ }
558
+
559
+ function prependFragmentToSpan(span, fragment) {
560
+ var first = span.firstChild;
561
+ while (fragment.firstChild) span.insertBefore(fragment.firstChild, first);
562
+ }
563
+
564
+ function fragmentHasText(fragment) {
565
+ var t = (fragment.textContent || "").replace(/\s+/g, "");
566
+ return !!t;
567
+ }
568
+
569
+ function cleanupEmptyInsertSpans() {
570
+ var spans = editor.getEditable().querySelectorAll("." + "rte-tc-insert");
571
+ for (var i = 0; i < spans.length; i++) {
572
+ var s = spans[i];
573
+ if (!s.textContent || !s.textContent.length) {
574
+ var id = s.getAttribute("data-tc-id");
575
+ if (id && editor.reviewLedger) editor.reviewLedger.remove(id);
576
+ s.remove();
577
+ } else {
578
+ updateEntryForSpan(s);
579
+ }
580
+ }
581
+ }
582
+
583
+ function placeCaretAtEnd(node) {
584
+ if (!node) return;
585
+ var editdoc = editor.getDocument();
586
+ var range = editdoc.createRange();
587
+ if (node.nodeType === 3) {
588
+ range.setStart(node, node.nodeValue.length);
589
+ } else {
590
+ range.setStart(node, node.childNodes.length);
591
+ }
592
+ range.collapse(true);
593
+ var sel = editor.getSelection();
594
+ sel.removeAllRanges();
595
+ sel.addRange(range);
596
+ }
597
+
598
+ // --- accept / reject ---
599
+
600
+ function decisionUser() {
601
+ return config.currentUser || { id: "user", name: "User", color: "#2563eb" };
602
+ }
603
+
604
+ function canDecide(entry, action) {
605
+ if (typeof config.reviewCanDecide !== "function") return true;
606
+ var allowed = false;
607
+ var user = decisionUser();
608
+ try { allowed = config.reviewCanDecide(entry, action, user, editor) !== false; }
609
+ catch (err) {
610
+ console.warn("trackedchanges: reviewCanDecide threw; denying decision.", err);
611
+ }
612
+ if (allowed) return true;
613
+ var detail = { entry: entry, action: action, user: user };
614
+ try { typeof config.onReviewDecisionDenied === "function" && config.onReviewDecisionDenied(detail); } catch (ignore) { }
615
+ try { typeof editor.fireEvent === "function" && editor.fireEvent("review_decision_denied", detail); } catch (ignore2) { }
616
+ return false;
617
+ }
618
+
619
+ function acceptEntry(id) {
620
+ var entry = editor.reviewLedger.get(id);
621
+ if (!entry) return false;
622
+ if (!canDecide(entry, "accept")) return false;
623
+ var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
624
+ if (entry.changeType === "insert") {
625
+ if (span) unwrapKeepChildren(span);
626
+ } else if (entry.changeType === "delete") {
627
+ if (span) span.remove();
628
+ }
629
+ editor.reviewLedger.update(id, { status: "accepted", decidedAt: Date.now(), decidedBy: decisionUser() });
630
+ return true;
631
+ }
632
+
633
+ function rejectEntry(id) {
634
+ var entry = editor.reviewLedger.get(id);
635
+ if (!entry) return false;
636
+ if (!canDecide(entry, "reject")) return false;
637
+ var span = editor.getEditable().querySelector('[data-tc-id="' + cssEscape(id) + '"]');
638
+ if (entry.changeType === "insert") {
639
+ if (span) span.remove();
640
+ } else if (entry.changeType === "delete") {
641
+ if (span) unwrapKeepChildren(span);
642
+ }
643
+ editor.reviewLedger.update(id, { status: "rejected", decidedAt: Date.now(), decidedBy: decisionUser() });
644
+ return true;
645
+ }
646
+
647
+ function acceptAll(filter) {
648
+ var entries = editor.reviewLedger.list(filter).filter(function (e) {
649
+ return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
650
+ });
651
+ var accepted = 0;
652
+ for (var i = 0; i < entries.length; i++) if (acceptEntry(entries[i].id)) accepted++;
653
+ return accepted;
654
+ }
655
+
656
+ function rejectAll(filter) {
657
+ var entries = editor.reviewLedger.list(filter).filter(function (e) {
658
+ return (e.changeType === "insert" || e.changeType === "delete") && e.status === "pending";
659
+ });
660
+ var rejected = 0;
661
+ for (var i = 0; i < entries.length; i++) if (rejectEntry(entries[i].id)) rejected++;
662
+ return rejected;
663
+ }
664
+
665
+ function unwrapKeepChildren(el) {
666
+ var parent = el.parentNode;
667
+ if (!parent) return;
668
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
669
+ parent.removeChild(el);
670
+ }
671
+
672
+ // 2026-05-28 Prefer native CSS.escape — same hardening as comments.js.
673
+ // Defense-in-depth for customers who wire reviewLedger.add() with custom IDs
674
+ // containing punctuation (e.g. UUIDs with dashes, OAuth subject claims with
675
+ // colons, base64 IDs with `=` padding).
676
+ function cssEscape(s) {
677
+ var str = String(s);
678
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
679
+ try { return CSS.escape(str); } catch (e) { /* fall through */ }
680
+ }
681
+ return str.replace(/["\\\]\[\(\)\{\}'#:;,<>=`~!@$%^&*+/?\s\x00-\x1F]/g, function (c) {
682
+ return "\\" + c;
683
+ });
684
+ }
685
+
686
+ // --- styles ---
687
+
688
+ function injectStyles() {
689
+ var host = (editor && editor.iframe && editor.iframe.ownerDocument) || document;
690
+ if (!host.querySelector("style[data-rte-trackchanges]")) {
691
+ var style = host.createElement("style");
692
+ style.setAttribute("data-rte-trackchanges", "1");
693
+ style.textContent = [
694
+ ".richtexteditor rte-toolbar-button.rte_command_trackchanges{position:relative;border-radius:8px;transition:background .14s ease,color .14s ease,box-shadow .14s ease}",
695
+ ".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
+ ".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
+ ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
698
+ ".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
+ ".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
+ ].join("\n");
701
+ host.head.appendChild(style);
702
+ }
703
+
704
+ var editdoc = editor.getDocument();
705
+ if (editdoc && editdoc.head && !editdoc.querySelector("style[data-rte-trackchanges-inline]")) {
706
+ var iStyle = editdoc.createElement("style");
707
+ iStyle.setAttribute("data-rte-trackchanges-inline", "1");
708
+ iStyle.textContent = [
709
+ ".rte-tc{border-radius:4px;padding:0 2px;box-decoration-break:clone;-webkit-box-decoration-break:clone}",
710
+ ".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}",
711
+ ".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}"
712
+ ].join("\n");
713
+ editdoc.head.appendChild(iStyle);
714
+ }
715
+ }
716
+ }