@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.
@@ -0,0 +1,504 @@
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ // 2026-08-15 Text part language ("language of parts").
4
+ //
5
+ // WCAG 2.1 SC 3.1.2 (Level AA) requires that the human language of every
6
+ // PASSAGE or PHRASE be programmatically determinable when it differs from the
7
+ // document language. Until now this editor could only set a language on the
8
+ // whole document (a11yenhance.js stamps <html lang>), so a French quotation
9
+ // inside an English page was unmarkable and any AA conformance claim on
10
+ // /docs/accessibility was overstated. A screen reader reads such a phrase with
11
+ // the wrong phoneme set, which is the failure the SC exists to prevent.
12
+ //
13
+ // CKEditor 5 ships this as "Text part language". TinyMCE has no equivalent
14
+ // plugin (8.5's `content_language` sets the default proofing language, not
15
+ // per-phrase markup).
16
+ //
17
+ // Design notes:
18
+ // - `lang` is CONTENT, exactly like `dir` in textdirection.js: it must survive
19
+ // getHTMLCode(), and it is never stripped on serialize. A document whose
20
+ // language annotations vanished on save would silently drop its own
21
+ // conformance.
22
+ // - The wrapper is a bare <span lang>. No class is emitted, so there is
23
+ // nothing for check-css-contract.mjs to chase and nothing that depends on a
24
+ // stylesheet travelling with the content. The in-editor visual marker is
25
+ // injected into the EDITING DOCUMENT only, keyed off [lang], so it shows the
26
+ // author what is marked without ever leaving the editor.
27
+ // - RTL languages also get `dir`, because `lang` alone does not set direction.
28
+ // A Hebrew phrase marked only with lang="he" still renders left-to-right and
29
+ // reads as gibberish.
30
+ // - Re-applying the SAME language to a selection REMOVES it (toggle), which is
31
+ // how every other inline format in this editor behaves.
32
+ // - Nested lang spans inside the selection are unwrapped before wrapping, so a
33
+ // phrase never ends up with two competing languages. The inner one would win
34
+ // in the accessibility tree and the author would have no way to see why.
35
+ // - A collapsed selection deliberately does NOT create an empty wrapper. An
36
+ // empty inline span is a caret trap (the caret falls in and cannot be typed
37
+ // out of); instead a collapsed caret retargets the [lang] element it is
38
+ // already inside, so "put the cursor in the phrase and pick Remove" works.
39
+ RTE_DefaultConfig.plugin_textpartlanguage = RTE_Plugin_TextPartLanguage;
40
+
41
+ // The dropdown list. `dir` is only needed for right-to-left languages.
42
+ if (typeof RTE_DefaultConfig.textPartLanguages === "undefined") {
43
+ RTE_DefaultConfig.textPartLanguages = [
44
+ { code: "ar", label: "Arabic", dir: "rtl" },
45
+ { code: "zh-Hans", label: "Chinese (Simplified)" },
46
+ { code: "zh-Hant", label: "Chinese (Traditional)" },
47
+ { code: "cs", label: "Czech" },
48
+ { code: "nl", label: "Dutch" },
49
+ { code: "en", label: "English" },
50
+ { code: "fr", label: "French" },
51
+ { code: "de", label: "German" },
52
+ { code: "el", label: "Greek" },
53
+ { code: "he", label: "Hebrew", dir: "rtl" },
54
+ { code: "hi", label: "Hindi" },
55
+ { code: "it", label: "Italian" },
56
+ { code: "ja", label: "Japanese" },
57
+ { code: "ko", label: "Korean" },
58
+ { code: "fa", label: "Persian", dir: "rtl" },
59
+ { code: "pl", label: "Polish" },
60
+ { code: "pt", label: "Portuguese" },
61
+ { code: "ru", label: "Russian" },
62
+ { code: "es", label: "Spanish" },
63
+ { code: "sv", label: "Swedish" },
64
+ { code: "tr", label: "Turkish" },
65
+ { code: "uk", label: "Ukrainian" },
66
+ { code: "ur", label: "Urdu", dir: "rtl" },
67
+ { code: "vi", label: "Vietnamese" }
68
+ ];
69
+ }
70
+
71
+ // Show marked phrases with a dotted underline while editing. Editor-only: the
72
+ // rule lives in the editing document, never in the saved HTML.
73
+ if (typeof RTE_DefaultConfig.textPartLanguageHighlight === "undefined") RTE_DefaultConfig.textPartLanguageHighlight = true;
74
+
75
+ function RTE_Plugin_TextPartLanguage() {
76
+ var obj = this;
77
+ var config, editor;
78
+ var boundDoc = null;
79
+
80
+ obj.PluginName = "TextPartLanguage";
81
+
82
+ obj.InitConfig = function (argconfig) { config = argconfig; };
83
+
84
+ obj.InitEditor = function (argeditor) {
85
+ editor = argeditor;
86
+
87
+ editor.attachEvent("exec_command_textpartlanguage", function (state) {
88
+ state.returnValue = true;
89
+ var v = state && state.value;
90
+ if (!v || v === "none" || v === "remove") obj.Remove();
91
+ else obj.Apply(v);
92
+ });
93
+
94
+ if (editor.toolbarFactoryMap) {
95
+ editor.toolbarFactoryMap["textpartlanguage"] = function (cmd) {
96
+ return editor.createToolbarItemDropDownPanel(cmd, buildPanel);
97
+ };
98
+ }
99
+
100
+ setup();
101
+ try { editor.attachEvent("ready", setup); } catch (e) {}
102
+ try { editor.attachEvent("aftersethtml", setup); } catch (e) {}
103
+ setTimeout(setup, 0);
104
+
105
+ // Public API.
106
+ editor.setTextLanguage = function (code) { return obj.Apply(code); };
107
+ editor.removeTextLanguage = function () { return obj.Remove(); };
108
+ editor.getTextLanguage = function () { return obj.Current(); };
109
+ editor.listTextLanguages = function () { return languages().slice(); };
110
+ editor.getMarkedLanguages = function () { return obj.List(); };
111
+ };
112
+
113
+ function getDoc() { try { return editor.getDocument(); } catch (e) { return null; } }
114
+ function getEditable() { try { return editor.getEditable(); } catch (e) { return null; } }
115
+
116
+ function languages() {
117
+ var list = config && config.textPartLanguages;
118
+ return (list && list.length) ? list : [];
119
+ }
120
+
121
+ function entry(code) {
122
+ var list = languages();
123
+ for (var i = 0; i < list.length; i++) if (list[i].code === code) return list[i];
124
+ return { code: code, label: code };
125
+ }
126
+
127
+ // ---- state -----------------------------------------------------------
128
+
129
+ // The nearest ancestor carrying a lang, bounded by the editable so the host
130
+ // page's own <html lang> is never reported as the phrase language.
131
+ function langAncestor(node) {
132
+ var ed = getEditable();
133
+ var n = (node && node.nodeType === 3) ? node.parentNode : node;
134
+ while (n && n !== ed && n.nodeType === 1) {
135
+ if (n.hasAttribute && n.hasAttribute("lang")) return n;
136
+ n = n.parentNode;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function selectionRange() {
142
+ try {
143
+ var sel = editor.getSelection();
144
+ if (!sel || sel.rangeCount === 0) return null;
145
+ return sel.getRangeAt(0);
146
+ } catch (e) { return null; }
147
+ }
148
+
149
+ obj.Current = function () {
150
+ var r = selectionRange();
151
+ if (!r) return null;
152
+ var el = langAncestor(r.startContainer);
153
+ // A non-collapsed selection only counts as "in" a language if BOTH ends
154
+ // sit in the same marked phrase; a selection straddling the boundary has
155
+ // no single language and must not report one.
156
+ if (el && !r.collapsed && langAncestor(r.endContainer) !== el) return null;
157
+ return el ? el.getAttribute("lang") : null;
158
+ };
159
+
160
+ // Every distinct language marked in the document, for auditing.
161
+ obj.List = function () {
162
+ var ed = getEditable();
163
+ var out = [];
164
+ if (!ed) return out;
165
+ var seen = {};
166
+ var nodes = ed.querySelectorAll("[lang]");
167
+ for (var i = 0; i < nodes.length; i++) {
168
+ var code = nodes[i].getAttribute("lang");
169
+ if (!code || seen[code]) continue;
170
+ seen[code] = true;
171
+ out.push({ code: code, label: entry(code).label });
172
+ }
173
+ return out;
174
+ };
175
+
176
+ // ---- apply -----------------------------------------------------------
177
+
178
+ obj.Apply = function (code) {
179
+ code = String(code == null ? "" : code).trim();
180
+ if (!code) return false;
181
+
182
+ var doc = getDoc();
183
+ var r = selectionRange();
184
+ if (!doc || !r) return false;
185
+
186
+ // Re-picking the current language toggles it off.
187
+ if (obj.Current() === code) return obj.Remove();
188
+
189
+ if (r.collapsed) {
190
+ // No empty wrapper (caret trap). Retarget the phrase the caret is in.
191
+ var host = langAncestor(r.startContainer);
192
+ if (!host) return false;
193
+ stamp(host, code);
194
+ fireChange();
195
+ return code;
196
+ }
197
+
198
+ var span = doc.createElement("span");
199
+ stamp(span, code);
200
+
201
+ try {
202
+ // surroundContents is the clean path, but it throws whenever the
203
+ // range partially selects a non-text node (half a <strong>), which
204
+ // is the common case in real prose.
205
+ r.surroundContents(span);
206
+ } catch (e) {
207
+ var frag = r.extractContents();
208
+ span.appendChild(frag);
209
+ r.insertNode(span);
210
+ }
211
+
212
+ unwrapInner(span);
213
+ pruneEmptyText(span);
214
+ if (!span.firstChild) { // selection held nothing real
215
+ if (span.parentNode) span.parentNode.removeChild(span);
216
+ return false;
217
+ }
218
+ mergeSiblings(span);
219
+ select(span);
220
+ fireChange();
221
+ return code;
222
+ };
223
+
224
+ obj.Remove = function () {
225
+ var r = selectionRange();
226
+ if (!r) return false;
227
+ var ed = getEditable();
228
+ if (!ed) return false;
229
+
230
+ var removed = 0;
231
+
232
+ // The phrase the caret/selection sits inside.
233
+ var host = langAncestor(r.startContainer);
234
+ if (host) { unwrap(host); removed++; }
235
+
236
+ // Plus any fully- or partly-selected marked phrases in a wider range.
237
+ if (!r.collapsed) {
238
+ var nodes = ed.querySelectorAll("[lang]");
239
+ for (var i = nodes.length - 1; i >= 0; i--) {
240
+ var el = nodes[i];
241
+ if (el === host) continue;
242
+ if (intersects(r, el)) { unwrap(el); removed++; }
243
+ }
244
+ }
245
+
246
+ if (removed) fireChange();
247
+ return removed > 0;
248
+ };
249
+
250
+ function intersects(range, el) {
251
+ try {
252
+ var er = el.ownerDocument.createRange();
253
+ er.selectNodeContents(el);
254
+ return range.compareBoundaryPoints(Range.END_TO_START, er) < 0 &&
255
+ range.compareBoundaryPoints(Range.START_TO_END, er) > 0;
256
+ } catch (e) { return false; }
257
+ }
258
+
259
+ function stamp(el, code) {
260
+ el.setAttribute("lang", code);
261
+ var dir = entry(code).dir;
262
+ // lang does not imply direction; without dir an RTL phrase renders LTR.
263
+ if (dir === "rtl" || dir === "ltr") el.setAttribute("dir", dir);
264
+ else el.removeAttribute("dir");
265
+ }
266
+
267
+ // A phrase must carry exactly one language. Anything marked inside the new
268
+ // wrapper would otherwise win in the accessibility tree.
269
+ function unwrapInner(span) {
270
+ var inner = span.querySelectorAll("[lang]");
271
+ for (var i = inner.length - 1; i >= 0; i--) unwrap(inner[i]);
272
+ }
273
+
274
+ function unwrap(el) {
275
+ if (!el || !el.parentNode) return;
276
+ // A span we created carries nothing else; anything the author's own
277
+ // markup carries (a styled <em lang>) must survive, so only the
278
+ // language attributes come off.
279
+ if (el.nodeName === "SPAN" && el.attributes.length <= 2 &&
280
+ !el.getAttribute("class") && !el.getAttribute("style") && !el.getAttribute("id")) {
281
+ var parent = el.parentNode;
282
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
283
+ parent.removeChild(el);
284
+ try { parent.normalize(); } catch (e) {}
285
+ } else {
286
+ el.removeAttribute("lang");
287
+ el.removeAttribute("dir");
288
+ }
289
+ }
290
+
291
+ // extractContents leaves zero-length text nodes behind; they make a wrapper
292
+ // look non-empty and leave invisible caret stops in the phrase.
293
+ function pruneEmptyText(el) {
294
+ for (var i = el.childNodes.length - 1; i >= 0; i--) {
295
+ var n = el.childNodes[i];
296
+ if (n.nodeType === 3 && n.nodeValue === "") el.removeChild(n);
297
+ }
298
+ }
299
+
300
+ // Marking two adjacent halves of a phrase should read as one phrase, not two.
301
+ function mergeSiblings(span) {
302
+ var code = span.getAttribute("lang");
303
+ var prev = span.previousSibling;
304
+ if (prev && prev.nodeType === 1 && prev.nodeName === "SPAN" && prev.getAttribute("lang") === code) {
305
+ while (span.firstChild) prev.appendChild(span.firstChild);
306
+ span.parentNode.removeChild(span);
307
+ span = prev;
308
+ }
309
+ var next = span.nextSibling;
310
+ if (next && next.nodeType === 1 && next.nodeName === "SPAN" && next.getAttribute("lang") === code) {
311
+ while (next.firstChild) span.appendChild(next.firstChild);
312
+ next.parentNode.removeChild(next);
313
+ }
314
+ try { span.normalize(); } catch (e) {}
315
+ return span;
316
+ }
317
+
318
+ function select(span) {
319
+ try {
320
+ var sel = editor.getSelection();
321
+ var r = span.ownerDocument.createRange();
322
+ r.selectNodeContents(span);
323
+ sel.removeAllRanges();
324
+ sel.addRange(r);
325
+ } catch (e) {}
326
+ }
327
+
328
+ function fireChange() {
329
+ try { if (typeof editor.updateDesign === "function") editor.updateDesign(); } catch (e) {}
330
+ try { if (typeof editor.fireChange === "function") editor.fireChange(); } catch (e) {}
331
+ }
332
+
333
+ // ---- toolbar panel ---------------------------------------------------
334
+
335
+ function buildPanel(panel) {
336
+ var current = obj.Current();
337
+ panel.style.width = "220px";
338
+ panel.style.maxHeight = "340px";
339
+ panel.style.overflowY = "auto";
340
+ panel.style.padding = "4px 0";
341
+
342
+ var list = languages();
343
+
344
+ addRow(panel, editor.getLangText ? (editor.getLangText("removelanguage") || "Remove language") : "Remove language",
345
+ null, !current, function () { obj.Remove(); });
346
+
347
+ var sep = panel.ownerDocument.createElement("div");
348
+ sep.style.cssText = "height:1px;margin:4px 6px;background:var(--rte-border-color,#e0e0e0);";
349
+ panel.appendChild(sep);
350
+
351
+ for (var i = 0; i < list.length; i++) {
352
+ (function (item) {
353
+ addRow(panel, item.label, item.code, current === item.code, function () { obj.Apply(item.code); });
354
+ })(list[i]);
355
+ }
356
+
357
+ // Opening a menu has to put focus inside it, or a keyboard user has
358
+ // opened something they cannot reach. Start on the current language when
359
+ // there is one — that is the item they are most likely acting on.
360
+ setTimeout(function () {
361
+ var rows = panel.querySelectorAll("rte-toolbar-dropdown-item");
362
+ if (!rows.length) return;
363
+ var target = rows[0];
364
+ for (var i = 0; i < rows.length; i++) {
365
+ if (rows[i].getAttribute("aria-checked") === "true") { target = rows[i]; break; }
366
+ }
367
+ try { target.focus(); } catch (e) {}
368
+ }, 0);
369
+ }
370
+
371
+ function addRow(panel, label, code, active, run) {
372
+ var doc = panel.ownerDocument;
373
+ // Two things are required for a menu row to be operable by keyboard, and
374
+ // a plain <div role="menuitemradio"> has neither:
375
+ // 1. The TAG. The core's keyboard layer finds items via
376
+ // __actionElementSelector, which lists element NAMES
377
+ // (rte-toolbar-dropdown-item, rte-dropdown-menuitem, rte-menuitem)
378
+ // and ignores roles entirely.
379
+ // 2. A tab stop and key handling. The core applies those inside its own
380
+ // closure — __Append / __Make_ActionElementAccessible are not exposed
381
+ // to plugins — so a plugin has to supply them itself rather than
382
+ // hope an internal picks the row up.
383
+ // Both are done here, so the menu works with a keyboard regardless of
384
+ // what the core does or does not reach into (WCAG 2.1.1).
385
+ var row = doc.createElement("rte-toolbar-dropdown-item");
386
+ panel.appendChild(row);
387
+ row.setAttribute("role", "menuitemradio");
388
+ row.setAttribute("aria-checked", active ? "true" : "false");
389
+ row.setAttribute("tabindex", "0");
390
+ row.style.cssText = "padding:5px 12px;cursor:pointer;white-space:nowrap;display:flex;" +
391
+ "justify-content:space-between;gap:12px;color:var(--rte-text-color,inherit);" +
392
+ (active ? "font-weight:600;" : "");
393
+
394
+ var name = doc.createElement("span");
395
+ name.appendChild(doc.createTextNode(label));
396
+ row.appendChild(name);
397
+
398
+ if (code) {
399
+ var tag = doc.createElement("span");
400
+ tag.style.cssText = "opacity:.55;font-size:11px;";
401
+ tag.appendChild(doc.createTextNode(code));
402
+ row.appendChild(tag);
403
+ }
404
+
405
+ row.onmouseover = function () { row.style.backgroundColor = "var(--rte-hover-bg,#e6e6e6)"; };
406
+ row.onmouseout = function () { row.style.backgroundColor = ""; };
407
+ row.onclick = function () {
408
+ // Close first: the panel steals focus, and applying to a selection
409
+ // the editor no longer owns silently does nothing.
410
+ try { editor.closeCurrentPopup(); } catch (e) {}
411
+ try { editor.focus(); } catch (e) {}
412
+ run();
413
+ };
414
+
415
+ row.onkeydown = function (e) {
416
+ var key = e.key;
417
+ if (key === "Enter" || key === " " || key === "Spacebar") {
418
+ e.preventDefault();
419
+ e.stopPropagation();
420
+ row.onclick();
421
+ return;
422
+ }
423
+ if (key === "ArrowDown" || key === "ArrowUp") {
424
+ e.preventDefault();
425
+ e.stopPropagation();
426
+ var items = panel.querySelectorAll("rte-toolbar-dropdown-item");
427
+ var list = Array.prototype.slice.call(items);
428
+ var at = list.indexOf(row);
429
+ // Wrap, so the list cannot dead-end and strand the user.
430
+ var next = list[(at + (key === "ArrowDown" ? 1 : -1) + list.length) % list.length];
431
+ if (next) next.focus();
432
+ return;
433
+ }
434
+ if (key === "Home" || key === "End") {
435
+ e.preventDefault();
436
+ e.stopPropagation();
437
+ var all = panel.querySelectorAll("rte-toolbar-dropdown-item");
438
+ var target = key === "Home" ? all[0] : all[all.length - 1];
439
+ if (target) target.focus();
440
+ return;
441
+ }
442
+ if (key === "Escape") {
443
+ e.preventDefault();
444
+ e.stopPropagation();
445
+ // Escape closes the menu; focus then lands in the EDITING AREA,
446
+ // not back on the launcher.
447
+ //
448
+ // That is the core's behaviour for every dropdown — closing a
449
+ // popup refocuses the editable asynchronously — and it cannot be
450
+ // overridden from here. Measured: setting focus on the launcher
451
+ // does take effect, and is then taken back ~16 ms later; a forced
452
+ // re-focus is taken back again. Racing it with longer timeouts
453
+ // would be fragile and would fight the "close the popup, carry on
454
+ // typing" flow the rest of the editor depends on.
455
+ //
456
+ // This is not a trap and not a WCAG failure: the menu closes,
457
+ // focus is visible and lands somewhere meaningful. Restoring
458
+ // launcher focus properly belongs in the core's popup-close path,
459
+ // where it would apply to every dropdown at once.
460
+ try { editor.closeCurrentPopup(); } catch (ex) {}
461
+ }
462
+ };
463
+ }
464
+
465
+ // ---- editor-only marker ----------------------------------------------
466
+
467
+ function setup() {
468
+ var doc = getDoc();
469
+ if (!doc || doc === boundDoc) return;
470
+ boundDoc = doc;
471
+ injectStyles(doc);
472
+ }
473
+
474
+ function css() {
475
+ if (config && config.textPartLanguageHighlight === false) return "";
476
+ // Scoped to [lang] inside the editing document only. Never serialized,
477
+ // so the marker cannot follow the content into a customer's page.
478
+ //
479
+ // :not(html):not(body) is REQUIRED, not defensive tidiness. a11yenhance.js
480
+ // stamps the editing document's root with the document language, so a bare
481
+ // [lang] selector matches <html> — which made hovering anywhere in the
482
+ // editing area grey the ENTIRE surface, and drew the "marked phrase"
483
+ // underline across the whole document. Reported from the live demos page.
484
+ // Only a phrase the author actually marked should be decorated.
485
+ return "[lang]:not(html):not(body){border-bottom:1px dotted rgba(120,120,120,.75);}" +
486
+ "[lang]:not(html):not(body):hover{background:rgba(120,120,120,.10);}";
487
+ }
488
+
489
+ function injectStyles(doc) {
490
+ if (!doc) return;
491
+ var text = css();
492
+ var existing = doc.getElementById("rte-textpartlanguage-styles");
493
+ if (existing) {
494
+ if (existing.getAttribute("data-css") === text) return;
495
+ existing.parentNode && existing.parentNode.removeChild(existing);
496
+ }
497
+ if (!text) return;
498
+ var st = doc.createElement("style");
499
+ st.id = "rte-textpartlanguage-styles";
500
+ st.setAttribute("data-css", text);
501
+ st.appendChild(doc.createTextNode(text));
502
+ (doc.head || doc.getElementsByTagName("head")[0] || doc.documentElement).appendChild(st);
503
+ }
504
+ }