lib/editor/tiny/plugins/accessibilitychecker/amd/src/checker.js

// 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
        ];
    }
}