@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.
Files changed (238) hide show
  1. package/README.md +173 -10
  2. package/angular/build-manifest.json +23 -0
  3. package/angular/index.d.ts +71 -76
  4. package/angular/index.mjs +2 -0
  5. package/angular/package.json +4 -0
  6. package/headless/index.d.ts +3 -1
  7. package/headless/index.js +72 -16
  8. package/index.d.ts +33 -54
  9. package/integrations/initialization-scope.js +27 -0
  10. package/integrations/native-mount.js +30 -0
  11. package/integrations/shared.d.ts +23 -1
  12. package/integrations/shared.js +58 -22
  13. package/package.json +25 -3
  14. package/react/index.js +118 -37
  15. package/react/index.mjs +29 -0
  16. package/richtexteditor/all_plugins.js +7113 -1111
  17. package/richtexteditor/all_plugins.min.js +62 -59
  18. package/richtexteditor/lang/emoji/LICENSE-CLDR.txt +41 -0
  19. package/richtexteditor/lang/emoji/rte-emoji-de.js +3 -0
  20. package/richtexteditor/lang/emoji/rte-emoji-es.js +3 -0
  21. package/richtexteditor/lang/emoji/rte-emoji-fr.js +3 -0
  22. package/richtexteditor/lang/emoji/rte-emoji-it.js +3 -0
  23. package/richtexteditor/lang/emoji/rte-emoji-ja.js +3 -0
  24. package/richtexteditor/lang/emoji/rte-emoji-ko.js +3 -0
  25. package/richtexteditor/lang/emoji/rte-emoji-nl.js +3 -0
  26. package/richtexteditor/lang/emoji/rte-emoji-pt-pt.js +3 -0
  27. package/richtexteditor/lang/emoji/rte-emoji-pt.js +3 -0
  28. package/richtexteditor/lang/emoji/rte-emoji-ru.js +3 -0
  29. package/richtexteditor/lang/emoji/rte-emoji-zh-Hans.js +3 -0
  30. package/richtexteditor/lang/emoji/rte-emoji-zh-Hant.js +3 -0
  31. package/richtexteditor/lang/rte-lang-af.js +1 -0
  32. package/richtexteditor/lang/rte-lang-ar.js +1 -0
  33. package/richtexteditor/lang/rte-lang-bg.js +1 -0
  34. package/richtexteditor/lang/rte-lang-bn.js +1 -0
  35. package/richtexteditor/lang/rte-lang-bs.js +1 -0
  36. package/richtexteditor/lang/rte-lang-ca.js +1 -0
  37. package/richtexteditor/lang/rte-lang-cs.js +1 -0
  38. package/richtexteditor/lang/rte-lang-cy.js +1 -0
  39. package/richtexteditor/lang/rte-lang-da.js +1 -0
  40. package/richtexteditor/lang/rte-lang-de.js +1017 -2
  41. package/richtexteditor/lang/rte-lang-el.js +1 -0
  42. package/richtexteditor/lang/rte-lang-en.js +1 -0
  43. package/richtexteditor/lang/rte-lang-es.js +1023 -2
  44. package/richtexteditor/lang/rte-lang-et.js +1 -0
  45. package/richtexteditor/lang/rte-lang-fa.js +1 -0
  46. package/richtexteditor/lang/rte-lang-fi.js +1 -0
  47. package/richtexteditor/lang/rte-lang-fil.js +1 -0
  48. package/richtexteditor/lang/rte-lang-fj.js +1 -0
  49. package/richtexteditor/lang/rte-lang-fr.js +1023 -2
  50. package/richtexteditor/lang/rte-lang-ga.js +1 -0
  51. package/richtexteditor/lang/rte-lang-gu.js +1 -0
  52. package/richtexteditor/lang/rte-lang-he.js +1 -0
  53. package/richtexteditor/lang/rte-lang-hi.js +1 -0
  54. package/richtexteditor/lang/rte-lang-hr.js +1 -0
  55. package/richtexteditor/lang/rte-lang-ht.js +1 -0
  56. package/richtexteditor/lang/rte-lang-hu.js +1 -0
  57. package/richtexteditor/lang/rte-lang-id.js +1 -0
  58. package/richtexteditor/lang/rte-lang-is.js +1 -0
  59. package/richtexteditor/lang/rte-lang-it.js +1023 -2
  60. package/richtexteditor/lang/rte-lang-ja.js +1023 -2
  61. package/richtexteditor/lang/rte-lang-kn.js +1 -0
  62. package/richtexteditor/lang/rte-lang-ko.js +1023 -2
  63. package/richtexteditor/lang/rte-lang-lt.js +1 -0
  64. package/richtexteditor/lang/rte-lang-lv.js +1 -0
  65. package/richtexteditor/lang/rte-lang-mg.js +1 -0
  66. package/richtexteditor/lang/rte-lang-mi.js +1 -0
  67. package/richtexteditor/lang/rte-lang-ml.js +1 -0
  68. package/richtexteditor/lang/rte-lang-mr.js +1 -0
  69. package/richtexteditor/lang/rte-lang-ms.js +1 -0
  70. package/richtexteditor/lang/rte-lang-mt.js +1 -0
  71. package/richtexteditor/lang/rte-lang-mww.js +1 -0
  72. package/richtexteditor/lang/rte-lang-nb.js +1 -0
  73. package/richtexteditor/lang/rte-lang-nl.js +1023 -2
  74. package/richtexteditor/lang/rte-lang-otq.js +1 -0
  75. package/richtexteditor/lang/rte-lang-pa.js +1 -0
  76. package/richtexteditor/lang/rte-lang-pl.js +1 -0
  77. package/richtexteditor/lang/rte-lang-pt-pt.js +1023 -2
  78. package/richtexteditor/lang/rte-lang-pt.js +1023 -2
  79. package/richtexteditor/lang/rte-lang-ro.js +1 -0
  80. package/richtexteditor/lang/rte-lang-ru.js +1023 -2
  81. package/richtexteditor/lang/rte-lang-sk.js +1 -0
  82. package/richtexteditor/lang/rte-lang-sl.js +1 -0
  83. package/richtexteditor/lang/rte-lang-sm.js +1 -0
  84. package/richtexteditor/lang/rte-lang-sr-Cyrl.js +1 -0
  85. package/richtexteditor/lang/rte-lang-sr-Latn.js +1 -0
  86. package/richtexteditor/lang/rte-lang-sv.js +1 -0
  87. package/richtexteditor/lang/rte-lang-sw.js +1 -0
  88. package/richtexteditor/lang/rte-lang-ta.js +1 -0
  89. package/richtexteditor/lang/rte-lang-te.js +1 -0
  90. package/richtexteditor/lang/rte-lang-th.js +1 -0
  91. package/richtexteditor/lang/rte-lang-tlh-Latn.js +1 -0
  92. package/richtexteditor/lang/rte-lang-tlh-Piqd.js +1 -0
  93. package/richtexteditor/lang/rte-lang-to.js +1 -0
  94. package/richtexteditor/lang/rte-lang-tr.js +1 -0
  95. package/richtexteditor/lang/rte-lang-ty.js +1 -0
  96. package/richtexteditor/lang/rte-lang-uk.js +1 -0
  97. package/richtexteditor/lang/rte-lang-ur.js +1 -0
  98. package/richtexteditor/lang/rte-lang-vi.js +1 -0
  99. package/richtexteditor/lang/rte-lang-yua.js +1 -0
  100. package/richtexteditor/lang/rte-lang-zh-Hans.js +1017 -2
  101. package/richtexteditor/lang/rte-lang-zh-Hant.js +1017 -2
  102. package/richtexteditor/plugins/a11yenhance.js +2 -1
  103. package/richtexteditor/plugins/a11yenhance.min.js +1 -1
  104. package/richtexteditor/plugins/accessibilitychecker.js +252 -60
  105. package/richtexteditor/plugins/accessibilitychecker.min.js +1 -1
  106. package/richtexteditor/plugins/aitoolkit.js +966 -303
  107. package/richtexteditor/plugins/aitoolkit.min.js +7 -7
  108. package/richtexteditor/plugins/all_plugins.js +7113 -1111
  109. package/richtexteditor/plugins/all_plugins.min.js +62 -59
  110. package/richtexteditor/plugins/autocorrect.js +66 -31
  111. package/richtexteditor/plugins/autocorrect.min.js +1 -1
  112. package/richtexteditor/plugins/autoembed.js +17 -0
  113. package/richtexteditor/plugins/autoembed.min.js +1 -1
  114. package/richtexteditor/plugins/blocktypes.js +18 -9
  115. package/richtexteditor/plugins/blocktypes.min.js +1 -1
  116. package/richtexteditor/plugins/bookmarkcard.js +12 -5
  117. package/richtexteditor/plugins/bookmarkcard.min.js +2 -2
  118. package/richtexteditor/plugins/changecase.js +72 -19
  119. package/richtexteditor/plugins/changecase.min.js +1 -1
  120. package/richtexteditor/plugins/charlimit.js +62 -2
  121. package/richtexteditor/plugins/charlimit.min.js +2 -2
  122. package/richtexteditor/plugins/comments.js +458 -59
  123. package/richtexteditor/plugins/comments.min.js +4 -3
  124. package/richtexteditor/plugins/contentminimap.js +25 -6
  125. package/richtexteditor/plugins/contentminimap.min.js +1 -1
  126. package/richtexteditor/plugins/crossreference.js +1 -1
  127. package/richtexteditor/plugins/crossreference.min.js +1 -1
  128. package/richtexteditor/plugins/dialoga11y.js +26 -4
  129. package/richtexteditor/plugins/dialoga11y.min.js +1 -1
  130. package/richtexteditor/plugins/dictation.js +1 -1
  131. package/richtexteditor/plugins/documentimport.js +220 -51
  132. package/richtexteditor/plugins/documentimport.min.js +4 -4
  133. package/richtexteditor/plugins/documentoutline.js +88 -38
  134. package/richtexteditor/plugins/documentoutline.min.js +1 -1
  135. package/richtexteditor/plugins/docxexport.js +204 -32
  136. package/richtexteditor/plugins/docxexport.min.js +2 -2
  137. package/richtexteditor/plugins/draghandle.js +267 -3
  138. package/richtexteditor/plugins/draghandle.min.js +1 -1
  139. package/richtexteditor/plugins/emailtoolkit.js +28 -14
  140. package/richtexteditor/plugins/emailtoolkit.min.js +1 -1
  141. package/richtexteditor/plugins/emojiautocomplete.js +110 -56
  142. package/richtexteditor/plugins/emojiautocomplete.min.js +1 -1
  143. package/richtexteditor/plugins/footnotes.js +65 -11
  144. package/richtexteditor/plugins/footnotes.min.js +1 -1
  145. package/richtexteditor/plugins/formatpainter.js +10 -0
  146. package/richtexteditor/plugins/formatpainter.min.js +1 -1
  147. package/richtexteditor/plugins/formattingmarks.js +53 -8
  148. package/richtexteditor/plugins/formattingmarks.min.js +1 -1
  149. package/richtexteditor/plugins/gapcursor.js +60 -0
  150. package/richtexteditor/plugins/gapcursor.min.js +1 -1
  151. package/richtexteditor/plugins/ghostcomplete.js +1 -1
  152. package/richtexteditor/plugins/html2pdf.js +1 -1
  153. package/richtexteditor/plugins/html2pdf.min.js +1 -1
  154. package/richtexteditor/plugins/imageeditor.js +12 -6
  155. package/richtexteditor/plugins/imageeditor.min.js +1 -1
  156. package/richtexteditor/plugins/importstyles.js +1 -1
  157. package/richtexteditor/plugins/inlinecode.js +25 -0
  158. package/richtexteditor/plugins/inlinecode.min.js +1 -1
  159. package/richtexteditor/plugins/insertcode.js +21 -3
  160. package/richtexteditor/plugins/insertcode.min.js +4 -4
  161. package/richtexteditor/plugins/insertemoji.js +112 -15
  162. package/richtexteditor/plugins/insertemoji.min.js +1 -1
  163. package/richtexteditor/plugins/insertgallery.js +42 -36
  164. package/richtexteditor/plugins/insertgallery.min.js +1 -1
  165. package/richtexteditor/plugins/inserttemplate.js +33 -17
  166. package/richtexteditor/plugins/inserttemplate.min.js +1 -1
  167. package/richtexteditor/plugins/keyboarda11y.js +66 -3
  168. package/richtexteditor/plugins/keyboarda11y.min.js +1 -1
  169. package/richtexteditor/plugins/layouttable.js +20 -0
  170. package/richtexteditor/plugins/layouttable.min.js +1 -1
  171. package/richtexteditor/plugins/linkchecker.js +29 -9
  172. package/richtexteditor/plugins/linkchecker.min.js +1 -1
  173. package/richtexteditor/plugins/localdraft.js +51 -7
  174. package/richtexteditor/plugins/localdraft.min.js +1 -1
  175. package/richtexteditor/plugins/markdownexport.js +159 -0
  176. package/richtexteditor/plugins/markdownexport.min.js +2 -2
  177. package/richtexteditor/plugins/mathdialog.js +11 -5
  178. package/richtexteditor/plugins/mathdialog.min.js +1 -1
  179. package/richtexteditor/plugins/mentionplugin.js +88 -25
  180. package/richtexteditor/plugins/mentionplugin.min.js +1 -1
  181. package/richtexteditor/plugins/mermaiddiagram.js +9 -3
  182. package/richtexteditor/plugins/mermaiddiagram.min.js +2 -2
  183. package/richtexteditor/plugins/multilevellist.js +4 -0
  184. package/richtexteditor/plugins/multilevellist.min.js +1 -1
  185. package/richtexteditor/plugins/pdfexport.js +235 -35
  186. package/richtexteditor/plugins/pdfexport.min.js +12 -12
  187. package/richtexteditor/plugins/permanentpen.js +3 -3
  188. package/richtexteditor/plugins/permanentpen.min.js +1 -1
  189. package/richtexteditor/plugins/readabilitystats.js +25 -11
  190. package/richtexteditor/plugins/readabilitystats.min.js +1 -1
  191. package/richtexteditor/plugins/readaloud.js +29 -15
  192. package/richtexteditor/plugins/readaloud.min.js +1 -1
  193. package/richtexteditor/plugins/restrictedediting.js +166 -0
  194. package/richtexteditor/plugins/restrictedediting.min.js +2 -2
  195. package/richtexteditor/plugins/revisionhistory.js +169 -37
  196. package/richtexteditor/plugins/revisionhistory.min.js +3 -3
  197. package/richtexteditor/plugins/rubyannotation.js +19 -9
  198. package/richtexteditor/plugins/rubyannotation.min.js +1 -1
  199. package/richtexteditor/plugins/sanitizer.js +86 -4
  200. package/richtexteditor/plugins/sanitizer.min.js +1 -1
  201. package/richtexteditor/plugins/slashcommand.js +30 -16
  202. package/richtexteditor/plugins/slashcommand.min.js +2 -2
  203. package/richtexteditor/plugins/smartchips.js +36 -22
  204. package/richtexteditor/plugins/smartchips.min.js +2 -2
  205. package/richtexteditor/plugins/spellcheck.js +17 -8
  206. package/richtexteditor/plugins/spellcheck.min.js +2 -2
  207. package/richtexteditor/plugins/stateinspector.js +19 -9
  208. package/richtexteditor/plugins/stateinspector.min.js +2 -2
  209. package/richtexteditor/plugins/structured-content-bridge.js +56 -24
  210. package/richtexteditor/plugins/structured-content-bridge.min.js +7 -7
  211. package/richtexteditor/plugins/tableofcontents.js +5 -2
  212. package/richtexteditor/plugins/tableofcontents.min.js +1 -1
  213. package/richtexteditor/plugins/tabletools.js +388 -3
  214. package/richtexteditor/plugins/tabletools.min.js +1 -1
  215. package/richtexteditor/plugins/textdirection.js +18 -5
  216. package/richtexteditor/plugins/textdirection.min.js +1 -1
  217. package/richtexteditor/plugins/textpartlanguage.js +161 -32
  218. package/richtexteditor/plugins/textpartlanguage.min.js +1 -1
  219. package/richtexteditor/plugins/todolist.js +90 -6
  220. package/richtexteditor/plugins/todolist.min.js +1 -1
  221. package/richtexteditor/plugins/trackedchanges.js +1812 -25
  222. package/richtexteditor/plugins/trackedchanges.min.js +5 -3
  223. package/richtexteditor/plugins/wordexport.js +2 -2
  224. package/richtexteditor/plugins/yjscollab.js +9 -4
  225. package/richtexteditor/plugins/yjscollab.min.js +1 -1
  226. package/richtexteditor/plugins/zz_richtextbox_dialog_style.js +22 -13
  227. package/richtexteditor/plugins/zz_richtextbox_dialog_style.min.js +1 -1
  228. package/richtexteditor/rte-config.js +131 -50
  229. package/richtexteditor/rte-headless.js +19 -4
  230. package/richtexteditor/rte-webcomponent.js +89 -16
  231. package/richtexteditor/rte.js +133 -52
  232. package/richtexteditor/runtime/colorpicker_more_ns.htm +93 -16
  233. package/scripts/build-angular.mjs +97 -0
  234. package/svelte/index.d.ts +2 -1
  235. package/svelte/index.js +80 -9
  236. package/vue/index.js +130 -34
  237. package/angular/component.ts +0 -297
  238. 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
- editor.getEditable().addEventListener("beforeinput", onBeforeInput, true);
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
- // insertParagraph, insertCompositionText, insertFromPaste, historyUndo/Redo: pass through for v1.
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: 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.
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
- wrapRangeAsDelete(range);
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
- 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();
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 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);
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