// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/*
* @package tiny_accessibilitychecker
* @copyright 2022, Stevani Andolo <stevani@hotmail.com.au>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Templates from 'core/templates';
import {getString, getStrings} from 'core/str';
import {component} from './common';
import Modal from 'core/modal';
import * as ModalEvents from 'core/modal_events';
import ColorBase from './colorbase';
import {getPlaceholderSelectors} from 'editor_tiny/options';
/**
* @typedef ProblemDetail
* @type {object}
* @param {string} description The description of the problem
* @param {ProblemNode[]} problemNodes The list of affected nodes
*/
/**
* @typedef ProblemNode
* @type {object}
* @param {string} nodeName The node name for the affected node
* @param {string} nodeIndex The indexd of the node
* @param {string} text A description of the issue
* @param {string} src The source of the image
*/
export default class {
constructor(editor) {
this.editor = editor;
this.colorBase = new ColorBase();
this.modal = null;
this.placeholderSelectors = null;
const placeholders = getPlaceholderSelectors(this.editor);
if (placeholders.length) {
this.placeholderSelectors = placeholders.join(', ');
}
}
destroy() {
delete this.editor;
delete this.colorBase;
this.modal.destroy();
delete this.modal;
}
async displayDialogue() {
this.modal = await Modal.create({
large: true,
title: getString('pluginname', component),
body: this.getDialogueContent(),
show: true,
});
// Destroy the class when hiding the modal.
this.modal.getRoot().on(ModalEvents.hidden, () => this.destroy());
this.modal.getRoot()[0].addEventListener('click', (event) => {
const faultLink = event.target.closest('[data-action="highlightfault"]');
if (!faultLink) {
return;
}
event.preventDefault();
const nodeName = faultLink.dataset.nodeName;
let selectedNode = null;
if (nodeName) {
if (nodeName.includes(',') || nodeName === 'body') {
selectedNode = this.editor.dom.select('body')[0];
} else {
const nodeIndex = faultLink.dataset.nodeIndex ?? 0;
selectedNode = this.editor.dom.select(nodeName)[nodeIndex];
}
}
if (selectedNode && selectedNode.nodeName.toUpperCase() !== 'BODY') {
this.selectAndScroll(selectedNode);
}
this.modal.hide();
});
}
async getAllWarningStrings() {
const keys = [
'emptytext',
'entiredocument',
'imagesmissingalt',
'needsmorecontrast',
'needsmoreheadings',
'tablesmissingcaption',
'tablesmissingheaders',
'tableswithmergedcells',
];
const stringValues = await getStrings(keys.map((key) => ({key, component})));
return new Map(keys.map((key, index) => ([key, stringValues[index]])));
}
/**
* Return the dialogue content.
*
* @return {Promise<Array>} A template promise containing the rendered dialogue content.
*/
async getDialogueContent() {
const langStrings = await this.getAllWarningStrings();
// Translate langstrings into real strings.
const warnings = this.getWarnings().map((warning) => {
if (warning.description) {
if (warning.description.type === 'langstring') {
warning.description = langStrings.get(warning.description.value);
} else {
warning.description = warning.description.value;
}
}
warning.nodeData = warning.nodeData.map((problemNode) => {
if (problemNode.text) {
if (problemNode.text.type === 'langstring') {
problemNode.text = langStrings.get(problemNode.text.value);
} else {
problemNode.text = problemNode.text.value;
}
}
return problemNode;
});
return warning;
});
return Templates.render('tiny_accessibilitychecker/warning_content', {
warnings
});
}
/**
* Set the selection and scroll to the selected element.
*
* @param {node} node
*/
selectAndScroll(node) {
this.editor.selection.select(node).scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
/**
* Find all problems with the content editable region.
*
* @return {ProblemDetail[]} A complete list of all warnings and problems.
*/
getWarnings() {
const warnings = [];
// Check Images with no alt text or dodgy alt text.
warnings.push(this.createWarnings('imagesmissingalt', this.checkImage(), true));
warnings.push(this.createWarnings('needsmorecontrast', this.checkOtherElements(), false));
// Check for no headings.
if (this.editor.getContent({format: 'text'}).length > 1000 && this.editor.dom.select('h3,h4,h5').length < 1) {
warnings.push(this.createWarnings('needsmoreheadings', [this.editor], false));
}
// Check for tables with no captions.
warnings.push(this.createWarnings('tablesmissingcaption', this.checkTableCaption(), false));
// Check for tables with merged cells.
warnings.push(this.createWarnings('tableswithmergedcells', this.checkTableMergedCells(), false));
// Check for tables with no row/col headers.
warnings.push(this.createWarnings('tablesmissingheaders', this.checkTableHeaders(), false));
return warnings.filter((warning) => warning.nodeData.length > 0);
}
/**
* Generate the data that describes the issues found.
*
* @param {String} description Description of this failure.
* @param {HTMLElement[]} nodes An array of failing nodes.
* @param {boolean} isImageType Whether the warnings are related to image type checks
* @return {ProblemDetail[]} A set of problem details
*/
createWarnings(description, nodes, isImageType) {
const getTextValue = (node) => {
if (node === this.editor) {
return {
type: 'langstring',
value: 'entiredocument',
};
}
const emptyStringValue = {
type: 'langstring',
value: 'emptytext',
};
if ('innerText' in node) {
const value = node.innerText.trim();
return value.length ? {type: 'raw', value} : emptyStringValue;
} else if ('textContent' in node) {
const value = node.textContent.trim();
return value.length ? {type: 'raw', value} : emptyStringValue;
}
return {type: 'raw', value: node.nodeName};
};
const getEventualNode = (node) => {
if (node !== this.editor) {
return node;
}
const childNodes = node.dom.select('body')[0].childNodes;
if (childNodes.length) {
return document.body;
} else {
return childNodes;
}
};
const warning = {
description: {
type: 'langstring',
value: description,
},
nodeData: [],
};
warning.nodeData = [...nodes].filter((node) => {
// If the failed node is a placeholder element. We should remove it from the list.
if (node !== this.editor && this.placeholderSelectors) {
return node.matches(this.placeholderSelectors) === false;
}
return node;
}).map((node) => {
const describedNode = getEventualNode(node);
// Find the index of the node within the type of node.
// This is used to select the correct node when the user selects it.
const nodeIndex = this.editor.dom.select(describedNode.nodeName).indexOf(describedNode);
const warning = {
src: null,
text: null,
nodeName: describedNode.nodeName,
nodeIndex,
};
if (isImageType) {
warning.src = node.getAttribute('src');
} else {
warning.text = getTextValue(node);
}
return warning;
});
return warning;
}
/**
* Check accessiblity issue only for img type.
*
* @return {Node} A complete list of all warnings and problems.
*/
checkImage() {
const problemNodes = [];
this.editor.dom.select('img').forEach((img) => {
const alt = img.getAttribute('alt');
if (!alt && img.getAttribute('role') !== 'presentation') {
problemNodes.push(img);
}
});
return problemNodes;
}
/**
* Look for any table without a caption.
*
* @return {Node} A complete list of all warnings and problems.
*/
checkTableCaption() {
const problemNodes = [];
this.editor.dom.select('table').forEach((table) => {
const caption = table.querySelector('caption');
if (!caption?.textContent.trim()) {
problemNodes.push(table);
}
});
return problemNodes;
}
/**
* Check accessiblity issue for not img and table only.
*
* @return {Node} A complete list of all warnings and problems.
* @private
*/
checkOtherElements() {
const problemNodes = [];
const getRatio = (lum1, lum2) => {
// Algorithm from "http://www.w3.org/TR/WCAG20-GENERAL/G18.html".
if (lum1 > lum2) {
return (lum1 + 0.05) / (lum2 + 0.05);
} else {
return (lum2 + 0.05) / (lum1 + 0.05);
}
};
this.editor.dom.select('body *')
.filter((node) => node.hasChildNodes() && node.childNodes[0].nodeValue !== null)
.forEach((node) => {
const foreground = this.colorBase.fromArray(
this.getComputedBackgroundColor(
node,
window.getComputedStyle(node, null).getPropertyValue('color')
),
this.colorBase.TYPES.RGBA
);
const background = this.colorBase.fromArray(
this.getComputedBackgroundColor(
node
),
this.colorBase.TYPES.RGBA
);
const lum1 = this.getLuminanceFromCssColor(foreground);
const lum2 = this.getLuminanceFromCssColor(background);
const ratio = getRatio(lum1, lum2);
if (ratio <= 4.5) {
window.console.log(`
Contrast ratio is too low: ${ratio}
Colour 1: ${foreground}
Colour 2: ${background}
Luminance 1: ${lum1}
Luminance 2: ${lum2}
`);
// We only want the highest node with dodgy contrast reported.
if (!problemNodes.find((existingProblemNode) => existingProblemNode.contains(node))) {
problemNodes.push(node);
}
}
});
return problemNodes;
}
/**
* Check accessiblity issue only for table with merged cells.
*
* @return {Node} A complete list of all warnings and problems.
* @private
*/
checkTableMergedCells() {
const problemNodes = [];
this.editor.dom.select('table').forEach((table) => {
const rowcolspan = table.querySelectorAll('[colspan], [rowspan]');
if (rowcolspan.length) {
problemNodes.push(table);
}
});
return problemNodes;
}
/**
* Check accessiblity issue only for table with no headers.
*
* @return {Node} A complete list of all warnings and problems.
* @private
*/
checkTableHeaders() {
const problemNodes = [];
this.editor.dom.select('table').forEach((table) => {
if (table.querySelector('tr').querySelector('td')) {
// The first row has a non-header cell, so all rows must have at least one header.
const missingHeader = [...table.querySelectorAll('tr')].some((row) => {
const header = row.querySelector('th');
if (!header) {
return true;
}
if (!header.textContent.trim()) {
return true;
}
return false;
});
if (missingHeader) {
// At least one row is missing the header, or it is empty.
problemNodes.push(table);
}
} else {
// Every header must have some content.
if ([...table.querySelectorAll('tr th')].some((header) => !header.textContent.trim())) {
problemNodes.push(table);
}
}
});
return problemNodes;
}
/**
* Convert a CSS color to a luminance value.
*
* @param {String} colortext The Hex value for the colour
* @return {Number} The luminance value.
* @private
*/
getLuminanceFromCssColor(colortext) {
if (colortext === 'transparent') {
colortext = '#ffffff';
}
const color = this.colorBase.toArray(this.colorBase.toRGB(colortext));
// Algorithm from "http://www.w3.org/TR/WCAG20-GENERAL/G18.html".
const part1 = (a) => {
a = parseInt(a, 10) / 255.0;
if (a <= 0.03928) {
a = a / 12.92;
} else {
a = Math.pow(((a + 0.055) / 1.055), 2.4);
}
return a;
};
const r1 = part1(color[0]);
const g1 = part1(color[1]);
const b1 = part1(color[2]);
return 0.2126 * r1 + 0.7152 * g1 + 0.0722 * b1;
}
/**
* Get the computed RGB converted to full alpha value, considering the node hierarchy.
*
* @param {Node} node
* @param {String} color The initial colour. If not specified, fetches the backgroundColor from the node.
* @return {Array} Colour in Array form (RGBA)
* @private
*/
getComputedBackgroundColor(node, color) {
if (!node.parentNode) {
// This is the document node and has no colour.
// We cannot use window.getComputedStyle on the document.
// If we got here, then the document has no background colour. Fall back to white.
return this.colorBase.toArray('rgba(255, 255, 255, 1)');
}
color = color ? color : window.getComputedStyle(node, null).getPropertyValue('background-color');
if (color.toLowerCase() === 'rgba(0, 0, 0, 0)' || color.toLowerCase() === 'transparent') {
color = 'rgba(1, 1, 1, 0)';
}
// Convert the colour to its constituent parts in RGBA format, then fetch the alpha.
const colorParts = this.colorBase.toArray(color);
const alpha = colorParts[3];
if (alpha === 1) {
// If the alpha of the background is already 1, then the parent background colour does not change anything.
return colorParts;
}
// Fetch the computed background colour of the parent and use it to calculate the RGB of this item.
const parentColor = this.getComputedBackgroundColor(node.parentNode);
return [
// RGB = (alpha * R|G|B) + (1 - alpha * solid parent colour).
(1 - alpha) * parentColor[0] + alpha * colorParts[0],
(1 - alpha) * parentColor[1] + alpha * colorParts[1],
(1 - alpha) * parentColor[2] + alpha * colorParts[2],
// We always return a colour with full alpha.
1
];
}
}