@richscripts/richtexteditor 2.6.3 → 2.7.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 (33) hide show
  1. package/README.md +21 -1
  2. package/angular/component.ts +16 -3
  3. package/integrations/shared.js +27 -0
  4. package/package.json +2 -2
  5. package/react/index.d.ts +7 -0
  6. package/react/index.js +25 -3
  7. package/richtexteditor/CHANGELOG.md +87 -0
  8. package/richtexteditor/all_plugins.js +721 -53
  9. package/richtexteditor/all_plugins.min.js +59 -59
  10. package/richtexteditor/lang/rte-lang-de.js +96 -0
  11. package/richtexteditor/lang/rte-lang-zh-Hans.js +181 -0
  12. package/richtexteditor/lang/rte-lang-zh-Hant.js +181 -0
  13. package/richtexteditor/plugins/all_plugins.js +721 -53
  14. package/richtexteditor/plugins/all_plugins.min.js +59 -59
  15. package/richtexteditor/plugins/comments.js +11 -1
  16. package/richtexteditor/plugins/crdt-engine.js +244 -17
  17. package/richtexteditor/plugins/crdt-engine.min.js +5 -5
  18. package/richtexteditor/plugins/documentimport.js +31 -13
  19. package/richtexteditor/plugins/docxexport.js +31 -0
  20. package/richtexteditor/plugins/inlinecode.js +270 -0
  21. package/richtexteditor/plugins/insertcode.js +2 -2
  22. package/richtexteditor/plugins/pdfexport.js +25 -2
  23. package/richtexteditor/plugins/readabilitystats.js +6 -11
  24. package/richtexteditor/plugins/sanitizer.js +136 -10
  25. package/richtexteditor/plugins/shadowdom.js +11 -1
  26. package/richtexteditor/plugins/slashcommand.js +78 -7
  27. package/richtexteditor/plugins/wordexport.js +3 -1
  28. package/richtexteditor/plugins/yjscollab.js +116 -5
  29. package/richtexteditor/rte-webcomponent.js +15 -0
  30. package/richtexteditor/rte.js +15 -3
  31. package/richtexteditor/rte_theme_default.css +13 -4
  32. package/richtexteditor/rte_theme_default.min.css +1 -1
  33. package/vue/index.js +12 -2
@@ -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) {
@@ -24515,6 +24520,19 @@ function RTE_Plugin_DocumentImport() {
24515
24520
  // matches the old behaviour rather than inventing <ol>s.
24516
24521
  info.ordered = !!fmt && fmt !== "bullet" && fmt !== "none";
24517
24522
  }
24523
+ // Word paragraph indentation. Nothing read this before 2026-09-04,
24524
+ // so every indented paragraph in every imported .docx arrived
24525
+ // flush left and the indentation was lost silently.
24526
+ // w:left is in twips; 15 twips = 1px.
24527
+ var ind = firstChildEl(ppr, "ind");
24528
+ if (ind) {
24529
+ var lft = ind.getAttributeNS(W, "left") || ind.getAttribute("w:left") || "";
24530
+ var tw = parseInt(lft, 10);
24531
+ // 720 twips alongside the Quote style is the blockquote's own
24532
+ // indent, already carried by the blockquote element itself.
24533
+ if (tw > 0 && !(info.quote && tw === 720))
24534
+ info.indentPx = Math.round(tw / 15);
24535
+ }
24518
24536
  var jc = firstChildEl(ppr, "jc");
24519
24537
  if (jc) {
24520
24538
  var a = jc.getAttributeNS(W, "val") || jc.getAttribute("w:val") || "";
@@ -24581,10 +24599,20 @@ function RTE_Plugin_DocumentImport() {
24581
24599
  continue;
24582
24600
  }
24583
24601
  closeListsTo(0);
24584
- if (st.heading) out.push("<h" + st.heading + ">" + (inner || "") + "</h" + st.heading + ">");
24602
+ if (st.heading) {
24603
+ // Headings carry w:ind too; without this an indented
24604
+ // heading imported flush left.
24605
+ var hs = st.indentPx ? ' style="margin-left:' + st.indentPx + 'px"' : "";
24606
+ out.push("<h" + st.heading + hs + ">" + (inner || "") + "</h" + st.heading + ">");
24607
+ }
24585
24608
  else if (st.quote) out.push("<blockquote>" + (inner || "<br>") + "</blockquote>");
24586
24609
  else {
24587
- var style = st.align ? ' style="text-align:' + st.align + '"' : "";
24610
+ var decl = [];
24611
+ if (st.align) decl.push("text-align:" + st.align);
24612
+ // indentUseMargin defaults to margin, and margin is what
24613
+ // w:ind means, so import writes margin-left to match.
24614
+ if (st.indentPx) decl.push("margin-left:" + st.indentPx + "px");
24615
+ var style = decl.length ? ' style="' + decl.join(";") + '"' : "";
24588
24616
  // A paragraph that contained ONLY a text box has no text of
24589
24617
  // its own; emitting an empty <p> before the box adds a blank
24590
24618
  // line that was never in the document.
@@ -25607,6 +25635,31 @@ function RTE_Plugin_DocxExport() {
25607
25635
  var revSeq = { n: 1 };
25608
25636
  var base = { bold: false, italic: false, underline: false, strike: false, sizeHalfPoints: 0, color: null, highlight: null, vertAlign: null, mono: false };
25609
25637
 
25638
+
25639
+ // A block indented in the editor carries margin-left (or padding-left,
25640
+ // per indentUseMargin). Word expresses the same thing as w:ind, in
25641
+ // TWIPS. Before 2026-09-04 indentation reached Word only when it was
25642
+ // spelled <blockquote>, which dragged the Quote style along with it -
25643
+ // so indented body text arrived italic and accent-coloured.
25644
+ // 1px = 0.75pt and 1pt = 20 twips, hence px * 15.
25645
+ function indentTwips(node) {
25646
+ if (!node || !node.style) return 0;
25647
+ var raw = node.style.marginLeft || node.style.paddingLeft || "";
25648
+ var m = /^\s*(-?[\d.]+)\s*([a-z%]*)\s*$/i.exec(raw);
25649
+ if (!m) return 0;
25650
+ var n = parseFloat(m[1]);
25651
+ if (!n || n <= 0) return 0;
25652
+ var unit = (m[2] || "px").toLowerCase();
25653
+ var px = unit === "px" ? n
25654
+ : unit === "pt" ? n * (4 / 3)
25655
+ : unit === "in" ? n * 96
25656
+ : unit === "cm" ? n * 37.795
25657
+ : unit === "mm" ? n * 3.7795
25658
+ : (unit === "em" || unit === "rem") ? n * 16
25659
+ : 0;
25660
+ return Math.round(px * 15);
25661
+ }
25662
+
25610
25663
  function para(node, style, opts) {
25611
25664
  opts = opts || {};
25612
25665
  var pr = "";
@@ -25615,6 +25668,12 @@ function RTE_Plugin_DocxExport() {
25615
25668
  if (opts.pre) pr += '<w:pStyle w:val="Code"/>';
25616
25669
  if (opts.numId) pr += "<w:numPr><w:ilvl w:val=\"" + (opts.level || 0) + "\"/><w:numId w:val=\"" + opts.numId + "\"/></w:numPr>";
25617
25670
  if (opts.pageBreakBefore) pr += "<w:pageBreakBefore/>";
25671
+ // Independent of the Quote style: a real blockquote already got
25672
+ // its w:ind above, so only emit this for everything else.
25673
+ if (!opts.quote) {
25674
+ var indTw = indentTwips(node);
25675
+ if (indTw > 0) pr += '<w:ind w:left="' + indTw + '"/>';
25676
+ }
25618
25677
  var align = node && node.style ? node.style.textAlign : "";
25619
25678
  if (align === "center" || align === "right") pr += '<w:jc w:val="' + align + '"/>';
25620
25679
  else if (align === "justify") pr += '<w:jc w:val="both"/>';
@@ -29256,6 +29315,277 @@ function RTE_Plugin_ImportStyles() {
29256
29315
  }
29257
29316
  }
29258
29317
 
29318
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
29319
+
29320
+ // 2026-09-04 Inline code — CKEditor 5's "Code" feature (`<code>`), the one
29321
+ // basic-style it ships that this editor had no command for. Markdown `x` was
29322
+ // the only way to produce inline code here, which meant there was no way to
29323
+ // REMOVE it and no toolbar affordance at all.
29324
+ //
29325
+ // The command is "inlinecode", NOT "code". "code" is already taken by the
29326
+ // HTML-source-view toggle in the core (__Is_Cmd_Active case "code" ->
29327
+ // ___Is_CodeMode()), so registering "code" here would have collided with source
29328
+ // view. keyboarda11y.js has carried "inlinecode" in its TOGGLE_CMD list all
29329
+ // along, with a comment that it matched nothing in this build; a core
29330
+ // __Is_Cmd_Active case added alongside this plugin is what makes it match, so
29331
+ // the button reports aria-pressed and not just a highlight.
29332
+ //
29333
+ // Design notes:
29334
+ // - Toggling is done by SPLITTING the <code> element around the selection
29335
+ // rather than by lifting text nodes out of it. Splitting preserves nested
29336
+ // inline markup: un-coding the middle of <code>a <em>b</em> c</code> keeps
29337
+ // the <em>. Lifting text nodes would have dropped it.
29338
+ // - A collapsed selection acts on the word under the caret. That is the same
29339
+ // choice changecase.js makes, and for the same reason: it makes the command
29340
+ // usable from a keyboard shortcut without selecting first. The alternative -
29341
+ // inserting an empty <code> and putting the caret inside - is the caret trap
29342
+ // documented in the footnotes work, so it is deliberately not done.
29343
+ // - Never nests inside <pre> or an existing <code>: both already mean
29344
+ // "preformatted", and nesting produces markup no serializer round-trips.
29345
+ // - Emits a bare <code> element and no rte-* class. That keeps the styling in
29346
+ // the content stylesheet where authors can override it, and keeps the plugin
29347
+ // out of check-css-contract.mjs, which only scans the four theme sheets.
29348
+ RTE_DefaultConfig.plugin_inlinecode = RTE_Plugin_InlineCode;
29349
+
29350
+ if (!RTE_DefaultConfig.text_inlinecode) RTE_DefaultConfig.text_inlinecode = "Inline code";
29351
+
29352
+ if (!RTE_DefaultConfig.svgCode_inlinecode) {
29353
+ RTE_DefaultConfig.svgCode_inlinecode = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 3 12 7 16"/><polyline points="17 8 21 12 17 16"/></svg>';
29354
+ }
29355
+
29356
+ function RTE_Plugin_InlineCode() {
29357
+ var obj = this;
29358
+ var config, editor;
29359
+
29360
+ obj.PluginName = "InlineCode";
29361
+
29362
+ obj.InitConfig = function (argconfig) { config = argconfig; };
29363
+
29364
+ obj.InitEditor = function (argeditor) {
29365
+ editor = argeditor;
29366
+
29367
+ editor.attachEvent("exec_command_inlinecode", function (state) {
29368
+ state.returnValue = true;
29369
+ obj.Toggle();
29370
+ });
29371
+
29372
+ // Public API, matching the shape the other toggle plugins expose.
29373
+ editor.toggleInlineCode = function () { return obj.Toggle(); };
29374
+ editor.isInlineCode = function () { return obj.IsActive(); };
29375
+ };
29376
+
29377
+ function getDoc() { try { return editor.getDocument(); } catch (e) { return null; } }
29378
+ function getEditable() { try { return editor.getEditable(); } catch (e) { return null; } }
29379
+
29380
+ function getRange(doc) {
29381
+ var sel = doc && doc.getSelection ? doc.getSelection() : null;
29382
+ if (!sel || !sel.rangeCount) return null;
29383
+ return sel.getRangeAt(0);
29384
+ }
29385
+
29386
+ // Nearest <code> ancestor that is still inside the editable area.
29387
+ function codeAncestor(node, editable) {
29388
+ while (node && node !== editable) {
29389
+ if (node.nodeType === 1 && node.nodeName === "CODE") return node;
29390
+ node = node.parentNode;
29391
+ }
29392
+ return null;
29393
+ }
29394
+
29395
+ function inPre(node, editable) {
29396
+ while (node && node !== editable) {
29397
+ if (node.nodeType === 1 && node.nodeName === "PRE") return true;
29398
+ node = node.parentNode;
29399
+ }
29400
+ return false;
29401
+ }
29402
+
29403
+ obj.IsActive = function () {
29404
+ var doc = getDoc(), editable = getEditable();
29405
+ if (!doc || !editable) return false;
29406
+ var range = getRange(doc);
29407
+ if (!range) return false;
29408
+ return !!codeAncestor(range.startContainer, editable);
29409
+ };
29410
+
29411
+ // ---- selection helpers -----------------------------------------------
29412
+
29413
+ // Expand a collapsed caret to the word under it. Returns false when the
29414
+ // caret is not sitting in a word, in which case the command does nothing
29415
+ // rather than creating an empty element.
29416
+ function expandToWord(range) {
29417
+ var node = range.startContainer;
29418
+ if (!node || node.nodeType !== 3) return false;
29419
+ var text = node.data || "";
29420
+ var offset = range.startOffset;
29421
+ var start = offset, end = offset;
29422
+ while (start > 0 && /\S/.test(text.charAt(start - 1))) start--;
29423
+ while (end < text.length && /\S/.test(text.charAt(end))) end++;
29424
+ if (start === end) return false;
29425
+ range.setStart(node, start);
29426
+ range.setEnd(node, end);
29427
+ return true;
29428
+ }
29429
+
29430
+ // Every text node the range actually covers. Boundary text nodes are split
29431
+ // first so a partially-selected node is not treated as fully selected.
29432
+ function selectedTextNodes(doc, range, editable) {
29433
+ if (range.startContainer.nodeType === 3 && range.startOffset > 0) {
29434
+ var s = range.startContainer;
29435
+ if (range.startOffset < s.data.length) {
29436
+ var rest = s.splitText(range.startOffset);
29437
+ var endSame = (range.endContainer === s);
29438
+ var endOff = range.endOffset;
29439
+ range.setStart(rest, 0);
29440
+ if (endSame) range.setEnd(rest, endOff - s.data.length);
29441
+ }
29442
+ }
29443
+ if (range.endContainer.nodeType === 3 && range.endOffset < range.endContainer.data.length && range.endOffset > 0) {
29444
+ range.endContainer.splitText(range.endOffset);
29445
+ }
29446
+
29447
+ var out = [];
29448
+ var walker = doc.createTreeWalker(editable, 4 /* SHOW_TEXT */, null, false);
29449
+ var n;
29450
+ while ((n = walker.nextNode())) {
29451
+ if (!n.data) continue;
29452
+ if (range.intersectsNode ? range.intersectsNode(n) : true) {
29453
+ // intersectsNode is true for merely-touching nodes, so confirm
29454
+ // the node is genuinely within the boundaries.
29455
+ var r = doc.createRange();
29456
+ r.selectNodeContents(n);
29457
+ if (range.compareBoundaryPoints(Range.END_TO_START, r) < 0 &&
29458
+ range.compareBoundaryPoints(Range.START_TO_END, r) > 0) {
29459
+ out.push(n);
29460
+ }
29461
+ }
29462
+ }
29463
+ return out;
29464
+ }
29465
+
29466
+ // ---- apply / remove ---------------------------------------------------
29467
+
29468
+ // Split `code` around the selection and unwrap only the overlapping part,
29469
+ // so partial removal keeps the untouched halves coded.
29470
+ function unwrapPart(doc, code, range) {
29471
+ var parent = code.parentNode;
29472
+ if (!parent) return;
29473
+
29474
+ var startsInside = code.contains(range.startContainer);
29475
+ var endsInside = code.contains(range.endContainer);
29476
+
29477
+ // Tail first: extracting it cannot disturb the head's boundaries.
29478
+ var after = doc.createRange();
29479
+ if (endsInside) after.setStart(range.endContainer, range.endOffset);
29480
+ else after.setStart(code, code.childNodes.length);
29481
+ after.setEnd(code, code.childNodes.length);
29482
+ var afterFrag = after.extractContents();
29483
+
29484
+ var before = doc.createRange();
29485
+ before.setStart(code, 0);
29486
+ if (startsInside) before.setEnd(range.startContainer, range.startOffset);
29487
+ else before.setEnd(code, 0);
29488
+ var beforeFrag = before.extractContents();
29489
+
29490
+ if (beforeFrag && beforeFrag.textContent !== "") {
29491
+ var head = doc.createElement("code");
29492
+ head.appendChild(beforeFrag);
29493
+ parent.insertBefore(head, code);
29494
+ }
29495
+
29496
+ // What is left in `code` is exactly the selected part: unwrap it.
29497
+ while (code.firstChild) parent.insertBefore(code.firstChild, code);
29498
+
29499
+ if (afterFrag && afterFrag.textContent !== "") {
29500
+ var tail = doc.createElement("code");
29501
+ tail.appendChild(afterFrag);
29502
+ parent.insertBefore(tail, code);
29503
+ }
29504
+
29505
+ parent.removeChild(code);
29506
+ }
29507
+
29508
+ function wrapNodes(doc, nodes, editable) {
29509
+ // Group contiguous siblings so a run of text under one parent becomes a
29510
+ // single <code>, not one per text node.
29511
+ var group = [], groups = [];
29512
+ for (var i = 0; i < nodes.length; i++) {
29513
+ var n = nodes[i];
29514
+ if (inPre(n, editable) || codeAncestor(n, editable)) continue;
29515
+ if (group.length && group[group.length - 1].parentNode === n.parentNode &&
29516
+ group[group.length - 1].nextSibling === n) {
29517
+ group.push(n);
29518
+ } else {
29519
+ if (group.length) groups.push(group);
29520
+ group = [n];
29521
+ }
29522
+ }
29523
+ if (group.length) groups.push(group);
29524
+
29525
+ for (var g = 0; g < groups.length; g++) {
29526
+ var run = groups[g];
29527
+ // Whitespace-only runs would produce a <code> containing nothing but
29528
+ // a space, which is invisible formatting debris.
29529
+ var joined = "";
29530
+ for (var k = 0; k < run.length; k++) joined += run[k].data;
29531
+ if (!/\S/.test(joined)) continue;
29532
+
29533
+ var code = doc.createElement("code");
29534
+ run[0].parentNode.insertBefore(code, run[0]);
29535
+ for (var j = 0; j < run.length; j++) code.appendChild(run[j]);
29536
+ }
29537
+ }
29538
+
29539
+ // Adjacent <code><code> pairs read as one to the user but serialize as two.
29540
+ function mergeAdjacent(editable) {
29541
+ var all = editable.querySelectorAll("code");
29542
+ for (var i = 0; i < all.length; i++) {
29543
+ var code = all[i];
29544
+ var next = code.nextSibling;
29545
+ while (next && next.nodeType === 1 && next.nodeName === "CODE") {
29546
+ while (next.firstChild) code.appendChild(next.firstChild);
29547
+ next.parentNode.removeChild(next);
29548
+ next = code.nextSibling;
29549
+ }
29550
+ }
29551
+ }
29552
+
29553
+ obj.Toggle = function () {
29554
+ var doc = getDoc(), editable = getEditable();
29555
+ if (!doc || !editable) return false;
29556
+ var range = getRange(doc);
29557
+ if (!range) return false;
29558
+ if (!editable.contains(range.startContainer)) return false;
29559
+
29560
+ if (range.collapsed && !expandToWord(range)) return false;
29561
+
29562
+ var nodes = selectedTextNodes(doc, range, editable);
29563
+ if (!nodes.length) return false;
29564
+
29565
+ // Remove when every selected text node is already coded; otherwise apply.
29566
+ var allCoded = true;
29567
+ for (var i = 0; i < nodes.length; i++) {
29568
+ if (!/\S/.test(nodes[i].data)) continue;
29569
+ if (!codeAncestor(nodes[i], editable)) { allCoded = false; break; }
29570
+ }
29571
+
29572
+ if (allCoded) {
29573
+ var codes = [], seen = [];
29574
+ for (var n = 0; n < nodes.length; n++) {
29575
+ var c = codeAncestor(nodes[n], editable);
29576
+ if (c && seen.indexOf(c) < 0) { seen.push(c); codes.push(c); }
29577
+ }
29578
+ for (var x = 0; x < codes.length; x++) unwrapPart(doc, codes[x], range);
29579
+ } else {
29580
+ wrapNodes(doc, nodes, editable);
29581
+ mergeAdjacent(editable);
29582
+ }
29583
+
29584
+ try { editor.updateToolbarStatus && editor.updateToolbarStatus(); } catch (e) { }
29585
+ return true;
29586
+ };
29587
+ }
29588
+
29259
29589
 
29260
29590
  if (!RTE_DefaultConfig.svgCode_insertcode) {
29261
29591
  RTE_DefaultConfig.svgCode_insertcode = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 7 3 12 8 17"/><polyline points="16 7 21 12 16 17"/><line x1="14" y1="5" x2="10" y2="19"/></svg>';
@@ -29588,7 +29918,7 @@ function RTE_Plugin_InsertCode() {
29588
29918
  sel_lang.options.add(new Option(aliases, brush));
29589
29919
 
29590
29920
 
29591
- var b = sessionStorage.getItem("rte-insertcode-lang")
29921
+ var b = null; try { b = sessionStorage.getItem("rte-insertcode-lang"); } catch (e) { } // storage throws in sandboxed / opaque-origin embeds
29592
29922
  if (b) sel_lang.value = b;
29593
29923
  }
29594
29924
 
@@ -29608,7 +29938,7 @@ function RTE_Plugin_InsertCode() {
29608
29938
  btn.onclick = function () {
29609
29939
  dialoginner.close();
29610
29940
 
29611
- sessionStorage.setItem("rte-insertcode-lang", sel_lang.value)
29941
+ try { sessionStorage.setItem("rte-insertcode-lang", sel_lang.value); } catch (e) { }
29612
29942
 
29613
29943
  if (sel_lang.value != "") {
29614
29944
  var b = dp.sh.Brushes[sel_lang.value];
@@ -35445,6 +35775,28 @@ function RTE_Plugin_PdfExport() {
35445
35775
  var self = this;
35446
35776
  var base = { bold: false, italic: false, underline: false, strike: false, size: this.baseSize, color: [0, 0, 0], mono: false, link: null };
35447
35777
 
35778
+ // A block indented in the editor carries margin-left (or padding-left, per
35779
+ // indentUseMargin). Before 2026-09-04 the PDF honoured an indent only when
35780
+ // it was spelled <blockquote>, because that was the only way the editor
35781
+ // could express one. Points, since that is the PDF's unit: 1px = 0.75pt.
35782
+ function cssIndentPt(node) {
35783
+ if (!node || !node.style) return 0;
35784
+ var raw = node.style.marginLeft || node.style.paddingLeft || "";
35785
+ var m = /^\s*(-?[\d.]+)\s*([a-z%]*)\s*$/i.exec(raw);
35786
+ if (!m) return 0;
35787
+ var n = parseFloat(m[1]);
35788
+ if (!n || n <= 0) return 0;
35789
+ var unit = (m[2] || "px").toLowerCase();
35790
+ var px = unit === "px" ? n
35791
+ : unit === "pt" ? n * (4 / 3)
35792
+ : unit === "in" ? n * 96
35793
+ : unit === "cm" ? n * 37.795
35794
+ : unit === "mm" ? n * 3.7795
35795
+ : (unit === "em" || unit === "rem") ? n * 16
35796
+ : 0;
35797
+ return px * 0.75;
35798
+ }
35799
+
35448
35800
  function block(node, style, indent, listMarker, listId, listOrdered) {
35449
35801
  var tag = node.tagName.toLowerCase();
35450
35802
  var s = styleOf(node, style);
@@ -35517,8 +35869,9 @@ function RTE_Plugin_PdfExport() {
35517
35869
  continue;
35518
35870
  }
35519
35871
  if (isBlockTag(tag)) {
35520
- if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + (tag === "blockquote" ? self.baseSize * 1.5 : 0)); continue; }
35521
- block(c, style, indent + (tag === "blockquote" ? self.baseSize * 1.5 : 0), null);
35872
+ var own = (tag === "blockquote" ? self.baseSize * 1.5 : 0) + cssIndentPt(c);
35873
+ if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + own); continue; }
35874
+ block(c, style, indent + own, null);
35522
35875
  continue;
35523
35876
  }
35524
35877
  // Inline content sitting directly under a container.
@@ -36441,17 +36794,12 @@ function RTE_Plugin_ReadabilityStats() {
36441
36794
  openDialog();
36442
36795
  });
36443
36796
 
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
- }
36797
+ // NOTE: do NOT call editor.slashCommands.register() from here. Plugins
36798
+ // initialise in bundle (alphabetical) order and this file sorts before
36799
+ // "slashcommand", so editor.slashCommands does not exist yet and the guarded
36800
+ // call silently did nothing - the "readability-stats" slash entry never appeared for any
36801
+ // customer until 2026-09-02. The entry now lives in slashcommand.js, gated on
36802
+ // typeof editor.getReadabilityStats === "function" (same pattern as exportToPdf).
36455
36803
  };
36456
36804
 
36457
36805
  function currentText() {
@@ -37877,8 +38225,9 @@ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
37877
38225
  // config.sanitizerAllowStyleTags = true // keep <style> in content
37878
38226
  // config.sanitizerAllowIframes = false // drop <iframe> outright
37879
38227
  // config.sanitizerAllowedIframeHosts = ["www.youtube.com"]
37880
- // config.sanitizerAllowTags = ["custom-el"]
37881
- // config.sanitizerAllowAttributes = ["my-attr"]
38228
+ // config.sanitizerAllowTags = ["custom-el", "x-*", /^ui-/]
38229
+ // config.sanitizerAllowAttributes = ["my-attr", "data-cms-*"]
38230
+ // exact names, "*" wildcards, or RegExp -- see buildMatcher()
37882
38231
  RTE_DefaultConfig.plugin_sanitizer = RTE_Plugin_Sanitizer;
37883
38232
  if (typeof RTE_DefaultConfig.contentSanitizer === "undefined") RTE_DefaultConfig.contentSanitizer = true;
37884
38233
 
@@ -38065,12 +38414,63 @@ function RTE_Plugin_Sanitizer() {
38065
38414
  return false;
38066
38415
  }
38067
38416
 
38417
+ // Build a name matcher from a config list. Entries may be:
38418
+ // "my-widget" an exact name
38419
+ // "my-*" a wildcard, where * matches any run of characters
38420
+ // /^my-/ a RegExp
38421
+ // The wildcard form is what makes this a workable answer to CKEditor's General
38422
+ // HTML Support: without it an integrator has to enumerate every tag and
38423
+ // attribute a CMS might emit, by name, which nobody does -- so the content gets
38424
+ // unwrapped instead and the integrator concludes the editor destroys it.
38425
+ //
38426
+ // A pattern widens what is KEPT, never what is executed: on* handlers,
38427
+ // javascript: URLs, <script> and iframe[srcdoc] are all handled before this is
38428
+ // consulted, so allowing a tag or attribute by pattern cannot re-open those.
38429
+ //
38430
+ // Wildcards are matched by hand rather than compiled to a RegExp, so a config
38431
+ // value can never be a regex-injection vector and no escaping table has to be
38432
+ // kept correct.
38433
+ function buildMatcher(list) {
38434
+ var literal = {};
38435
+ var globs = [];
38436
+ var regexps = [];
38437
+ (list || []).forEach(function (entry) {
38438
+ if (entry instanceof RegExp) { regexps.push(entry); return; }
38439
+ var t = String(entry).toLowerCase();
38440
+ if (t.indexOf("*") === -1) { literal[t] = true; return; }
38441
+ globs.push(t.split("*"));
38442
+ });
38443
+ function globMatches(parts, name) {
38444
+ // parts came from splitting on "*": they must appear in order, the first
38445
+ // anchored at the start and the last at the end.
38446
+ var first = parts[0];
38447
+ if (name.lastIndexOf(first, 0) !== 0) return false;
38448
+ var last = parts[parts.length - 1];
38449
+ if (last) {
38450
+ if (name.length < last.length) return false;
38451
+ if (name.indexOf(last, name.length - last.length) === -1) return false;
38452
+ }
38453
+ var pos = first.length;
38454
+ for (var i = 1; i < parts.length; i++) {
38455
+ if (!parts[i]) continue;
38456
+ var at = name.indexOf(parts[i], pos);
38457
+ if (at === -1) return false;
38458
+ pos = at + parts[i].length;
38459
+ }
38460
+ return true;
38461
+ }
38462
+ return function (name) {
38463
+ if (literal[name]) return true;
38464
+ for (var i = 0; i < globs.length; i++) if (globMatches(globs[i], name)) return true;
38465
+ for (var j = 0; j < regexps.length; j++) if (regexps[j].test(name)) return true;
38466
+ return false;
38467
+ };
38468
+ }
38469
+
38068
38470
  // --------------------------------------------------------------- engine
38069
38471
  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; });
38472
+ var allowExtra = buildMatcher(config.sanitizerAllowTags);
38473
+ var attrExtra = buildMatcher(config.sanitizerAllowAttributes);
38074
38474
  var allowStyleTag = config.sanitizerAllowStyleTags === true;
38075
38475
 
38076
38476
  // Collect first: removing nodes while walking a live list skips siblings.
@@ -38079,7 +38479,7 @@ function RTE_Plugin_Sanitizer() {
38079
38479
  for (var i = 0; i < n.childNodes.length; i++) {
38080
38480
  var c = n.childNodes[i];
38081
38481
  if (c.nodeType === 1) { all.push(c); collect(c); }
38082
- else if (c.nodeType === 8) all.push(c); // comments can hide markup
38482
+ else if (c.nodeType === 8) all.push(c); // checked, see commentEscapes()
38083
38483
  }
38084
38484
  })(root);
38085
38485
 
@@ -38087,7 +38487,33 @@ function RTE_Plugin_Sanitizer() {
38087
38487
  var el = all[i];
38088
38488
  if (!el.parentNode) continue; // already removed with an ancestor
38089
38489
 
38090
- if (el.nodeType === 8) { el.parentNode.removeChild(el); continue; }
38490
+ if (el.nodeType === 8) {
38491
+ // Comments used to be removed wholesale, on the stated grounds that
38492
+ // "comments can hide markup". That reason does not survive testing:
38493
+ // serialise <!--<script>alert(1)</script>--> and re-parse it and the
38494
+ // script is inert -- a well-formed comment is just text.
38495
+ //
38496
+ // What IS unsafe is a narrow, enumerable set of comment DATA that
38497
+ // cannot survive serialisation, because the serialiser writes the
38498
+ // data verbatim between <!-- and -->. Re-parsing then ends the
38499
+ // comment early and the remainder becomes live markup:
38500
+ //
38501
+ // "--> <img src=x onerror=...>" -> <!----> <img ...>--> ESCAPES
38502
+ // "--!> <img ...>" -> <!----!> <img ...>--> ESCAPES
38503
+ // "> <img ...>" -> <!--> <img ...>--> ESCAPES
38504
+ // "-> <img ...>" -> <!---> <img ...>--> ESCAPES
38505
+ //
38506
+ // That is the WHATWG serialisation constraint, not a heuristic, so
38507
+ // it can be enumerated rather than guessed at. Everything else is
38508
+ // kept, which is what lets a CMS round-trip its own markers and lets
38509
+ // an email template keep its Outlook conditional comments -- the
38510
+ // mechanism emailtoolkit.js needs and could not previously rely on.
38511
+ if (commentEscapes(el.data)) {
38512
+ note(report.removedTags, "#comment");
38513
+ el.parentNode.removeChild(el);
38514
+ }
38515
+ continue;
38516
+ }
38091
38517
 
38092
38518
  var tag = (el.localName || el.nodeName || "").toLowerCase();
38093
38519
 
@@ -38115,7 +38541,7 @@ function RTE_Plugin_Sanitizer() {
38115
38541
  note(report.removedTags, tag);
38116
38542
  el.parentNode.removeChild(el);
38117
38543
  continue;
38118
- } else if (!ALLOWED_TAGS[tag] && !allowExtra[tag]) {
38544
+ } else if (!ALLOWED_TAGS[tag] && !allowExtra(tag)) {
38119
38545
  // Unknown element: unwrap rather than delete, so the words a
38120
38546
  // user typed inside a stray tag are not silently lost.
38121
38547
  note(report.removedTags, tag);
@@ -38144,7 +38570,7 @@ function RTE_Plugin_Sanitizer() {
38144
38570
  if (!isSafeUrl(value, tag, name)) { note(report.removedAttributes, tag + "[" + name + "]"); el.removeAttribute(attrs[k].name); }
38145
38571
  continue;
38146
38572
  }
38147
- if (!ALLOWED_ATTRS[name] && !attrExtra[name]) {
38573
+ if (!ALLOWED_ATTRS[name] && !attrExtra(name)) {
38148
38574
  note(report.removedAttributes, name);
38149
38575
  el.removeAttribute(attrs[k].name);
38150
38576
  }
@@ -38257,6 +38683,44 @@ function RTE_Plugin_Sanitizer() {
38257
38683
  // Measured: the payload ran locally AND replicated to the peer with its
38258
38684
  // onerror attribute intact.
38259
38685
  //
38686
+ // THREAT MODEL -- read this before tightening or loosening the rule below.
38687
+ //
38688
+ // An escaping comment CANNOT BE AUTHORED AS AN HTML STRING. Feed
38689
+ // `<!--x--> <img src=q onerror=...>-->` to setHTMLCode and the parser yields
38690
+ // comment("x") + text + a normal <img> element, which the element/attribute
38691
+ // filters already handle. The ONLY way to obtain a comment node whose data
38692
+ // escapes is programmatic DOM construction -- document.createComment(), or a
38693
+ // sync layer building nodes directly.
38694
+ //
38695
+ // That is why the guard lives on the DOM-level paths (the sanitise pass and the
38696
+ // live-DOM MutationObserver) and why the string paths need nothing extra. Both
38697
+ // string directions were measured: sender getHTMLCode, receiver setHTMLCode, and
38698
+ // the receiver's live DOM all yield zero executable attributes, because
38699
+ // wrapSerializers() and wrapSetters() filter on the way out and on the way in.
38700
+ //
38701
+ // The previous rule here deleted EVERY comment, justified only by the remark
38702
+ // "comments can hide markup". That is false -- serialise
38703
+ // <!--<script>alert(1)</script>--> and re-parse it and the script is inert -- and
38704
+ // it cost the product CMS round-tripping and Outlook conditional comments (the
38705
+ // mechanism emailtoolkit.js needs) for as long as nobody re-checked it. An
38706
+ // unexplained restriction outlives its reason, so this one is written down.
38707
+ //
38708
+ // A comment's data is written verbatim between <!-- and -->, so data that
38709
+ // contains a terminator (or opens with one) breaks out of the comment when the
38710
+ // serialised output is re-parsed, turning the remainder into live markup.
38711
+ // Module scope on purpose: both the sanitiser pass and the live-DOM
38712
+ // MutationObserver below need it, and they must agree exactly on what is unsafe.
38713
+ // Deliberately conservative -- it rejects rather than rewrites, because silently
38714
+ // editing someone's comment text is its own kind of surprise.
38715
+ function commentEscapes(data) {
38716
+ var d = String(data == null ? "" : data);
38717
+ if (d.indexOf("-->") !== -1) return true;
38718
+ if (d.indexOf("--!>") !== -1) return true;
38719
+ if (d.charAt(0) === ">") return true;
38720
+ if (d.charAt(0) === "-" && d.charAt(1) === ">") return true;
38721
+ return false;
38722
+ }
38723
+
38260
38724
  // A MutationObserver is the only place to catch every arrival path — sync,
38261
38725
  // drop, a third-party plugin, host-page code. It is honest to say this
38262
38726
  // narrows the window rather than closing it completely: the observer runs
@@ -38307,6 +38771,16 @@ function RTE_Plugin_Sanitizer() {
38307
38771
  }
38308
38772
  for (var a = 0; a < rec.addedNodes.length; a++) {
38309
38773
  var node = rec.addedNodes[a];
38774
+ // Comments are preserved now, so an injected comment whose data
38775
+ // escapes on re-parse has to wake the sanitiser. This loop used
38776
+ // to skip every non-element node, which was safe only while
38777
+ // comments were being deleted unconditionally. The CRDT sync
38778
+ // path is exactly how a node arrives here without passing the
38779
+ // input filter -- that is the path that produced the stored XSS.
38780
+ if (node.nodeType === 8) {
38781
+ if (commentEscapes(node.data)) { suspect = true; break; }
38782
+ continue;
38783
+ }
38310
38784
  if (node.nodeType !== 1) continue; // typing inserts text: free
38311
38785
  if (looksDangerous(node)) { suspect = true; break; }
38312
38786
  }
@@ -38495,7 +38969,17 @@ function RTE_Plugin_ShadowDom() {
38495
38969
  var id = String(node.id || "");
38496
38970
  if (/^rte[-_]|^__rte/.test(id)) return true;
38497
38971
  var href = node.href || "";
38498
- if (!href) return false;
38972
+ if (!href) {
38973
+ // An inline <style> with no id and no href is what bundlers (Vite,
38974
+ // webpack style-loader) inject for an imported theme. Recognise it by
38975
+ // its content, or a shadow-root editor is unstyled (39,726 px toolbar,
38976
+ // Times New Roman) exactly as before this plugin existed. 2026-09-02.
38977
+ if (node.tagName === "STYLE") {
38978
+ var css = node.textContent || "";
38979
+ return /[.]richtexteditor|rte-toolbar|rte-editor|rte-dialog|[.]rte-a11y/.test(css);
38980
+ }
38981
+ return false;
38982
+ }
38499
38983
  var base = assetBase();
38500
38984
  if (base && href.indexOf(base) === 0) return true;
38501
38985
  // Fall back to the file names this product actually ships, so an
@@ -38643,7 +39127,7 @@ function RTE_Plugin_SlashCommand() {
38643
39127
  editor.slashCommands = {
38644
39128
  register: registerCommand,
38645
39129
  remove: removeCommand,
38646
- list: function () { return commands.slice(); },
39130
+ list: function () { refreshDefaults(); return commands.slice(); },
38647
39131
  open: function () { openPopup(true); },
38648
39132
  close: closePopup,
38649
39133
  isOpen: function () { return !!popupEl; }
@@ -38655,6 +39139,15 @@ function RTE_Plugin_SlashCommand() {
38655
39139
  registerCommand(config.slashCommands[i]);
38656
39140
  }
38657
39141
  }
39142
+ // Plugins initialise synchronously in bundle (alphabetical) order, so at
39143
+ // this point every plugin sorting AFTER "slashcommand" (tabletools,
39144
+ // textdirection, watermark, tableofcontents, typewriter, ...) has not
39145
+ // defined its editor.* API yet and the typeof gates in
39146
+ // buildDefaultCommands() were false: nine entries (sort asc/desc, rtl/
39147
+ // ltr/autodir, watermark, toc, typewriter, focusmode) were missing for
39148
+ // every customer until 2026-09-02. Rebuild once the constructor has
39149
+ // finished (setTimeout 0 runs after all plugin inits) and again on open.
39150
+ setTimeout(refreshDefaults, 0);
38658
39151
 
38659
39152
  injectStyles();
38660
39153
 
@@ -38677,7 +39170,26 @@ function RTE_Plugin_SlashCommand() {
38677
39170
  else commands.push(normalizeCommand(def));
38678
39171
  }
38679
39172
 
39173
+ var removedIds = {};
39174
+ // Rebuild the gated default entries (their typeof checks may only be true
39175
+ // now) while keeping every custom registration and honouring remove().
39176
+ function refreshDefaults() {
39177
+ if (!editor) return;
39178
+ var custom = [];
39179
+ for (var i = 0; i < commands.length; i++) if (!commands[i]._default) custom.push(commands[i]);
39180
+ var defaults = buildDefaultCommands();
39181
+ var next = [];
39182
+ for (var d = 0; d < defaults.length; d++) {
39183
+ var id = defaults[d].id;
39184
+ if (removedIds[id]) continue;
39185
+ var overridden = false;
39186
+ for (var c = 0; c < custom.length; c++) if (custom[c].id === id) { overridden = true; break; }
39187
+ if (!overridden) next.push(defaults[d]);
39188
+ }
39189
+ commands = next.concat(custom);
39190
+ }
38680
39191
  function removeCommand(id) {
39192
+ removedIds[id] = true;
38681
39193
  var idx = findCommandIndex(id);
38682
39194
  if (idx >= 0) commands.splice(idx, 1);
38683
39195
  }
@@ -38687,12 +39199,25 @@ function RTE_Plugin_SlashCommand() {
38687
39199
  return -1;
38688
39200
  }
38689
39201
 
39202
+ // Localization with an English fallback for EVERY entry (defaults, plugin
39203
+ // register() calls, AI actions, config.slashCommands): the lang packs set
39204
+ // config.text_<key>; __GetLangText returns the raw KEY when one is missing,
39205
+ // so resolve only when a translation exists. Keys: text_slash_<id> (title),
39206
+ // text_slash_<id>_desc (description), text_slash_section_<section>.
39207
+ function L(key, fallback) {
39208
+ try {
39209
+ var k = String(key).toLowerCase().replace(/[^a-z0-9_]/g, "_");
39210
+ if (config && config["text_" + k] && editor && typeof editor.getLangText === "function") return editor.getLangText(k);
39211
+ } catch (e) { }
39212
+ return fallback;
39213
+ }
38690
39214
  function normalizeCommand(def) {
39215
+ var section = def.section || "Blocks";
38691
39216
  return {
38692
39217
  id: def.id,
38693
- section: def.section || "Blocks",
38694
- title: def.title || def.id,
38695
- description: def.description || "",
39218
+ section: L("slash_section_" + section, section),
39219
+ title: L("slash_" + def.id, def.title || def.id),
39220
+ description: L("slash_" + def.id + "_desc", def.description || ""),
38696
39221
  keywords: (def.keywords || []).slice(),
38697
39222
  icon: def.icon || "",
38698
39223
  iconSvg: def.iconSvg || "",
@@ -38743,11 +39268,13 @@ function RTE_Plugin_SlashCommand() {
38743
39268
  var list = [];
38744
39269
 
38745
39270
  function push(section, id, title, description, keywords, iconSvg, run) {
38746
- list.push(normalizeCommand({
39271
+ var cmd = normalizeCommand({ // normalizeCommand localizes title/description/section
38747
39272
  id: id, section: section, title: title,
38748
39273
  description: description, keywords: keywords,
38749
39274
  iconSvg: iconSvg, run: run
38750
- }));
39275
+ });
39276
+ cmd._default = true;
39277
+ list.push(cmd);
38751
39278
  }
38752
39279
 
38753
39280
  push("Blocks", "heading1", "Heading 1", "Large section heading", ["h1", "title"], iconHeading("1"),
@@ -38963,7 +39490,8 @@ function RTE_Plugin_SlashCommand() {
38963
39490
  // Gated on the API rather than registered by the plugin itself: plugins
38964
39491
  // initialise in bundle order, so anything sorting before "slashcommand"
38965
39492
  // would call slashCommands.register() before it exists and vanish from
38966
- // this menu without any error. This check runs when the menu opens.
39493
+ // this menu without any error. These checks are re-run by refreshDefaults()
39494
+ // after all plugins have initialised and again each time the menu opens.
38967
39495
  if (typeof editor.exportToPdf === "function") {
38968
39496
  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
39497
  function () { editor.exportToPdf(); });
@@ -38972,6 +39500,32 @@ function RTE_Plugin_SlashCommand() {
38972
39500
  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
39501
  function () { editor.exportToDocx(); });
38974
39502
  }
39503
+ // documentimport.js and readabilitystats.js sort before this file, so they
39504
+ // cannot register at init (editor.slashCommands does not exist yet); their
39505
+ // guarded calls silently did nothing and these two entries were missing for
39506
+ // every customer until 2026-09-02. Declared here, gated on the plugin API.
39507
+ if (typeof editor.openImportDialog === "function") {
39508
+ 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(),
39509
+ function () { editor.openImportDialog({}); });
39510
+ }
39511
+ if (typeof editor.getReadabilityStats === "function") {
39512
+ push("Tools", "readability-stats", "Readability statistics", "Flesch reading ease, grade level, and reading time", ["readability", "flesch", "grade", "reading", "stats", "score"], iconSpell(),
39513
+ function () { editor.execCommand("readability"); });
39514
+ }
39515
+ // Marketed features that had NO toolbar button and NO slash entry (reachable
39516
+ // only via the API) until 2026-09-02. All gated on the owning plugin.
39517
+ if (typeof editor.insertCrossReference === "function") {
39518
+ push("Insert", "crossreference", "Cross-reference", "Insert a live reference to a heading, figure, table, or bookmark", ["cross", "reference", "xref", "see", "figure", "heading"], iconXref(),
39519
+ function () { editor.execCommand("insertcrossreference"); });
39520
+ }
39521
+ if (editor.restrictedEditing) {
39522
+ push("Tools", "restrictedediting", "Restricted editing", "Lock the document and mark only the regions others may edit", ["restrict", "lock", "protect", "editable", "region", "permission"], iconPen(),
39523
+ function () { editor.execCommand("restrictedediting"); });
39524
+ }
39525
+ if (editor.dictation) {
39526
+ push("Tools", "dictation", "Dictation", "Speak and have your words typed into the document", ["dictate", "speech", "voice", "microphone", "talk"], iconReadAloud(),
39527
+ function () { editor.execCommand("dictation"); });
39528
+ }
38975
39529
  if (typeof editor.copyAsMarkdown === "function") {
38976
39530
  push("Tools", "copymarkdown", "Copy as Markdown", "Copy the whole document to the clipboard as Markdown", ["markdown", "md", "copy", "clipboard", "export"], iconMarkdown(),
38977
39531
  function () { editor.copyAsMarkdown(); });
@@ -39103,6 +39657,7 @@ function RTE_Plugin_SlashCommand() {
39103
39657
  }
39104
39658
 
39105
39659
  function openPopup(manual) {
39660
+ refreshDefaults();
39106
39661
  closePopup();
39107
39662
  var sel = editor.getSelection();
39108
39663
  if (!sel || sel.rangeCount === 0) return;
@@ -42819,7 +43374,9 @@ function RTE_Plugin_WordExport() {
42819
43374
  "table { border-collapse: collapse; }\r\n" +
42820
43375
  "td, th { border: 1px solid #999; padding: 4px 8px; }\r\n" +
42821
43376
  "img { max-width: 100%; height: auto; }\r\n" +
42822
- "blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 12px; color: #444; }\r\n" +
43377
+ // margin-left was zeroed here, which also flattened any indentation
43378
+ // expressed as a margin. Keep the quote bar, keep the offset.
43379
+ "blockquote { border-left: 3px solid #ccc; padding-left: 12px; color: #444; }\r\n" +
42823
43380
  "pre { font-family: Consolas, 'Courier New', monospace; background: #f4f4f4; padding: 8px; }\r\n" +
42824
43381
  "</style>\r\n" +
42825
43382
  "</head>\r\n" +
@@ -43018,9 +43575,42 @@ function RTE_Plugin_YjsCollab() {
43018
43575
  // and the review ledger bridge.
43019
43576
  if (session.textSyncMode === "crdt") {
43020
43577
  try {
43578
+ // 2026-09-02: the engine bundles its OWN Yjs and builds nodes with it.
43579
+ // A doc built by the host's Yjs (npm/esm "yjs" + y-websocket - the
43580
+ // documented path) is a FOREIGN instance: inserting engine-built nodes
43581
+ // into it throws "Unexpected content type in insert operation" and text
43582
+ // never syncs, while presence still works and the status still said
43583
+ // "crdt". Yjs updates are instance-independent bytes, so bind the
43584
+ // engine to an inner doc built with ITS Yjs and bridge updates both
43585
+ // ways: host->inner with the engine's applyUpdate, inner->host with the
43586
+ // host's module (options.Y or window.Y). Without the host module the
43587
+ // outbound leg cannot be applied safely, so fail LOUDLY instead.
43588
+ var engineY = window.RichTextEditorCrdt.Y || null;
43589
+ var hostY = options.Y || (typeof window !== "undefined" ? window.Y : null) || null;
43590
+ var bindDoc = doc;
43591
+ var foreign = !!(engineY && engineY.Doc && !(doc instanceof engineY.Doc));
43592
+ if (foreign) {
43593
+ if (!hostY || typeof hostY.applyUpdate !== "function" || typeof hostY.encodeStateAsUpdate !== "function") {
43594
+ 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.");
43595
+ }
43596
+ var BRIDGE = { bridge: "rte-yjs-bridge" };
43597
+ var inner = new engineY.Doc();
43598
+ engineY.applyUpdate(inner, hostY.encodeStateAsUpdate(doc), BRIDGE);
43599
+ var LOCAL = window.RichTextEditorCrdt.LOCAL_ORIGIN;
43600
+ // A host-doc write the plugin tagged as LOCAL (ledger entries) must not
43601
+ // make the engine re-render the editable (see flush race in the engine).
43602
+ 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); } };
43603
+ 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); } };
43604
+ doc.on("update", hostToInner);
43605
+ inner.on("update", innerToHost);
43606
+ session.cleanup.push(function () { try { doc.off("update", hostToInner); } catch (e) { } try { inner.off("update", innerToHost); } catch (e) { } try { inner.destroy(); } catch (e) { } });
43607
+ session.yjsBridge = true;
43608
+ bindDoc = inner;
43609
+ if (window.console && console.info) console.info("yjscollab: bridging a foreign Yjs doc into the CRDT engine (updates relayed both ways).");
43610
+ }
43021
43611
  session.crdtBinding = window.RichTextEditorCrdt.attachCrdtBinding({
43022
43612
  editable: editor.getEditable(),
43023
- ydoc: doc,
43613
+ ydoc: bindDoc,
43024
43614
  provider: provider,
43025
43615
  awareness: provider.awareness,
43026
43616
  fragmentName: options.fragmentName || "default",
@@ -43033,6 +43623,24 @@ function RTE_Plugin_YjsCollab() {
43033
43623
  try { session.crdtBinding && session.crdtBinding.dispose(); }
43034
43624
  catch (ignore) { }
43035
43625
  });
43626
+ // Per-author undo. The engine ships a Y.UndoManager scoped to LOCAL_ORIGIN
43627
+ // (undo.ts) but nothing attached it, so Ctrl+Z ran the core DOM-snapshot
43628
+ // undo and reverted OTHER people's edits on every peer (2026-09-02). Route
43629
+ // undo/redo to the engine while crdt mode is active.
43630
+ try {
43631
+ if (typeof window.RichTextEditorCrdt.attachUndoManager === "function" && session.crdtBinding && session.crdtBinding.fragment) {
43632
+ session.undoManager = window.RichTextEditorCrdt.attachUndoManager({ fragment: session.crdtBinding.fragment });
43633
+ var undoHook = function (state) { if (!session || !session.undoManager) return; state.returnValue = true; state.stopBubble = true; try { session.undoManager.undo(); } catch (e) { } };
43634
+ var redoHook = function (state) { if (!session || !session.undoManager) return; state.returnValue = true; state.stopBubble = true; try { session.undoManager.redo(); } catch (e) { } };
43635
+ editor.attachEvent("exec_command_undo", undoHook);
43636
+ editor.attachEvent("exec_command_redo", redoHook);
43637
+ session.cleanup.push(function () {
43638
+ try { if (typeof editor.detachEvent === "function") { editor.detachEvent("exec_command_undo", undoHook); editor.detachEvent("exec_command_redo", redoHook); } } catch (e) { }
43639
+ try { session.undoManager && session.undoManager.destroy(); } catch (e) { }
43640
+ session.undoManager = null;
43641
+ });
43642
+ }
43643
+ } catch (undoErr) { if (window.console) console.warn("yjscollab: undo manager not attached:", undoErr); }
43036
43644
  } catch (err) {
43037
43645
  session.fallbackReason = "crdt-attach-failed";
43038
43646
  session.crdtError = err;
@@ -43065,7 +43673,7 @@ function RTE_Plugin_YjsCollab() {
43065
43673
  }
43066
43674
  return {
43067
43675
  attached: true,
43068
- textSyncMode: session.textSyncMode,
43676
+ textSyncMode: session.textSyncMode, yjsBridge: !!session.yjsBridge,
43069
43677
  requestedTextSync: session.requestedTextSync,
43070
43678
  fallbackReason: session.fallbackReason || null,
43071
43679
  peerCount: getRemotePeers().length
@@ -43310,6 +43918,52 @@ function RTE_Plugin_YjsCollab() {
43310
43918
  lastKnownHtml = localInitial;
43311
43919
  }
43312
43920
 
43921
+ // --- IME composition gate (legacy snapshot mode) ---------------------
43922
+ // setEditorHtml() replaces the whole document. Doing that while an IME
43923
+ // is composing swaps the live text node out from under it, so the
43924
+ // commit lands at a stale offset and the raw buffer survives: composing
43925
+ // "shi" and committing a character while a collaborator typed produced
43926
+ // "Xbasesh<char>i" on BOTH peers (2026-09-03 — the same corruption the
43927
+ // crdt binding had, on the deprecated-but-supported path v2.0 customers
43928
+ // still run). Hold the repaint until the composition commits.
43929
+ var composing = false;
43930
+ var pendingRemoteHtml = null;
43931
+ var compositionDoc = (editor.getDocument && editor.getDocument()) || null;
43932
+ if (compositionDoc) {
43933
+ var onCompStart = function () { composing = true; };
43934
+ var onCompEnd = function () {
43935
+ if (!composing) return;
43936
+ composing = false;
43937
+ setTimeout(function () {
43938
+ if (pendingRemoteHtml !== null) {
43939
+ // Deliberately NOT repainting: that would discard the
43940
+ // character the IME just committed. Adopt the remote text as
43941
+ // the diff baseline instead, so the local push that follows
43942
+ // is computed against what the peers actually hold. Legacy
43943
+ // mode is documented last-write-wins on same-paragraph
43944
+ // conflicts, so the local commit winning is correct here —
43945
+ // corrupted text was not.
43946
+ lastKnownHtml = pendingRemoteHtml;
43947
+ pendingRemoteHtml = null;
43948
+ }
43949
+ // Push explicitly. The commit's DOM mutation is delivered to
43950
+ // the MutationObserver BEFORE compositionend fires, so it was
43951
+ // dropped by the `composing` guard — without this the
43952
+ // committed character never reaches the peers at all
43953
+ // (measured: A "base<char>", B "base").
43954
+ onEditorMutation();
43955
+ }, 0);
43956
+ };
43957
+ // Bound on the DOCUMENT, not the body: some skins remount the body
43958
+ // (see attachMutationObserver below) and composition events bubble.
43959
+ compositionDoc.addEventListener("compositionstart", onCompStart, true);
43960
+ compositionDoc.addEventListener("compositionend", onCompEnd, true);
43961
+ session.cleanup.push(function () {
43962
+ compositionDoc.removeEventListener("compositionstart", onCompStart, true);
43963
+ compositionDoc.removeEventListener("compositionend", onCompEnd, true);
43964
+ });
43965
+ }
43966
+
43313
43967
  // Y.Text → editor.
43314
43968
  var onTextChange = function (event, transaction) {
43315
43969
  if (pushing) return;
@@ -43317,6 +43971,7 @@ function RTE_Plugin_YjsCollab() {
43317
43971
  if (transaction && transaction.local) return;
43318
43972
  var fresh = session.textMap.toString();
43319
43973
  if (fresh === lastKnownHtml) return;
43974
+ if (composing) { pendingRemoteHtml = fresh; return; }
43320
43975
  setEditorHtml(fresh);
43321
43976
  lastKnownHtml = fresh;
43322
43977
  // 2026-05-28 setEditorHtml writes into editable.innerHTML; if that
@@ -43331,6 +43986,9 @@ function RTE_Plugin_YjsCollab() {
43331
43986
  // Editor DOM → Y.Text. Debounced so bursts collapse to one CRDT diff.
43332
43987
  var onEditorMutation = function () {
43333
43988
  if (applying) return;
43989
+ // Intermediate IME buffers are not real edits; the commit fires its
43990
+ // own mutation. Pushing them also races the composition gate above.
43991
+ if (composing) return;
43334
43992
  if (pendingPushTimer) return;
43335
43993
  // 2026-05-28 Re-attach observer if the body was remounted since last
43336
43994
  // mutation. Cheap no-op when body is unchanged.
@@ -43418,17 +44076,17 @@ function RTE_Plugin_YjsCollab() {
43418
44076
 
43419
44077
  ledger.add = function (entry) {
43420
44078
  var result = originalAdd.call(ledger, entry);
43421
- if (result && !echoGuard) session.ledgerMap.set(result.id, cloneEntry(result));
44079
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.set(result.id, cloneEntry(result)); });
43422
44080
  return result;
43423
44081
  };
43424
44082
  ledger.update = function (id, patch) {
43425
44083
  var result = originalUpdate.call(ledger, id, patch);
43426
- if (result && !echoGuard) session.ledgerMap.set(result.id, cloneEntry(result));
44084
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.set(result.id, cloneEntry(result)); });
43427
44085
  return result;
43428
44086
  };
43429
44087
  ledger.remove = function (id) {
43430
44088
  var result = originalRemove.call(ledger, id);
43431
- if (result && !echoGuard) session.ledgerMap.delete(id);
44089
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.delete(id); });
43432
44090
  return result;
43433
44091
  };
43434
44092
 
@@ -43462,6 +44120,16 @@ function RTE_Plugin_YjsCollab() {
43462
44120
  });
43463
44121
  }
43464
44122
 
44123
+ // Ledger writes are the plugin's own (non-text) Y.Doc transactions. In crdt
44124
+ // mode they are tagged with the engine's LOCAL_ORIGIN so the engine does not
44125
+ // re-render the editable for them - a re-render between a DOM mutation and
44126
+ // the observer flush discarded that mutation (2026-09-02: comment anchors
44127
+ // vanished the instant the ledger entry was written).
44128
+ function ledgerWrite(fn) {
44129
+ var origin = (session && session.textSyncMode === "crdt" && window.RichTextEditorCrdt) ? window.RichTextEditorCrdt.LOCAL_ORIGIN : null;
44130
+ if (origin && session && session.doc && typeof session.doc.transact === "function") { session.doc.transact(fn, origin); }
44131
+ else { fn(); }
44132
+ }
43465
44133
  function seedLedgerFromRemote() {
43466
44134
  if (!editor.reviewLedger || !session.ledgerMap) return;
43467
44135
  echoGuard = true;