@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.
- package/angular/component.ts +16 -3
- package/integrations/shared.js +27 -0
- package/package.json +1 -1
- package/react/index.d.ts +7 -0
- package/react/index.js +25 -3
- package/richtexteditor/CHANGELOG.md +87 -0
- package/richtexteditor/all_plugins.js +366 -48
- package/richtexteditor/all_plugins.min.js +57 -57
- package/richtexteditor/lang/rte-lang-de.js +96 -0
- package/richtexteditor/lang/rte-lang-zh-Hans.js +181 -0
- package/richtexteditor/lang/rte-lang-zh-Hant.js +181 -0
- package/richtexteditor/plugins/all_plugins.js +366 -48
- package/richtexteditor/plugins/all_plugins.min.js +57 -57
- package/richtexteditor/plugins/comments.js +11 -1
- package/richtexteditor/plugins/crdt-engine.js +244 -17
- package/richtexteditor/plugins/crdt-engine.min.js +5 -5
- package/richtexteditor/plugins/documentimport.js +6 -11
- package/richtexteditor/plugins/insertcode.js +2 -2
- package/richtexteditor/plugins/readabilitystats.js +6 -11
- package/richtexteditor/plugins/sanitizer.js +136 -10
- package/richtexteditor/plugins/shadowdom.js +11 -1
- package/richtexteditor/plugins/slashcommand.js +78 -7
- package/richtexteditor/plugins/yjscollab.js +116 -5
- package/richtexteditor/rte-webcomponent.js +15 -0
- package/richtexteditor/rte.js +2 -2
- package/richtexteditor/rte_theme_default.css +13 -4
- package/richtexteditor/rte_theme_default.min.css +1 -1
- package/vue/index.js +12 -2
|
@@ -65,17 +65,12 @@ function RTE_Plugin_DocumentImport() {
|
|
|
65
65
|
openPicker({});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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); //
|
|
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) {
|
|
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
|
|
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
|
|
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)
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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;
|