@richscripts/richtexteditor 2.7.0 → 2.7.1

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.
@@ -82,9 +82,24 @@ function RTE_Plugin_DialogA11y() {
82
82
  }
83
83
  }
84
84
 
85
+ // 2026-09-05 Widened. The original selector only matched
86
+ // <rte-dropdown-panel>/.rte-panel-general, but Find&Replace, Insert Link and
87
+ // their siblings render as <rte-dialog-float class="rte-panel-find"> wrapping
88
+ // an <rte-dialog-inner role="dialog">. Those carry EXACTLY the markup this
89
+ // plugin consumes - an <rte-dialog-header> and <rte-dialog-line-X> field
90
+ // wrappers - and were simply never visited, so the plugin reported nothing
91
+ // and covered nothing. Measured before the change: calling
92
+ // editor.applyDialogAccessibility() with the find dialog open labelled 0 of
93
+ // its 4 inputs, leaving "Find" and "Replace" announced as bare "edit"
94
+ // (WCAG 1.3.1 / 3.3.2 / 4.1.2) while the two checkboxes were fine because
95
+ // the core happens to wrap those in <label>.
96
+ //
97
+ // Widening is safe because enhancePanel() still requires an
98
+ // <rte-dialog-header>, which is what keeps colour/font dropdowns out.
85
99
  function isPanel(el) {
86
100
  return el && el.nodeType === 1 &&
87
- (el.tagName === "RTE-DROPDOWN-PANEL" || /rte-panel-general/.test(el.className || ""));
101
+ (el.tagName === "RTE-DROPDOWN-PANEL" || el.tagName === "RTE-DIALOG-FLOAT" ||
102
+ /rte-panel-general/.test(el.className || ""));
88
103
  }
89
104
 
90
105
  function schedule(panel) {
@@ -104,7 +119,7 @@ function RTE_Plugin_DialogA11y() {
104
119
 
105
120
  function enhanceAll(host) {
106
121
  if (!host || !host.querySelectorAll) return;
107
- var ps = host.querySelectorAll("rte-dropdown-panel,[class*='rte-panel-general']");
122
+ var ps = host.querySelectorAll("rte-dropdown-panel,rte-dialog-float,[class*='rte-panel-general']");
108
123
  for (var i = 0; i < ps.length; i++) enhancePanel(ps[i]);
109
124
  }
110
125
 
@@ -116,12 +131,22 @@ function RTE_Plugin_DialogA11y() {
116
131
  var name = (headerEl.textContent || "").trim()
117
132
  .replace(/\s*\((?:Ctrl|Cmd|Alt|Shift|⌘|⇧|⌥)[^)]*\)\s*$/i, "").trim();
118
133
 
134
+ // Name the element that IS the dialog. The float-style panels already
135
+ // carry role="dialog" on an inner <rte-dialog-inner>; adding a second
136
+ // role="dialog" to the wrapper would announce two nested dialogs, so
137
+ // when an inner one exists it is the target and the wrapper is left
138
+ // alone. When there is none (the original dropdown-panel shape) this
139
+ // resolves to the panel itself and behaves exactly as before.
140
+ var dialogEl = panel.getAttribute("role") === "dialog"
141
+ ? panel
142
+ : (panel.querySelector('[role="dialog"]') || panel);
143
+
119
144
  // A titled, form-bearing popup is a dialog, not a menu — override the
120
145
  // core's default role="menu" (a menu must not contain text fields).
121
- var role = panel.getAttribute("role");
122
- if (!role || role === "menu") panel.setAttribute("role", "dialog");
123
- if (name && !panel.getAttribute("aria-label") && !panel.getAttribute("aria-labelledby")) {
124
- panel.setAttribute("aria-label", name);
146
+ var role = dialogEl.getAttribute("role");
147
+ if (!role || role === "menu") dialogEl.setAttribute("role", "dialog");
148
+ if (name && !dialogEl.getAttribute("aria-label") && !dialogEl.getAttribute("aria-labelledby")) {
149
+ dialogEl.setAttribute("aria-label", name);
125
150
  }
126
151
 
127
152
  var fields = panel.querySelectorAll("input,select,textarea");
@@ -136,6 +161,25 @@ function RTE_Plugin_DialogA11y() {
136
161
  }
137
162
 
138
163
  function fieldLabel(f, dialogName) {
164
+ // 0) THE VISIBLE LABEL WINS. 2026-09-05: deriving the name from the
165
+ // <rte-dialog-line-X> suffix alone produced "Keyword" for a field whose
166
+ // visible label reads "Find". That fails WCAG 2.5.3 Label in Name, and
167
+ // it breaks speech input concretely: a user says "Find" and nothing
168
+ // matches, because the accessible name does not contain the words they
169
+ // can see. The markup already carries the right text in a sibling
170
+ // <rte-dialog-input-label>, so prefer it and keep the suffix as the
171
+ // fallback for fields that have no visible label.
172
+ var lineEl = f.parentElement;
173
+ for (var d = 0; d < 8 && lineEl; d++) {
174
+ if (/^RTE-DIALOG-LINE-/.test(lineEl.tagName || "")) break;
175
+ lineEl = lineEl.parentElement;
176
+ }
177
+ if (lineEl && lineEl.querySelector) {
178
+ var visEl = lineEl.querySelector("rte-dialog-input-label");
179
+ var vis = visEl ? (visEl.textContent || "").replace(/^\s+|\s+$/g, "").replace(/[::]\s*$/, "") : "";
180
+ if (vis) return vis;
181
+ }
182
+
139
183
  // 1) nearest <rte-dialog-line-X> ancestor — the suffix is the field meaning
140
184
  var n = f.parentElement;
141
185
  for (var i = 0; i < 8 && n; i++) {
@@ -951,6 +951,25 @@ function RTE_Plugin_DocumentImport() {
951
951
  var hv = hl.getAttributeNS(W, "val") || hl.getAttribute("w:val") || "";
952
952
  if (hv && hv !== "none") css += "background-color:" + hv + ";";
953
953
  }
954
+ // w:shd is the OTHER way Word carries a background, and it is the
955
+ // one our own exporter writes (w:highlight only accepts a fixed
956
+ // set of colour NAMES, shd takes any hex). Reading only
957
+ // w:highlight meant every highlight we exported came back
958
+ // unhighlighted - the two sides used different vocabulary for the
959
+ // same thing.
960
+ var shd = firstChildEl(rpr, "shd");
961
+ if (shd) {
962
+ var fill = shd.getAttributeNS(W, "fill") || shd.getAttribute("w:fill") || "";
963
+ if (fill && /^[0-9A-Fa-f]{6}$/.test(fill)) css += "background-color:#" + fill + ";";
964
+ }
965
+ // Monospace run -> inline code. Our exporter marks <code>/<kbd>/
966
+ // <samp>/<pre> with w:rFonts Consolas; nothing read it back.
967
+ var rf = firstChildEl(rpr, "rFonts");
968
+ if (rf) {
969
+ var asc = rf.getAttributeNS(W, "ascii") || rf.getAttribute("w:ascii") || "";
970
+ if (/consolas|courier|monospace|mono$/i.test(asc)) txt = "<code>" + txt + "</code>";
971
+ else if (asc) css += "font-family:" + asc.replace(/[<>"]/g, "") + ";";
972
+ }
954
973
  var sz = firstChildEl(rpr, "sz");
955
974
  if (sz) {
956
975
  var sv = parseInt(sz.getAttributeNS(W, "val") || sz.getAttribute("w:val") || "", 10);
@@ -1123,7 +1142,16 @@ function RTE_Plugin_DocumentImport() {
1123
1142
  // without this they came back as plain <p> and the round-trip lost
1124
1143
  // every blockquote. Match Quote / IntenseQuote / BlockQuote.
1125
1144
  else if (/^(intense)?quote$|blockquote/i.test(v.replace(/\s+/g, ""))) info.quote = true;
1145
+ // docxexport.js writes <w:pStyle w:val="Code"/> for <pre>; without
1146
+ // this a code block came back as an ordinary paragraph and lost
1147
+ // both its monospace and its whitespace.
1148
+ else if (/^(code|sourcecode|htmlpre|preformatted)$/i.test(v.replace(/\s+/g, ""))) info.pre = true;
1126
1149
  }
1150
+ // A horizontal rule is exported as an EMPTY paragraph carrying a
1151
+ // bottom border - Word has no <hr>. Nothing read it back, so every
1152
+ // rule became a stray blank paragraph on import.
1153
+ var pbdr = firstChildEl(ppr, "pBdr");
1154
+ if (pbdr && firstChildEl(pbdr, "bottom")) info.rule = true;
1127
1155
  var numPr = firstChildEl(ppr, "numPr");
1128
1156
  if (numPr) {
1129
1157
  info.list = true;
@@ -1217,6 +1245,12 @@ function RTE_Plugin_DocumentImport() {
1217
1245
  continue;
1218
1246
  }
1219
1247
  closeListsTo(0);
1248
+ // Only a rule when the paragraph carries no text of its own -
1249
+ // a bordered paragraph WITH content is a real bordered paragraph.
1250
+ if (st.rule && !(inner || "").replace(/<[^>]*>/g, "").trim()) {
1251
+ closeListsTo(0); out.push("<hr>"); continue;
1252
+ }
1253
+ if (st.pre) { out.push("<pre>" + (inner || "<br>") + "</pre>"); continue; }
1220
1254
  if (st.heading) {
1221
1255
  // Headings carry w:ind too; without this an indented
1222
1256
  // heading imported flush left.
@@ -194,7 +194,7 @@ function RTE_Plugin_DocxExport() {
194
194
  bold: inherited.bold, italic: inherited.italic, underline: inherited.underline,
195
195
  strike: inherited.strike, sizeHalfPoints: inherited.sizeHalfPoints,
196
196
  color: inherited.color, highlight: inherited.highlight, vertAlign: inherited.vertAlign,
197
- mono: inherited.mono
197
+ mono: inherited.mono, font: inherited.font
198
198
  };
199
199
  var tag = node.tagName ? node.tagName.toLowerCase() : "";
200
200
  if (tag === "b" || tag === "strong") s.bold = true;
@@ -204,6 +204,10 @@ function RTE_Plugin_DocxExport() {
204
204
  if (tag === "sup") s.vertAlign = "superscript";
205
205
  if (tag === "sub") s.vertAlign = "subscript";
206
206
  if (tag === "code" || tag === "kbd" || tag === "samp" || tag === "pre") s.mono = true;
207
+ // <mark> carries no inline style, so the CSS branch below never sees it
208
+ // and highlighted text was dropped entirely on export. Word's own
209
+ // default highlight is yellow.
210
+ if (tag === "mark") s.highlight = s.highlight || "FFFF00";
207
211
 
208
212
  var css = node.style;
209
213
  if (css) {
@@ -214,6 +218,12 @@ function RTE_Plugin_DocxExport() {
214
218
  if (deco.indexOf("line-through") >= 0) s.strike = true;
215
219
  if (css.color) { var c = hexColor(css.color); if (c) s.color = c; }
216
220
  if (css.backgroundColor) { var h = hexColor(css.backgroundColor); if (h) s.highlight = h; }
221
+ // Font family was read by nothing, so an exported document lost every
222
+ // typeface choice. Word wants ONE family name, not a CSS stack.
223
+ if (css.fontFamily) {
224
+ var fam = String(css.fontFamily).split(",")[0].replace(/^\s*['"]?|['"]?\s*$/g, "");
225
+ if (fam) s.font = fam;
226
+ }
217
227
  if (css.fontSize) {
218
228
  var pt = cssToPoints(css.fontSize);
219
229
  // w:sz is in HALF-POINTS. Writing points here makes every
@@ -250,7 +260,10 @@ function RTE_Plugin_DocxExport() {
250
260
 
251
261
  function runProps(s) {
252
262
  var p = "";
253
- if (s.mono) p += '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>';
263
+ // One rFonts element only: mono wins, because <code> inside a styled span
264
+ // should still read as code.
265
+ var fontName = s.mono ? "Consolas" : (s.font || "");
266
+ if (fontName) p += '<w:rFonts w:ascii="' + esc(fontName) + '" w:hAnsi="' + esc(fontName) + '"/>';
254
267
  if (s.bold) p += "<w:b/>";
255
268
  if (s.italic) p += "<w:i/>";
256
269
  if (s.underline) p += '<w:u w:val="single"/>';
@@ -668,7 +681,17 @@ function RTE_Plugin_DocxExport() {
668
681
  for (var i = 0; i < node.childNodes.length; i++) {
669
682
  var li = node.childNodes[i];
670
683
  if (li.nodeType !== 1 || li.tagName.toLowerCase() !== "li") continue;
671
- xml += para(li, styleFrom(li, style), { numId: numId, level: level, pageBreakBefore: pendingBreak });
684
+ // Serialise the item's OWN content only. para() walks every
685
+ // descendant, so passing the whole <li> emitted the nested list's
686
+ // text here AND again as its own items below - a round trip turned
687
+ // "xx" with a child "yy" into "xxyy". Content duplication, not loss.
688
+ var own = li.ownerDocument.createElement("li");
689
+ for (var k = 0; k < li.childNodes.length; k++) {
690
+ var ch = li.childNodes[k];
691
+ if (ch.nodeType === 1 && /^(ul|ol)$/.test(ch.tagName.toLowerCase())) continue;
692
+ own.appendChild(ch.cloneNode(true));
693
+ }
694
+ xml += para(own, styleFrom(li, style), { numId: numId, level: level, pageBreakBefore: pendingBreak });
672
695
  pendingBreak = false;
673
696
  for (var j = 0; j < li.childNodes.length; j++) {
674
697
  var sub = li.childNodes[j];
@@ -55,6 +55,21 @@ function RTE_Plugin_KeyboardA11y() {
55
55
  if (config.keyboardA11y === false) return;
56
56
 
57
57
  editor.focusToolbar = function () { return focusFirstToolbarButton(); };
58
+ // Public so plugins can report their own outcomes -- find/replace match
59
+ // counts, what the paste filter removed, export completion.
60
+ editor.announce = function (text, opts) { return announce(text, opts); };
61
+ // setReadOnly() lives in the core and cannot call into a plugin, so wrap
62
+ // it here rather than leaving the two states to drift apart.
63
+ if (typeof editor.setReadOnly === "function" && !editor.setReadOnly.__a11yWrapped) {
64
+ var origSetReadOnly = editor.setReadOnly;
65
+ var wrapped = function (v) {
66
+ var r = origSetReadOnly.apply(editor, arguments);
67
+ try { syncReadOnlyState(); } catch (e) { }
68
+ return r;
69
+ };
70
+ wrapped.__a11yWrapped = true;
71
+ editor.setReadOnly = wrapped;
72
+ }
58
73
 
59
74
  setup();
60
75
  try { editor.attachEvent("ready", setup); } catch (e) {}
@@ -78,6 +93,8 @@ function RTE_Plugin_KeyboardA11y() {
78
93
  function setup() {
79
94
  var root = shell();
80
95
  if (!root) return;
96
+ ensureLiveRegions();
97
+ syncReadOnlyState();
81
98
  bindEscapeHatch();
82
99
  applyRovingTabindex(root);
83
100
  syncToggleStates(root);
@@ -85,6 +102,97 @@ function RTE_Plugin_KeyboardA11y() {
85
102
  watch(root);
86
103
  }
87
104
 
105
+
106
+ // ------------------------------------------------- 4. status messages
107
+ //
108
+ // WCAG 4.1.3 Status Messages (Level AA): a change of state that is NOT
109
+ // given focus still has to reach assistive technology. Before this the
110
+ // editor had no live region at all -- not one aria-live node, in the page
111
+ // or in the iframe -- so a screen reader user got silence for every
112
+ // outcome the sighted user reads off the chrome: how many matches Find
113
+ // found, what the paste filter stripped, that the length limit was hit,
114
+ // that the document went read-only.
115
+ //
116
+ // Two regions, because politeness is not a detail: 'polite' waits for a
117
+ // pause in speech (counts, confirmations), 'assertive' interrupts (errors,
118
+ // refusals). aria-atomic="true" on both so the whole message is re-read
119
+ // rather than only the words that changed -- reading a diff aloud produces
120
+ // sentences that were never written.
121
+ //
122
+ // The region lives in the HOST document, not the editing iframe: a message
123
+ // announced from inside the document the user is editing would also become
124
+ // part of what they are editing.
125
+ var liveNodes = null;
126
+ function ensureLiveRegions() {
127
+ if (liveNodes && liveNodes.polite && liveNodes.polite.isConnected) return liveNodes;
128
+ var root = shell();
129
+ if (!root) return null;
130
+ function mk(politeness) {
131
+ var el = root.querySelector('[data-rte-live="' + politeness + '"]');
132
+ if (el) return el;
133
+ el = document.createElement("div");
134
+ el.setAttribute("data-rte-live", politeness);
135
+ el.setAttribute("aria-live", politeness);
136
+ el.setAttribute("aria-atomic", "true");
137
+ el.setAttribute("role", politeness === "assertive" ? "alert" : "status");
138
+ // Visually hidden but still rendered: display:none and
139
+ // visibility:hidden remove the node from the accessibility tree,
140
+ // which silences it. Clip is the technique that does not.
141
+ el.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;" +
142
+ "padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;";
143
+ root.appendChild(el);
144
+ return el;
145
+ }
146
+ liveNodes = { polite: mk("polite"), assertive: mk("assertive") };
147
+ return liveNodes;
148
+ }
149
+
150
+ // editor.announce(text, {assertive}) -- also used by plugins.
151
+ function announce(text, opts) {
152
+ var msg = String(text == null ? "" : text).trim();
153
+ if (!msg) return false;
154
+ var nodes = ensureLiveRegions();
155
+ if (!nodes) return false;
156
+ var el = (opts && opts.assertive) ? nodes.assertive : nodes.polite;
157
+ // Re-announcing the SAME string is a no-op for most screen readers
158
+ // unless the node is cleared first, so "3 results" after "3 results"
159
+ // would be silent. Clear, then set on the next frame.
160
+ el.textContent = "";
161
+ setTimeout(function () { el.textContent = msg; }, 30);
162
+ return true;
163
+ }
164
+
165
+ // ------------------------------------------------- 5. read-only state
166
+ //
167
+ // setReadOnly() flips designMode, which genuinely blocks editing -- but
168
+ // designMode is invisible to assistive technology. The toolbar already
169
+ // marks its buttons aria-disabled; the editing region itself said nothing,
170
+ // so a screen reader user entered a field described as a rich text editor,
171
+ // typed, and got no response and no explanation. aria-readonly is the
172
+ // attribute that carries this, and the accessible name says it too because
173
+ // a name is what gets announced on entry.
174
+ var lastReadOnly = null;
175
+ function syncReadOnlyState() {
176
+ var ed;
177
+ try { ed = editor.getEditable(); } catch (e) { return; }
178
+ if (!ed) return;
179
+ var ro = false;
180
+ try { ro = !!(editor.getReadOnly ? editor.getReadOnly() : editor.isReadOnly && editor.isReadOnly()); } catch (e) { }
181
+ ed.setAttribute("aria-readonly", ro ? "true" : "false");
182
+ var suffix = config.a11yReadOnlySuffix || "Read only.";
183
+ // Plain string trimming rather than a built regex: the suffix is
184
+ // config-supplied and would otherwise need escaping to be safe.
185
+ var label = (ed.getAttribute("aria-label") || "");
186
+ if (label.length >= suffix.length && label.slice(-suffix.length) === suffix) {
187
+ label = label.slice(0, -suffix.length).replace(/\s+$/, "");
188
+ }
189
+ ed.setAttribute("aria-label", ro ? (label + " " + suffix) : label);
190
+ if (lastReadOnly !== null && lastReadOnly !== ro) {
191
+ announce(ro ? suffix : (config.a11yEditableAgain || "Editing enabled."));
192
+ }
193
+ lastReadOnly = ro;
194
+ }
195
+
88
196
  // ---------------------------------------------------- 1. keyboard trap
89
197
  //
90
198
  // Tab keeps its editing meaning (indent, next table cell) because that is
@@ -415,7 +415,7 @@ function RTE_Plugin_PdfExport() {
415
415
  return px * 0.75;
416
416
  }
417
417
 
418
- function block(node, style, indent, listMarker, listId, listOrdered) {
418
+ function block(node, style, indent, listMarker, listId, listOrdered, roleOverride) {
419
419
  var tag = node.tagName.toLowerCase();
420
420
  var s = styleOf(node, style);
421
421
  var align = "left";
@@ -441,12 +441,18 @@ function RTE_Plugin_PdfExport() {
441
441
  // untagged still fails an accessibility audit: assistive
442
442
  // technology gets a flat stream with no headings, no list
443
443
  // structure and no reliable reading order.
444
- role: HEADING_SCALE[tag] ? "H" + tag.charAt(1) : (tag === "blockquote" ? "BlockQuote" : "P"),
444
+ // roleOverride carries the quotation down to the paragraphs INSIDE a
445
+ // <blockquote>. The role was only ever emitted for a blockquote
446
+ // holding inline text directly - and the editor writes
447
+ // <blockquote><p>...</p></blockquote>, so in practice every
448
+ // blockquote we produce was tagged /P and the quotation was absent
449
+ // from the accessibility tree we publish claims about.
450
+ role: roleOverride || (HEADING_SCALE[tag] ? "H" + tag.charAt(1) : (tag === "blockquote" ? "BlockQuote" : "P")),
445
451
  listId: listId || null, listOrdered: !!listOrdered
446
452
  });
447
453
  }
448
454
 
449
- function walk(node, style, indent) {
455
+ function walk(node, style, indent, roleOverride) {
450
456
  for (var i = 0; i < node.childNodes.length; i++) {
451
457
  var c = node.childNodes[i];
452
458
  if (c.nodeType === 3) {
@@ -488,8 +494,9 @@ function RTE_Plugin_PdfExport() {
488
494
  }
489
495
  if (isBlockTag(tag)) {
490
496
  var own = (tag === "blockquote" ? self.baseSize * 1.5 : 0) + cssIndentPt(c);
491
- if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + own); continue; }
492
- block(c, style, indent + own, null);
497
+ var childRole = (tag === "blockquote") ? "BlockQuote" : roleOverride;
498
+ if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + own, childRole); continue; }
499
+ block(c, style, indent + own, null, null, false, childRole);
493
500
  continue;
494
501
  }
495
502
  // Inline content sitting directly under a container.