Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/*
17
 * @package    tiny_accessibilitychecker
18
 * @copyright  2022, Stevani Andolo  <stevani@hotmail.com.au>
19
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
20
 */
21
 
22
import Templates from 'core/templates';
23
import {getString, getStrings} from 'core/str';
24
import {component} from './common';
25
import Modal from 'core/modal';
26
import * as ModalEvents from 'core/modal_events';
27
import ColorBase from './colorbase';
28
import {getPlaceholderSelectors} from 'editor_tiny/options';
29
 
30
/**
31
 * @typedef ProblemDetail
32
 * @type {object}
33
 * @param {string} description The description of the problem
34
 * @param {ProblemNode[]} problemNodes The list of affected nodes
35
 */
36
 
37
/**
38
 * @typedef ProblemNode
39
 * @type {object}
40
 * @param {string} nodeName The node name for the affected node
41
 * @param {string} nodeIndex The indexd of the node
42
 * @param {string} text A description of the issue
43
 * @param {string} src The source of the image
44
 */
45
 
46
export default class {
47
 
48
    constructor(editor) {
49
        this.editor = editor;
50
        this.colorBase = new ColorBase();
51
        this.modal = null;
52
        this.placeholderSelectors = null;
53
        const placeholders = getPlaceholderSelectors(this.editor);
54
        if (placeholders.length) {
55
            this.placeholderSelectors = placeholders.join(', ');
56
        }
57
    }
58
 
59
    destroy() {
60
        delete this.editor;
61
        delete this.colorBase;
62
 
63
        this.modal.destroy();
64
        delete this.modal;
65
    }
66
 
67
    async displayDialogue() {
68
        this.modal = await Modal.create({
69
            large: true,
70
            title: getString('pluginname', component),
71
            body: this.getDialogueContent(),
72
            show: true,
73
        });
74
 
75
        // Destroy the class when hiding the modal.
76
        this.modal.getRoot().on(ModalEvents.hidden, () => this.destroy());
77
 
78
        this.modal.getRoot()[0].addEventListener('click', (event) => {
79
            const faultLink = event.target.closest('[data-action="highlightfault"]');
80
            if (!faultLink) {
81
                return;
82
            }
83
 
84
            event.preventDefault();
85
 
86
            const nodeName = faultLink.dataset.nodeName;
87
            let selectedNode = null;
88
            if (nodeName) {
89
                if (nodeName.includes(',') || nodeName === 'body') {
90
                    selectedNode = this.editor.dom.select('body')[0];
91
                } else {
92
                    const nodeIndex = faultLink.dataset.nodeIndex ?? 0;
93
                    selectedNode = this.editor.dom.select(nodeName)[nodeIndex];
94
                }
95
            }
96
 
97
            if (selectedNode && selectedNode.nodeName.toUpperCase() !== 'BODY') {
98
                this.selectAndScroll(selectedNode);
99
            }
100
 
101
            this.modal.hide();
102
        });
103
    }
104
 
105
    async getAllWarningStrings() {
106
        const keys = [
107
            'emptytext',
108
            'entiredocument',
109
            'imagesmissingalt',
110
            'needsmorecontrast',
111
            'needsmoreheadings',
112
            'tablesmissingcaption',
113
            'tablesmissingheaders',
114
            'tableswithmergedcells',
115
        ];
116
 
117
        const stringValues = await getStrings(keys.map((key) => ({key, component})));
118
        return new Map(keys.map((key, index) => ([key, stringValues[index]])));
119
    }
120
 
121
    /**
122
     * Return the dialogue content.
123
     *
124
     * @return {Promise<Array>} A template promise containing the rendered dialogue content.
125
     */
126
     async getDialogueContent() {
127
        const langStrings = await this.getAllWarningStrings();
128
 
129
        // Translate langstrings into real strings.
130
        const warnings = this.getWarnings().map((warning) => {
131
            if (warning.description) {
132
                if (warning.description.type === 'langstring') {
133
                    warning.description = langStrings.get(warning.description.value);
134
                } else {
135
                    warning.description = warning.description.value;
136
                }
137
            }
138
 
139
            warning.nodeData = warning.nodeData.map((problemNode) => {
140
                if (problemNode.text) {
141
                    if (problemNode.text.type === 'langstring') {
142
                        problemNode.text = langStrings.get(problemNode.text.value);
143
                    } else {
144
                        problemNode.text = problemNode.text.value;
145
                    }
146
                }
147
 
148
                return problemNode;
149
            });
150
 
151
            return warning;
152
        });
153
 
154
        return Templates.render('tiny_accessibilitychecker/warning_content', {
155
            warnings
156
        });
157
    }
158
 
159
    /**
160
     * Set the selection and scroll to the selected element.
161
     *
162
     * @param {node} node
163
     */
164
    selectAndScroll(node) {
165
        this.editor.selection.select(node).scrollIntoView({
166
            behavior: 'smooth',
167
            block: 'nearest'
168
        });
169
    }
170
 
171
    /**
172
     * Find all problems with the content editable region.
173
     *
174
     * @return {ProblemDetail[]} A complete list of all warnings and problems.
175
     */
176
    getWarnings() {
177
        const warnings = [];
178
 
179
        // Check Images with no alt text or dodgy alt text.
180
        warnings.push(this.createWarnings('imagesmissingalt', this.checkImage(), true));
181
        warnings.push(this.createWarnings('needsmorecontrast', this.checkOtherElements(), false));
182
 
183
        // Check for no headings.
184
        if (this.editor.getContent({format: 'text'}).length > 1000 && this.editor.dom.select('h3,h4,h5').length < 1) {
185
            warnings.push(this.createWarnings('needsmoreheadings', [this.editor], false));
186
        }
187
 
188
        // Check for tables with no captions.
189
        warnings.push(this.createWarnings('tablesmissingcaption', this.checkTableCaption(), false));
190
 
191
        // Check for tables with merged cells.
192
        warnings.push(this.createWarnings('tableswithmergedcells', this.checkTableMergedCells(), false));
193
 
194
        // Check for tables with no row/col headers.
195
        warnings.push(this.createWarnings('tablesmissingheaders', this.checkTableHeaders(), false));
196
 
197
        return warnings.filter((warning) => warning.nodeData.length > 0);
198
    }
199
 
200
    /**
201
     * Generate the data that describes the issues found.
202
     *
203
     * @param {String} description Description of this failure.
204
     * @param {HTMLElement[]} nodes An array of failing nodes.
205
     * @param {boolean} isImageType Whether the warnings are related to image type checks
206
     * @return {ProblemDetail[]} A set of problem details
207
     */
208
    createWarnings(description, nodes, isImageType) {
209
        const getTextValue = (node) => {
210
            if (node === this.editor) {
211
                return {
212
                    type: 'langstring',
213
                    value: 'entiredocument',
214
                };
215
            }
216
 
217
            const emptyStringValue = {
218
                type: 'langstring',
219
                value: 'emptytext',
220
            };
221
            if ('innerText' in node) {
222
                const value = node.innerText.trim();
223
                return value.length ? {type: 'raw', value} : emptyStringValue;
224
            } else if ('textContent' in node) {
225
                const value = node.textContent.trim();
226
                return value.length ? {type: 'raw', value} : emptyStringValue;
227
            }
228
 
229
            return {type: 'raw', value: node.nodeName};
230
        };
231
 
232
        const getEventualNode = (node) => {
233
            if (node !== this.editor) {
234
                return node;
235
            }
236
            const childNodes = node.dom.select('body')[0].childNodes;
237
            if (childNodes.length) {
238
                return document.body;
239
            } else {
240
                return childNodes;
241
            }
242
        };
243
 
244
        const warning = {
245
            description: {
246
                type: 'langstring',
247
                value: description,
248
            },
249
            nodeData: [],
250
        };
251
 
252
        warning.nodeData = [...nodes].filter((node) => {
253
            // If the failed node is a placeholder element. We should remove it from the list.
254
            if (node !== this.editor && this.placeholderSelectors) {
255
                return node.matches(this.placeholderSelectors) === false;
256
            }
257
 
258
            return node;
259
        }).map((node) => {
260
            const describedNode = getEventualNode(node);
261
 
262
            // Find the index of the node within the type of node.
263
            // This is used to select the correct node when the user selects it.
264
            const nodeIndex = this.editor.dom.select(describedNode.nodeName).indexOf(describedNode);
265
            const warning = {
266
                src: null,
267
                text: null,
268
                nodeName: describedNode.nodeName,
269
                nodeIndex,
270
            };
271
 
272
            if (isImageType) {
273
                warning.src = node.getAttribute('src');
274
            } else {
275
                warning.text = getTextValue(node);
276
            }
277
 
278
            return warning;
279
        });
280
 
281
        return warning;
282
    }
283
 
284
    /**
285
     * Check accessiblity issue only for img type.
286
     *
287
     * @return {Node} A complete list of all warnings and problems.
288
     */
289
    checkImage() {
290
        const problemNodes = [];
291
        this.editor.dom.select('img').forEach((img) => {
292
            const alt = img.getAttribute('alt');
293
            if (!alt && img.getAttribute('role') !== 'presentation') {
294
                problemNodes.push(img);
295
            }
296
        });
297
        return problemNodes;
298
    }
299
 
300
    /**
301
     * Look for any table without a caption.
302
     *
303
     * @return {Node} A complete list of all warnings and problems.
304
     */
305
    checkTableCaption() {
306
        const problemNodes = [];
307
        this.editor.dom.select('table').forEach((table) => {
308
            const caption = table.querySelector('caption');
309
            if (!caption?.textContent.trim()) {
310
                problemNodes.push(table);
311
            }
312
        });
313
 
314
        return problemNodes;
315
    }
316
 
317
    /**
318
     * Check accessiblity issue for not img and table only.
319
     *
320
     * @return {Node} A complete list of all warnings and problems.
321
     * @private
322
     */
323
    checkOtherElements() {
324
        const problemNodes = [];
325
 
326
        const getRatio = (lum1, lum2) => {
327
            // Algorithm from "http://www.w3.org/TR/WCAG20-GENERAL/G18.html".
328
            if (lum1 > lum2) {
329
                return (lum1 + 0.05) / (lum2 + 0.05);
330
            } else {
331
                return (lum2 + 0.05) / (lum1 + 0.05);
332
            }
333
        };
334
 
335
        this.editor.dom.select('body *')
336
            .filter((node) => node.hasChildNodes() && node.childNodes[0].nodeValue !== null)
337
            .forEach((node) => {
338
                const foreground = this.colorBase.fromArray(
339
                    this.getComputedBackgroundColor(
340
                        node,
341
                        window.getComputedStyle(node, null).getPropertyValue('color')
342
                    ),
343
                    this.colorBase.TYPES.RGBA
344
                );
345
                const background = this.colorBase.fromArray(
346
                    this.getComputedBackgroundColor(
347
                        node
348
                    ),
349
                    this.colorBase.TYPES.RGBA
350
                );
351
 
352
                const lum1 = this.getLuminanceFromCssColor(foreground);
353
                const lum2 = this.getLuminanceFromCssColor(background);
354
                const ratio = getRatio(lum1, lum2);
355
 
356
                if (ratio <= 4.5) {
357
                    window.console.log(`
358
                        Contrast ratio is too low: ${ratio}
359
                        Colour 1: ${foreground}
360
                        Colour 2: ${background}
361
                        Luminance 1: ${lum1}
362
                        Luminance 2: ${lum2}
363
                    `);
364
 
365
                    // We only want the highest node with dodgy contrast reported.
366
                    if (!problemNodes.find((existingProblemNode) => existingProblemNode.contains(node))) {
367
                        problemNodes.push(node);
368
                    }
369
                }
370
            });
371
        return problemNodes;
372
    }
373
 
374
    /**
375
     * Check accessiblity issue only for table with merged cells.
376
     *
377
     * @return {Node} A complete list of all warnings and problems.
378
     * @private
379
     */
380
    checkTableMergedCells() {
381
        const problemNodes = [];
382
        this.editor.dom.select('table').forEach((table) => {
383
            const rowcolspan = table.querySelectorAll('[colspan], [rowspan]');
384
            if (rowcolspan.length) {
385
                problemNodes.push(table);
386
            }
387
        });
388
        return problemNodes;
389
    }
390
 
391
    /**
392
     * Check accessiblity issue only for table with no headers.
393
     *
394
     * @return {Node} A complete list of all warnings and problems.
395
     * @private
396
     */
397
    checkTableHeaders() {
398
        const problemNodes = [];
399
 
400
        this.editor.dom.select('table').forEach((table) => {
401
            if (table.querySelector('tr').querySelector('td')) {
402
                // The first row has a non-header cell, so all rows must have at least one header.
403
                const missingHeader = [...table.querySelectorAll('tr')].some((row) => {
404
                    const header = row.querySelector('th');
405
                    if (!header) {
406
                        return true;
407
                    }
408
 
409
                    if (!header.textContent.trim()) {
410
                        return true;
411
                    }
412
 
413
                    return false;
414
                });
415
                if (missingHeader) {
416
                    // At least one row is missing the header, or it is empty.
417
                    problemNodes.push(table);
418
                }
419
            } else {
420
                // Every header must have some content.
421
                if ([...table.querySelectorAll('tr th')].some((header) => !header.textContent.trim())) {
422
                    problemNodes.push(table);
423
                }
424
            }
425
        });
426
        return problemNodes;
427
    }
428
 
429
    /**
430
     * Convert a CSS color to a luminance value.
431
     *
432
     * @param {String} colortext The Hex value for the colour
433
     * @return {Number} The luminance value.
434
     * @private
435
     */
436
    getLuminanceFromCssColor(colortext) {
437
        if (colortext === 'transparent') {
438
            colortext = '#ffffff';
439
        }
440
        const color = this.colorBase.toArray(this.colorBase.toRGB(colortext));
441
 
442
        // Algorithm from "http://www.w3.org/TR/WCAG20-GENERAL/G18.html".
443
        const part1 = (a) => {
444
            a = parseInt(a, 10) / 255.0;
445
            if (a <= 0.03928) {
446
                a = a / 12.92;
447
            } else {
448
                a = Math.pow(((a + 0.055) / 1.055), 2.4);
449
            }
450
            return a;
451
        };
452
 
453
        const r1 = part1(color[0]);
454
        const g1 = part1(color[1]);
455
        const b1 = part1(color[2]);
456
 
457
        return 0.2126 * r1 + 0.7152 * g1 + 0.0722 * b1;
458
    }
459
 
460
    /**
461
     * Get the computed RGB converted to full alpha value, considering the node hierarchy.
462
     *
463
     * @param {Node} node
464
     * @param {String} color The initial colour. If not specified, fetches the backgroundColor from the node.
465
     * @return {Array} Colour in Array form (RGBA)
466
     * @private
467
     */
468
    getComputedBackgroundColor(node, color) {
469
        if (!node.parentNode) {
470
            // This is the document node and has no colour.
471
            // We cannot use window.getComputedStyle on the document.
472
            // If we got here, then the document has no background colour. Fall back to white.
473
            return this.colorBase.toArray('rgba(255, 255, 255, 1)');
474
        }
475
        color = color ? color : window.getComputedStyle(node, null).getPropertyValue('background-color');
476
 
477
        if (color.toLowerCase() === 'rgba(0, 0, 0, 0)' || color.toLowerCase() === 'transparent') {
478
            color = 'rgba(1, 1, 1, 0)';
479
        }
480
 
481
        // Convert the colour to its constituent parts in RGBA format, then fetch the alpha.
482
        const colorParts = this.colorBase.toArray(color);
483
        const alpha = colorParts[3];
484
 
485
        if (alpha === 1) {
486
            // If the alpha of the background is already 1, then the parent background colour does not change anything.
487
            return colorParts;
488
        }
489
 
490
        // Fetch the computed background colour of the parent and use it to calculate the RGB of this item.
491
        const parentColor = this.getComputedBackgroundColor(node.parentNode);
492
        return [
493
            // RGB = (alpha * R|G|B) + (1 - alpha * solid parent colour).
494
            (1 - alpha) * parentColor[0] + alpha * colorParts[0],
495
            (1 - alpha) * parentColor[1] + alpha * colorParts[1],
496
            (1 - alpha) * parentColor[2] + alpha * colorParts[2],
497
            // We always return a colour with full alpha.
498
            1
499
        ];
500
    }
501
}