@richscripts/richtexteditor 2.5.3 → 2.6.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.
@@ -499,7 +499,13 @@ function RTE_Plugin_AccessibilityChecker() {
499
499
  if (headingText) previousHeadingLevel = level;
500
500
  }
501
501
 
502
- var images = node.querySelectorAll ? node.querySelectorAll("img") : [];
502
+ // querySelectorAll only returns DESCENDANTS, so an image that is
503
+ // itself a top-level block — which is exactly where a pasted or
504
+ // dropped image lands in an empty editor — was never checked, and
505
+ // missing alt text went unreported. The table rule below already
506
+ // guards this case; the image rule did not. Measured 2026-08-27:
507
+ // bare top-level img => 0 issues, the same img inside a <p> => 1.
508
+ var images = tag === "img" ? [node] : (node.querySelectorAll ? node.querySelectorAll("img") : []);
503
509
  for (var imageIndex = 0; imageIndex < images.length; imageIndex++) {
504
510
  var image = images[imageIndex];
505
511
  if (!String(image.getAttribute("alt") || "").replace(/^\s+|\s+$/g, "")) {
@@ -516,11 +522,33 @@ function RTE_Plugin_AccessibilityChecker() {
516
522
  var tables = tag === "table" ? [node] : (node.querySelectorAll ? node.querySelectorAll("table") : []);
517
523
  for (var tableIndex = 0; tableIndex < tables.length; tableIndex++) {
518
524
  var table = tables[tableIndex];
519
- if (!table.querySelector("th")) {
525
+ var tableRole = String(table.getAttribute("role") || "").toLowerCase();
526
+ var isLayoutTable = (tableRole === "presentation" || tableRole === "none");
527
+
528
+ // 2026-08-30 A layout table MUST NOT have headers, so demanding
529
+ // them here produced an error the author could never clear —
530
+ // which is how a checker teaches people to ignore it. Presentation
531
+ // tables are held to the opposite rule instead: they must not
532
+ // carry data-table semantics, because <th>/scope/<caption> make a
533
+ // screen reader announce relationships that do not exist
534
+ // (WCAG 1.3.1). See layouttable.js.
535
+ if (isLayoutTable) {
536
+ var semantic = table.querySelector("th, [scope], caption") || table.getAttribute("summary");
537
+ if (semantic) {
538
+ issues.push({
539
+ code: "layout-table-semantics",
540
+ severity: "error",
541
+ message: "Layout table still carries data-table markup (header cells, scope or a caption). A screen reader will announce relationships this table does not have.",
542
+ path: path + ".table[" + tableIndex + "]",
543
+ _target: table
544
+ });
545
+ }
546
+ }
547
+ else if (!table.querySelector("th")) {
520
548
  issues.push({
521
549
  code: "table-missing-header",
522
550
  severity: "warning",
523
- message: "Table has no header cells. Promote the first row to headers when it describes the columns.",
551
+ message: "Table has no header cells. Promote the first row to headers, or mark it as a layout table when it only positions content.",
524
552
  path: path + ".table[" + tableIndex + "]",
525
553
  _target: table
526
554
  });
@@ -551,7 +579,13 @@ function RTE_Plugin_AccessibilityChecker() {
551
579
  target.parentNode.replaceChild(replacement, target);
552
580
  }
553
581
  else if (issue.code === "table-missing-header") {
554
- promoteFirstTableRow(target);
582
+ // Two valid outcomes, and only the author knows which: the table
583
+ // describes data (give it headers) or only positions it (say so).
584
+ if (options && options.markLayout) markTableAsLayout(target);
585
+ else promoteFirstTableRow(target);
586
+ }
587
+ else if (issue.code === "layout-table-semantics") {
588
+ markTableAsLayout(target);
555
589
  }
556
590
  else if (issue.code === "language-of-parts") {
557
591
  markLanguageRuns(target, options && options.lang ? options.lang : issue._lang, issue._script);
@@ -636,6 +670,35 @@ function RTE_Plugin_AccessibilityChecker() {
636
670
  }
637
671
  }
638
672
 
673
+ // Delegates to layouttable.js when it is loaded so there is ONE definition of
674
+ // what "layout table" means; the fallback keeps the repair working in builds
675
+ // where that plugin was left out of the bundle.
676
+ function markTableAsLayout(table) {
677
+ if (!table) return;
678
+ if (editor && typeof editor.setTableLayoutMode === "function") {
679
+ try { editor.setTableLayoutMode("layout", table); return; } catch (e) {}
680
+ }
681
+ table.setAttribute("role", "presentation");
682
+ table.removeAttribute("summary");
683
+ var caption = table.querySelector("caption");
684
+ if (caption && caption.parentNode === table) caption.parentNode.removeChild(caption);
685
+ var cells = table.querySelectorAll("th,[scope]");
686
+ for (var i = 0; i < cells.length; i++) {
687
+ var cell = cells[i];
688
+ // Tables nest — do not strip the headers of a data table that
689
+ // happens to sit inside a cell of this one.
690
+ if (cell.closest && cell.closest("table") !== table) continue;
691
+ cell.removeAttribute("scope");
692
+ cell.removeAttribute("headers");
693
+ if (String(cell.nodeName || "").toLowerCase() !== "th") continue;
694
+ var td = table.ownerDocument.createElement("td");
695
+ while (cell.firstChild) td.appendChild(cell.firstChild);
696
+ copyAttributes(cell, td);
697
+ td.removeAttribute("scope");
698
+ cell.parentNode.replaceChild(td, cell);
699
+ }
700
+ }
701
+
639
702
  function copyAttributes(source, target) {
640
703
  if (!source || !target || !source.attributes) return;
641
704
  for (var index = 0; index < source.attributes.length; index++) {
@@ -869,7 +932,9 @@ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
869
932
  var existing = document.querySelectorAll("link[rel=stylesheet]");
870
933
  for (var i = 0; i < existing.length; i++) {
871
934
  var href = existing[i].getAttribute("href") || "";
872
- if (/(?:^|\/)aitoolkit\.css(?:\?.*)?$/i.test(href)) return;
935
+ // Matches the minified variant too - a host page that links either one
936
+ // must not get a second copy injected.
937
+ if (/(?:^|\/)aitoolkit(?:\.min)?\.css(?:\?.*)?$/i.test(href)) return;
873
938
  }
874
939
  // Locate our own script tag (aitoolkit.js or all_plugins.js) so we
875
940
  // can resolve aitoolkit.css next to it. Matches a custom mount path.
@@ -883,14 +948,30 @@ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
883
948
  }
884
949
  }
885
950
  var srcAttr = ourScript ? ourScript.getAttribute("src") : "";
886
- var cssHref = srcAttr
887
- ? srcAttr.replace(/[^/]+$/, "aitoolkit.css")
888
- : (((window.RTE_DefaultConfig && window.RTE_DefaultConfig.url_base) || "/richtexteditor") + "/plugins/aitoolkit.css");
889
- if (cssHref.indexOf("?") < 0) cssHref += "?v=20260703a";
951
+ // Prefer the minified stylesheet. aitoolkit.css is 537 KB unminified - larger
952
+ // than the obfuscated rte.js - and every page that loads the AI toolkit was
953
+ // paying for it. The readable .css is still shipped and still the one to edit
954
+ // or override; this only changes what gets fetched by default.
955
+ function resolve(name) {
956
+ return srcAttr
957
+ ? srcAttr.replace(/[^/]+$/, name)
958
+ : (((window.RTE_DefaultConfig && window.RTE_DefaultConfig.url_base) || "/richtexteditor") + "/plugins/" + name);
959
+ }
960
+ function bust(href) { return href.indexOf("?") < 0 ? href + "?v=20260830a" : href; }
961
+
890
962
  var link = document.createElement("link");
891
963
  link.rel = "stylesheet";
892
- link.href = cssHref;
964
+ link.href = bust(resolve("aitoolkit.min.css"));
893
965
  link.setAttribute("data-rte-auto-injected", "aitoolkit");
966
+ // .min.css is newer than .css. An install that predates it - an unpacked older
967
+ // zip, a tier the mirror missed - would otherwise lose ALL AI toolkit styling
968
+ // silently, which is a far worse failure than shipping the larger file. Fall
969
+ // back to the readable stylesheet if the minified one is not there.
970
+ link.onerror = function () {
971
+ if (link.getAttribute("data-rte-css-fallback")) return;
972
+ link.setAttribute("data-rte-css-fallback", "1");
973
+ link.href = bust(resolve("aitoolkit.css"));
974
+ };
894
975
  (document.head || document.documentElement).appendChild(link);
895
976
  })();
896
977
 
@@ -30895,346 +30976,758 @@ function RTE_Plugin_InsertTemplate() {
30895
30976
  };
30896
30977
  }
30897
30978
 
30979
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
30980
+
30981
+ // 2026-08-02 Keyboard and state accessibility for the editor CHROME.
30982
+ //
30983
+ // a11yenhance.js names the editing region and dialoga11y.js names the dialogs.
30984
+ // This closes what an audit of the running editor found still open — four
30985
+ // findings, three of them WCAG Level A:
30986
+ //
30987
+ // 1. KEYBOARD TRAP (2.1.2, Level A). Tab inside the editing area is
30988
+ // preventDefault-ed and inserts spaces, so a keyboard-only or screen
30989
+ // reader user who enters the editor can never leave it. Reloading the page
30990
+ // is the only way out. This is the most serious kind of accessibility
30991
+ // defect: it does not degrade the experience, it ends it.
30992
+ //
30993
+ // 2. TOGGLE STATE NOT EXPOSED (4.1.2, Level A). Bold, italic, underline and
30994
+ // the alignment buttons carry their state in a CSS class
30995
+ // (rte-command-active / rte-command-deactive) and nowhere else. Sighted
30996
+ // users see a highlighted button; assistive technology is told nothing.
30997
+ // Measured: 0 of 6 toggle buttons exposed aria-pressed.
30998
+ //
30999
+ // 3. POPUP STATE NOT EXPOSED (4.1.2, Level A). Buttons carry
31000
+ // aria-haspopup but never aria-expanded, so there is no way to know
31001
+ // whether a menu is open. Measured: 0 of 7.
31002
+ //
31003
+ // 4. EVERY TOOLBAR BUTTON IS A TAB STOP (2.4.3, and plain usability).
31004
+ // Measured 53. The ARIA Authoring Practices toolbar pattern is a single
31005
+ // tab stop per toolbar with arrow keys moving between buttons. 53 presses
31006
+ // of Tab to reach the text you came to write is not operable in any
31007
+ // meaningful sense.
31008
+ //
31009
+ // All four are fixed here rather than in the core, so they ship without a
31010
+ // re-obfuscation cycle.
31011
+ //
31012
+ // Config:
31013
+ // config.keyboardA11y = false // opt out entirely
31014
+ // config.a11yEscapeHint = "..." // wording appended to the editing area's name
31015
+ // config.a11yEditorLabel = "..." // accessible NAME of the editing area (default "Rich text editor")
31016
+ // config.a11yRovingToolbar = false // keep every button as a tab stop
31017
+ RTE_DefaultConfig.plugin_keyboarda11y = RTE_Plugin_KeyboardA11y;
31018
+ if (typeof RTE_DefaultConfig.keyboardA11y === "undefined") RTE_DefaultConfig.keyboardA11y = true;
31019
+
31020
+ function RTE_Plugin_KeyboardA11y() {
31021
+ var obj = this;
31022
+ var config, editor;
31023
+ var observers = [];
31024
+ var popupOwner = null; // last activated [aria-haspopup]
31025
+ var openPanels = []; // [{ panel, owner }]
31026
+
31027
+ obj.PluginName = "KeyboardA11y";
31028
+
31029
+ obj.InitConfig = function (argconfig) { config = argconfig; };
31030
+
31031
+ obj.InitEditor = function (argeditor) {
31032
+ editor = argeditor;
31033
+ if (config.keyboardA11y === false) return;
31034
+
31035
+ editor.focusToolbar = function () { return focusFirstToolbarButton(); };
31036
+
31037
+ setup();
31038
+ try { editor.attachEvent("ready", setup); } catch (e) {}
31039
+ // The toolbar is built asynchronously; a deferred pass catches the case
31040
+ // where InitEditor runs before it exists.
31041
+ setTimeout(setup, 0);
31042
+ setTimeout(setup, 400);
31043
+ };
31044
+
31045
+ function shell() {
31046
+ try {
31047
+ var ed = editor.getEditable();
31048
+ if (!ed) return null;
31049
+ var win = ed.ownerDocument.defaultView;
31050
+ var node = (win && win.frameElement) ? win.frameElement : ed;
31051
+ while (node && node.classList && !node.classList.contains("richtexteditor")) node = node.parentNode;
31052
+ return (node && node.classList) ? node : null;
31053
+ } catch (e) { return null; }
31054
+ }
31055
+
31056
+ function setup() {
31057
+ var root = shell();
31058
+ if (!root) return;
31059
+ bindEscapeHatch();
31060
+ applyRovingTabindex(root);
31061
+ syncToggleStates(root);
31062
+ trackPopups(root);
31063
+ watch(root);
31064
+ }
31065
+
31066
+ // ---------------------------------------------------- 1. keyboard trap
31067
+ //
31068
+ // Tab keeps its editing meaning (indent, next table cell) because that is
31069
+ // what writers expect and what every other editor does. The escape is a
31070
+ // separate, documented key: Escape leaves the editing area and puts focus
31071
+ // on the toolbar, from which Tab continues through the page normally.
31072
+ //
31073
+ // Escape is only intercepted when nothing is open — a dialog or dropdown
31074
+ // must still get its own Escape first, or closing a colour picker would
31075
+ // throw the user out of the editor.
31076
+ // Bound on the DOCUMENT rather than on the body element. Escape bubbles, so
31077
+ // one listener at document level covers the editing area no matter how the
31078
+ // surface is re-created, and it does not depend on a marker flag pinned to a
31079
+ // particular element surviving. Defensive only: no shipped operation is known
31080
+ // to replace the editing body — setHTML, htmlview, fullscreen, readingmode,
31081
+ // preview and toggleborder all preserve its identity (measured 2026-08-27).
31082
+ // Keeping the binding here costs nothing and removes an assumption, but it
31083
+ // fixes no known live defect; do not describe it as a bug fix.
31084
+ function bindEscapeHatch() {
31085
+ var ed, doc;
31086
+ try { ed = editor.getEditable(); doc = ed && ed.ownerDocument; } catch (e) { return; }
31087
+ if (!ed || !doc) return;
31088
+
31089
+ if (!doc.__rteEscapeHatch) {
31090
+ doc.__rteEscapeHatch = true;
31091
+ doc.addEventListener("keydown", function (e) {
31092
+ if (e.key !== "Escape" && e.keyCode !== 27) return;
31093
+ if (anythingOpen()) return; // let the panel close itself
31094
+ e.preventDefault();
31095
+ e.stopPropagation();
31096
+ if (!focusFirstToolbarButton()) focusAfterEditor();
31097
+ }, false);
31098
+
31099
+ // Deliberately not re-asserting the body's aria-label here. An
31100
+ // earlier version did, on the theory that the editing body could be
31101
+ // replaced underneath us; that mechanism was retracted 2026-08-27
31102
+ // after it turned out to be an instrumentation artefact, and
31103
+ // editor.getEditable() is verified to return the live, connected
31104
+ // body on every shipped path. announceEscape() at the end of this
31105
+ // function is sufficient.
31106
+ }
31107
+
31108
+ announceEscape(ed);
31109
+ }
31110
+
31111
+ function anythingOpen() {
31112
+ try {
31113
+ var panels = document.querySelectorAll("rte-dropdown-panel, rte-dialog-float, rte-floatpanel");
31114
+ for (var i = 0; i < panels.length; i++) {
31115
+ var s = getComputedStyle(panels[i]);
31116
+ if (s.display !== "none" && s.visibility !== "hidden") return true;
31117
+ }
31118
+ } catch (e) {}
31119
+ return false;
31120
+ }
31121
+
31122
+ // The way out has to be discoverable, or it may as well not exist. The
31123
+ // editing region's accessible name is the one thing a screen reader always
31124
+ // announces on entry, so the hint goes there rather than into the content.
31125
+ //
31126
+ // But an instruction is not a NAME. When the host page had not labelled the
31127
+ // editing area, appending the hint to an empty label left the region's
31128
+ // accessible name as nothing but "Press Escape to leave the editing area." —
31129
+ // a screen reader then told the user how to LEAVE a field without ever
31130
+ // saying what it was, and two editors on one page were indistinguishable.
31131
+ // So: a host-supplied label still wins as the name; otherwise we supply a
31132
+ // real one and the hint follows it.
31133
+ function announceEscape(ed) {
31134
+ var hint = config.a11yEscapeHint ||
31135
+ "Press Escape to leave the editing area.";
31136
+ var label = ed.getAttribute("aria-label") || "";
31137
+ if (label.indexOf(hint) >= 0) return;
31138
+ var name = label || config.a11yEditorLabel || "Rich text editor";
31139
+ ed.setAttribute("aria-label", name.replace(/\s*$/, "") + " " + hint);
31140
+ }
31141
+
31142
+ function focusAfterEditor() {
31143
+ var root = shell();
31144
+ if (!root) return false;
31145
+ var all = [].slice.call(document.querySelectorAll(
31146
+ 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'
31147
+ )).filter(function (el) {
31148
+ return !root.contains(el) && el.offsetParent !== null;
31149
+ });
31150
+ // The first focusable that follows the editor in document order.
31151
+ for (var i = 0; i < all.length; i++) {
31152
+ if (root.compareDocumentPosition(all[i]) & Node.DOCUMENT_POSITION_FOLLOWING) {
31153
+ all[i].focus();
31154
+ return true;
31155
+ }
31156
+ }
31157
+ return false;
31158
+ }
31159
+
31160
+ // ------------------------------------------------- 4. roving tabindex
31161
+ function toolbarsIn(root) {
31162
+ return [].slice.call(root.querySelectorAll('[role="toolbar"]'));
31163
+ }
31164
+ // Toolbars nest — an overflow or ribbon toolbar sits inside the main one.
31165
+ // A plain descendant query therefore makes the outer toolbar claim the
31166
+ // inner one's buttons, and the two passes fight: the first run measured two
31167
+ // tab stops in one toolbar and NONE in the other two, which left those
31168
+ // buttons unreachable by keyboard altogether. Each button belongs to its
31169
+ // NEAREST toolbar and to no other.
31170
+ function buttonsIn(bar) {
31171
+ return [].slice.call(bar.querySelectorAll('[role="button"]')).filter(function (b) {
31172
+ if (b.getAttribute("aria-disabled") === "true") return false;
31173
+ if (b.offsetParent === null) return false; // hidden: not reachable anyway
31174
+ return (b.closest && b.closest('[role="toolbar"]')) === bar;
31175
+ });
31176
+ }
31177
+
31178
+ function applyRovingTabindex(root) {
31179
+ if (config.a11yRovingToolbar === false) return;
31180
+ var bars = toolbarsIn(root);
31181
+ for (var i = 0; i < bars.length; i++) {
31182
+ (function (bar) {
31183
+ var btns = buttonsIn(bar);
31184
+ if (!btns.length) return;
31185
+ // Exactly one tab stop per toolbar, which is the ARIA pattern.
31186
+ var current = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31187
+ for (var j = 0; j < btns.length; j++) btns[j].setAttribute("tabindex", btns[j] === current ? "0" : "-1");
31188
+
31189
+ if (bar.__rteRoving) return;
31190
+ bar.__rteRoving = true;
31191
+ bar.addEventListener("keydown", function (e) {
31192
+ var list = buttonsIn(bar);
31193
+ var at = list.indexOf(document.activeElement);
31194
+ if (at < 0) return;
31195
+ var rtl = (bar.closest && bar.closest('[dir="rtl"]')) ? true : false;
31196
+ var next = null;
31197
+ if (e.key === "ArrowRight") next = list[(at + (rtl ? -1 : 1) + list.length) % list.length];
31198
+ else if (e.key === "ArrowLeft") next = list[(at + (rtl ? 1 : -1) + list.length) % list.length];
31199
+ else if (e.key === "Home") next = list[0];
31200
+ else if (e.key === "End") next = list[list.length - 1];
31201
+ else return;
31202
+ e.preventDefault();
31203
+ for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === next ? "0" : "-1");
31204
+ next.focus();
31205
+ }, false);
31206
+ // Clicking a button makes it the new tab stop, so returning by
31207
+ // Tab lands where the user last was.
31208
+ bar.addEventListener("focusin", function (e) {
31209
+ var list = buttonsIn(bar);
31210
+ if (list.indexOf(e.target) < 0) return;
31211
+ for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === e.target ? "0" : "-1");
31212
+ }, false);
31213
+ })(bars[i]);
31214
+ }
31215
+ }
31216
+
31217
+ function focusFirstToolbarButton() {
31218
+ var root = shell();
31219
+ if (!root) return false;
31220
+ var bars = toolbarsIn(root);
31221
+ for (var i = 0; i < bars.length; i++) {
31222
+ var btns = buttonsIn(bars[i]);
31223
+ if (!btns.length) continue;
31224
+ var target = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31225
+ target.focus();
31226
+ return true;
31227
+ }
31228
+ return false;
31229
+ }
31230
+
31231
+ // ------------------------------------------------- 2. aria-pressed
31232
+ //
31233
+ // The editor already tracks active state — it just keeps it in a class.
31234
+ // Mirroring rather than recomputing means the announced state can never
31235
+ // disagree with the highlighted button.
31236
+ // Command names here must match the TOOLBAR command exactly — the regex is
31237
+ // anchored, so a near-miss is a silent no-op that looks like coverage.
31238
+ // "blockquote" is one: the command is `insertblockquote`, so the entry
31239
+ // written specifically for it never matched and that button ships with no
31240
+ // aria-pressed. Found by checking every genuine toggle on the full toolbar
31241
+ // rather than the three the published verify page samples.
31242
+ //
31243
+ // This mirror only copies the editor's own active state, so a command may
31244
+ // only be listed here once core actually tracks it. Emitting the attribute
31245
+ // for an untracked command yields a permanent aria-pressed="false" —
31246
+ // confidently announcing "not pressed" while the formatting IS applied,
31247
+ // which makes announced state DISAGREE with the highlight. That is the exact
31248
+ // failure this mirror exists to prevent, so silence beats a wrong answer.
31249
+ // - `toggleborder` was already tracked (core checks the editable's
31250
+ // rte-toggleborder class); it reads inactive simply when borders are off.
31251
+ // - `insertblockquote` was NOT tracked and now is, via a core case added
31252
+ // alongside `indent`, which resolves the same way.
31253
+ // Both verified on the running editor rather than inferred.
31254
+ //
31255
+ // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
31256
+ // this build, and now the reason is known rather than assumed: pagination.js
31257
+ // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
31258
+ // and register no toolbar command, and insertcode.js registers `insertcode`,
31259
+ // which inserts a block rather than toggling the selection. They are kept —
31260
+ // harmless, and correct the moment a toolbar button is added — but they
31261
+ // cover nothing today, so do not read this list as evidence of coverage.
31262
+ 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)$/;
31263
+
31264
+ function syncToggleStates(root) {
31265
+ var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
31266
+ for (var i = 0; i < btns.length; i++) {
31267
+ var cmd = (btns[i].getAttribute("rte-cmd-name") || "").toLowerCase();
31268
+ if (!TOGGLE_CMD.test(cmd)) continue;
31269
+ var active = btns[i].classList.contains("rte-command-active");
31270
+ var pressed = active ? "true" : "false";
31271
+ if (btns[i].getAttribute("aria-pressed") !== pressed) btns[i].setAttribute("aria-pressed", pressed);
31272
+ }
31273
+ }
31274
+
31275
+ // ------------------------------------------------- 3. aria-expanded
31276
+ //
31277
+ // Opening a menu inserts an <rte-dropdown-panel> elsewhere in the document
31278
+ // and leaves the button untouched, so the button and its panel have to be
31279
+ // correlated: remember which popup button was activated, then pair it with
31280
+ // the panel that appears.
31281
+ function trackPopups(root) {
31282
+ var pops = [].slice.call(root.querySelectorAll("[aria-haspopup]"));
31283
+ for (var i = 0; i < pops.length; i++) {
31284
+ if (!pops[i].hasAttribute("aria-expanded")) pops[i].setAttribute("aria-expanded", "false");
31285
+ if (pops[i].__rtePopupBound) continue;
31286
+ pops[i].__rtePopupBound = true;
31287
+ (function (el) {
31288
+ function remember() { popupOwner = el; }
31289
+ el.addEventListener("mousedown", remember, true);
31290
+ el.addEventListener("keydown", function (e) {
31291
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") remember();
31292
+ }, true);
31293
+ })(pops[i]);
31294
+ }
31295
+
31296
+ if (trackPopups.bound) return;
31297
+ trackPopups.bound = true;
31298
+ var mo = new MutationObserver(function (records) {
31299
+ for (var r = 0; r < records.length; r++) {
31300
+ var rec = records[r];
31301
+ for (var a = 0; a < rec.addedNodes.length; a++) {
31302
+ var n = rec.addedNodes[a];
31303
+ if (n.nodeType !== 1 || !isPanel(n)) continue;
31304
+ if (popupOwner) {
31305
+ popupOwner.setAttribute("aria-expanded", "true");
31306
+ openPanels.push({ panel: n, owner: popupOwner });
31307
+ popupOwner = null;
31308
+ }
31309
+ }
31310
+ for (var d = 0; d < rec.removedNodes.length; d++) {
31311
+ var m = rec.removedNodes[d];
31312
+ if (m.nodeType !== 1 || !isPanel(m)) continue;
31313
+ for (var k = openPanels.length - 1; k >= 0; k--) {
31314
+ if (openPanels[k].panel === m) {
31315
+ openPanels[k].owner.setAttribute("aria-expanded", "false");
31316
+ openPanels.splice(k, 1);
31317
+ }
31318
+ }
31319
+ }
31320
+ }
31321
+ });
31322
+ mo.observe(document.body, { childList: true, subtree: true });
31323
+ observers.push(mo);
31324
+ }
31325
+ function isPanel(el) {
31326
+ var t = (el.tagName || "").toLowerCase();
31327
+ return t === "rte-dropdown-panel" || t === "rte-floatpanel" || t === "rte-dialog-float";
31328
+ }
31329
+
31330
+ // Toolbar buttons are rebuilt and re-classed as the selection moves, so the
31331
+ // mirrored state has to follow rather than be set once.
31332
+ function watch(root) {
31333
+ if (root.__rteA11yWatch) return;
31334
+ root.__rteA11yWatch = true;
31335
+ var pending = null;
31336
+ var mo = new MutationObserver(function () {
31337
+ if (pending) return;
31338
+ pending = setTimeout(function () {
31339
+ pending = null;
31340
+ syncToggleStates(root);
31341
+ applyRovingTabindex(root);
31342
+ trackPopups(root);
31343
+ }, 60);
31344
+ });
31345
+ mo.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-disabled"] });
31346
+ observers.push(mo);
31347
+ }
31348
+ }
31349
+
30898
31350
  if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
30899
31351
 
30900
- // 2026-08-02 Keyboard and state accessibility for the editor CHROME.
30901
- //
30902
- // a11yenhance.js names the editing region and dialoga11y.js names the dialogs.
30903
- // This closes what an audit of the running editor found still open — four
30904
- // findings, three of them WCAG Level A:
31352
+ // 2026-08-30 Layout tables — say whether a table carries data or only geometry.
30905
31353
  //
30906
- // 1. KEYBOARD TRAP (2.1.2, Level A). Tab inside the editing area is
30907
- // preventDefault-ed and inserts spaces, so a keyboard-only or screen
30908
- // reader user who enters the editor can never leave it. Reloading the page
30909
- // is the only way out. This is the most serious kind of accessibility
30910
- // defect: it does not degrade the experience, it ends it.
31354
+ // Found by re-sweeping CKEditor 5's feature pages (they ship `layout-tables`);
31355
+ // TinyMCE has no equivalent. Every other editor treats <table> as one thing.
31356
+ // It is two:
30911
31357
  //
30912
- // 2. TOGGLE STATE NOT EXPOSED (4.1.2, Level A). Bold, italic, underline and
30913
- // the alignment buttons carry their state in a CSS class
30914
- // (rte-command-active / rte-command-deactive) and nowhere else. Sighted
30915
- // users see a highlighted button; assistive technology is told nothing.
30916
- // Measured: 0 of 6 toggle buttons exposed aria-pressed.
31358
+ // * a DATA table states relationships — this cell is described by that header —
31359
+ // and a screen reader announces them on every cell;
31360
+ // * a LAYOUT table states nothing. Newsletters, signatures and pre-CSS page
31361
+ // furniture use it purely for geometry, and the correct markup is
31362
+ // role="presentation", which tells assistive technology to read the cells as
31363
+ // plain flow content instead of narrating a fake grid.
30917
31364
  //
30918
- // 3. POPUP STATE NOT EXPOSED (4.1.2, Level A). Buttons carry
30919
- // aria-haspopup but never aria-expanded, so there is no way to know
30920
- // whether a menu is open. Measured: 0 of 7.
31365
+ // Using one where the other is meant is a real WCAG 1.3.1 failure, not a style
31366
+ // preference: a layout table with <th>/scope/<caption> makes a screen reader
31367
+ // announce column headers that describe nothing.
30921
31368
  //
30922
- // 4. EVERY TOOLBAR BUTTON IS A TAB STOP (2.4.3, and plain usability).
30923
- // Measured 53. The ARIA Authoring Practices toolbar pattern is a single
30924
- // tab stop per toolbar with arrow keys moving between buttons. 53 presses
30925
- // of Tab to reach the text you came to write is not operable in any
30926
- // meaningful sense.
31369
+ // It also fixes a false alarm we were shipping. `accessibilitychecker.js` raised
31370
+ // `table-missing-header` on EVERY table without a <th>, so an email layout — which
31371
+ // must not have one — produced a permanent error the author could never clear. A
31372
+ // checker that cannot be satisfied teaches people to ignore it, so the rule now
31373
+ // skips presentation tables and instead flags the genuine fault: a layout table
31374
+ // that still carries data-table semantics.
30927
31375
  //
30928
- // All four are fixed here rather than in the core, so they ship without a
30929
- // re-obfuscation cycle.
30930
- //
30931
- // Config:
30932
- // config.keyboardA11y = false // opt out entirely
30933
- // config.a11yEscapeHint = "..." // wording appended to the editing area's name
30934
- // config.a11yRovingToolbar = false // keep every button as a tab stop
30935
- RTE_DefaultConfig.plugin_keyboarda11y = RTE_Plugin_KeyboardA11y;
30936
- if (typeof RTE_DefaultConfig.keyboardA11y === "undefined") RTE_DefaultConfig.keyboardA11y = true;
30937
-
30938
- function RTE_Plugin_KeyboardA11y() {
31376
+ // Design notes:
31377
+ // - role="presentation" is the whole marker. No vendor class is added, so the
31378
+ // exported HTML gains nothing this editor has to own; `role=none` is the ARIA
31379
+ // synonym and is accepted on read, though we always write "presentation"
31380
+ // because Outlook-era mail clients are more likely to have heard of it.
31381
+ // - The dashed outline is injected into the EDITING document only (a <style> in
31382
+ // the iframe head, like formattingmarks.js), never into the content. A layout
31383
+ // table is invisible by design; without an affordance the author cannot see
31384
+ // the thing they are editing.
31385
+ // - Tables nest. Marking the outer table layout must not strip the headers of a
31386
+ // data table sitting inside one of its cells, so every walk is restricted to
31387
+ // cells whose nearest ancestor table is this one.
31388
+ // - Converting <th> to <td> loses information. Which cells were headers is
31389
+ // remembered per table in a WeakMap so an accidental toggle is reversible in
31390
+ // the session, and that memory is deliberately NOT written into the markup —
31391
+ // restoring it is a convenience, not a document property. Reloaded documents
31392
+ // therefore restore nothing; `promoteFirstRow` is offered for that case.
31393
+ RTE_DefaultConfig.plugin_layouttable = RTE_Plugin_LayoutTable;
31394
+
31395
+ // Show a dashed outline around layout tables while editing.
31396
+ if (typeof RTE_DefaultConfig.layoutTableOutline === "undefined") RTE_DefaultConfig.layoutTableOutline = true;
31397
+
31398
+ function RTE_Plugin_LayoutTable() {
30939
31399
  var obj = this;
30940
31400
  var config, editor;
30941
- var observers = [];
30942
- var popupOwner = null; // last activated [aria-haspopup]
30943
- var openPanels = []; // [{ panel, owner }]
31401
+ var headerMemory = (typeof WeakMap === "function") ? new WeakMap() : null;
30944
31402
 
30945
- obj.PluginName = "KeyboardA11y";
31403
+ obj.PluginName = "LayoutTable";
30946
31404
 
30947
31405
  obj.InitConfig = function (argconfig) { config = argconfig; };
30948
31406
 
30949
31407
  obj.InitEditor = function (argeditor) {
30950
31408
  editor = argeditor;
30951
- if (config.keyboardA11y === false) return;
30952
31409
 
30953
- editor.focusToolbar = function () { return focusFirstToolbarButton(); };
31410
+ editor.attachEvent("exec_command_tablelayoutmode", function (state) {
31411
+ state.returnValue = true;
31412
+ obj.SetMode(tableAtCaret(), "layout");
31413
+ });
31414
+ editor.attachEvent("exec_command_tabledatamode", function (state) {
31415
+ state.returnValue = true;
31416
+ obj.SetMode(tableAtCaret(), "data");
31417
+ });
31418
+ editor.attachEvent("exec_command_toggletablelayout", function (state) {
31419
+ state.returnValue = true;
31420
+ obj.Toggle();
31421
+ });
31422
+ editor.attachEvent("exec_command_insertlayouttable", function (state) {
31423
+ state.returnValue = true;
31424
+ obj.InsertLayoutTable();
31425
+ });
30954
31426
 
30955
- setup();
30956
- try { editor.attachEvent("ready", setup); } catch (e) {}
30957
- // The toolbar is built asynchronously; a deferred pass catches the case
30958
- // where InitEditor runs before it exists.
30959
- setTimeout(setup, 0);
30960
- setTimeout(setup, 400);
30961
- };
31427
+ // Public API.
31428
+ editor.setTableLayoutMode = function (mode, table, options) {
31429
+ return obj.SetMode(table || tableAtCaret(), mode, options);
31430
+ };
31431
+ editor.getTableLayoutMode = function (table) {
31432
+ var t = table || tableAtCaret();
31433
+ return t ? (isLayout(t) ? "layout" : "data") : null;
31434
+ };
31435
+ editor.isLayoutTable = function (table) { return isLayout(table || tableAtCaret()); };
31436
+ editor.toggleTableLayoutMode = function () { return obj.Toggle(); };
31437
+ editor.insertLayoutTable = function (rows, cols, options) {
31438
+ return obj.InsertLayoutTable(rows, cols, options);
31439
+ };
31440
+ editor.listLayoutTables = function () {
31441
+ var ed = getEditable();
31442
+ if (!ed) return [];
31443
+ var out = [], all = ed.getElementsByTagName("table");
31444
+ for (var i = 0; i < all.length; i++) if (isLayout(all[i])) out.push(all[i]);
31445
+ return out;
31446
+ };
31447
+ editor.showLayoutTableOutlines = function (on) {
31448
+ outlineOn = (on !== false);
31449
+ injectStyles(editableDoc());
31450
+ return outlineOn;
31451
+ };
30962
31452
 
30963
- function shell() {
30964
31453
  try {
30965
- var ed = editor.getEditable();
30966
- if (!ed) return null;
30967
- var win = ed.ownerDocument.defaultView;
30968
- var node = (win && win.frameElement) ? win.frameElement : ed;
30969
- while (node && node.classList && !node.classList.contains("richtexteditor")) node = node.parentNode;
30970
- return (node && node.classList) ? node : null;
30971
- } catch (e) { return null; }
31454
+ if (config && typeof config.layoutTableOutline !== "undefined") outlineOn = !!config.layoutTableOutline;
31455
+ } catch (e) {}
31456
+ injectStyles(editableDoc());
31457
+ try {
31458
+ editor.attachEvent("editor_ready", function () { injectStyles(editableDoc()); });
31459
+ editor.attachEvent("update_design", function () { injectStyles(editableDoc()); });
31460
+ } catch (e) {}
31461
+ };
31462
+
31463
+ var outlineOn = true;
31464
+
31465
+ // ---------------------------------------------------------------- commands
31466
+
31467
+ obj.SetMode = function (table, mode, options) {
31468
+ if (!table) return null;
31469
+ if (String(mode).toLowerCase() === "layout") return toLayout(table);
31470
+ return toData(table, options);
31471
+ };
31472
+
31473
+ obj.Toggle = function () {
31474
+ var t = tableAtCaret();
31475
+ if (!t) return null;
31476
+ return isLayout(t) ? toData(t) : toLayout(t);
31477
+ };
31478
+
31479
+ obj.InsertLayoutTable = function (rows, cols, options) {
31480
+ var doc = editableDoc();
31481
+ if (!doc) return null;
31482
+ rows = Math.max(1, parseInt(rows, 10) || 2);
31483
+ cols = Math.max(1, parseInt(cols, 10) || 2);
31484
+ options = options || {};
31485
+
31486
+ var table = doc.createElement("table");
31487
+ table.setAttribute("role", "presentation");
31488
+ // The three attributes every email layout table needs; without
31489
+ // border-collapse the cells show a 2px gap in Outlook.
31490
+ table.setAttribute("cellpadding", "0");
31491
+ table.setAttribute("cellspacing", "0");
31492
+ table.style.width = options.width || "100%";
31493
+ table.style.borderCollapse = "collapse";
31494
+
31495
+ var tbody = doc.createElement("tbody");
31496
+ for (var r = 0; r < rows; r++) {
31497
+ var tr = doc.createElement("tr");
31498
+ for (var c = 0; c < cols; c++) {
31499
+ var td = doc.createElement("td");
31500
+ td.style.verticalAlign = "top";
31501
+ td.appendChild(doc.createElement("br"));
31502
+ tr.appendChild(td);
31503
+ }
31504
+ tbody.appendChild(tr);
31505
+ }
31506
+ table.appendChild(tbody);
31507
+
31508
+ insertAtCaret(table, doc);
31509
+ injectStyles(doc);
31510
+ fireChange();
31511
+ return table;
31512
+ };
31513
+
31514
+ // ------------------------------------------------------------- conversions
31515
+
31516
+ function toLayout(table) {
31517
+ table.setAttribute("role", "presentation");
31518
+
31519
+ // A caption is announced as the table's name; a layout table has no name
31520
+ // to announce. Keep the text — it is content the author typed — by moving
31521
+ // it out into a paragraph before the table rather than deleting it.
31522
+ var caption = firstOwnChild(table, "caption");
31523
+ if (caption) {
31524
+ var text = String(caption.textContent || "").replace(/^\s+|\s+$/g, "");
31525
+ if (text) {
31526
+ var p = table.ownerDocument.createElement("p");
31527
+ while (caption.firstChild) p.appendChild(caption.firstChild);
31528
+ if (table.parentNode) table.parentNode.insertBefore(p, table);
31529
+ }
31530
+ caption.parentNode.removeChild(caption);
31531
+ }
31532
+
31533
+ table.removeAttribute("summary");
31534
+
31535
+ var cells = ownCells(table), converted = [];
31536
+ for (var i = 0; i < cells.length; i++) {
31537
+ var cell = cells[i];
31538
+ cell.removeAttribute("scope");
31539
+ cell.removeAttribute("headers");
31540
+ cell.removeAttribute("abbr");
31541
+ if (cell.nodeName === "TH") {
31542
+ var td = rename(cell, "td");
31543
+ converted.push(td);
31544
+ }
31545
+ }
31546
+ if (headerMemory && converted.length) headerMemory.set(table, converted);
31547
+
31548
+ // <thead> on a layout table is harmless to AT once role=presentation is
31549
+ // set, but it makes the markup lie to the next human who reads it.
31550
+ var thead = firstOwnChild(table, "thead");
31551
+ if (thead) unwrapSection(table, thead);
31552
+
31553
+ fireChange();
31554
+ return table;
30972
31555
  }
30973
31556
 
30974
- function setup() {
30975
- var root = shell();
30976
- if (!root) return;
30977
- bindEscapeHatch();
30978
- applyRovingTabindex(root);
30979
- syncToggleStates(root);
30980
- trackPopups(root);
30981
- watch(root);
31557
+ function toData(table, options) {
31558
+ table.removeAttribute("role");
31559
+
31560
+ var restored = headerMemory ? headerMemory.get(table) : null;
31561
+ var promoted = false;
31562
+ if (restored && restored.length) {
31563
+ for (var i = 0; i < restored.length; i++) {
31564
+ var cell = restored[i];
31565
+ // The cell may have been deleted, or the whole table replaced,
31566
+ // since it was remembered.
31567
+ if (cell && cell.parentNode && contains(table, cell)) {
31568
+ rename(cell, "th");
31569
+ promoted = true;
31570
+ }
31571
+ }
31572
+ headerMemory["delete"](table);
31573
+ }
31574
+ if (!promoted && options && options.promoteFirstRow) promoteFirstRow(table);
31575
+
31576
+ fireChange();
31577
+ return table;
30982
31578
  }
30983
31579
 
30984
- // ---------------------------------------------------- 1. keyboard trap
30985
- //
30986
- // Tab keeps its editing meaning (indent, next table cell) because that is
30987
- // what writers expect and what every other editor does. The escape is a
30988
- // separate, documented key: Escape leaves the editing area and puts focus
30989
- // on the toolbar, from which Tab continues through the page normally.
30990
- //
30991
- // Escape is only intercepted when nothing is open — a dialog or dropdown
30992
- // must still get its own Escape first, or closing a colour picker would
30993
- // throw the user out of the editor.
30994
- function bindEscapeHatch() {
30995
- var ed;
30996
- try { ed = editor.getEditable(); } catch (e) { return; }
30997
- if (!ed || ed.__rteEscapeHatch) return;
30998
- ed.__rteEscapeHatch = true;
31580
+ function promoteFirstRow(table) {
31581
+ var rows = ownRows(table);
31582
+ if (!rows.length) return;
31583
+ var cells = rowCells(rows[0]);
31584
+ for (var i = 0; i < cells.length; i++) {
31585
+ var th = rename(cells[i], "th");
31586
+ th.setAttribute("scope", "col");
31587
+ }
31588
+ }
30999
31589
 
31000
- ed.addEventListener("keydown", function (e) {
31001
- if (e.key !== "Escape" && e.keyCode !== 27) return;
31002
- if (anythingOpen()) return; // let the panel close itself
31003
- e.preventDefault();
31004
- e.stopPropagation();
31005
- if (!focusFirstToolbarButton()) focusAfterEditor();
31006
- }, false);
31590
+ // ------------------------------------------------------------------ helpers
31007
31591
 
31008
- announceEscape(ed);
31592
+ function isLayout(table) {
31593
+ if (!table || table.nodeName !== "TABLE") return false;
31594
+ var role = String(table.getAttribute("role") || "").toLowerCase();
31595
+ return role === "presentation" || role === "none";
31009
31596
  }
31010
31597
 
31011
- function anythingOpen() {
31012
- try {
31013
- var panels = document.querySelectorAll("rte-dropdown-panel, rte-dialog-float, rte-floatpanel");
31014
- for (var i = 0; i < panels.length; i++) {
31015
- var s = getComputedStyle(panels[i]);
31016
- if (s.display !== "none" && s.visibility !== "hidden") return true;
31017
- }
31018
- } catch (e) {}
31019
- return false;
31598
+ // Tables nest: only touch nodes whose nearest ancestor table is this one.
31599
+ function ownsNode(table, node) {
31600
+ var n = node.parentNode;
31601
+ while (n && n !== table) {
31602
+ if (n.nodeName === "TABLE") return false;
31603
+ n = n.parentNode;
31604
+ }
31605
+ return n === table;
31020
31606
  }
31021
31607
 
31022
- // The way out has to be discoverable, or it may as well not exist. The
31023
- // editing region's accessible name is the one thing a screen reader always
31024
- // announces on entry, so the hint goes there rather than into the content.
31025
- function announceEscape(ed) {
31026
- var hint = config.a11yEscapeHint ||
31027
- "Press Escape to leave the editing area.";
31028
- var label = ed.getAttribute("aria-label") || "";
31029
- if (label.indexOf(hint) >= 0) return;
31030
- ed.setAttribute("aria-label", (label ? label.replace(/\s*$/, " ") : "") + hint);
31031
- }
31032
-
31033
- function focusAfterEditor() {
31034
- var root = shell();
31035
- if (!root) return false;
31036
- var all = [].slice.call(document.querySelectorAll(
31037
- 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'
31038
- )).filter(function (el) {
31039
- return !root.contains(el) && el.offsetParent !== null;
31040
- });
31041
- // The first focusable that follows the editor in document order.
31042
- for (var i = 0; i < all.length; i++) {
31043
- if (root.compareDocumentPosition(all[i]) & Node.DOCUMENT_POSITION_FOLLOWING) {
31044
- all[i].focus();
31045
- return true;
31046
- }
31608
+ function ownCells(table) {
31609
+ var out = [], all = table.querySelectorAll ? table.querySelectorAll("th,td") : [];
31610
+ for (var i = 0; i < all.length; i++) if (ownsNode(table, all[i])) out.push(all[i]);
31611
+ return out;
31612
+ }
31613
+
31614
+ function ownRows(table) {
31615
+ var out = [], all = table.querySelectorAll ? table.querySelectorAll("tr") : [];
31616
+ for (var i = 0; i < all.length; i++) if (ownsNode(table, all[i])) out.push(all[i]);
31617
+ return out;
31618
+ }
31619
+
31620
+ function rowCells(row) {
31621
+ var out = [];
31622
+ for (var n = row.firstChild; n; n = n.nextSibling) {
31623
+ if (n.nodeName === "TH" || n.nodeName === "TD") out.push(n);
31047
31624
  }
31625
+ return out;
31626
+ }
31627
+
31628
+ function firstOwnChild(table, name) {
31629
+ name = name.toUpperCase();
31630
+ for (var n = table.firstChild; n; n = n.nextSibling) if (n.nodeName === name) return n;
31631
+ return null;
31632
+ }
31633
+
31634
+ function contains(root, node) {
31635
+ var n = node;
31636
+ while (n) { if (n === root) return true; n = n.parentNode; }
31048
31637
  return false;
31049
31638
  }
31050
31639
 
31051
- // ------------------------------------------------- 4. roving tabindex
31052
- function toolbarsIn(root) {
31053
- return [].slice.call(root.querySelectorAll('[role="toolbar"]'));
31054
- }
31055
- // Toolbars nest — an overflow or ribbon toolbar sits inside the main one.
31056
- // A plain descendant query therefore makes the outer toolbar claim the
31057
- // inner one's buttons, and the two passes fight: the first run measured two
31058
- // tab stops in one toolbar and NONE in the other two, which left those
31059
- // buttons unreachable by keyboard altogether. Each button belongs to its
31060
- // NEAREST toolbar and to no other.
31061
- function buttonsIn(bar) {
31062
- return [].slice.call(bar.querySelectorAll('[role="button"]')).filter(function (b) {
31063
- if (b.getAttribute("aria-disabled") === "true") return false;
31064
- if (b.offsetParent === null) return false; // hidden: not reachable anyway
31065
- return (b.closest && b.closest('[role="toolbar"]')) === bar;
31066
- });
31640
+ // Move a <thead>'s rows into the table body and drop the section.
31641
+ function unwrapSection(table, section) {
31642
+ var body = firstOwnChild(table, "tbody");
31643
+ if (!body) {
31644
+ body = table.ownerDocument.createElement("tbody");
31645
+ table.insertBefore(body, section.nextSibling);
31646
+ }
31647
+ while (section.firstChild) body.insertBefore(section.firstChild, body.firstChild);
31648
+ section.parentNode.removeChild(section);
31067
31649
  }
31068
31650
 
31069
- function applyRovingTabindex(root) {
31070
- if (config.a11yRovingToolbar === false) return;
31071
- var bars = toolbarsIn(root);
31072
- for (var i = 0; i < bars.length; i++) {
31073
- (function (bar) {
31074
- var btns = buttonsIn(bar);
31075
- if (!btns.length) return;
31076
- // Exactly one tab stop per toolbar, which is the ARIA pattern.
31077
- var current = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31078
- for (var j = 0; j < btns.length; j++) btns[j].setAttribute("tabindex", btns[j] === current ? "0" : "-1");
31079
-
31080
- if (bar.__rteRoving) return;
31081
- bar.__rteRoving = true;
31082
- bar.addEventListener("keydown", function (e) {
31083
- var list = buttonsIn(bar);
31084
- var at = list.indexOf(document.activeElement);
31085
- if (at < 0) return;
31086
- var rtl = (bar.closest && bar.closest('[dir="rtl"]')) ? true : false;
31087
- var next = null;
31088
- if (e.key === "ArrowRight") next = list[(at + (rtl ? -1 : 1) + list.length) % list.length];
31089
- else if (e.key === "ArrowLeft") next = list[(at + (rtl ? 1 : -1) + list.length) % list.length];
31090
- else if (e.key === "Home") next = list[0];
31091
- else if (e.key === "End") next = list[list.length - 1];
31092
- else return;
31093
- e.preventDefault();
31094
- for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === next ? "0" : "-1");
31095
- next.focus();
31096
- }, false);
31097
- // Clicking a button makes it the new tab stop, so returning by
31098
- // Tab lands where the user last was.
31099
- bar.addEventListener("focusin", function (e) {
31100
- var list = buttonsIn(bar);
31101
- if (list.indexOf(e.target) < 0) return;
31102
- for (var k = 0; k < list.length; k++) list[k].setAttribute("tabindex", list[k] === e.target ? "0" : "-1");
31103
- }, false);
31104
- })(bars[i]);
31105
- }
31106
- }
31107
-
31108
- function focusFirstToolbarButton() {
31109
- var root = shell();
31110
- if (!root) return false;
31111
- var bars = toolbarsIn(root);
31112
- for (var i = 0; i < bars.length; i++) {
31113
- var btns = buttonsIn(bars[i]);
31114
- if (!btns.length) continue;
31115
- var target = btns.filter(function (b) { return b.getAttribute("tabindex") === "0"; })[0] || btns[0];
31116
- target.focus();
31117
- return true;
31651
+ // Replace an element with the same content and attributes under a new tag.
31652
+ // The caret may be inside it, so the range is restored afterwards.
31653
+ function rename(el, tagName) {
31654
+ var doc = el.ownerDocument;
31655
+ var replacement = doc.createElement(tagName);
31656
+ for (var i = 0; i < el.attributes.length; i++) {
31657
+ var a = el.attributes[i];
31658
+ try { replacement.setAttribute(a.name, a.value); } catch (e) {}
31118
31659
  }
31119
- return false;
31660
+ while (el.firstChild) replacement.appendChild(el.firstChild);
31661
+ el.parentNode.replaceChild(replacement, el);
31662
+ return replacement;
31120
31663
  }
31121
31664
 
31122
- // ------------------------------------------------- 2. aria-pressed
31123
- //
31124
- // The editor already tracks active state — it just keeps it in a class.
31125
- // Mirroring rather than recomputing means the announced state can never
31126
- // disagree with the highlighted button.
31127
- // Command names here must match the TOOLBAR command exactly — the regex is
31128
- // anchored, so a near-miss is a silent no-op that looks like coverage.
31129
- // "blockquote" is one: the command is `insertblockquote`, so the entry
31130
- // written specifically for it never matched and that button ships with no
31131
- // aria-pressed. Found by checking every genuine toggle on the full toolbar
31132
- // rather than the three the published verify page samples.
31133
- //
31134
- // This mirror only copies the editor's own active state, so a command may
31135
- // only be listed here once core actually tracks it. Emitting the attribute
31136
- // for an untracked command yields a permanent aria-pressed="false" —
31137
- // confidently announcing "not pressed" while the formatting IS applied,
31138
- // which makes announced state DISAGREE with the highlight. That is the exact
31139
- // failure this mirror exists to prevent, so silence beats a wrong answer.
31140
- // - `toggleborder` was already tracked (core checks the editable's
31141
- // rte-toggleborder class); it reads inactive simply when borders are off.
31142
- // - `insertblockquote` was NOT tracked and now is, via a core case added
31143
- // alongside `indent`, which resolves the same way.
31144
- // Both verified on the running editor rather than inferred.
31145
- //
31146
- // `inlinecode`, `pagination`, `typewriter` and `focusmode` match nothing in
31147
- // this build, and now the reason is known rather than assumed: pagination.js
31148
- // and typewriter.js are API-only (togglePageView / toggleTypewriterMode …)
31149
- // and register no toolbar command, and insertcode.js registers `insertcode`,
31150
- // which inserts a block rather than toggling the selection. They are kept —
31151
- // harmless, and correct the moment a toolbar button is added — but they
31152
- // cover nothing today, so do not read this list as evidence of coverage.
31153
- 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)$/;
31154
-
31155
- function syncToggleStates(root) {
31156
- var btns = [].slice.call(root.querySelectorAll('[role="button"][rte-cmd-name]'));
31157
- for (var i = 0; i < btns.length; i++) {
31158
- var cmd = (btns[i].getAttribute("rte-cmd-name") || "").toLowerCase();
31159
- if (!TOGGLE_CMD.test(cmd)) continue;
31160
- var active = btns[i].classList.contains("rte-command-active");
31161
- var pressed = active ? "true" : "false";
31162
- if (btns[i].getAttribute("aria-pressed") !== pressed) btns[i].setAttribute("aria-pressed", pressed);
31163
- }
31164
- }
31165
-
31166
- // ------------------------------------------------- 3. aria-expanded
31167
- //
31168
- // Opening a menu inserts an <rte-dropdown-panel> elsewhere in the document
31169
- // and leaves the button untouched, so the button and its panel have to be
31170
- // correlated: remember which popup button was activated, then pair it with
31171
- // the panel that appears.
31172
- function trackPopups(root) {
31173
- var pops = [].slice.call(root.querySelectorAll("[aria-haspopup]"));
31174
- for (var i = 0; i < pops.length; i++) {
31175
- if (!pops[i].hasAttribute("aria-expanded")) pops[i].setAttribute("aria-expanded", "false");
31176
- if (pops[i].__rtePopupBound) continue;
31177
- pops[i].__rtePopupBound = true;
31178
- (function (el) {
31179
- function remember() { popupOwner = el; }
31180
- el.addEventListener("mousedown", remember, true);
31181
- el.addEventListener("keydown", function (e) {
31182
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") remember();
31183
- }, true);
31184
- })(pops[i]);
31185
- }
31186
-
31187
- if (trackPopups.bound) return;
31188
- trackPopups.bound = true;
31189
- var mo = new MutationObserver(function (records) {
31190
- for (var r = 0; r < records.length; r++) {
31191
- var rec = records[r];
31192
- for (var a = 0; a < rec.addedNodes.length; a++) {
31193
- var n = rec.addedNodes[a];
31194
- if (n.nodeType !== 1 || !isPanel(n)) continue;
31195
- if (popupOwner) {
31196
- popupOwner.setAttribute("aria-expanded", "true");
31197
- openPanels.push({ panel: n, owner: popupOwner });
31198
- popupOwner = null;
31199
- }
31200
- }
31201
- for (var d = 0; d < rec.removedNodes.length; d++) {
31202
- var m = rec.removedNodes[d];
31203
- if (m.nodeType !== 1 || !isPanel(m)) continue;
31204
- for (var k = openPanels.length - 1; k >= 0; k--) {
31205
- if (openPanels[k].panel === m) {
31206
- openPanels[k].owner.setAttribute("aria-expanded", "false");
31207
- openPanels.splice(k, 1);
31208
- }
31209
- }
31210
- }
31665
+ function tableAtCaret() {
31666
+ var ed = getEditable();
31667
+ if (!ed) return null;
31668
+ var node = null;
31669
+ try {
31670
+ var sel = editor.getSelection();
31671
+ if (sel && sel.rangeCount) node = sel.getRangeAt(0).startContainer;
31672
+ } catch (e) {}
31673
+ if (!node) return null;
31674
+ if (node.nodeType === 3) node = node.parentNode;
31675
+ while (node && node !== ed) {
31676
+ if (node.nodeName === "TABLE") return node;
31677
+ node = node.parentNode;
31678
+ }
31679
+ return null;
31680
+ }
31681
+
31682
+ function insertAtCaret(node, doc) {
31683
+ var inserted = false;
31684
+ try {
31685
+ var sel = editor.getSelection();
31686
+ if (sel && sel.rangeCount) {
31687
+ var range = sel.getRangeAt(0);
31688
+ range.deleteContents();
31689
+ range.insertNode(node);
31690
+ inserted = true;
31211
31691
  }
31212
- });
31213
- mo.observe(document.body, { childList: true, subtree: true });
31214
- observers.push(mo);
31692
+ } catch (e) {}
31693
+ if (!inserted) {
31694
+ var ed = getEditable();
31695
+ if (ed) { ed.appendChild(node); inserted = true; }
31696
+ }
31697
+ // A table at the very end of the document is a caret trap; gapcursor.js
31698
+ // handles escaping it, so nothing is appended here.
31699
+ return inserted;
31215
31700
  }
31216
- function isPanel(el) {
31217
- var t = (el.tagName || "").toLowerCase();
31218
- return t === "rte-dropdown-panel" || t === "rte-floatpanel" || t === "rte-dialog-float";
31219
- }
31220
-
31221
- // Toolbar buttons are rebuilt and re-classed as the selection moves, so the
31222
- // mirrored state has to follow rather than be set once.
31223
- function watch(root) {
31224
- if (root.__rteA11yWatch) return;
31225
- root.__rteA11yWatch = true;
31226
- var pending = null;
31227
- var mo = new MutationObserver(function () {
31228
- if (pending) return;
31229
- pending = setTimeout(function () {
31230
- pending = null;
31231
- syncToggleStates(root);
31232
- applyRovingTabindex(root);
31233
- trackPopups(root);
31234
- }, 60);
31235
- });
31236
- mo.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-disabled"] });
31237
- observers.push(mo);
31701
+
31702
+ function getEditable() { try { return editor.getEditable(); } catch (e) { return null; } }
31703
+
31704
+ function editableDoc() {
31705
+ var ed = getEditable();
31706
+ return ed ? ed.ownerDocument : null;
31707
+ }
31708
+
31709
+ function injectStyles(doc) {
31710
+ if (!doc) return;
31711
+ var text = outlineOn
31712
+ ? "table[role=presentation]{outline:1px dashed rgba(120,120,140,.55);outline-offset:1px;}" +
31713
+ "table[role=presentation] td{outline:1px dashed rgba(120,120,140,.28);}"
31714
+ : "";
31715
+ var existing = doc.getElementById("rte-layout-table-styles");
31716
+ if (existing) {
31717
+ if (existing.getAttribute("data-css") === text) return;
31718
+ existing.parentNode && existing.parentNode.removeChild(existing);
31719
+ }
31720
+ if (!text) return;
31721
+ var st = doc.createElement("style");
31722
+ st.id = "rte-layout-table-styles";
31723
+ st.setAttribute("data-css", text);
31724
+ st.appendChild(doc.createTextNode(text));
31725
+ (doc.head || doc.getElementsByTagName("head")[0] || doc.documentElement).appendChild(st);
31726
+ }
31727
+
31728
+ function fireChange() {
31729
+ try { if (typeof editor.updateDesign === "function") editor.updateDesign(); } catch (e) {}
31730
+ try { if (typeof editor.fireChange === "function") editor.fireChange(); } catch (e) {}
31238
31731
  }
31239
31732
  }
31240
31733
 
@@ -37846,6 +38339,259 @@ function RTE_Plugin_Sanitizer() {
37846
38339
 
37847
38340
  if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
37848
38341
 
38342
+ // 2026-08-30 Shadow DOM support — make the editor usable inside a shadow root.
38343
+ //
38344
+ // TinyMCE documents shadow-DOM support as a supported integration; we had none
38345
+ // under any name. Design systems, micro-frontends and anything built as a custom
38346
+ // element put their markup inside a shadow root, and that is where this editor
38347
+ // was quietly broken.
38348
+ //
38349
+ // Measured before writing a line of it, on a real instance in Chromium:
38350
+ //
38351
+ // | mounted in | toolbar height | font |
38352
+ // |-----------------------|----------------|-------------------|
38353
+ // | the page (light DOM) | 82 px | system-ui |
38354
+ // | a shadow root | 45,468 px | Times New Roman |
38355
+ //
38356
+ // So it is not "mostly fine". The editor CONSTRUCTS, the iframe appears, and
38357
+ // setHTMLCode/getHTMLCode round-trip correctly — which is exactly why this was
38358
+ // never noticed — but a style boundary is a wall: `<link rel=stylesheet>` in the
38359
+ // document head does not cross into a shadow root, so every rule in
38360
+ // rte_theme_default.css misses, and the toolbar renders as a 45,000-pixel column
38361
+ // of unstyled buttons.
38362
+ //
38363
+ // The fix is to put the editor's own stylesheets on the inside of the wall too.
38364
+ //
38365
+ // Design notes:
38366
+ // - <link> clones, not fetch + CSSStyleSheet. Constructing a stylesheet means
38367
+ // fetching the CSS with JS, which needs CORS on a CDN-hosted theme and fails
38368
+ // closed — a cloned <link> hits the HTTP cache, needs no CORS, and works on
38369
+ // every browser that has shadow DOM at all. adoptedStyleSheets is used only
38370
+ // for sheets already adopted by the document, which have no URL to clone.
38371
+ // - Only the EDITOR's stylesheets are copied. Cloning the whole head into a
38372
+ // shadow root would undo the encapsulation the integrator chose it for. A
38373
+ // sheet counts as ours by origin — served from the folder rte.js was loaded
38374
+ // from — plus an `rte-`/`__rte` element id, the same origin-based test that
38375
+ // importstyles.js had to arrive at after prefix matching failed.
38376
+ // - A MutationObserver keeps watching the head. Plugins inject their styles
38377
+ // lazily, when their panel first opens — the accessibility checker and the AI
38378
+ // toolkit both do — so a one-shot copy at init produces a styled toolbar and
38379
+ // an unstyled dialog half an hour later, which is a worse bug to diagnose
38380
+ // than a wholly unstyled editor.
38381
+ // - Nothing is copied when the editor is NOT in a shadow root, and the
38382
+ // observer is not even created. This plugin must cost nothing for the 99%.
38383
+ RTE_DefaultConfig.plugin_shadowdom = RTE_Plugin_ShadowDom;
38384
+
38385
+ // Copy the editor's stylesheets into the shadow root that contains it.
38386
+ if (typeof RTE_DefaultConfig.shadowStyleAdoption === "undefined") RTE_DefaultConfig.shadowStyleAdoption = true;
38387
+ // Extra stylesheet URLs to copy in as well (an integrator's own skin overrides).
38388
+ if (typeof RTE_DefaultConfig.shadowStyleSources === "undefined") RTE_DefaultConfig.shadowStyleSources = null;
38389
+
38390
+ function RTE_Plugin_ShadowDom() {
38391
+ var obj = this;
38392
+ var config, editor;
38393
+ var root = null;
38394
+ var observer = null;
38395
+ var copied = null;
38396
+
38397
+ obj.PluginName = "ShadowDom";
38398
+
38399
+ obj.InitConfig = function (argconfig) { config = argconfig; };
38400
+
38401
+ obj.InitEditor = function (argeditor) {
38402
+ editor = argeditor;
38403
+
38404
+ // Public API — useful even outside a shadow root, for a host page that
38405
+ // wants to style a second root (a dialog portal, a preview pane).
38406
+ editor.getShadowRoot = function () { return findRoot(); };
38407
+ editor.isInShadowDom = function () { return !!findRoot(); };
38408
+ editor.adoptEditorStyles = function (target) { return adopt(target || findRoot(), true); };
38409
+ editor.getAdoptedStyleCount = function () { return copied ? copied.size : 0; };
38410
+
38411
+ if (config && config.shadowStyleAdoption === false) return;
38412
+
38413
+ // The chrome is built asynchronously; the container may not exist yet.
38414
+ whenReady(function () {
38415
+ root = findRoot();
38416
+ if (!root) return;
38417
+ copied = new (window.Set || Array)();
38418
+ adopt(root, false);
38419
+ watchHead(root);
38420
+ });
38421
+ };
38422
+
38423
+ obj.Destroy = function () {
38424
+ if (observer) { try { observer.disconnect(); } catch (e) {} observer = null; }
38425
+ };
38426
+
38427
+ // ------------------------------------------------------------------ lookup
38428
+
38429
+ // The editor's container is the element the integrator passed in; from the
38430
+ // editing iframe it is the nearest ancestor carrying the editor's own class.
38431
+ function container() {
38432
+ try {
38433
+ var editable = editor.getEditable();
38434
+ var frame = editable && editable.ownerDocument && editable.ownerDocument.defaultView
38435
+ ? editable.ownerDocument.defaultView.frameElement : null;
38436
+ if (!frame) return null;
38437
+ var node = frame;
38438
+ while (node) {
38439
+ if (node.classList && node.classList.contains("richtexteditor")) return node;
38440
+ node = node.parentNode || node.host;
38441
+ }
38442
+ return frame;
38443
+ }
38444
+ catch (e) { return null; }
38445
+ }
38446
+
38447
+ function findRoot() {
38448
+ var el = container();
38449
+ if (!el || typeof el.getRootNode !== "function") return null;
38450
+ var r = el.getRootNode();
38451
+ // A DocumentFragment with a `host` is a shadow root; a plain document is not.
38452
+ return (r && r.host && r !== document) ? r : null;
38453
+ }
38454
+
38455
+ function whenReady(callback) {
38456
+ if (container()) { callback(); return; }
38457
+ var tries = 0;
38458
+ var timer = window.setInterval(function () {
38459
+ if (container() || ++tries > 100) { window.clearInterval(timer); callback(); }
38460
+ }, 50);
38461
+ }
38462
+
38463
+ // ------------------------------------------------------------------ adopt
38464
+
38465
+ // Where rte.js itself was served from. Everything under that folder is ours.
38466
+ function assetBase() {
38467
+ try {
38468
+ var scripts = document.getElementsByTagName("script");
38469
+ for (var i = 0; i < scripts.length; i++) {
38470
+ var src = scripts[i].src || "";
38471
+ if (/(^|\/)rte(\.min)?\.js(\?|$)/.test(src)) return src.replace(/[^\/]*$/, "");
38472
+ }
38473
+ }
38474
+ catch (e) {}
38475
+ return null;
38476
+ }
38477
+
38478
+ function isOurs(node) {
38479
+ var id = String(node.id || "");
38480
+ if (/^rte[-_]|^__rte/.test(id)) return true;
38481
+ var href = node.href || "";
38482
+ if (!href) return false;
38483
+ var base = assetBase();
38484
+ if (base && href.indexOf(base) === 0) return true;
38485
+ // Fall back to the file names this product actually ships, so an
38486
+ // integrator who renamed or re-hosted the folder still gets styled
38487
+ // chrome rather than a silent 45,000-pixel toolbar.
38488
+ return /rte_theme|rte_mobile|richtexteditor_content|richtexteditor_preview|aitoolkit/.test(href);
38489
+ }
38490
+
38491
+ function extraSources() {
38492
+ var list = (config && config.shadowStyleSources) || null;
38493
+ if (!list) return [];
38494
+ return typeof list === "string" ? [list] : list;
38495
+ }
38496
+
38497
+ function key(node) {
38498
+ return (node.href || "") + "|" + (node.id || "") + "|" + (node.textContent || "").length;
38499
+ }
38500
+
38501
+ function has(k) {
38502
+ return copied && (copied.has ? copied.has(k) : copied.indexOf(k) >= 0);
38503
+ }
38504
+
38505
+ function remember(k) {
38506
+ if (!copied) return;
38507
+ if (copied.add) copied.add(k); else copied.push(k);
38508
+ }
38509
+
38510
+ function adopt(target, force) {
38511
+ if (!target) return 0;
38512
+ if (!copied) copied = new (window.Set || Array)();
38513
+ var added = 0;
38514
+ var nodes = document.querySelectorAll("link[rel~=stylesheet],style");
38515
+
38516
+ for (var i = 0; i < nodes.length; i++) {
38517
+ var node = nodes[i];
38518
+ if (!isOurs(node)) continue;
38519
+ var k = key(node);
38520
+ if (!force && has(k)) continue;
38521
+ if (has(k)) continue;
38522
+ target.appendChild(cloneSheet(node, target));
38523
+ remember(k);
38524
+ added++;
38525
+ }
38526
+
38527
+ var extras = extraSources();
38528
+ for (var e = 0; e < extras.length; e++) {
38529
+ var ek = "extra|" + extras[e];
38530
+ if (has(ek)) continue;
38531
+ var link = document.createElement("link");
38532
+ link.rel = "stylesheet";
38533
+ link.href = extras[e];
38534
+ link.setAttribute("data-rte-adopted", "1");
38535
+ target.appendChild(link);
38536
+ remember(ek);
38537
+ added++;
38538
+ }
38539
+
38540
+ // Sheets the page adopted programmatically have no URL to clone, so they
38541
+ // are re-adopted by reference where the browser supports it.
38542
+ try {
38543
+ if (target.adoptedStyleSheets && document.adoptedStyleSheets && document.adoptedStyleSheets.length) {
38544
+ var merged = target.adoptedStyleSheets.slice();
38545
+ for (var a = 0; a < document.adoptedStyleSheets.length; a++) {
38546
+ if (merged.indexOf(document.adoptedStyleSheets[a]) < 0) merged.push(document.adoptedStyleSheets[a]);
38547
+ }
38548
+ target.adoptedStyleSheets = merged;
38549
+ }
38550
+ }
38551
+ catch (e2) {}
38552
+
38553
+ return added;
38554
+ }
38555
+
38556
+ function cloneSheet(node, target) {
38557
+ if (node.tagName === "LINK") {
38558
+ var link = document.createElement("link");
38559
+ link.rel = "stylesheet";
38560
+ // Resolved (absolute) href — a relative one would resolve against the
38561
+ // document anyway, but being explicit survives a <base> element.
38562
+ link.href = node.href;
38563
+ if (node.media) link.media = node.media;
38564
+ link.setAttribute("data-rte-adopted", "1");
38565
+ return link;
38566
+ }
38567
+ var style = document.createElement("style");
38568
+ style.textContent = node.textContent;
38569
+ if (node.id) style.setAttribute("data-rte-source-id", node.id);
38570
+ style.setAttribute("data-rte-adopted", "1");
38571
+ return style;
38572
+ }
38573
+
38574
+ // A plugin's <style> arrives when its panel first opens, long after init.
38575
+ function watchHead(target) {
38576
+ if (!window.MutationObserver) return;
38577
+ observer = new MutationObserver(function (records) {
38578
+ var interesting = false;
38579
+ for (var i = 0; i < records.length && !interesting; i++) {
38580
+ var added = records[i].addedNodes || [];
38581
+ for (var j = 0; j < added.length; j++) {
38582
+ var n = added[j];
38583
+ if (n.nodeType !== 1) continue;
38584
+ if ((n.tagName === "STYLE" || n.tagName === "LINK") && isOurs(n)) { interesting = true; break; }
38585
+ }
38586
+ }
38587
+ if (interesting) adopt(target, false);
38588
+ });
38589
+ observer.observe(document.head || document.documentElement, { childList: true, subtree: true });
38590
+ }
38591
+ }
38592
+
38593
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
38594
+
37849
38595
  RTE_DefaultConfig.plugin_slashcommand = RTE_Plugin_SlashCommand;
37850
38596
 
37851
38597
  function RTE_Plugin_SlashCommand() {
@@ -38091,6 +38837,14 @@ function RTE_Plugin_SlashCommand() {
38091
38837
  push("Tools", "tablerownumbers", "Number table rows", "Add or remove an automatic row-numbering column", ["number", "rows", "table", "count", "index"], iconSort(),
38092
38838
  function () { editor.execCommand("tablerownumbers"); });
38093
38839
  }
38840
+ if (typeof editor.setTableLayoutMode === "function") {
38841
+ push("Insert", "layouttable", "Layout table", "Insert a borderless table for positioning content (role=presentation)", ["layout", "table", "grid", "email", "columns", "presentation"], iconTable(),
38842
+ function () { editor.insertLayoutTable(2, 2); });
38843
+ push("Tools", "tablelayoutmode", "Mark table as layout", "This table only positions content — stop screen readers announcing it as data", ["layout", "table", "presentation", "accessibility", "screen reader"], iconTable(),
38844
+ function () { editor.setTableLayoutMode("layout"); });
38845
+ push("Tools", "tabledatamode", "Mark table as data", "This table states relationships — restore its header cells", ["data", "table", "header", "accessibility"], iconTable(),
38846
+ function () { editor.setTableLayoutMode("data", null, { promoteFirstRow: true }); });
38847
+ }
38094
38848
  if (typeof editor.moveBlockUp === "function") {
38095
38849
  push("Tools", "moveblockup", "Move block up", "Move this paragraph or block above the previous one (Alt+Shift+Up)", ["move", "block", "up", "reorder", "drag"], iconMoveBlock(),
38096
38850
  function () { editor.moveBlockUp(); });