@richscripts/richtexteditor 2.4.1 → 2.5.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.
@@ -231,6 +231,15 @@ function RTE_Plugin_InsertGallery() {
231
231
 
232
232
  var browser = append(dialoginner, "div", "", "rte-gallery-browser");
233
233
 
234
+ // Exactly what the upload endpoint accepts, and nothing more. The file
235
+ // picker used to advertise "image/*,...,.svg", so it offered files the
236
+ // server then rejected with "Invalid file extension" and the user had no
237
+ // way to know the real list until after the failure. Declared here
238
+ // because both the header hint and the file input read it.
239
+ // SVG is excluded on purpose: it can carry script, so it is not a safe
240
+ // thing to store and serve back from our own origin.
241
+ var GALLERY_ACCEPT = [".jpg", ".jpeg", ".jfif", ".png", ".gif", ".webp", ".bmp", ".avif"];
242
+
234
243
  // The dialog frame already draws a title bar and close button, so this is
235
244
  // a one-line subtitle, not a second heading.
236
245
  var header = append(browser, "div", "", "rte-dialog-browser-header");
@@ -239,6 +248,12 @@ function RTE_Plugin_InsertGallery() {
239
248
  ? "Browse folders, upload new files, and insert the selected image into the editor."
240
249
  : "Browse the available images, filter by name, and insert the selected image into the editor.";
241
250
 
251
+ // State the accepted formats up front. Previously the only way to learn
252
+ // them was to pick an unsupported file and read "Invalid file extension".
253
+ var acceptHint = append(header, "div", "", "rte-dialog-browser-accept");
254
+ acceptHint.innerText = "Accepted formats: " +
255
+ GALLERY_ACCEPT.map(function (ext) { return ext.replace(".", "").toUpperCase(); }).join(", ");
256
+
242
257
  var toolbar = append(browser, "div", "", "rte-gallery-browser-toolbar");
243
258
  var toolbarLeft = append(toolbar, "div", "", "rte-gallery-browser-toolbar-group");
244
259
  var toolbarRight = append(toolbar, "div", "", "rte-gallery-browser-toolbar-group rte-gallery-browser-toolbar-group-right");
@@ -273,7 +288,7 @@ function RTE_Plugin_InsertGallery() {
273
288
 
274
289
  var fileInput = append(toolbarRight, "input", "display:none;");
275
290
  fileInput.type = "file";
276
- fileInput.accept = "image/*,.jpg,.jpeg,.png,.gif,.bmp,.webp,.svg";
291
+ fileInput.accept = GALLERY_ACCEPT.join(",");
277
292
  fileInput.multiple = true;
278
293
 
279
294
  // Without a server there is no folder tree to walk and nothing to create
@@ -299,6 +314,12 @@ function RTE_Plugin_InsertGallery() {
299
314
  grid.setAttribute("role", "listbox");
300
315
  grid.setAttribute("aria-label", "Available images");
301
316
 
317
+ // Non-blocking failure notice. Sits above the grid instead of replacing
318
+ // it, so a rejected upload never hides the images you can still pick.
319
+ var errorBar = append(surface, "div", "", "rte-gallery-browser-error");
320
+ errorBar.setAttribute("role", "status");
321
+ show(errorBar, false);
322
+
302
323
  var empty = append(surface, "div", "", "rte-gallery-browser-empty");
303
324
 
304
325
  var footer = append(browser, "div", "", "rte-gallery-browser-footer");
@@ -341,6 +362,9 @@ function RTE_Plugin_InsertGallery() {
341
362
 
342
363
  function setSelected(url) {
343
364
  state.selectedUrl = url || "";
365
+ // Picking an image is a fresh action, so a stale failure notice from
366
+ // an earlier upload must not linger next to it.
367
+ state.error = "";
344
368
  render();
345
369
  }
346
370
 
@@ -494,12 +518,16 @@ function RTE_Plugin_InsertGallery() {
494
518
  return;
495
519
  }
496
520
 
521
+ // An upload failure must not wipe the browser. It used to replace the
522
+ // whole grid, so one rejected file left the dialog reading "0 items"
523
+ // with an error where the images should be - while the footer still
524
+ // said the previously selected image was ready to insert. Two panels
525
+ // contradicting each other. Show it as a banner and keep browsing.
497
526
  if (state.error) {
498
- empty.innerText = state.error;
499
- show(empty, true);
500
- show(grid, false);
501
- updateStatus(0, 0);
502
- return;
527
+ errorBar.innerText = state.error;
528
+ show(errorBar, true);
529
+ } else {
530
+ show(errorBar, false);
503
531
  }
504
532
 
505
533
  for (var folderIndex = 0; folderIndex < visibleFolders.length; folderIndex++) {
@@ -227,7 +227,33 @@ function RTE_Plugin_KeyboardA11y() {
227
227
  // The editor already tracks active state — it just keeps it in a class.
228
228
  // Mirroring rather than recomputing means the announced state can never
229
229
  // disagree with the highlighted button.
230
- var TOGGLE_CMD = /^(bold|italic|underline|strikethrough|subscript|superscript|justifyleft|justifycenter|justifyright|justifyfull|insertorderedlist|insertunorderedlist|outdent|indent|blockquote|inlinecode|trackchanges|typewriter|focusmode|pagination|formattingmarks|linenumbers|permanentpen|rtlui)$/;
230
+ // Command names here must match the TOOLBAR command exactly — the regex is
231
+ // anchored, so a near-miss is a silent no-op that looks like coverage.
232
+ // "blockquote" is one: the command is `insertblockquote`, so the entry
233
+ // written specifically for it never matched and that button ships with no
234
+ // aria-pressed. Found by checking every genuine toggle on the full toolbar
235
+ // rather than the three the published verify page samples.
236
+ //
237
+ // This mirror only copies the editor's own active state, so a command may
238
+ // only be listed here once core actually tracks it. Emitting the attribute
239
+ // for an untracked command yields a permanent aria-pressed="false" —
240
+ // confidently announcing "not pressed" while the formatting IS applied,
241
+ // which makes announced state DISAGREE with the highlight. That is the exact
242
+ // failure this mirror exists to prevent, so silence beats a wrong answer.
243
+ // - `toggleborder` was already tracked (core checks the editable's
244
+ // rte-toggleborder class); it reads inactive simply when borders are off.
245
+ // - `insertblockquote` was NOT tracked and now is, via a core case added
246
+ // alongside `indent`, which resolves the same way.
247
+ // Both verified on the running editor rather than inferred.
248
+ //
249
+ // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
250
+ // this build, and now the reason is known rather than assumed: pagination.js
251
+ // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
252
+ // and register no toolbar command, and insertcode.js registers `insertcode`,
253
+ // which inserts a block rather than toggling the selection. They are kept —
254
+ // harmless, and correct the moment a toolbar button is added — but they
255
+ // cover nothing today, so do not read this list as evidence of coverage.
256
+ var TOGGLE_CMD = /^(bold|italic|underline|strikethrough|subscript|superscript|justifyleft|justifycenter|justifyright|justifyfull|insertorderedlist|insertunorderedlist|outdent|indent|insertblockquote|toggleborder|inlinecode|trackchanges|typewriter|focusmode|pagination|formattingmarks|linenumbers|permanentpen|rtlui)$/;
231
257
 
232
258
  function syncToggleStates(root) {
233
259
  var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
@@ -0,0 +1,269 @@
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ // 2026-08-15 Multi-root editing — one toolbar, several editable regions.
4
+ //
5
+ // The CMS / page-builder case: a page has a title, a summary and a body, and the
6
+ // author wants one toolbar that acts on whichever region they are in, not three
7
+ // stacked toolbars eating the viewport.
8
+ //
9
+ // SCOPE — read this before assuming parity with CKEditor's multi-root:
10
+ // This editor edits <body> inside an IFRAME and has no div/inline mode. One
11
+ // instance therefore owns exactly one editable document, and every plugin,
12
+ // the selection model and the CRDT binding are written against that
13
+ // assumption. Making a single instance own N editables is a core rewrite, not
14
+ // a plugin.
15
+ // What this does instead is COORDINATE several ordinary instances: they share
16
+ // one visible toolbar and one group-level API. That covers the layout and the
17
+ // workflow, which is what the feature is actually for.
18
+ // What it does NOT do, and cannot without core work:
19
+ // - a shared undo stack (Ctrl+Z undoes within the focused region only),
20
+ // - a selection spanning two regions,
21
+ // - one collaboration session across regions (each root syncs separately).
22
+ // Those are stated in getCapabilities() as well as here, so an integrator
23
+ // finds out from the API rather than from a bug report.
24
+ //
25
+ // Design notes:
26
+ // - Toolbars are SHOWN/HIDDEN, not rebuilt or re-pointed. Each instance's
27
+ // toolbar already drives its own editor correctly; re-routing commands
28
+ // across instances would mean reimplementing every command's notion of
29
+ // "current editor" and would break the moment a plugin cached one.
30
+ // - Moving the active toolbar into a shared host is optional. DOM moves
31
+ // preserve listeners, so the toolbar keeps working, but a host that is
32
+ // positioned or scrolled differently can change the float-panel anchoring —
33
+ // hence opt-in rather than default.
34
+ // - Registration is by GROUP NAME in a module-level registry, because plugins
35
+ // are constructed per instance and have no other way to see each other.
36
+ RTE_DefaultConfig.plugin_multiroot = RTE_Plugin_MultiRoot;
37
+
38
+ // Instances sharing a group name act as one multi-root editor. null = off.
39
+ if (typeof RTE_DefaultConfig.multiRootGroup === "undefined") RTE_DefaultConfig.multiRootGroup = null;
40
+ // A name for this root in the group API. Defaults to the container's id.
41
+ if (typeof RTE_DefaultConfig.multiRootName === "undefined") RTE_DefaultConfig.multiRootName = null;
42
+ // Element (or its id) to move the active toolbar into. null = leave in place.
43
+ if (typeof RTE_DefaultConfig.multiRootToolbarHost === "undefined") RTE_DefaultConfig.multiRootToolbarHost = null;
44
+
45
+ // group name -> { roots: [entry], active: entry }
46
+ if (!window.__RTE_MultiRootGroups) window.__RTE_MultiRootGroups = {};
47
+
48
+ function RTE_Plugin_MultiRoot() {
49
+ var obj = this;
50
+ var config, editor;
51
+ var entry = null;
52
+
53
+ obj.PluginName = "MultiRoot";
54
+
55
+ obj.InitConfig = function (argconfig) { config = argconfig; };
56
+
57
+ obj.InitEditor = function (argeditor) {
58
+ editor = argeditor;
59
+
60
+ var group = config.multiRootGroup;
61
+ if (!group) return; // opt-in
62
+
63
+ entry = {
64
+ name: config.multiRootName || containerId() || ("root" + (groupOf(group).roots.length + 1)),
65
+ editor: editor,
66
+ container: container(),
67
+ group: group
68
+ };
69
+ groupOf(group).roots.push(entry);
70
+
71
+ editor.multiRoot = api(group);
72
+
73
+ // Wait for the toolbar to exist before hiding anything.
74
+ setTimeout(function () { bindFocus(); settle(group); }, 0);
75
+ try { editor.attachEvent("ready", function () { bindFocus(); settle(group); }); } catch (e) {}
76
+ };
77
+
78
+ function groupOf(name) {
79
+ // `listeners` lives on the GROUP, not in this closure. Every root builds
80
+ // its own copy of this plugin, so an instance-local list means a callback
81
+ // registered through one root's API never hears an activation fired by a
82
+ // different root — the subscriber silently lags by one event, or misses
83
+ // everything, depending on which root the user clicks.
84
+ if (!window.__RTE_MultiRootGroups[name]) {
85
+ window.__RTE_MultiRootGroups[name] = { roots: [], active: null, listeners: [] };
86
+ }
87
+ var g = window.__RTE_MultiRootGroups[name];
88
+ if (!g.listeners) g.listeners = [];
89
+ return g;
90
+ }
91
+
92
+ // The editor exposes its host element as `container` (a property, not a
93
+ // getter — there is no getContainer()). Falling back through the iframe
94
+ // covers any build where that property is absent, rather than silently
95
+ // returning null and leaving every toolbar visible.
96
+ function container() {
97
+ try {
98
+ if (editor.container) return editor.container;
99
+ if (editor.iframe) {
100
+ var n = editor.iframe.parentNode;
101
+ while (n && !(n.classList && n.classList.contains("richtexteditor"))) n = n.parentNode;
102
+ return n || null;
103
+ }
104
+ } catch (e) {}
105
+ return null;
106
+ }
107
+
108
+ function containerId() {
109
+ var c = container();
110
+ return c && c.id ? c.id : null;
111
+ }
112
+
113
+ // ---- toolbar visibility ----------------------------------------------
114
+
115
+ function toolbarsOf(root) {
116
+ var c = root.container;
117
+ if (!c) return [];
118
+ return [].slice.call(c.querySelectorAll("rte-toolbar"));
119
+ }
120
+
121
+ function setVisible(root, visible) {
122
+ var bars = toolbarsOf(root);
123
+ for (var i = 0; i < bars.length; i++) {
124
+ var bar = bars[i];
125
+ if (visible) {
126
+ // Restore whatever the editor's own responsive logic had chosen
127
+ // (desktop vs mobile bar), rather than forcing display:flex on
128
+ // both and showing two toolbars at once.
129
+ if (bar.__rteMultiRootPrevDisplay !== undefined) {
130
+ bar.style.display = bar.__rteMultiRootPrevDisplay;
131
+ delete bar.__rteMultiRootPrevDisplay;
132
+ }
133
+ } else {
134
+ if (bar.__rteMultiRootPrevDisplay === undefined) {
135
+ bar.__rteMultiRootPrevDisplay = bar.style.display;
136
+ }
137
+ bar.style.display = "none";
138
+ }
139
+ }
140
+ }
141
+
142
+ function settle(groupName) {
143
+ var g = groupOf(groupName);
144
+ if (!g.roots.length) return;
145
+ if (!g.active) g.active = g.roots[0];
146
+ for (var i = 0; i < g.roots.length; i++) {
147
+ setVisible(g.roots[i], g.roots[i] === g.active);
148
+ }
149
+ hostToolbar(g.active);
150
+ }
151
+
152
+ function hostToolbar(root) {
153
+ var hostRef = config.multiRootToolbarHost;
154
+ if (!hostRef || !root) return;
155
+ var host = (typeof hostRef === "string") ? document.getElementById(hostRef) : hostRef;
156
+ if (!host) return;
157
+ var bars = toolbarsOf(root);
158
+ for (var i = 0; i < bars.length; i++) {
159
+ // Moving a node keeps its listeners, so the toolbar stays live.
160
+ if (bars[i].parentNode !== host) host.appendChild(bars[i]);
161
+ }
162
+ }
163
+
164
+ // ---- focus tracking ---------------------------------------------------
165
+
166
+ function bindFocus() {
167
+ var doc;
168
+ try { doc = editor.getDocument(); } catch (e) { return; }
169
+ if (!doc || doc.__rteMultiRootBound) return;
170
+ doc.__rteMultiRootBound = true;
171
+
172
+ var activate = function () {
173
+ if (!entry) return;
174
+ var g = groupOf(entry.group);
175
+ if (g.active === entry) return;
176
+ g.active = entry;
177
+ settle(entry.group);
178
+ fire(entry.group, entry);
179
+ };
180
+
181
+ // focusin bubbles; focus does not.
182
+ doc.addEventListener("focusin", activate, true);
183
+ doc.addEventListener("mousedown", activate, true);
184
+ }
185
+
186
+ function fire(groupName, root) {
187
+ var list = groupOf(groupName).listeners;
188
+ for (var i = 0; i < list.length; i++) {
189
+ try { list[i]({ active: root.name, editor: root.editor }); } catch (e) {}
190
+ }
191
+ }
192
+
193
+ // ---- group API --------------------------------------------------------
194
+
195
+ function api(groupName) {
196
+ var g = groupOf(groupName);
197
+ return {
198
+ group: groupName,
199
+
200
+ getRoots: function () {
201
+ return g.roots.map(function (r) { return r.name; });
202
+ },
203
+
204
+ getActive: function () {
205
+ return g.active ? g.active.name : null;
206
+ },
207
+
208
+ getEditor: function (name) {
209
+ for (var i = 0; i < g.roots.length; i++) if (g.roots[i].name === name) return g.roots[i].editor;
210
+ return null;
211
+ },
212
+
213
+ focusRoot: function (name) {
214
+ var target = null;
215
+ for (var i = 0; i < g.roots.length; i++) if (g.roots[i].name === name) target = g.roots[i];
216
+ if (!target) return false;
217
+ g.active = target;
218
+ settle(groupName);
219
+ try { target.editor.focus(); } catch (e) {}
220
+ fire(groupName, target);
221
+ return true;
222
+ },
223
+
224
+ // The whole document, one entry per root — what a CMS saves.
225
+ getHTML: function () {
226
+ var out = {};
227
+ for (var i = 0; i < g.roots.length; i++) {
228
+ try { out[g.roots[i].name] = g.roots[i].editor.getHTMLCode(); } catch (e) { out[g.roots[i].name] = null; }
229
+ }
230
+ return out;
231
+ },
232
+
233
+ setHTML: function (map) {
234
+ if (!map) return false;
235
+ for (var i = 0; i < g.roots.length; i++) {
236
+ var r = g.roots[i];
237
+ if (!(r.name in map)) continue;
238
+ try { r.editor.setHTMLCode(map[r.name]); } catch (e) {}
239
+ }
240
+ return true;
241
+ },
242
+
243
+ onActiveChange: function (fn) {
244
+ if (typeof fn !== "function") return function () {};
245
+ g.listeners.push(fn);
246
+ return function () {
247
+ var at = g.listeners.indexOf(fn);
248
+ if (at >= 0) g.listeners.splice(at, 1);
249
+ };
250
+ },
251
+
252
+ // Say plainly what this does and does not do, so an integrator does
253
+ // not discover the limits from a support ticket.
254
+ getCapabilities: function () {
255
+ return {
256
+ sharedToolbar: true,
257
+ sharedGroupApi: true,
258
+ perRootUndo: true,
259
+ sharedUndoStack: false,
260
+ selectionAcrossRoots: false,
261
+ sharedCollaborationSession: false,
262
+ note: "Each root is a separate editor instance sharing one visible toolbar. " +
263
+ "Undo, selection and collaboration are per-root; the editor edits an " +
264
+ "iframe body, so a single instance cannot own several editables without core changes."
265
+ };
266
+ }
267
+ };
268
+ }
269
+ }
@@ -270,6 +270,11 @@ function RTE_Plugin_RevisionHistory() {
270
270
  }
271
271
 
272
272
  function restore(id, opts) {
273
+ // The dialog's own Restore button calls restore(id) with no options, so
274
+ // reading opts.skipConfirm threw and the button did nothing at all. The
275
+ // public API wrapper defaulted this, the internal caller did not - so
276
+ // default it here, where every caller is covered.
277
+ opts = opts || {};
273
278
  var entry = findById(id);
274
279
  if (!entry) return false;
275
280
  if (!opts.skipConfirm) {
@@ -141,6 +141,17 @@ function RTE_Plugin_Sanitizer() {
141
141
  var v = String(value == null ? "" : value).replace(/[\u0000-\u0020\u007F-\u00A0]+/g, "").trim();
142
142
  if (!v) return false;
143
143
 
144
+ // <use> resolves its reference by FETCHING it. The only legitimate form
145
+ // in editor content is a same-document fragment (#icon-id) pointing at a
146
+ // <symbol>/<defs> in the same markup — nobody authoring a document means
147
+ // to reference a remote SVG. Left unrestricted it is a beacon that fires
148
+ // in every reader's browser for content one author saved, which is the
149
+ // same class of problem as an embedded remote logo or analytics pixel
150
+ // (see the no-external-calls hardening). Browsers additionally refuse
151
+ // cross-origin <use>, but relying on that is relying on someone else's
152
+ // policy to enforce ours.
153
+ if (tag === "use") return v.charAt(0) === "#";
154
+
144
155
  // Fragments, absolute paths and query-only URLs carry no scheme.
145
156
  if (/^[#?/]/.test(v)) return true;
146
157
 
@@ -243,6 +254,19 @@ function RTE_Plugin_Sanitizer() {
243
254
  // This is the construct that actually executed downstream.
244
255
  if (el.hasAttribute("srcdoc")) { note(report.removedAttributes, "iframe[srcdoc]"); el.removeAttribute("srcdoc"); }
245
256
  if (!iframeAllowed(el)) { note(report.removedTags, "iframe"); el.parentNode.removeChild(el); continue; }
257
+ } else if (tag === "title" || tag === "desc") {
258
+ // Legal inside <svg> (they supply its accessible name), which is
259
+ // why they are in ALLOWED_TAGS. But the allowance was
260
+ // unqualified, so a document-level <title> from a pasted or
261
+ // set full HTML document survived into SAVED content while
262
+ // <style>/<meta>/<link> beside it were stripped. Head-level
263
+ // markup has no business in body content; keep these only where
264
+ // they are actually valid.
265
+ if (!el.closest || !el.closest("svg")) {
266
+ note(report.removedTags, tag);
267
+ el.parentNode.removeChild(el);
268
+ continue;
269
+ }
246
270
  } else if (DROP_WHOLE[tag]) {
247
271
  note(report.removedTags, tag);
248
272
  el.parentNode.removeChild(el);