@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.
- package/LICENSE.md +66 -0
- package/README.md +22 -22
- package/package.json +146 -142
- package/richtexteditor/all_plugins.js +513 -14
- package/richtexteditor/all_plugins.min.js +58 -58
- package/richtexteditor/plugins/accessibilitychecker.js +283 -0
- package/richtexteditor/plugins/all_plugins.js +513 -14
- package/richtexteditor/plugins/all_plugins.min.js +58 -58
- package/richtexteditor/plugins/dialoga11y.js +50 -6
- package/richtexteditor/plugins/documentimport.js +34 -0
- package/richtexteditor/plugins/docxexport.js +26 -3
- package/richtexteditor/plugins/keyboarda11y.js +108 -0
- package/richtexteditor/plugins/pdfexport.js +12 -5
- package/richtexteditor/rte.js +41 -3
|
@@ -428,6 +428,29 @@ function RTE_Plugin_AccessibilityChecker() {
|
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
// 2026-09-04 A link with no accessible name is announced as just
|
|
432
|
+
// "link", giving a keyboard or screen-reader user nothing to decide
|
|
433
|
+
// on (WCAG 2.4.4 / 4.1.2). Reached the editor easily: an image-only
|
|
434
|
+
// link whose <img> has empty alt, or a link left empty after its
|
|
435
|
+
// text was deleted.
|
|
436
|
+
//
|
|
437
|
+
// Only elements with href are links. A bare <a name="x"> or an <a>
|
|
438
|
+
// with no href is an anchor TARGET and has no name to announce, so
|
|
439
|
+
// flagging it would be an error with no valid fix.
|
|
440
|
+
var links = tag === "a" ? [node] : (node.querySelectorAll ? node.querySelectorAll("a") : []);
|
|
441
|
+
for (var linkIndex = 0; linkIndex < links.length; linkIndex++) {
|
|
442
|
+
var link = links[linkIndex];
|
|
443
|
+
if (!link.getAttribute || link.getAttribute("href") === null) continue;
|
|
444
|
+
if (accessibleNameOf(link)) continue;
|
|
445
|
+
issues.push({
|
|
446
|
+
code: "link-empty",
|
|
447
|
+
severity: "error",
|
|
448
|
+
message: "Link has no text, so it is announced only as \"link\". Add link text, an aria-label, or alt text on the image inside it.",
|
|
449
|
+
path: path + ".link[" + linkIndex + "]",
|
|
450
|
+
_target: link
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
431
454
|
var tables = tag === "table" ? [node] : (node.querySelectorAll ? node.querySelectorAll("table") : []);
|
|
432
455
|
for (var tableIndex = 0; tableIndex < tables.length; tableIndex++) {
|
|
433
456
|
var table = tables[tableIndex];
|
|
@@ -462,11 +485,168 @@ function RTE_Plugin_AccessibilityChecker() {
|
|
|
462
485
|
_target: table
|
|
463
486
|
});
|
|
464
487
|
}
|
|
488
|
+
else {
|
|
489
|
+
// 2026-09-04 A table can HAVE headers and still be unusable if
|
|
490
|
+
// one is blank: the screen reader announces the column's name as
|
|
491
|
+
// an empty string, which is worse than no header at all because
|
|
492
|
+
// the relationship exists and says nothing (WCAG 1.3.1).
|
|
493
|
+
// `table-missing-header` above only fires when there is not a
|
|
494
|
+
// single <th>, so this case reported valid. Found by testing
|
|
495
|
+
// CKEditor issue #19204 against this checker.
|
|
496
|
+
//
|
|
497
|
+
// THE CORNER CELL IS EXEMPT. In a cross-tab - column headers
|
|
498
|
+
// across the top, row headers down the side - the top-left cell
|
|
499
|
+
// is conventionally and correctly empty; it names neither axis.
|
|
500
|
+
// Flagging it would be an error the author can only clear by
|
|
501
|
+
// inventing a word, which is how a checker teaches people to
|
|
502
|
+
// ignore it. Same reasoning that exempts layout tables above.
|
|
503
|
+
var headerCells = table.querySelectorAll("th");
|
|
504
|
+
var hasRowHeaders = false;
|
|
505
|
+
for (var probeIndex = 0; probeIndex < headerCells.length; probeIndex++) {
|
|
506
|
+
var probeRow = headerCells[probeIndex].parentNode;
|
|
507
|
+
if (probeRow && probeRow.rowIndex > 0) { hasRowHeaders = true; break; }
|
|
508
|
+
}
|
|
509
|
+
for (var headerIndex = 0; headerIndex < headerCells.length; headerIndex++) {
|
|
510
|
+
var headerCell = headerCells[headerIndex];
|
|
511
|
+
if (accessibleNameOf(headerCell)) continue;
|
|
512
|
+
var ownerRow = headerCell.parentNode;
|
|
513
|
+
if (hasRowHeaders && ownerRow && ownerRow.rowIndex === 0 && headerCell.cellIndex === 0) continue;
|
|
514
|
+
issues.push({
|
|
515
|
+
code: "table-header-empty",
|
|
516
|
+
severity: "error",
|
|
517
|
+
message: "Header cell is empty, so the column or row it labels is announced with no name. Give it text, or make it a normal cell if it labels nothing.",
|
|
518
|
+
path: path + ".table[" + tableIndex + "].th[" + headerIndex + "]",
|
|
519
|
+
_target: headerCell
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
// 2026-09-07 COLOUR CONTRAST (WCAG 1.4.3). The most-cited criterion in
|
|
527
|
+
// any accessibility audit and the checker had no rule for it -- and
|
|
528
|
+
// our own comparison page once claimed one, which had to be retracted.
|
|
529
|
+
//
|
|
530
|
+
// Only INLINE colour is judged. Contrast against a stylesheet the
|
|
531
|
+
// editor cannot see would be a guess, and a checker that guesses
|
|
532
|
+
// teaches people to ignore it. Where no background is declared on the
|
|
533
|
+
// element or an ancestor, white is assumed and stated in the message,
|
|
534
|
+
// because that is the editing surface's own ground.
|
|
535
|
+
var coloured = node.querySelectorAll ? node.querySelectorAll("[style*='color']") : [];
|
|
536
|
+
var colouredList = (node.getAttribute && /(^|;)\s*color\s*:/i.test(node.getAttribute("style") || ""))
|
|
537
|
+
? [node].concat(Array.prototype.slice.call(coloured))
|
|
538
|
+
: Array.prototype.slice.call(coloured);
|
|
539
|
+
for (var cIndex = 0; cIndex < colouredList.length; cIndex++) {
|
|
540
|
+
var cEl = colouredList[cIndex];
|
|
541
|
+
var fg = parseCssColour(cEl.style && cEl.style.color);
|
|
542
|
+
if (!fg) continue;
|
|
543
|
+
var bg = nearestBackground(cEl);
|
|
544
|
+
var ratio = contrastRatio(fg, bg.rgb);
|
|
545
|
+
// 4.5:1 is the AA threshold for body text; 3:1 for large text
|
|
546
|
+
// (>=18pt, or >=14pt bold). Using the body number on a heading
|
|
547
|
+
// would report a failure that is not one.
|
|
548
|
+
var large = isLargeText(cEl);
|
|
549
|
+
var need = large ? 3 : 4.5;
|
|
550
|
+
// Epsilon absorbs float noise ONLY. 0.05 was 1% of the threshold and
|
|
551
|
+
// swallowed real near-misses: #777777 on white is 4.48:1, a genuine
|
|
552
|
+
// failure, and went unreported. It must also not fire on a colour that
|
|
553
|
+
// passes at exactly 4.5, so the comparison errs toward silence.
|
|
554
|
+
if (ratio < need - 0.001) {
|
|
555
|
+
issues.push({
|
|
556
|
+
code: "contrast-insufficient",
|
|
557
|
+
severity: "error",
|
|
558
|
+
message: "Text contrast is " + ratio.toFixed(2) + ":1 against " +
|
|
559
|
+
(bg.assumed ? "an assumed white background" : "its background") +
|
|
560
|
+
", below the " + need + ":1 needed for " +
|
|
561
|
+
(large ? "large text" : "body text") + " (WCAG 1.4.3).",
|
|
562
|
+
path: path + ".contrast[" + cIndex + "]",
|
|
563
|
+
_target: cEl
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// 2026-09-07 BLOCKQUOTE USED AS INDENTATION (WCAG 1.3.1). A quotation
|
|
569
|
+
// element used for visual offset is announced as a quotation by screen
|
|
570
|
+
// readers and exported to Word as the Quote style.
|
|
571
|
+
//
|
|
572
|
+
// This is OUR OWN historical output: until 2026-09-04 the indent button
|
|
573
|
+
// fell through to the browser's execCommand, which wraps the block in
|
|
574
|
+
// <blockquote style="margin:0 0 0 40px;border:none;padding:0px">. Every
|
|
575
|
+
// document indented in an older build carries these, so the rule
|
|
576
|
+
// matters most for content we produced ourselves.
|
|
577
|
+
//
|
|
578
|
+
// Recognise only that SHAPE -- an offset with the quote decoration
|
|
579
|
+
// explicitly switched off. A blockquote that keeps its border is a real
|
|
580
|
+
// quotation and must not be flagged.
|
|
581
|
+
var quotes = tag === "blockquote" ? [node] : (node.querySelectorAll ? node.querySelectorAll("blockquote") : []);
|
|
582
|
+
for (var qIndex = 0; qIndex < quotes.length; qIndex++) {
|
|
583
|
+
var q = quotes[qIndex];
|
|
584
|
+
var qs = q.style;
|
|
585
|
+
if (!qs) continue;
|
|
586
|
+
var hasOffset = /^\s*[\d.]+\s*[a-z%]+\s*$/i.test(qs.marginLeft || "") && parseFloat(qs.marginLeft) > 0;
|
|
587
|
+
var borderOff = /^(none|0|0px)$/i.test(String(qs.borderStyle || qs.border || "").trim());
|
|
588
|
+
if (hasOffset && borderOff) {
|
|
589
|
+
issues.push({
|
|
590
|
+
code: "blockquote-as-indent",
|
|
591
|
+
severity: "warning",
|
|
592
|
+
message: "This looks like indentation, not a quotation: a <blockquote> with a left margin and no quote styling. Screen readers announce it as a quotation. Use the indent button instead.",
|
|
593
|
+
path: path + ".blockquote[" + qIndex + "]",
|
|
594
|
+
_target: q
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 2026-09-07 AMBIGUOUS LINK TEXT (WCAG 2.4.4). "Click here" read out of
|
|
600
|
+
// context -- which is how a screen-reader user listing links hears it --
|
|
601
|
+
// conveys nothing about the destination.
|
|
602
|
+
var ambiguous = /^(click here|here|read more|more|link|this|this link|learn more|details|go)$/i;
|
|
603
|
+
var namedLinks = tag === "a" ? [node] : (node.querySelectorAll ? node.querySelectorAll("a") : []);
|
|
604
|
+
for (var aIndex = 0; aIndex < namedLinks.length; aIndex++) {
|
|
605
|
+
var aEl = namedLinks[aIndex];
|
|
606
|
+
if (!aEl.getAttribute || aEl.getAttribute("href") === null) continue;
|
|
607
|
+
var aName = accessibleNameOf(aEl);
|
|
608
|
+
if (aName && ambiguous.test(aName.replace(/[\s.,!:;]+$/g, "").replace(/^\s+/, ""))) {
|
|
609
|
+
issues.push({
|
|
610
|
+
code: "link-ambiguous-text",
|
|
611
|
+
severity: "warning",
|
|
612
|
+
message: "Link text \"" + aName + "\" does not say where it goes. Out of context a screen reader announces only this text.",
|
|
613
|
+
path: path + ".link[" + aIndex + "]",
|
|
614
|
+
_target: aEl
|
|
615
|
+
});
|
|
616
|
+
}
|
|
465
617
|
}
|
|
466
618
|
|
|
467
619
|
collectUnmarkedLanguageRuns(node, editable, path, issues);
|
|
468
620
|
}
|
|
469
621
|
|
|
622
|
+
// 2026-09-07 DUPLICATE id (WCAG 4.1.1). Two elements sharing an id break
|
|
623
|
+
// every aria-labelledby / aria-describedby / label that points at it --
|
|
624
|
+
// the reference silently resolves to the first one. Reached easily by
|
|
625
|
+
// copying and pasting a block that carries an id.
|
|
626
|
+
//
|
|
627
|
+
// Runs ONCE over the whole editable, deliberately NOT inside the per-node
|
|
628
|
+
// walk: querySelectorAll returns DESCENDANTS only, so a walk visiting each
|
|
629
|
+
// sibling separately sees one id at a time and can never detect a pair.
|
|
630
|
+
// The same trap already cost this file its top-level image check.
|
|
631
|
+
if (editable && editable.querySelectorAll) {
|
|
632
|
+
var allWithIds = editable.querySelectorAll("[id]");
|
|
633
|
+
var idSeen = {};
|
|
634
|
+
for (var dupIndex = 0; dupIndex < allWithIds.length; dupIndex++) {
|
|
635
|
+
var dupId = allWithIds[dupIndex].getAttribute("id");
|
|
636
|
+
if (!dupId) continue;
|
|
637
|
+
if (idSeen[dupId]) {
|
|
638
|
+
issues.push({
|
|
639
|
+
code: "duplicate-id",
|
|
640
|
+
severity: "error",
|
|
641
|
+
message: "id \"" + dupId + "\" is used more than once. Any aria-labelledby, aria-describedby or label pointing at it resolves to only the first element.",
|
|
642
|
+
path: "document.id[" + dupIndex + "]",
|
|
643
|
+
_target: allWithIds[dupIndex]
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
idSeen[dupId] = true;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
470
650
|
return { document: null, issues: issues, valid: !issues.length, source: "dom" };
|
|
471
651
|
}
|
|
472
652
|
|
|
@@ -496,6 +676,29 @@ function RTE_Plugin_AccessibilityChecker() {
|
|
|
496
676
|
else if (issue.code === "layout-table-semantics") {
|
|
497
677
|
markTableAsLayout(target);
|
|
498
678
|
}
|
|
679
|
+
else if (issue.code === "table-header-empty") {
|
|
680
|
+
// Two valid outcomes and only the author knows which: the cell labels
|
|
681
|
+
// something (give it text) or it labels nothing (it is not a header).
|
|
682
|
+
var headerText = String((options && options.headerText) || "").replace(/^\s+|\s+$/g, "");
|
|
683
|
+
if (headerText) target.textContent = headerText;
|
|
684
|
+
else if (options && options.demoteToCell) {
|
|
685
|
+
var cell = target.ownerDocument.createElement("td");
|
|
686
|
+
while (target.firstChild) cell.appendChild(target.firstChild);
|
|
687
|
+
copyAttributes(target, cell);
|
|
688
|
+
cell.removeAttribute("scope");
|
|
689
|
+
target.parentNode.replaceChild(cell, target);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
else if (issue.code === "link-empty") {
|
|
693
|
+
// Prefer real text: an aria-label is invisible, so a sighted editor
|
|
694
|
+
// cannot see what the link says and it drifts out of date.
|
|
695
|
+
var linkText = String((options && options.linkText) || "").replace(/^\s+|\s+$/g, "");
|
|
696
|
+
if (linkText) target.textContent = linkText;
|
|
697
|
+
else {
|
|
698
|
+
var linkLabel = String((options && options.linkLabel) || "").replace(/^\s+|\s+$/g, "");
|
|
699
|
+
if (linkLabel) target.setAttribute("aria-label", linkLabel);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
499
702
|
else if (issue.code === "language-of-parts") {
|
|
500
703
|
markLanguageRuns(target, options && options.lang ? options.lang : issue._lang, issue._script);
|
|
501
704
|
}
|
|
@@ -582,6 +785,86 @@ function RTE_Plugin_AccessibilityChecker() {
|
|
|
582
785
|
// Delegates to layouttable.js when it is loaded so there is ONE definition of
|
|
583
786
|
// what "layout table" means; the fallback keeps the repair working in builds
|
|
584
787
|
// where that plugin was left out of the bundle.
|
|
788
|
+
// The accessible name of an element, as a screen reader would compute it:
|
|
789
|
+
// aria-label wins, then visible text, then title, then the alt text of an
|
|
790
|
+
// image standing in for the text. Empty string means "announced as nothing".
|
|
791
|
+
// Deliberately NOT aria-labelledby - resolving it needs the whole document
|
|
792
|
+
// and a wrong answer here would produce an error the author cannot clear.
|
|
793
|
+
|
|
794
|
+
// --- contrast helpers (WCAG 1.4.3) ---------------------------------------
|
|
795
|
+
function parseCssColour(v) {
|
|
796
|
+
if (!v) return null;
|
|
797
|
+
var str = String(v).trim();
|
|
798
|
+
var m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(str);
|
|
799
|
+
if (m) {
|
|
800
|
+
var hex = m[1];
|
|
801
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
802
|
+
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
|
|
803
|
+
}
|
|
804
|
+
var rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(str);
|
|
805
|
+
if (rgb) return [Math.round(+rgb[1]), Math.round(+rgb[2]), Math.round(+rgb[3])];
|
|
806
|
+
return null; // named colours are not resolved: a guess is worse than silence
|
|
807
|
+
}
|
|
808
|
+
// Nearest ancestor that DECLARES a background. Reports whether it had to
|
|
809
|
+
// fall back, so the message can say "assumed white" rather than asserting a
|
|
810
|
+
// background the document never set.
|
|
811
|
+
function nearestBackground(el) {
|
|
812
|
+
var n = el;
|
|
813
|
+
while (n && n.style) {
|
|
814
|
+
var c = parseCssColour(n.style.backgroundColor);
|
|
815
|
+
if (c) return { rgb: c, assumed: false };
|
|
816
|
+
n = n.parentElement;
|
|
817
|
+
}
|
|
818
|
+
return { rgb: [255, 255, 255], assumed: true };
|
|
819
|
+
}
|
|
820
|
+
function relativeLuminance(rgb) {
|
|
821
|
+
var a = [rgb[0], rgb[1], rgb[2]].map(function (v) {
|
|
822
|
+
v = v / 255;
|
|
823
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
824
|
+
});
|
|
825
|
+
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
|
|
826
|
+
}
|
|
827
|
+
function contrastRatio(fg, bg) {
|
|
828
|
+
var l1 = relativeLuminance(fg), l2 = relativeLuminance(bg);
|
|
829
|
+
var hi = Math.max(l1, l2), lo = Math.min(l1, l2);
|
|
830
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
831
|
+
}
|
|
832
|
+
// WCAG "large text": >=18pt, or >=14pt bold. A heading judged against the
|
|
833
|
+
// body threshold reports a failure that is not one.
|
|
834
|
+
function isLargeText(el) {
|
|
835
|
+
var st = el.style || {};
|
|
836
|
+
var size = String(st.fontSize || "");
|
|
837
|
+
var pt = null;
|
|
838
|
+
var m = /^([\d.]+)(px|pt)$/i.exec(size.trim());
|
|
839
|
+
if (m) pt = m[2].toLowerCase() === "pt" ? parseFloat(m[1]) : parseFloat(m[1]) * 0.75;
|
|
840
|
+
if (pt === null) {
|
|
841
|
+
var tagName = (el.tagName || "").toLowerCase();
|
|
842
|
+
if (/^h[1-3]$/.test(tagName)) return true;
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
var bold = /bold|^[6-9]00$/.test(String(st.fontWeight || ""));
|
|
846
|
+
return pt >= 18 || (pt >= 14 && bold);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function accessibleNameOf(element) {
|
|
850
|
+
if (!element) return "";
|
|
851
|
+
var trim = function (v) { return String(v || "").replace(/^\s+|\s+$/g, ""); };
|
|
852
|
+
var label = trim(element.getAttribute && element.getAttribute("aria-label"));
|
|
853
|
+
if (label) return label;
|
|
854
|
+
var text = trim(element.textContent);
|
|
855
|
+
if (text) return text;
|
|
856
|
+
var title = trim(element.getAttribute && element.getAttribute("title"));
|
|
857
|
+
if (title) return title;
|
|
858
|
+
if (element.querySelectorAll) {
|
|
859
|
+
var imgs = element.querySelectorAll("img");
|
|
860
|
+
for (var i = 0; i < imgs.length; i++) {
|
|
861
|
+
var alt = trim(imgs[i].getAttribute("alt"));
|
|
862
|
+
if (alt) return alt;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return "";
|
|
866
|
+
}
|
|
867
|
+
|
|
585
868
|
function markTableAsLayout(table) {
|
|
586
869
|
if (!table) return;
|
|
587
870
|
if (editor && typeof editor.setTableLayoutMode === "function") {
|