@richscripts/richtexteditor 2.6.2 → 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.
@@ -65,17 +65,12 @@ function RTE_Plugin_DocumentImport() {
65
65
  openPicker({});
66
66
  });
67
67
 
68
- if (editor.slashCommands && typeof editor.slashCommands.register === "function") {
69
- try {
70
- editor.slashCommands.register({
71
- id: "import-document",
72
- title: "Import document",
73
- description: "Open a Markdown, HTML, text, or Word (.doc) file into the editor",
74
- keywords: ["import", "open", "file", "word", "markdown", "upload"],
75
- action: function () { openPicker({}); }
76
- });
77
- } catch (e) {}
78
- }
68
+ // NOTE: do NOT call editor.slashCommands.register() from here. Plugins
69
+ // initialise in bundle (alphabetical) order and this file sorts before
70
+ // "slashcommand", so editor.slashCommands does not exist yet and the guarded
71
+ // call silently did nothing - the "import-document" slash entry never appeared for any
72
+ // customer until 2026-09-02. The entry now lives in slashcommand.js, gated on
73
+ // typeof editor.openImportDialog === "function" (same pattern as exportToPdf).
79
74
  };
80
75
 
81
76
  function kindFromName(name) {
@@ -330,7 +330,7 @@ function RTE_Plugin_InsertCode() {
330
330
  sel_lang.options.add(new Option(aliases, brush));
331
331
 
332
332
 
333
- var b = sessionStorage.getItem("rte-insertcode-lang")
333
+ var b = null; try { b = sessionStorage.getItem("rte-insertcode-lang"); } catch (e) { } // storage throws in sandboxed / opaque-origin embeds
334
334
  if (b) sel_lang.value = b;
335
335
  }
336
336
 
@@ -350,7 +350,7 @@ function RTE_Plugin_InsertCode() {
350
350
  btn.onclick = function () {
351
351
  dialoginner.close();
352
352
 
353
- sessionStorage.setItem("rte-insertcode-lang", sel_lang.value)
353
+ try { sessionStorage.setItem("rte-insertcode-lang", sel_lang.value); } catch (e) { }
354
354
 
355
355
  if (sel_lang.value != "") {
356
356
  var b = dp.sh.Brushes[sel_lang.value];
@@ -39,17 +39,12 @@ function RTE_Plugin_ReadabilityStats() {
39
39
  openDialog();
40
40
  });
41
41
 
42
- if (editor.slashCommands && typeof editor.slashCommands.register === "function") {
43
- try {
44
- editor.slashCommands.register({
45
- id: "readability-stats",
46
- title: "Readability statistics",
47
- description: "Flesch reading ease, grade level, and reading time",
48
- keywords: ["readability", "flesch", "grade", "reading", "stats", "score"],
49
- action: function () { openDialog(); }
50
- });
51
- } catch (e) {}
52
- }
42
+ // NOTE: do NOT call editor.slashCommands.register() from here. Plugins
43
+ // initialise in bundle (alphabetical) order and this file sorts before
44
+ // "slashcommand", so editor.slashCommands does not exist yet and the guarded
45
+ // call silently did nothing - the "readability-stats" slash entry never appeared for any
46
+ // customer until 2026-09-02. The entry now lives in slashcommand.js, gated on
47
+ // typeof editor.getReadabilityStats === "function" (same pattern as exportToPdf).
53
48
  };
54
49
 
55
50
  function currentText() {
@@ -33,8 +33,9 @@ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
33
33
  // config.sanitizerAllowStyleTags = true // keep <style> in content
34
34
  // config.sanitizerAllowIframes = false // drop <iframe> outright
35
35
  // config.sanitizerAllowedIframeHosts = ["www.youtube.com"]
36
- // config.sanitizerAllowTags = ["custom-el"]
37
- // config.sanitizerAllowAttributes = ["my-attr"]
36
+ // config.sanitizerAllowTags = ["custom-el", "x-*", /^ui-/]
37
+ // config.sanitizerAllowAttributes = ["my-attr", "data-cms-*"]
38
+ // exact names, "*" wildcards, or RegExp -- see buildMatcher()
38
39
  RTE_DefaultConfig.plugin_sanitizer = RTE_Plugin_Sanitizer;
39
40
  if (typeof RTE_DefaultConfig.contentSanitizer === "undefined") RTE_DefaultConfig.contentSanitizer = true;
40
41
 
@@ -221,12 +222,63 @@ function RTE_Plugin_Sanitizer() {
221
222
  return false;
222
223
  }
223
224
 
225
+ // Build a name matcher from a config list. Entries may be:
226
+ // "my-widget" an exact name
227
+ // "my-*" a wildcard, where * matches any run of characters
228
+ // /^my-/ a RegExp
229
+ // The wildcard form is what makes this a workable answer to CKEditor's General
230
+ // HTML Support: without it an integrator has to enumerate every tag and
231
+ // attribute a CMS might emit, by name, which nobody does -- so the content gets
232
+ // unwrapped instead and the integrator concludes the editor destroys it.
233
+ //
234
+ // A pattern widens what is KEPT, never what is executed: on* handlers,
235
+ // javascript: URLs, <script> and iframe[srcdoc] are all handled before this is
236
+ // consulted, so allowing a tag or attribute by pattern cannot re-open those.
237
+ //
238
+ // Wildcards are matched by hand rather than compiled to a RegExp, so a config
239
+ // value can never be a regex-injection vector and no escaping table has to be
240
+ // kept correct.
241
+ function buildMatcher(list) {
242
+ var literal = {};
243
+ var globs = [];
244
+ var regexps = [];
245
+ (list || []).forEach(function (entry) {
246
+ if (entry instanceof RegExp) { regexps.push(entry); return; }
247
+ var t = String(entry).toLowerCase();
248
+ if (t.indexOf("*") === -1) { literal[t] = true; return; }
249
+ globs.push(t.split("*"));
250
+ });
251
+ function globMatches(parts, name) {
252
+ // parts came from splitting on "*": they must appear in order, the first
253
+ // anchored at the start and the last at the end.
254
+ var first = parts[0];
255
+ if (name.lastIndexOf(first, 0) !== 0) return false;
256
+ var last = parts[parts.length - 1];
257
+ if (last) {
258
+ if (name.length < last.length) return false;
259
+ if (name.indexOf(last, name.length - last.length) === -1) return false;
260
+ }
261
+ var pos = first.length;
262
+ for (var i = 1; i < parts.length; i++) {
263
+ if (!parts[i]) continue;
264
+ var at = name.indexOf(parts[i], pos);
265
+ if (at === -1) return false;
266
+ pos = at + parts[i].length;
267
+ }
268
+ return true;
269
+ }
270
+ return function (name) {
271
+ if (literal[name]) return true;
272
+ for (var i = 0; i < globs.length; i++) if (globMatches(globs[i], name)) return true;
273
+ for (var j = 0; j < regexps.length; j++) if (regexps[j].test(name)) return true;
274
+ return false;
275
+ };
276
+ }
277
+
224
278
  // --------------------------------------------------------------- engine
225
279
  function sanitizeNode(root, report) {
226
- var allowExtra = {};
227
- (config.sanitizerAllowTags || []).forEach(function (t) { allowExtra[String(t).toLowerCase()] = true; });
228
- var attrExtra = {};
229
- (config.sanitizerAllowAttributes || []).forEach(function (a) { attrExtra[String(a).toLowerCase()] = true; });
280
+ var allowExtra = buildMatcher(config.sanitizerAllowTags);
281
+ var attrExtra = buildMatcher(config.sanitizerAllowAttributes);
230
282
  var allowStyleTag = config.sanitizerAllowStyleTags === true;
231
283
 
232
284
  // Collect first: removing nodes while walking a live list skips siblings.
@@ -235,7 +287,7 @@ function RTE_Plugin_Sanitizer() {
235
287
  for (var i = 0; i < n.childNodes.length; i++) {
236
288
  var c = n.childNodes[i];
237
289
  if (c.nodeType === 1) { all.push(c); collect(c); }
238
- else if (c.nodeType === 8) all.push(c); // comments can hide markup
290
+ else if (c.nodeType === 8) all.push(c); // checked, see commentEscapes()
239
291
  }
240
292
  })(root);
241
293
 
@@ -243,7 +295,33 @@ function RTE_Plugin_Sanitizer() {
243
295
  var el = all[i];
244
296
  if (!el.parentNode) continue; // already removed with an ancestor
245
297
 
246
- if (el.nodeType === 8) { el.parentNode.removeChild(el); continue; }
298
+ if (el.nodeType === 8) {
299
+ // Comments used to be removed wholesale, on the stated grounds that
300
+ // "comments can hide markup". That reason does not survive testing:
301
+ // serialise <!--<script>alert(1)</script>--> and re-parse it and the
302
+ // script is inert -- a well-formed comment is just text.
303
+ //
304
+ // What IS unsafe is a narrow, enumerable set of comment DATA that
305
+ // cannot survive serialisation, because the serialiser writes the
306
+ // data verbatim between <!-- and -->. Re-parsing then ends the
307
+ // comment early and the remainder becomes live markup:
308
+ //
309
+ // "--> <img src=x onerror=...>" -> <!----> <img ...>--> ESCAPES
310
+ // "--!> <img ...>" -> <!----!> <img ...>--> ESCAPES
311
+ // "> <img ...>" -> <!--> <img ...>--> ESCAPES
312
+ // "-> <img ...>" -> <!---> <img ...>--> ESCAPES
313
+ //
314
+ // That is the WHATWG serialisation constraint, not a heuristic, so
315
+ // it can be enumerated rather than guessed at. Everything else is
316
+ // kept, which is what lets a CMS round-trip its own markers and lets
317
+ // an email template keep its Outlook conditional comments -- the
318
+ // mechanism emailtoolkit.js needs and could not previously rely on.
319
+ if (commentEscapes(el.data)) {
320
+ note(report.removedTags, "#comment");
321
+ el.parentNode.removeChild(el);
322
+ }
323
+ continue;
324
+ }
247
325
 
248
326
  var tag = (el.localName || el.nodeName || "").toLowerCase();
249
327
 
@@ -271,7 +349,7 @@ function RTE_Plugin_Sanitizer() {
271
349
  note(report.removedTags, tag);
272
350
  el.parentNode.removeChild(el);
273
351
  continue;
274
- } else if (!ALLOWED_TAGS[tag] && !allowExtra[tag]) {
352
+ } else if (!ALLOWED_TAGS[tag] && !allowExtra(tag)) {
275
353
  // Unknown element: unwrap rather than delete, so the words a
276
354
  // user typed inside a stray tag are not silently lost.
277
355
  note(report.removedTags, tag);
@@ -300,7 +378,7 @@ function RTE_Plugin_Sanitizer() {
300
378
  if (!isSafeUrl(value, tag, name)) { note(report.removedAttributes, tag + "[" + name + "]"); el.removeAttribute(attrs[k].name); }
301
379
  continue;
302
380
  }
303
- if (!ALLOWED_ATTRS[name] && !attrExtra[name]) {
381
+ if (!ALLOWED_ATTRS[name] && !attrExtra(name)) {
304
382
  note(report.removedAttributes, name);
305
383
  el.removeAttribute(attrs[k].name);
306
384
  }
@@ -413,6 +491,44 @@ function RTE_Plugin_Sanitizer() {
413
491
  // Measured: the payload ran locally AND replicated to the peer with its
414
492
  // onerror attribute intact.
415
493
  //
494
+ // THREAT MODEL -- read this before tightening or loosening the rule below.
495
+ //
496
+ // An escaping comment CANNOT BE AUTHORED AS AN HTML STRING. Feed
497
+ // `<!--x--> <img src=q onerror=...>-->` to setHTMLCode and the parser yields
498
+ // comment("x") + text + a normal <img> element, which the element/attribute
499
+ // filters already handle. The ONLY way to obtain a comment node whose data
500
+ // escapes is programmatic DOM construction -- document.createComment(), or a
501
+ // sync layer building nodes directly.
502
+ //
503
+ // That is why the guard lives on the DOM-level paths (the sanitise pass and the
504
+ // live-DOM MutationObserver) and why the string paths need nothing extra. Both
505
+ // string directions were measured: sender getHTMLCode, receiver setHTMLCode, and
506
+ // the receiver's live DOM all yield zero executable attributes, because
507
+ // wrapSerializers() and wrapSetters() filter on the way out and on the way in.
508
+ //
509
+ // The previous rule here deleted EVERY comment, justified only by the remark
510
+ // "comments can hide markup". That is false -- serialise
511
+ // <!--<script>alert(1)</script>--> and re-parse it and the script is inert -- and
512
+ // it cost the product CMS round-tripping and Outlook conditional comments (the
513
+ // mechanism emailtoolkit.js needs) for as long as nobody re-checked it. An
514
+ // unexplained restriction outlives its reason, so this one is written down.
515
+ //
516
+ // A comment's data is written verbatim between <!-- and -->, so data that
517
+ // contains a terminator (or opens with one) breaks out of the comment when the
518
+ // serialised output is re-parsed, turning the remainder into live markup.
519
+ // Module scope on purpose: both the sanitiser pass and the live-DOM
520
+ // MutationObserver below need it, and they must agree exactly on what is unsafe.
521
+ // Deliberately conservative -- it rejects rather than rewrites, because silently
522
+ // editing someone's comment text is its own kind of surprise.
523
+ function commentEscapes(data) {
524
+ var d = String(data == null ? "" : data);
525
+ if (d.indexOf("-->") !== -1) return true;
526
+ if (d.indexOf("--!>") !== -1) return true;
527
+ if (d.charAt(0) === ">") return true;
528
+ if (d.charAt(0) === "-" && d.charAt(1) === ">") return true;
529
+ return false;
530
+ }
531
+
416
532
  // A MutationObserver is the only place to catch every arrival path — sync,
417
533
  // drop, a third-party plugin, host-page code. It is honest to say this
418
534
  // narrows the window rather than closing it completely: the observer runs
@@ -463,6 +579,16 @@ function RTE_Plugin_Sanitizer() {
463
579
  }
464
580
  for (var a = 0; a < rec.addedNodes.length; a++) {
465
581
  var node = rec.addedNodes[a];
582
+ // Comments are preserved now, so an injected comment whose data
583
+ // escapes on re-parse has to wake the sanitiser. This loop used
584
+ // to skip every non-element node, which was safe only while
585
+ // comments were being deleted unconditionally. The CRDT sync
586
+ // path is exactly how a node arrives here without passing the
587
+ // input filter -- that is the path that produced the stored XSS.
588
+ if (node.nodeType === 8) {
589
+ if (commentEscapes(node.data)) { suspect = true; break; }
590
+ continue;
591
+ }
466
592
  if (node.nodeType !== 1) continue; // typing inserts text: free
467
593
  if (looksDangerous(node)) { suspect = true; break; }
468
594
  }
@@ -140,7 +140,17 @@ function RTE_Plugin_ShadowDom() {
140
140
  var id = String(node.id || "");
141
141
  if (/^rte[-_]|^__rte/.test(id)) return true;
142
142
  var href = node.href || "";
143
- if (!href) return false;
143
+ if (!href) {
144
+ // An inline <style> with no id and no href is what bundlers (Vite,
145
+ // webpack style-loader) inject for an imported theme. Recognise it by
146
+ // its content, or a shadow-root editor is unstyled (39,726 px toolbar,
147
+ // Times New Roman) exactly as before this plugin existed. 2026-09-02.
148
+ if (node.tagName === "STYLE") {
149
+ var css = node.textContent || "";
150
+ return /[.]richtexteditor|rte-toolbar|rte-editor|rte-dialog|[.]rte-a11y/.test(css);
151
+ }
152
+ return false;
153
+ }
144
154
  var base = assetBase();
145
155
  if (base && href.indexOf(base) === 0) return true;
146
156
  // Fall back to the file names this product actually ships, so an
@@ -35,7 +35,7 @@ function RTE_Plugin_SlashCommand() {
35
35
  editor.slashCommands = {
36
36
  register: registerCommand,
37
37
  remove: removeCommand,
38
- list: function () { return commands.slice(); },
38
+ list: function () { refreshDefaults(); return commands.slice(); },
39
39
  open: function () { openPopup(true); },
40
40
  close: closePopup,
41
41
  isOpen: function () { return !!popupEl; }
@@ -47,6 +47,15 @@ function RTE_Plugin_SlashCommand() {
47
47
  registerCommand(config.slashCommands[i]);
48
48
  }
49
49
  }
50
+ // Plugins initialise synchronously in bundle (alphabetical) order, so at
51
+ // this point every plugin sorting AFTER "slashcommand" (tabletools,
52
+ // textdirection, watermark, tableofcontents, typewriter, ...) has not
53
+ // defined its editor.* API yet and the typeof gates in
54
+ // buildDefaultCommands() were false: nine entries (sort asc/desc, rtl/
55
+ // ltr/autodir, watermark, toc, typewriter, focusmode) were missing for
56
+ // every customer until 2026-09-02. Rebuild once the constructor has
57
+ // finished (setTimeout 0 runs after all plugin inits) and again on open.
58
+ setTimeout(refreshDefaults, 0);
50
59
 
51
60
  injectStyles();
52
61
 
@@ -69,7 +78,26 @@ function RTE_Plugin_SlashCommand() {
69
78
  else commands.push(normalizeCommand(def));
70
79
  }
71
80
 
81
+ var removedIds = {};
82
+ // Rebuild the gated default entries (their typeof checks may only be true
83
+ // now) while keeping every custom registration and honouring remove().
84
+ function refreshDefaults() {
85
+ if (!editor) return;
86
+ var custom = [];
87
+ for (var i = 0; i < commands.length; i++) if (!commands[i]._default) custom.push(commands[i]);
88
+ var defaults = buildDefaultCommands();
89
+ var next = [];
90
+ for (var d = 0; d < defaults.length; d++) {
91
+ var id = defaults[d].id;
92
+ if (removedIds[id]) continue;
93
+ var overridden = false;
94
+ for (var c = 0; c < custom.length; c++) if (custom[c].id === id) { overridden = true; break; }
95
+ if (!overridden) next.push(defaults[d]);
96
+ }
97
+ commands = next.concat(custom);
98
+ }
72
99
  function removeCommand(id) {
100
+ removedIds[id] = true;
73
101
  var idx = findCommandIndex(id);
74
102
  if (idx >= 0) commands.splice(idx, 1);
75
103
  }
@@ -79,12 +107,25 @@ function RTE_Plugin_SlashCommand() {
79
107
  return -1;
80
108
  }
81
109
 
110
+ // Localization with an English fallback for EVERY entry (defaults, plugin
111
+ // register() calls, AI actions, config.slashCommands): the lang packs set
112
+ // config.text_<key>; __GetLangText returns the raw KEY when one is missing,
113
+ // so resolve only when a translation exists. Keys: text_slash_<id> (title),
114
+ // text_slash_<id>_desc (description), text_slash_section_<section>.
115
+ function L(key, fallback) {
116
+ try {
117
+ var k = String(key).toLowerCase().replace(/[^a-z0-9_]/g, "_");
118
+ if (config && config["text_" + k] && editor && typeof editor.getLangText === "function") return editor.getLangText(k);
119
+ } catch (e) { }
120
+ return fallback;
121
+ }
82
122
  function normalizeCommand(def) {
123
+ var section = def.section || "Blocks";
83
124
  return {
84
125
  id: def.id,
85
- section: def.section || "Blocks",
86
- title: def.title || def.id,
87
- description: def.description || "",
126
+ section: L("slash_section_" + section, section),
127
+ title: L("slash_" + def.id, def.title || def.id),
128
+ description: L("slash_" + def.id + "_desc", def.description || ""),
88
129
  keywords: (def.keywords || []).slice(),
89
130
  icon: def.icon || "",
90
131
  iconSvg: def.iconSvg || "",
@@ -135,11 +176,13 @@ function RTE_Plugin_SlashCommand() {
135
176
  var list = [];
136
177
 
137
178
  function push(section, id, title, description, keywords, iconSvg, run) {
138
- list.push(normalizeCommand({
179
+ var cmd = normalizeCommand({ // normalizeCommand localizes title/description/section
139
180
  id: id, section: section, title: title,
140
181
  description: description, keywords: keywords,
141
182
  iconSvg: iconSvg, run: run
142
- }));
183
+ });
184
+ cmd._default = true;
185
+ list.push(cmd);
143
186
  }
144
187
 
145
188
  push("Blocks", "heading1", "Heading 1", "Large section heading", ["h1", "title"], iconHeading("1"),
@@ -355,7 +398,8 @@ function RTE_Plugin_SlashCommand() {
355
398
  // Gated on the API rather than registered by the plugin itself: plugins
356
399
  // initialise in bundle order, so anything sorting before "slashcommand"
357
400
  // would call slashCommands.register() before it exists and vanish from
358
- // this menu without any error. This check runs when the menu opens.
401
+ // this menu without any error. These checks are re-run by refreshDefaults()
402
+ // after all plugins have initialised and again each time the menu opens.
359
403
  if (typeof editor.exportToPdf === "function") {
360
404
  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(),
361
405
  function () { editor.exportToPdf(); });
@@ -364,6 +408,32 @@ function RTE_Plugin_SlashCommand() {
364
408
  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(),
365
409
  function () { editor.exportToDocx(); });
366
410
  }
411
+ // documentimport.js and readabilitystats.js sort before this file, so they
412
+ // cannot register at init (editor.slashCommands does not exist yet); their
413
+ // guarded calls silently did nothing and these two entries were missing for
414
+ // every customer until 2026-09-02. Declared here, gated on the plugin API.
415
+ if (typeof editor.openImportDialog === "function") {
416
+ 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(),
417
+ function () { editor.openImportDialog({}); });
418
+ }
419
+ if (typeof editor.getReadabilityStats === "function") {
420
+ push("Tools", "readability-stats", "Readability statistics", "Flesch reading ease, grade level, and reading time", ["readability", "flesch", "grade", "reading", "stats", "score"], iconSpell(),
421
+ function () { editor.execCommand("readability"); });
422
+ }
423
+ // Marketed features that had NO toolbar button and NO slash entry (reachable
424
+ // only via the API) until 2026-09-02. All gated on the owning plugin.
425
+ if (typeof editor.insertCrossReference === "function") {
426
+ push("Insert", "crossreference", "Cross-reference", "Insert a live reference to a heading, figure, table, or bookmark", ["cross", "reference", "xref", "see", "figure", "heading"], iconXref(),
427
+ function () { editor.execCommand("insertcrossreference"); });
428
+ }
429
+ if (editor.restrictedEditing) {
430
+ push("Tools", "restrictedediting", "Restricted editing", "Lock the document and mark only the regions others may edit", ["restrict", "lock", "protect", "editable", "region", "permission"], iconPen(),
431
+ function () { editor.execCommand("restrictedediting"); });
432
+ }
433
+ if (editor.dictation) {
434
+ push("Tools", "dictation", "Dictation", "Speak and have your words typed into the document", ["dictate", "speech", "voice", "microphone", "talk"], iconReadAloud(),
435
+ function () { editor.execCommand("dictation"); });
436
+ }
367
437
  if (typeof editor.copyAsMarkdown === "function") {
368
438
  push("Tools", "copymarkdown", "Copy as Markdown", "Copy the whole document to the clipboard as Markdown", ["markdown", "md", "copy", "clipboard", "export"], iconMarkdown(),
369
439
  function () { editor.copyAsMarkdown(); });
@@ -495,6 +565,7 @@ function RTE_Plugin_SlashCommand() {
495
565
  }
496
566
 
497
567
  function openPopup(manual) {
568
+ refreshDefaults();
498
569
  closePopup();
499
570
  var sel = editor.getSelection();
500
571
  if (!sel || sel.rangeCount === 0) return;
@@ -140,9 +140,42 @@ function RTE_Plugin_YjsCollab() {
140
140
  // and the review ledger bridge.
141
141
  if (session.textSyncMode === "crdt") {
142
142
  try {
143
+ // 2026-09-02: the engine bundles its OWN Yjs and builds nodes with it.
144
+ // A doc built by the host's Yjs (npm/esm "yjs" + y-websocket - the
145
+ // documented path) is a FOREIGN instance: inserting engine-built nodes
146
+ // into it throws "Unexpected content type in insert operation" and text
147
+ // never syncs, while presence still works and the status still said
148
+ // "crdt". Yjs updates are instance-independent bytes, so bind the
149
+ // engine to an inner doc built with ITS Yjs and bridge updates both
150
+ // ways: host->inner with the engine's applyUpdate, inner->host with the
151
+ // host's module (options.Y or window.Y). Without the host module the
152
+ // outbound leg cannot be applied safely, so fail LOUDLY instead.
153
+ var engineY = window.RichTextEditorCrdt.Y || null;
154
+ var hostY = options.Y || (typeof window !== "undefined" ? window.Y : null) || null;
155
+ var bindDoc = doc;
156
+ var foreign = !!(engineY && engineY.Doc && !(doc instanceof engineY.Doc));
157
+ if (foreign) {
158
+ if (!hostY || typeof hostY.applyUpdate !== "function" || typeof hostY.encodeStateAsUpdate !== "function") {
159
+ 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.");
160
+ }
161
+ var BRIDGE = { bridge: "rte-yjs-bridge" };
162
+ var inner = new engineY.Doc();
163
+ engineY.applyUpdate(inner, hostY.encodeStateAsUpdate(doc), BRIDGE);
164
+ var LOCAL = window.RichTextEditorCrdt.LOCAL_ORIGIN;
165
+ // A host-doc write the plugin tagged as LOCAL (ledger entries) must not
166
+ // make the engine re-render the editable (see flush race in the engine).
167
+ 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); } };
168
+ 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); } };
169
+ doc.on("update", hostToInner);
170
+ inner.on("update", innerToHost);
171
+ session.cleanup.push(function () { try { doc.off("update", hostToInner); } catch (e) { } try { inner.off("update", innerToHost); } catch (e) { } try { inner.destroy(); } catch (e) { } });
172
+ session.yjsBridge = true;
173
+ bindDoc = inner;
174
+ if (window.console && console.info) console.info("yjscollab: bridging a foreign Yjs doc into the CRDT engine (updates relayed both ways).");
175
+ }
143
176
  session.crdtBinding = window.RichTextEditorCrdt.attachCrdtBinding({
144
177
  editable: editor.getEditable(),
145
- ydoc: doc,
178
+ ydoc: bindDoc,
146
179
  provider: provider,
147
180
  awareness: provider.awareness,
148
181
  fragmentName: options.fragmentName || "default",
@@ -155,6 +188,24 @@ function RTE_Plugin_YjsCollab() {
155
188
  try { session.crdtBinding && session.crdtBinding.dispose(); }
156
189
  catch (ignore) { }
157
190
  });
191
+ // Per-author undo. The engine ships a Y.UndoManager scoped to LOCAL_ORIGIN
192
+ // (undo.ts) but nothing attached it, so Ctrl+Z ran the core DOM-snapshot
193
+ // undo and reverted OTHER people's edits on every peer (2026-09-02). Route
194
+ // undo/redo to the engine while crdt mode is active.
195
+ try {
196
+ if (typeof window.RichTextEditorCrdt.attachUndoManager === "function" && session.crdtBinding && session.crdtBinding.fragment) {
197
+ session.undoManager = window.RichTextEditorCrdt.attachUndoManager({ fragment: session.crdtBinding.fragment });
198
+ var undoHook = function (state) { if (!session || !session.undoManager) return; state.returnValue = true; state.stopBubble = true; try { session.undoManager.undo(); } catch (e) { } };
199
+ var redoHook = function (state) { if (!session || !session.undoManager) return; state.returnValue = true; state.stopBubble = true; try { session.undoManager.redo(); } catch (e) { } };
200
+ editor.attachEvent("exec_command_undo", undoHook);
201
+ editor.attachEvent("exec_command_redo", redoHook);
202
+ session.cleanup.push(function () {
203
+ try { if (typeof editor.detachEvent === "function") { editor.detachEvent("exec_command_undo", undoHook); editor.detachEvent("exec_command_redo", redoHook); } } catch (e) { }
204
+ try { session.undoManager && session.undoManager.destroy(); } catch (e) { }
205
+ session.undoManager = null;
206
+ });
207
+ }
208
+ } catch (undoErr) { if (window.console) console.warn("yjscollab: undo manager not attached:", undoErr); }
158
209
  } catch (err) {
159
210
  session.fallbackReason = "crdt-attach-failed";
160
211
  session.crdtError = err;
@@ -187,7 +238,7 @@ function RTE_Plugin_YjsCollab() {
187
238
  }
188
239
  return {
189
240
  attached: true,
190
- textSyncMode: session.textSyncMode,
241
+ textSyncMode: session.textSyncMode, yjsBridge: !!session.yjsBridge,
191
242
  requestedTextSync: session.requestedTextSync,
192
243
  fallbackReason: session.fallbackReason || null,
193
244
  peerCount: getRemotePeers().length
@@ -432,6 +483,52 @@ function RTE_Plugin_YjsCollab() {
432
483
  lastKnownHtml = localInitial;
433
484
  }
434
485
 
486
+ // --- IME composition gate (legacy snapshot mode) ---------------------
487
+ // setEditorHtml() replaces the whole document. Doing that while an IME
488
+ // is composing swaps the live text node out from under it, so the
489
+ // commit lands at a stale offset and the raw buffer survives: composing
490
+ // "shi" and committing a character while a collaborator typed produced
491
+ // "Xbasesh<char>i" on BOTH peers (2026-09-03 — the same corruption the
492
+ // crdt binding had, on the deprecated-but-supported path v2.0 customers
493
+ // still run). Hold the repaint until the composition commits.
494
+ var composing = false;
495
+ var pendingRemoteHtml = null;
496
+ var compositionDoc = (editor.getDocument && editor.getDocument()) || null;
497
+ if (compositionDoc) {
498
+ var onCompStart = function () { composing = true; };
499
+ var onCompEnd = function () {
500
+ if (!composing) return;
501
+ composing = false;
502
+ setTimeout(function () {
503
+ if (pendingRemoteHtml !== null) {
504
+ // Deliberately NOT repainting: that would discard the
505
+ // character the IME just committed. Adopt the remote text as
506
+ // the diff baseline instead, so the local push that follows
507
+ // is computed against what the peers actually hold. Legacy
508
+ // mode is documented last-write-wins on same-paragraph
509
+ // conflicts, so the local commit winning is correct here —
510
+ // corrupted text was not.
511
+ lastKnownHtml = pendingRemoteHtml;
512
+ pendingRemoteHtml = null;
513
+ }
514
+ // Push explicitly. The commit's DOM mutation is delivered to
515
+ // the MutationObserver BEFORE compositionend fires, so it was
516
+ // dropped by the `composing` guard — without this the
517
+ // committed character never reaches the peers at all
518
+ // (measured: A "base<char>", B "base").
519
+ onEditorMutation();
520
+ }, 0);
521
+ };
522
+ // Bound on the DOCUMENT, not the body: some skins remount the body
523
+ // (see attachMutationObserver below) and composition events bubble.
524
+ compositionDoc.addEventListener("compositionstart", onCompStart, true);
525
+ compositionDoc.addEventListener("compositionend", onCompEnd, true);
526
+ session.cleanup.push(function () {
527
+ compositionDoc.removeEventListener("compositionstart", onCompStart, true);
528
+ compositionDoc.removeEventListener("compositionend", onCompEnd, true);
529
+ });
530
+ }
531
+
435
532
  // Y.Text → editor.
436
533
  var onTextChange = function (event, transaction) {
437
534
  if (pushing) return;
@@ -439,6 +536,7 @@ function RTE_Plugin_YjsCollab() {
439
536
  if (transaction && transaction.local) return;
440
537
  var fresh = session.textMap.toString();
441
538
  if (fresh === lastKnownHtml) return;
539
+ if (composing) { pendingRemoteHtml = fresh; return; }
442
540
  setEditorHtml(fresh);
443
541
  lastKnownHtml = fresh;
444
542
  // 2026-05-28 setEditorHtml writes into editable.innerHTML; if that
@@ -453,6 +551,9 @@ function RTE_Plugin_YjsCollab() {
453
551
  // Editor DOM → Y.Text. Debounced so bursts collapse to one CRDT diff.
454
552
  var onEditorMutation = function () {
455
553
  if (applying) return;
554
+ // Intermediate IME buffers are not real edits; the commit fires its
555
+ // own mutation. Pushing them also races the composition gate above.
556
+ if (composing) return;
456
557
  if (pendingPushTimer) return;
457
558
  // 2026-05-28 Re-attach observer if the body was remounted since last
458
559
  // mutation. Cheap no-op when body is unchanged.
@@ -540,17 +641,17 @@ function RTE_Plugin_YjsCollab() {
540
641
 
541
642
  ledger.add = function (entry) {
542
643
  var result = originalAdd.call(ledger, entry);
543
- if (result && !echoGuard) session.ledgerMap.set(result.id, cloneEntry(result));
644
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.set(result.id, cloneEntry(result)); });
544
645
  return result;
545
646
  };
546
647
  ledger.update = function (id, patch) {
547
648
  var result = originalUpdate.call(ledger, id, patch);
548
- if (result && !echoGuard) session.ledgerMap.set(result.id, cloneEntry(result));
649
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.set(result.id, cloneEntry(result)); });
549
650
  return result;
550
651
  };
551
652
  ledger.remove = function (id) {
552
653
  var result = originalRemove.call(ledger, id);
553
- if (result && !echoGuard) session.ledgerMap.delete(id);
654
+ if (result && !echoGuard) ledgerWrite(function () { session.ledgerMap.delete(id); });
554
655
  return result;
555
656
  };
556
657
 
@@ -584,6 +685,16 @@ function RTE_Plugin_YjsCollab() {
584
685
  });
585
686
  }
586
687
 
688
+ // Ledger writes are the plugin's own (non-text) Y.Doc transactions. In crdt
689
+ // mode they are tagged with the engine's LOCAL_ORIGIN so the engine does not
690
+ // re-render the editable for them - a re-render between a DOM mutation and
691
+ // the observer flush discarded that mutation (2026-09-02: comment anchors
692
+ // vanished the instant the ledger entry was written).
693
+ function ledgerWrite(fn) {
694
+ var origin = (session && session.textSyncMode === "crdt" && window.RichTextEditorCrdt) ? window.RichTextEditorCrdt.LOCAL_ORIGIN : null;
695
+ if (origin && session && session.doc && typeof session.doc.transact === "function") { session.doc.transact(fn, origin); }
696
+ else { fn(); }
697
+ }
587
698
  function seedLedgerFromRemote() {
588
699
  if (!editor.reviewLedger || !session.ledgerMap) return;
589
700
  echoGuard = true;
@@ -145,6 +145,21 @@
145
145
  // silently threw the edits away and restored stale text. Found by the
146
146
  // unmount/remount check in the test suite.
147
147
  if (!this._everConnected && this.hasAttribute("value")) this._value = this.getAttribute("value") || "";
148
+ if (!this._everConnected) {
149
+ // <textarea>-style progressive enhancement: markup written between the
150
+ // tags is the initial document when no value attribute seeds one. The
151
+ // children are consumed either way - until 2026-09-02 they were left
152
+ // in the light DOM and rendered as stray content beside the editor.
153
+ var seed = "";
154
+ var kids = Array.prototype.slice.call(this.childNodes);
155
+ for (var ki = 0; ki < kids.length; ki++) {
156
+ var kid = kids[ki];
157
+ if (kid.nodeType === 1) seed += kid.outerHTML;
158
+ else if (kid.nodeType === 3) seed += kid.data;
159
+ try { this.removeChild(kid); } catch (e) { }
160
+ }
161
+ if (!this.hasAttribute("value") && seed.trim()) this._value = seed;
162
+ }
148
163
  this._everConnected = true;
149
164
 
150
165
  var mount = this;