@richscripts/richtexteditor 2.6.4 → 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.
@@ -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
@@ -393,7 +393,29 @@ function RTE_Plugin_PdfExport() {
393
393
  var self = this;
394
394
  var base = { bold: false, italic: false, underline: false, strike: false, size: this.baseSize, color: [0, 0, 0], mono: false, link: null };
395
395
 
396
- function block(node, style, indent, listMarker, listId, listOrdered) {
396
+ // A block indented in the editor carries margin-left (or padding-left, per
397
+ // indentUseMargin). Before 2026-09-04 the PDF honoured an indent only when
398
+ // it was spelled <blockquote>, because that was the only way the editor
399
+ // could express one. Points, since that is the PDF's unit: 1px = 0.75pt.
400
+ function cssIndentPt(node) {
401
+ if (!node || !node.style) return 0;
402
+ var raw = node.style.marginLeft || node.style.paddingLeft || "";
403
+ var m = /^\s*(-?[\d.]+)\s*([a-z%]*)\s*$/i.exec(raw);
404
+ if (!m) return 0;
405
+ var n = parseFloat(m[1]);
406
+ if (!n || n <= 0) return 0;
407
+ var unit = (m[2] || "px").toLowerCase();
408
+ var px = unit === "px" ? n
409
+ : unit === "pt" ? n * (4 / 3)
410
+ : unit === "in" ? n * 96
411
+ : unit === "cm" ? n * 37.795
412
+ : unit === "mm" ? n * 3.7795
413
+ : (unit === "em" || unit === "rem") ? n * 16
414
+ : 0;
415
+ return px * 0.75;
416
+ }
417
+
418
+ function block(node, style, indent, listMarker, listId, listOrdered, roleOverride) {
397
419
  var tag = node.tagName.toLowerCase();
398
420
  var s = styleOf(node, style);
399
421
  var align = "left";
@@ -419,12 +441,18 @@ function RTE_Plugin_PdfExport() {
419
441
  // untagged still fails an accessibility audit: assistive
420
442
  // technology gets a flat stream with no headings, no list
421
443
  // structure and no reliable reading order.
422
- 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")),
423
451
  listId: listId || null, listOrdered: !!listOrdered
424
452
  });
425
453
  }
426
454
 
427
- function walk(node, style, indent) {
455
+ function walk(node, style, indent, roleOverride) {
428
456
  for (var i = 0; i < node.childNodes.length; i++) {
429
457
  var c = node.childNodes[i];
430
458
  if (c.nodeType === 3) {
@@ -465,8 +493,10 @@ function RTE_Plugin_PdfExport() {
465
493
  continue;
466
494
  }
467
495
  if (isBlockTag(tag)) {
468
- if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + (tag === "blockquote" ? self.baseSize * 1.5 : 0)); continue; }
469
- block(c, style, indent + (tag === "blockquote" ? self.baseSize * 1.5 : 0), null);
496
+ var own = (tag === "blockquote" ? self.baseSize * 1.5 : 0) + cssIndentPt(c);
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);
470
500
  continue;
471
501
  }
472
502
  // Inline content sitting directly under a container.
@@ -146,7 +146,9 @@ function RTE_Plugin_WordExport() {
146
146
  "table { border-collapse: collapse; }\r\n" +
147
147
  "td, th { border: 1px solid #999; padding: 4px 8px; }\r\n" +
148
148
  "img { max-width: 100%; height: auto; }\r\n" +
149
- "blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 12px; color: #444; }\r\n" +
149
+ // margin-left was zeroed here, which also flattened any indentation
150
+ // expressed as a margin. Keep the quote bar, keep the offset.
151
+ "blockquote { border-left: 3px solid #ccc; padding-left: 12px; color: #444; }\r\n" +
150
152
  "pre { font-family: Consolas, 'Courier New', monospace; background: #f4f4f4; padding: 8px; }\r\n" +
151
153
  "</style>\r\n" +
152
154
  "</head>\r\n" +