@richscripts/richtexteditor 2.6.3 → 2.6.4

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.
@@ -21718,7 +21718,17 @@ function RTE_Plugin_Comments() {
21718
21718
 
21719
21719
  var id = "cmt-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
21720
21720
  var user = getCurrentUser();
21721
- var anchor = options.anchor;
21721
+ var anchor = options.anchor || options.range || null;
21722
+ // Accept a plain DOM Range (or Range-like) as the anchor: it has the
21723
+ // container/offset fields but no `.text`, so until 2026-09-02 the common
21724
+ // call add({ anchor: range }) silently recorded the comment WITHOUT a
21725
+ // highlight. Derive the text from the range instead.
21726
+ if (anchor && anchor.startContainer && anchor.endContainer && !anchor.text) {
21727
+ var derived = "";
21728
+ try { derived = typeof anchor.toString === "function" ? String(anchor.toString()) : ""; } catch (e) { derived = ""; }
21729
+ if (!derived) { try { derived = anchor.cloneContents ? (anchor.cloneContents().textContent || "") : ""; } catch (e2) { derived = ""; } }
21730
+ anchor = { startContainer: anchor.startContainer, startOffset: anchor.startOffset, endContainer: anchor.endContainer, endOffset: anchor.endOffset, text: derived };
21731
+ }
21722
21732
  var wrappedText = "";
21723
21733
 
21724
21734
  if (anchor && anchor.startContainer && anchor.endContainer && anchor.text) {
@@ -23437,17 +23447,12 @@ function RTE_Plugin_DocumentImport() {
23437
23447
  openPicker({});
23438
23448
  });
23439
23449
 
23440
- if (editor.slashCommands && typeof editor.slashCommands.register === "function") {
23441
- try {
23442
- editor.slashCommands.register({
23443
- id: "import-document",
23444
- title: "Import document",
23445
- description: "Open a Markdown, HTML, text, or Word (.doc) file into the editor",
23446
- keywords: ["import", "open", "file", "word", "markdown", "upload"],
23447
- action: function () { openPicker({}); }
23448
- });
23449
- } catch (e) {}
23450
- }
23450
+ // NOTE: do NOT call editor.slashCommands.register() from here. Plugins
23451
+ // initialise in bundle (alphabetical) order and this file sorts before
23452
+ // "slashcommand", so editor.slashCommands does not exist yet and the guarded
23453
+ // call silently did nothing - the "import-document" slash entry never appeared for any
23454
+ // customer until 2026-09-02. The entry now lives in slashcommand.js, gated on
23455
+ // typeof editor.openImportDialog === "function" (same pattern as exportToPdf).
23451
23456
  };
23452
23457
 
23453
23458
  function kindFromName(name) {
@@ -29588,7 +29593,7 @@ function RTE_Plugin_InsertCode() {
29588
29593
  sel_lang.options.add(new Option(aliases, brush));
29589
29594
 
29590
29595
 
29591
- var b = sessionStorage.getItem("rte-insertcode-lang")
29596
+ var b = null; try { b = sessionStorage.getItem("rte-insertcode-lang"); } catch (e) { } // storage throws in sandboxed / opaque-origin embeds
29592
29597
  if (b) sel_lang.value = b;
29593
29598
  }
29594
29599
 
@@ -29608,7 +29613,7 @@ function RTE_Plugin_InsertCode() {
29608
29613
  btn.onclick = function () {
29609
29614
  dialoginner.close();
29610
29615
 
29611
- sessionStorage.setItem("rte-insertcode-lang", sel_lang.value)
29616
+ try { sessionStorage.setItem("rte-insertcode-lang", sel_lang.value); } catch (e) { }
29612
29617
 
29613
29618
  if (sel_lang.value != "") {
29614
29619
  var b = dp.sh.Brushes[sel_lang.value];
@@ -36441,17 +36446,12 @@ function RTE_Plugin_ReadabilityStats() {
36441
36446
  openDialog();
36442
36447
  });
36443
36448
 
36444
- if (editor.slashCommands && typeof editor.slashCommands.register === "function") {
36445
- try {
36446
- editor.slashCommands.register({
36447
- id: "readability-stats",
36448
- title: "Readability statistics",
36449
- description: "Flesch reading ease, grade level, and reading time",
36450
- keywords: ["readability", "flesch", "grade", "reading", "stats", "score"],
36451
- action: function () { openDialog(); }
36452
- });
36453
- } catch (e) {}
36454
- }
36449
+ // NOTE: do NOT call editor.slashCommands.register() from here. Plugins
36450
+ // initialise in bundle (alphabetical) order and this file sorts before
36451
+ // "slashcommand", so editor.slashCommands does not exist yet and the guarded
36452
+ // call silently did nothing - the "readability-stats" slash entry never appeared for any
36453
+ // customer until 2026-09-02. The entry now lives in slashcommand.js, gated on
36454
+ // typeof editor.getReadabilityStats === "function" (same pattern as exportToPdf).
36455
36455
  };
36456
36456
 
36457
36457
  function currentText() {
@@ -37877,8 +37877,9 @@ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
37877
37877
  // config.sanitizerAllowStyleTags = true // keep <style> in content
37878
37878
  // config.sanitizerAllowIframes = false // drop <iframe> outright
37879
37879
  // config.sanitizerAllowedIframeHosts = ["www.youtube.com"]
37880
- // config.sanitizerAllowTags = ["custom-el"]
37881
- // config.sanitizerAllowAttributes = ["my-attr"]
37880
+ // config.sanitizerAllowTags = ["custom-el", "x-*", /^ui-/]
37881
+ // config.sanitizerAllowAttributes = ["my-attr", "data-cms-*"]
37882
+ // exact names, "*" wildcards, or RegExp -- see buildMatcher()
37882
37883
  RTE_DefaultConfig.plugin_sanitizer = RTE_Plugin_Sanitizer;
37883
37884
  if (typeof RTE_DefaultConfig.contentSanitizer === "undefined") RTE_DefaultConfig.contentSanitizer = true;
37884
37885
 
@@ -38065,12 +38066,63 @@ function RTE_Plugin_Sanitizer() {
38065
38066
  return false;
38066
38067
  }
38067
38068
 
38069
+ // Build a name matcher from a config list. Entries may be:
38070
+ // "my-widget" an exact name
38071
+ // "my-*" a wildcard, where * matches any run of characters
38072
+ // /^my-/ a RegExp
38073
+ // The wildcard form is what makes this a workable answer to CKEditor's General
38074
+ // HTML Support: without it an integrator has to enumerate every tag and
38075
+ // attribute a CMS might emit, by name, which nobody does -- so the content gets
38076
+ // unwrapped instead and the integrator concludes the editor destroys it.
38077
+ //
38078
+ // A pattern widens what is KEPT, never what is executed: on* handlers,
38079
+ // javascript: URLs, <script> and iframe[srcdoc] are all handled before this is
38080
+ // consulted, so allowing a tag or attribute by pattern cannot re-open those.
38081
+ //
38082
+ // Wildcards are matched by hand rather than compiled to a RegExp, so a config
38083
+ // value can never be a regex-injection vector and no escaping table has to be
38084
+ // kept correct.
38085
+ function buildMatcher(list) {
38086
+ var literal = {};
38087
+ var globs = [];
38088
+ var regexps = [];
38089
+ (list || []).forEach(function (entry) {
38090
+ if (entry instanceof RegExp) { regexps.push(entry); return; }
38091
+ var t = String(entry).toLowerCase();
38092
+ if (t.indexOf("*") === -1) { literal[t] = true; return; }
38093
+ globs.push(t.split("*"));
38094
+ });
38095
+ function globMatches(parts, name) {
38096
+ // parts came from splitting on "*": they must appear in order, the first
38097
+ // anchored at the start and the last at the end.
38098
+ var first = parts[0];
38099
+ if (name.lastIndexOf(first, 0) !== 0) return false;
38100
+ var last = parts[parts.length - 1];
38101
+ if (last) {
38102
+ if (name.length < last.length) return false;
38103
+ if (name.indexOf(last, name.length - last.length) === -1) return false;
38104
+ }
38105
+ var pos = first.length;
38106
+ for (var i = 1; i < parts.length; i++) {
38107
+ if (!parts[i]) continue;
38108
+ var at = name.indexOf(parts[i], pos);
38109
+ if (at === -1) return false;
38110
+ pos = at + parts[i].length;
38111
+ }
38112
+ return true;
38113
+ }
38114
+ return function (name) {
38115
+ if (literal[name]) return true;
38116
+ for (var i = 0; i < globs.length; i++) if (globMatches(globs[i], name)) return true;
38117
+ for (var j = 0; j < regexps.length; j++) if (regexps[j].test(name)) return true;
38118
+ return false;
38119
+ };
38120
+ }
38121
+
38068
38122
  // --------------------------------------------------------------- engine
38069
38123
  function sanitizeNode(root, report) {
38070
- var allowExtra = {};
38071
- (config.sanitizerAllowTags || []).forEach(function (t) { allowExtra[String(t).toLowerCase()] = true; });
38072
- var attrExtra = {};
38073
- (config.sanitizerAllowAttributes || []).forEach(function (a) { attrExtra[String(a).toLowerCase()] = true; });
38124
+ var allowExtra = buildMatcher(config.sanitizerAllowTags);
38125
+ var attrExtra = buildMatcher(config.sanitizerAllowAttributes);
38074
38126
  var allowStyleTag = config.sanitizerAllowStyleTags === true;
38075
38127
 
38076
38128
  // Collect first: removing nodes while walking a live list skips siblings.
@@ -38079,7 +38131,7 @@ function RTE_Plugin_Sanitizer() {
38079
38131
  for (var i = 0; i < n.childNodes.length; i++) {
38080
38132
  var c = n.childNodes[i];
38081
38133
  if (c.nodeType === 1) { all.push(c); collect(c); }
38082
- else if (c.nodeType === 8) all.push(c); // comments can hide markup
38134
+ else if (c.nodeType === 8) all.push(c); // checked, see commentEscapes()
38083
38135
  }
38084
38136
  })(root);
38085
38137
 
@@ -38087,7 +38139,33 @@ function RTE_Plugin_Sanitizer() {
38087
38139
  var el = all[i];
38088
38140
  if (!el.parentNode) continue; // already removed with an ancestor
38089
38141
 
38090
- if (el.nodeType === 8) { el.parentNode.removeChild(el); continue; }
38142
+ if (el.nodeType === 8) {
38143
+ // Comments used to be removed wholesale, on the stated grounds that
38144
+ // "comments can hide markup". That reason does not survive testing:
38145
+ // serialise <!--<script>alert(1)</script>--> and re-parse it and the
38146
+ // script is inert -- a well-formed comment is just text.
38147
+ //
38148
+ // What IS unsafe is a narrow, enumerable set of comment DATA that
38149
+ // cannot survive serialisation, because the serialiser writes the
38150
+ // data verbatim between <!-- and -->. Re-parsing then ends the
38151
+ // comment early and the remainder becomes live markup:
38152
+ //
38153
+ // "--> <img src=x onerror=...>" -> <!----> <img ...>--> ESCAPES
38154
+ // "--!> <img ...>" -> <!----!> <img ...>--> ESCAPES
38155
+ // "> <img ...>" -> <!--> <img ...>--> ESCAPES
38156
+ // "-> <img ...>" -> <!---> <img ...>--> ESCAPES
38157
+ //
38158
+ // That is the WHATWG serialisation constraint, not a heuristic, so
38159
+ // it can be enumerated rather than guessed at. Everything else is
38160
+ // kept, which is what lets a CMS round-trip its own markers and lets
38161
+ // an email template keep its Outlook conditional comments -- the
38162
+ // mechanism emailtoolkit.js needs and could not previously rely on.
38163
+ if (commentEscapes(el.data)) {
38164
+ note(report.removedTags, "#comment");
38165
+ el.parentNode.removeChild(el);
38166
+ }
38167
+ continue;
38168
+ }
38091
38169
 
38092
38170
  var tag = (el.localName || el.nodeName || "").toLowerCase();
38093
38171
 
@@ -38115,7 +38193,7 @@ function RTE_Plugin_Sanitizer() {
38115
38193
  note(report.removedTags, tag);
38116
38194
  el.parentNode.removeChild(el);
38117
38195
  continue;
38118
- } else if (!ALLOWED_TAGS[tag] && !allowExtra[tag]) {
38196
+ } else if (!ALLOWED_TAGS[tag] && !allowExtra(tag)) {
38119
38197
  // Unknown element: unwrap rather than delete, so the words a
38120
38198
  // user typed inside a stray tag are not silently lost.
38121
38199
  note(report.removedTags, tag);
@@ -38144,7 +38222,7 @@ function RTE_Plugin_Sanitizer() {
38144
38222
  if (!isSafeUrl(value, tag, name)) { note(report.removedAttributes, tag + "[" + name + "]"); el.removeAttribute(attrs[k].name); }
38145
38223
  continue;
38146
38224
  }
38147
- if (!ALLOWED_ATTRS[name] && !attrExtra[name]) {
38225
+ if (!ALLOWED_ATTRS[name] && !attrExtra(name)) {
38148
38226
  note(report.removedAttributes, name);
38149
38227
  el.removeAttribute(attrs[k].name);
38150
38228
  }
@@ -38257,6 +38335,44 @@ function RTE_Plugin_Sanitizer() {
38257
38335
  // Measured: the payload ran locally AND replicated to the peer with its
38258
38336
  // onerror attribute intact.
38259
38337
  //
38338
+ // THREAT MODEL -- read this before tightening or loosening the rule below.
38339
+ //
38340
+ // An escaping comment CANNOT BE AUTHORED AS AN HTML STRING. Feed
38341
+ // `<!--x--> <img src=q onerror=...>-->` to setHTMLCode and the parser yields
38342
+ // comment("x") + text + a normal <img> element, which the element/attribute
38343
+ // filters already handle. The ONLY way to obtain a comment node whose data
38344
+ // escapes is programmatic DOM construction -- document.createComment(), or a
38345
+ // sync layer building nodes directly.
38346
+ //
38347
+ // That is why the guard lives on the DOM-level paths (the sanitise pass and the
38348
+ // live-DOM MutationObserver) and why the string paths need nothing extra. Both
38349
+ // string directions were measured: sender getHTMLCode, receiver setHTMLCode, and
38350
+ // the receiver's live DOM all yield zero executable attributes, because
38351
+ // wrapSerializers() and wrapSetters() filter on the way out and on the way in.
38352
+ //
38353
+ // The previous rule here deleted EVERY comment, justified only by the remark
38354
+ // "comments can hide markup". That is false -- serialise
38355
+ // <!--<script>alert(1)</script>--> and re-parse it and the script is inert -- and
38356
+ // it cost the product CMS round-tripping and Outlook conditional comments (the
38357
+ // mechanism emailtoolkit.js needs) for as long as nobody re-checked it. An
38358
+ // unexplained restriction outlives its reason, so this one is written down.
38359
+ //
38360
+ // A comment's data is written verbatim between <!-- and -->, so data that
38361
+ // contains a terminator (or opens with one) breaks out of the comment when the
38362
+ // serialised output is re-parsed, turning the remainder into live markup.
38363
+ // Module scope on purpose: both the sanitiser pass and the live-DOM
38364
+ // MutationObserver below need it, and they must agree exactly on what is unsafe.
38365
+ // Deliberately conservative -- it rejects rather than rewrites, because silently
38366
+ // editing someone's comment text is its own kind of surprise.
38367
+ function commentEscapes(data) {
38368
+ var d = String(data == null ? "" : data);
38369
+ if (d.indexOf("-->") !== -1) return true;
38370
+ if (d.indexOf("--!>") !== -1) return true;
38371
+ if (d.charAt(0) === ">") return true;
38372
+ if (d.charAt(0) === "-" && d.charAt(1) === ">") return true;
38373
+ return false;
38374
+ }
38375
+
38260
38376
  // A MutationObserver is the only place to catch every arrival path — sync,
38261
38377
  // drop, a third-party plugin, host-page code. It is honest to say this
38262
38378
  // narrows the window rather than closing it completely: the observer runs
@@ -38307,6 +38423,16 @@ function RTE_Plugin_Sanitizer() {
38307
38423
  }
38308
38424
  for (var a = 0; a < rec.addedNodes.length; a++) {
38309
38425
  var node = rec.addedNodes[a];
38426
+ // Comments are preserved now, so an injected comment whose data
38427
+ // escapes on re-parse has to wake the sanitiser. This loop used
38428
+ // to skip every non-element node, which was safe only while
38429
+ // comments were being deleted unconditionally. The CRDT sync
38430
+ // path is exactly how a node arrives here without passing the
38431
+ // input filter -- that is the path that produced the stored XSS.
38432
+ if (node.nodeType === 8) {
38433
+ if (commentEscapes(node.data)) { suspect = true; break; }
38434
+ continue;
38435
+ }
38310
38436
  if (node.nodeType !== 1) continue; // typing inserts text: free
38311
38437
  if (looksDangerous(node)) { suspect = true; break; }
38312
38438
  }
@@ -38495,7 +38621,17 @@ function RTE_Plugin_ShadowDom() {
38495
38621
  var id = String(node.id || "");
38496
38622
  if (/^rte[-_]|^__rte/.test(id)) return true;
38497
38623
  var href = node.href || "";
38498
- if (!href) return false;
38624
+ if (!href) {
38625
+ // An inline <style> with no id and no href is what bundlers (Vite,
38626
+ // webpack style-loader) inject for an imported theme. Recognise it by
38627
+ // its content, or a shadow-root editor is unstyled (39,726 px toolbar,
38628
+ // Times New Roman) exactly as before this plugin existed. 2026-09-02.
38629
+ if (node.tagName === "STYLE") {
38630
+ var css = node.textContent || "";
38631
+ return /[.]richtexteditor|rte-toolbar|rte-editor|rte-dialog|[.]rte-a11y/.test(css);
38632
+ }
38633
+ return false;
38634
+ }
38499
38635
  var base = assetBase();
38500
38636
  if (base && href.indexOf(base) === 0) return true;
38501
38637
  // Fall back to the file names this product actually ships, so an
@@ -38643,7 +38779,7 @@ function RTE_Plugin_SlashCommand() {
38643
38779
  editor.slashCommands = {
38644
38780
  register: registerCommand,
38645
38781
  remove: removeCommand,
38646
- list: function () { return commands.slice(); },
38782
+ list: function () { refreshDefaults(); return commands.slice(); },
38647
38783
  open: function () { openPopup(true); },
38648
38784
  close: closePopup,
38649
38785
  isOpen: function () { return !!popupEl; }
@@ -38655,6 +38791,15 @@ function RTE_Plugin_SlashCommand() {
38655
38791
  registerCommand(config.slashCommands[i]);
38656
38792
  }
38657
38793
  }
38794
+ // Plugins initialise synchronously in bundle (alphabetical) order, so at
38795
+ // this point every plugin sorting AFTER "slashcommand" (tabletools,
38796
+ // textdirection, watermark, tableofcontents, typewriter, ...) has not
38797
+ // defined its editor.* API yet and the typeof gates in
38798
+ // buildDefaultCommands() were false: nine entries (sort asc/desc, rtl/
38799
+ // ltr/autodir, watermark, toc, typewriter, focusmode) were missing for
38800
+ // every customer until 2026-09-02. Rebuild once the constructor has
38801
+ // finished (setTimeout 0 runs after all plugin inits) and again on open.
38802
+ setTimeout(refreshDefaults, 0);
38658
38803
 
38659
38804
  injectStyles();
38660
38805
 
@@ -38677,7 +38822,26 @@ function RTE_Plugin_SlashCommand() {
38677
38822
  else commands.push(normalizeCommand(def));
38678
38823
  }
38679
38824
 
38825
+ var removedIds = {};
38826
+ // Rebuild the gated default entries (their typeof checks may only be true
38827
+ // now) while keeping every custom registration and honouring remove().
38828
+ function refreshDefaults() {
38829
+ if (!editor) return;
38830
+ var custom = [];
38831
+ for (var i = 0; i < commands.length; i++) if (!commands[i]._default) custom.push(commands[i]);
38832
+ var defaults = buildDefaultCommands();
38833
+ var next = [];
38834
+ for (var d = 0; d < defaults.length; d++) {
38835
+ var id = defaults[d].id;
38836
+ if (removedIds[id]) continue;
38837
+ var overridden = false;
38838
+ for (var c = 0; c < custom.length; c++) if (custom[c].id === id) { overridden = true; break; }
38839
+ if (!overridden) next.push(defaults[d]);
38840
+ }
38841
+ commands = next.concat(custom);
38842
+ }
38680
38843
  function removeCommand(id) {
38844
+ removedIds[id] = true;
38681
38845
  var idx = findCommandIndex(id);
38682
38846
  if (idx >= 0) commands.splice(idx, 1);
38683
38847
  }
@@ -38687,12 +38851,25 @@ function RTE_Plugin_SlashCommand() {
38687
38851
  return -1;
38688
38852
  }
38689
38853
 
38854
+ // Localization with an English fallback for EVERY entry (defaults, plugin
38855
+ // register() calls, AI actions, config.slashCommands): the lang packs set
38856
+ // config.text_<key>; __GetLangText returns the raw KEY when one is missing,
38857
+ // so resolve only when a translation exists. Keys: text_slash_<id> (title),
38858
+ // text_slash_<id>_desc (description), text_slash_section_<section>.
38859
+ function L(key, fallback) {
38860
+ try {
38861
+ var k = String(key).toLowerCase().replace(/[^a-z0-9_]/g, "_");
38862
+ if (config && config["text_" + k] && editor && typeof editor.getLangText === "function") return editor.getLangText(k);
38863
+ } catch (e) { }
38864
+ return fallback;
38865
+ }
38690
38866
  function normalizeCommand(def) {
38867
+ var section = def.section || "Blocks";
38691
38868
  return {
38692
38869
  id: def.id,
38693
- section: def.section || "Blocks",
38694
- title: def.title || def.id,
38695
- description: def.description || "",
38870
+ section: L("slash_section_" + section, section),
38871
+ title: L("slash_" + def.id, def.title || def.id),
38872
+ description: L("slash_" + def.id + "_desc", def.description || ""),
38696
38873
  keywords: (def.keywords || []).slice(),
38697
38874
  icon: def.icon || "",
38698
38875
  iconSvg: def.iconSvg || "",
@@ -38743,11 +38920,13 @@ function RTE_Plugin_SlashCommand() {
38743
38920
  var list = [];
38744
38921
 
38745
38922
  function push(section, id, title, description, keywords, iconSvg, run) {
38746
- list.push(normalizeCommand({
38923
+ var cmd = normalizeCommand({ // normalizeCommand localizes title/description/section
38747
38924
  id: id, section: section, title: title,
38748
38925
  description: description, keywords: keywords,
38749
38926
  iconSvg: iconSvg, run: run
38750
- }));
38927
+ });
38928
+ cmd._default = true;
38929
+ list.push(cmd);
38751
38930
  }
38752
38931
 
38753
38932
  push("Blocks", "heading1", "Heading 1", "Large section heading", ["h1", "title"], iconHeading("1"),
@@ -38963,7 +39142,8 @@ function RTE_Plugin_SlashCommand() {
38963
39142
  // Gated on the API rather than registered by the plugin itself: plugins
38964
39143
  // initialise in bundle order, so anything sorting before "slashcommand"
38965
39144
  // would call slashCommands.register() before it exists and vanish from
38966
- // this menu without any error. This check runs when the menu opens.
39145
+ // this menu without any error. These checks are re-run by refreshDefaults()
39146
+ // after all plugins have initialised and again each time the menu opens.
38967
39147
  if (typeof editor.exportToPdf === "function") {
38968
39148
  push("Tools", "exportpdf", "Export to PDF", "Real text, not a screenshot — selectable, searchable and screen-reader friendly", ["pdf", "export", "download", "save", "print", "text", "accessible", "searchable"], iconPdfText(),
38969
39149
  function () { editor.exportToPdf(); });
@@ -38972,6 +39152,32 @@ function RTE_Plugin_SlashCommand() {
38972
39152
  push("Tools", "exportdocx", "Export to Word (.docx)", "A real OOXML document, built in the browser with no upload", ["word", "docx", "ooxml", "export", "download", "save", "office"], iconWordFile(),
38973
39153
  function () { editor.exportToDocx(); });
38974
39154
  }
39155
+ // documentimport.js and readabilitystats.js sort before this file, so they
39156
+ // cannot register at init (editor.slashCommands does not exist yet); their
39157
+ // guarded calls silently did nothing and these two entries were missing for
39158
+ // every customer until 2026-09-02. Declared here, gated on the plugin API.
39159
+ if (typeof editor.openImportDialog === "function") {
39160
+ push("Tools", "import-document", "Import document", "Open a Markdown, HTML, text, or Word file into the editor", ["import", "open", "file", "word", "docx", "markdown", "upload"], iconWordFile(),
39161
+ function () { editor.openImportDialog({}); });
39162
+ }
39163
+ if (typeof editor.getReadabilityStats === "function") {
39164
+ push("Tools", "readability-stats", "Readability statistics", "Flesch reading ease, grade level, and reading time", ["readability", "flesch", "grade", "reading", "stats", "score"], iconSpell(),
39165
+ function () { editor.execCommand("readability"); });
39166
+ }
39167
+ // Marketed features that had NO toolbar button and NO slash entry (reachable
39168
+ // only via the API) until 2026-09-02. All gated on the owning plugin.
39169
+ if (typeof editor.insertCrossReference === "function") {
39170
+ push("Insert", "crossreference", "Cross-reference", "Insert a live reference to a heading, figure, table, or bookmark", ["cross", "reference", "xref", "see", "figure", "heading"], iconXref(),
39171
+ function () { editor.execCommand("insertcrossreference"); });
39172
+ }
39173
+ if (editor.restrictedEditing) {
39174
+ push("Tools", "restrictedediting", "Restricted editing", "Lock the document and mark only the regions others may edit", ["restrict", "lock", "protect", "editable", "region", "permission"], iconPen(),
39175
+ function () { editor.execCommand("restrictedediting"); });
39176
+ }
39177
+ if (editor.dictation) {
39178
+ push("Tools", "dictation", "Dictation", "Speak and have your words typed into the document", ["dictate", "speech", "voice", "microphone", "talk"], iconReadAloud(),
39179
+ function () { editor.execCommand("dictation"); });
39180
+ }
38975
39181
  if (typeof editor.copyAsMarkdown === "function") {
38976
39182
  push("Tools", "copymarkdown", "Copy as Markdown", "Copy the whole document to the clipboard as Markdown", ["markdown", "md", "copy", "clipboard", "export"], iconMarkdown(),
38977
39183
  function () { editor.copyAsMarkdown(); });
@@ -39103,6 +39309,7 @@ function RTE_Plugin_SlashCommand() {
39103
39309
  }
39104
39310
 
39105
39311
  function openPopup(manual) {
39312
+ refreshDefaults();
39106
39313
  closePopup();
39107
39314
  var sel = editor.getSelection();
39108
39315
  if (!sel || sel.rangeCount === 0) return;
@@ -43018,9 +43225,42 @@ function RTE_Plugin_YjsCollab() {
43018
43225
  // and the review ledger bridge.
43019
43226
  if (session.textSyncMode === "crdt") {
43020
43227
  try {
43228
+ // 2026-09-02: the engine bundles its OWN Yjs and builds nodes with it.
43229
+ // A doc built by the host's Yjs (npm/esm "yjs" + y-websocket - the
43230
+ // documented path) is a FOREIGN instance: inserting engine-built nodes
43231
+ // into it throws "Unexpected content type in insert operation" and text
43232
+ // never syncs, while presence still works and the status still said
43233
+ // "crdt". Yjs updates are instance-independent bytes, so bind the
43234
+ // engine to an inner doc built with ITS Yjs and bridge updates both
43235
+ // ways: host->inner with the engine's applyUpdate, inner->host with the
43236
+ // host's module (options.Y or window.Y). Without the host module the
43237
+ // outbound leg cannot be applied safely, so fail LOUDLY instead.
43238
+ var engineY = window.RichTextEditorCrdt.Y || null;
43239
+ var hostY = options.Y || (typeof window !== "undefined" ? window.Y : null) || null;
43240
+ var bindDoc = doc;
43241
+ var foreign = !!(engineY && engineY.Doc && !(doc instanceof engineY.Doc));
43242
+ if (foreign) {
43243
+ if (!hostY || typeof hostY.applyUpdate !== "function" || typeof hostY.encodeStateAsUpdate !== "function") {
43244
+ throw new Error("yjscollab: the Y.Doc was created by a different Yjs instance than crdt-engine.js bundles. Pass your Yjs module to attach({ Y }) (or build the doc with window.RichTextEditorCrdt.Y) so text can be bridged.");
43245
+ }
43246
+ var BRIDGE = { bridge: "rte-yjs-bridge" };
43247
+ var inner = new engineY.Doc();
43248
+ engineY.applyUpdate(inner, hostY.encodeStateAsUpdate(doc), BRIDGE);
43249
+ var LOCAL = window.RichTextEditorCrdt.LOCAL_ORIGIN;
43250
+ // A host-doc write the plugin tagged as LOCAL (ledger entries) must not
43251
+ // make the engine re-render the editable (see flush race in the engine).
43252
+ var hostToInner = function (update, origin) { if (origin === BRIDGE) return; try { engineY.applyUpdate(inner, update, (LOCAL && origin === LOCAL) ? LOCAL : BRIDGE); } catch (e) { if (window.console) console.error("yjscollab bridge host->inner:", e); } };
43253
+ var innerToHost = function (update, origin) { if (origin === BRIDGE) return; try { hostY.applyUpdate(doc, update, BRIDGE); } catch (e) { if (window.console) console.error("yjscollab bridge inner->host:", e); } };
43254
+ doc.on("update", hostToInner);
43255
+ inner.on("update", innerToHost);
43256
+ session.cleanup.push(function () { try { doc.off("update", hostToInner); } catch (e) { } try { inner.off("update", innerToHost); } catch (e) { } try { inner.destroy(); } catch (e) { } });
43257
+ session.yjsBridge = true;
43258
+ bindDoc = inner;
43259
+ if (window.console && console.info) console.info("yjscollab: bridging a foreign Yjs doc into the CRDT engine (updates relayed both ways).");
43260
+ }
43021
43261
  session.crdtBinding = window.RichTextEditorCrdt.attachCrdtBinding({
43022
43262
  editable: editor.getEditable(),
43023
- ydoc: doc,
43263
+ ydoc: bindDoc,
43024
43264
  provider: provider,
43025
43265
  awareness: provider.awareness,
43026
43266
  fragmentName: options.fragmentName || "default",
@@ -43033,6 +43273,24 @@ function RTE_Plugin_YjsCollab() {
43033
43273
  try { session.crdtBinding && session.crdtBinding.dispose(); }
43034
43274
  catch (ignore) { }
43035
43275
  });
43276
+ // Per-author undo. The engine ships a Y.UndoManager scoped to LOCAL_ORIGIN
43277
+ // (undo.ts) but nothing attached it, so Ctrl+Z ran the core DOM-snapshot
43278
+ // undo and reverted OTHER people's edits on every peer (2026-09-02). Route
43279
+ // undo/redo to the engine while crdt mode is active.
43280
+ try {
43281
+ if (typeof window.RichTextEditorCrdt.attachUndoManager === "function" && session.crdtBinding && session.crdtBinding.fragment) {
43282
+ session.undoManager = window.RichTextEditorCrdt.attachUndoManager({ fragment: session.crdtBinding.fragment });
43283
+ var undoHook = function (state) { if (!session || !session.undoManager) return; state.returnValue = true; state.stopBubble = true; try { session.undoManager.undo(); } catch (e) { } };
43284
+ var redoHook = function (state) { if (!session || !session.undoManager) return; state.returnValue = true; state.stopBubble = true; try { session.undoManager.redo(); } catch (e) { } };
43285
+ editor.attachEvent("exec_command_undo", undoHook);
43286
+ editor.attachEvent("exec_command_redo", redoHook);
43287
+ session.cleanup.push(function () {
43288
+ try { if (typeof editor.detachEvent === "function") { editor.detachEvent("exec_command_undo", undoHook); editor.detachEvent("exec_command_redo", redoHook); } } catch (e) { }
43289
+ try { session.undoManager && session.undoManager.destroy(); } catch (e) { }
43290
+ session.undoManager = null;
43291
+ });
43292
+ }
43293
+ } catch (undoErr) { if (window.console) console.warn("yjscollab: undo manager not attached:", undoErr); }
43036
43294
  } catch (err) {
43037
43295
  session.fallbackReason = "crdt-attach-failed";
43038
43296
  session.crdtError = err;
@@ -43065,7 +43323,7 @@ function RTE_Plugin_YjsCollab() {
43065
43323
  }
43066
43324
  return {
43067
43325
  attached: true,
43068
- textSyncMode: session.textSyncMode,
43326
+ textSyncMode: session.textSyncMode, yjsBridge: !!session.yjsBridge,
43069
43327
  requestedTextSync: session.requestedTextSync,
43070
43328
  fallbackReason: session.fallbackReason || null,
43071
43329
  peerCount: getRemotePeers().length
@@ -43310,6 +43568,52 @@ function RTE_Plugin_YjsCollab() {
43310
43568
  lastKnownHtml = localInitial;
43311
43569
  }
43312
43570
 
43571
+ // --- IME composition gate (legacy snapshot mode) ---------------------
43572
+ // setEditorHtml() replaces the whole document. Doing that while an IME
43573
+ // is composing swaps the live text node out from under it, so the
43574
+ // commit lands at a stale offset and the raw buffer survives: composing
43575
+ // "shi" and committing a character while a collaborator typed produced
43576
+ // "Xbasesh<char>i" on BOTH peers (2026-09-03 — the same corruption the
43577
+ // crdt binding had, on the deprecated-but-supported path v2.0 customers
43578
+ // still run). Hold the repaint until the composition commits.
43579
+ var composing = false;
43580
+ var pendingRemoteHtml = null;
43581
+ var compositionDoc = (editor.getDocument && editor.getDocument()) || null;
43582
+ if (compositionDoc) {
43583
+ var onCompStart = function () { composing = true; };
43584
+ var onCompEnd = function () {
43585
+ if (!composing) return;
43586
+ composing = false;
43587
+ setTimeout(function () {
43588
+ if (pendingRemoteHtml !== null) {
43589
+ // Deliberately NOT repainting: that would discard the
43590
+ // character the IME just committed. Adopt the remote text as
43591
+ // the diff baseline instead, so the local push that follows
43592
+ // is computed against what the peers actually hold. Legacy
43593
+ // mode is documented last-write-wins on same-paragraph
43594
+ // conflicts, so the local commit winning is correct here —
43595
+ // corrupted text was not.
43596
+ lastKnownHtml = pendingRemoteHtml;
43597
+ pendingRemoteHtml = null;
43598
+ }
43599
+ // Push explicitly. The commit's DOM mutation is delivered to
43600
+ // the MutationObserver BEFORE compositionend fires, so it was
43601
+ // dropped by the `composing` guard — without this the
43602
+ // committed character never reaches the peers at all
43603
+ // (measured: A "base<char>", B "base").
43604
+ onEditorMutation();
43605
+ }, 0);
43606
+ };
43607
+ // Bound on the DOCUMENT, not the body: some skins remount the body
43608
+ // (see attachMutationObserver below) and composition events bubble.
43609
+ compositionDoc.addEventListener("compositionstart", onCompStart, true);
43610
+ compositionDoc.addEventListener("compositionend", onCompEnd, true);
43611
+ session.cleanup.push(function () {
43612
+ compositionDoc.removeEventListener("compositionstart", onCompStart, true);
43613
+ compositionDoc.removeEventListener("compositionend", onCompEnd, true);
43614
+ });
43615
+ }
43616
+
43313
43617
  // Y.Text → editor.
43314
43618
  var onTextChange = function (event, transaction) {
43315
43619
  if (pushing) return;
@@ -43317,6 +43621,7 @@ function RTE_Plugin_YjsCollab() {
43317
43621
  if (transaction && transaction.local) return;
43318
43622
  var fresh = session.textMap.toString();
43319
43623
  if (fresh === lastKnownHtml) return;
43624
+ if (composing) { pendingRemoteHtml = fresh; return; }
43320
43625
  setEditorHtml(fresh);
43321
43626
  lastKnownHtml = fresh;
43322
43627
  // 2026-05-28 setEditorHtml writes into editable.innerHTML; if that
@@ -43331,6 +43636,9 @@ function RTE_Plugin_YjsCollab() {
43331
43636
  // Editor DOM → Y.Text. Debounced so bursts collapse to one CRDT diff.
43332
43637
  var onEditorMutation = function () {
43333
43638
  if (applying) return;
43639
+ // Intermediate IME buffers are not real edits; the commit fires its
43640
+ // own mutation. Pushing them also races the composition gate above.
43641
+ if (composing) return;
43334
43642
  if (pendingPushTimer) return;
43335
43643
  // 2026-05-28 Re-attach observer if the body was remounted since last
43336
43644
  // mutation. Cheap no-op when body is unchanged.
@@ -43418,17 +43726,17 @@ function RTE_Plugin_YjsCollab() {
43418
43726
 
43419
43727
  ledger.add = function (entry) {
43420
43728
  var result = originalAdd.call(ledger, entry);
43421
- if (result && !echoGuard) session.ledgerMap.set(result.id, cloneEntry(result));
43729
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.set(result.id, cloneEntry(result)); });
43422
43730
  return result;
43423
43731
  };
43424
43732
  ledger.update = function (id, patch) {
43425
43733
  var result = originalUpdate.call(ledger, id, patch);
43426
- if (result && !echoGuard) session.ledgerMap.set(result.id, cloneEntry(result));
43734
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.set(result.id, cloneEntry(result)); });
43427
43735
  return result;
43428
43736
  };
43429
43737
  ledger.remove = function (id) {
43430
43738
  var result = originalRemove.call(ledger, id);
43431
- if (result && !echoGuard) session.ledgerMap.delete(id);
43739
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.delete(id); });
43432
43740
  return result;
43433
43741
  };
43434
43742
 
@@ -43462,6 +43770,16 @@ function RTE_Plugin_YjsCollab() {
43462
43770
  });
43463
43771
  }
43464
43772
 
43773
+ // Ledger writes are the plugin's own (non-text) Y.Doc transactions. In crdt
43774
+ // mode they are tagged with the engine's LOCAL_ORIGIN so the engine does not
43775
+ // re-render the editable for them - a re-render between a DOM mutation and
43776
+ // the observer flush discarded that mutation (2026-09-02: comment anchors
43777
+ // vanished the instant the ledger entry was written).
43778
+ function ledgerWrite(fn) {
43779
+ var origin = (session && session.textSyncMode === "crdt" && window.RichTextEditorCrdt) ? window.RichTextEditorCrdt.LOCAL_ORIGIN : null;
43780
+ if (origin && session && session.doc && typeof session.doc.transact === "function") { session.doc.transact(fn, origin); }
43781
+ else { fn(); }
43782
+ }
43465
43783
  function seedLedgerFromRemote() {
43466
43784
  if (!editor.reviewLedger || !session.ledgerMap) return;
43467
43785
  echoGuard = true;