Proyectos de Subversion Moodle

Rev

Rev 11 | | Comparar con el anterior | 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
 * TinyMCE Editor Manager.
18
 *
19
 * @module editor_tiny/editor
20
 * @copyright  2022 Andrew Lyons <andrew@nicols.co.uk>
21
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
 
24
import jQuery from 'jquery';
25
import Pending from 'core/pending';
11 efrain 26
import {getDefaultConfiguration, getDefaultQuickbarsSelectionToolbar} from './defaults';
1 efrain 27
import {getTinyMCE, baseUrl} from './loader';
28
import * as Options from './options';
29
import {addToolbarButton, addToolbarButtons, addToolbarSection,
30
    removeToolbarButton, removeSubmenuItem, updateEditorState} from './utils';
1441 ariadna 31
import {addMathMLSupport, addSVGSupport} from './content';
32
import Config from 'core/config';
1 efrain 33
 
34
/**
35
 * Storage for the TinyMCE instances on the page.
36
 * @type {Map}
37
 */
38
const instanceMap = new Map();
39
 
40
/**
41
 * The default editor configuration.
42
 * @type {Object}
43
 */
44
let defaultOptions = {};
45
 
46
/**
47
 * Require the modules for the named set of TinyMCE plugins.
48
 *
49
 * @param {string[]} pluginList The list of plugins
50
 * @return {Promise[]} A matching set of Promises relating to the requested plugins
51
 */
52
const importPluginList = async(pluginList) => {
53
    // Fetch all of the plugins from the list of plugins.
54
    // If a plugin contains a '/' then it is assumed to be a Moodle AMD module to import.
55
    const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {
56
        if (pluginPath.indexOf('/') === -1) {
57
            // A standard TinyMCE Plugin.
58
            return Promise.resolve(pluginPath);
59
        }
60
 
61
        return import(pluginPath);
62
    }));
63
 
64
    // Normalise the plugin data to a list of plugin names.
65
    // Two formats are supported:
66
    // - a string; and
67
    // - an array whose first element is the plugin name, and the second element is the plugin configuration.
68
    const pluginNames = pluginHandlers.map((pluginConfig) => {
69
        if (typeof pluginConfig === 'string') {
70
            return pluginConfig;
71
        }
72
        if (Array.isArray(pluginConfig)) {
73
            return pluginConfig[0];
74
        }
75
        return null;
76
    }).filter((value) => value);
77
 
78
    // Fetch the list of pluginConfig handlers.
79
    const pluginConfig = pluginHandlers.map((pluginConfig) => {
80
        if (Array.isArray(pluginConfig)) {
81
            return pluginConfig[1];
82
        }
83
        return null;
84
    }).filter((value) => value);
85
 
86
    return {
87
        pluginNames,
88
        pluginConfig,
89
    };
90
};
91
 
92
/**
93
 * Fetch the language data for the specified language.
94
 *
95
 * @param {string} language The language identifier
96
 * @returns {object}
97
 */
98
const fetchLanguage = (language) => fetch(
99
    `${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`
100
).then(response => response.json());
101
 
102
/**
103
 * Get a list of all Editors in a Map, keyed by the DOM Node that the Editor is associated with.
104
 *
105
 * @returns {Map<Node, Editor>}
106
 */
107
export const getAllInstances = () => new Map(instanceMap.entries());
108
 
109
/**
110
 * Get the TinyMCE instance for the specified Node ID.
111
 *
112
 * @param {string} elementId
113
 * @returns {TinyMCE|undefined}
114
 */
115
export const getInstanceForElementId = elementId => getInstanceForElement(document.getElementById(elementId));
116
 
117
/*
118
 * Get the TinyMCE instance for the specified HTMLElement.
119
 *
120
 * @param {HTMLElement} element
121
 * @returns {TinyMCE|undefined}
122
 */
123
export const getInstanceForElement = element => {
124
    const instance = instanceMap.get(element);
125
    if (instance && instance.removed) {
126
        instanceMap.delete(element);
127
        return undefined;
128
    }
129
    return instance;
130
};
131
 
132
/**
133
 * Set up TinyMCE for the selector at the specified HTML Node id.
134
 *
135
 * @param {object} config The configuration required to setup the editor
136
 * @param {string} config.elementId The HTML Node ID
137
 * @param {Object} config.options The editor plugin configuration
138
 */
139
export const setupForElementId = ({elementId, options}) => {
140
    const target = document.getElementById(elementId);
141
    // We will need to wrap the setupForTarget and editor.remove() calls in a setTimeout.
142
    // Because other events callbacks will still try to run on the removed instance.
143
    // This will cause an error on Firefox.
144
    // We need to make TinyMCE to remove itself outside the event loop.
145
    // @see https://github.com/tinymce/tinymce/issues/3129 for more details.
146
    setTimeout(() => {
147
        return setupForTarget(target, options);
148
    }, 1);
149
};
150
 
151
/**
152
 * Initialise the page with standard TinyMCE requirements.
153
 *
154
 * Currently this includes the language taken from the HTML lang property.
155
 */
156
const initialisePage = async() => {
157
    const lang = document.querySelector('html').lang;
158
 
1441 ariadna 159
    const [tinyMCE, langData] = await Promise.all([getTinyMCE(), fetchLanguage(Config.language)]);
1 efrain 160
    tinyMCE.addI18n(lang, langData);
161
};
162
initialisePage();
163
 
164
/**
165
 * Get the list of plugins to load for the specified configuration.
166
 *
167
 * If the specified configuration does not include a plugin configuration, then return the default configuration.
168
 *
169
 * @param {object} options
170
 * @param {array} [options.plugins=null] The plugin list
171
 * @returns {object}
172
 */
173
const getPlugins = ({plugins = null} = {}) => {
174
    if (plugins) {
175
        return plugins;
176
    }
177
 
178
    if (defaultOptions.plugins) {
179
        return defaultOptions.plugins;
180
    }
181
 
182
    return {};
183
};
184
 
185
/**
186
 * Adjust the editor size base on the target element.
187
 *
188
 * @param {TinyMCE} editor TinyMCE editor
189
 * @param {Node} target Target element
190
 */
191
const adjustEditorSize = (editor, target) => {
192
    let expectedEditingAreaHeight = 0;
193
    if (target.clientHeight) {
194
        expectedEditingAreaHeight = target.clientHeight;
195
    } else {
196
        // If the target element is hidden, we cannot get the lineHeight of the target element.
197
        // We don't have a proper way to retrieve the general lineHeight of the theme, so we use 22 here, it's equivalent to 1.5em.
198
        expectedEditingAreaHeight = target.rows * (parseFloat(window.getComputedStyle(target).lineHeight) || 22);
199
    }
200
    const currentEditingAreaHeight = editor.getContainer().querySelector('.tox-sidebar-wrap').clientHeight;
201
    if (currentEditingAreaHeight < expectedEditingAreaHeight) {
202
        // Change the height based on the target element's height.
203
        editor.getContainer().querySelector('.tox-sidebar-wrap').style.height = `${expectedEditingAreaHeight}px`;
204
    }
205
};
206
 
207
/**
208
 * Get the standard configuration for the specified options.
209
 *
210
 * @param {Node} target
211
 * @param {tinyMCE} tinyMCE
212
 * @param {object} options
213
 * @param {Array} plugins
214
 * @returns {object}
215
 */
216
const getStandardConfig = (target, tinyMCE, options, plugins) => {
217
    const lang = document.querySelector('html').lang;
218
 
219
    const config = Object.assign({}, getDefaultConfiguration(), {
220
        // eslint-disable-next-line camelcase
221
        base_url: baseUrl,
222
 
223
        // Set the editor target.
224
        // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target
225
        target,
226
 
227
        // https://www.tiny.cloud/docs/tinymce/6/customize-ui/#set-maximum-and-minimum-heights-and-widths
228
        // Set the minimum height to the smallest height that we can fit the Menu bar, Tool bar, Status bar and the text area.
229
        // eslint-disable-next-line camelcase
230
        min_height: 175,
231
 
232
        // Base the height on the size of the text area.
233
        // In some cases, E.g.: The target is an advanced element, it will be hidden. We cannot get the height at this time.
234
        // So set the height to auto, and adjust it later by adjustEditorSize().
235
        height: target.clientHeight || 'auto',
236
 
237
        // Set the language.
238
        // https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language
239
        // eslint-disable-next-line camelcase
240
        language: lang,
241
 
1441 ariadna 242
        // Disable default iframe sandboxing.
243
        // https://www.tiny.cloud/docs/tinymce/latest/content-filtering/#sandbox-iframes
244
        // eslint-disable-next-line camelcase
245
        sandbox_iframes: false,
246
 
1 efrain 247
        // Load the editor stylesheet into the editor iframe.
248
        // https://www.tiny.cloud/docs/tinymce/6/add-css-options/
249
        // eslint-disable-next-line camelcase
250
        content_css: [
251
            options.css,
252
        ],
253
 
254
        // Do not convert URLs to relative URLs.
255
        // https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls
256
        // eslint-disable-next-line camelcase
257
        convert_urls: false,
258
 
259
        // Enabled 'advanced' a11y options.
260
        // This includes allowing role="presentation" from the image uploader.
261
        // https://www.tiny.cloud/docs/tinymce/6/accessibility/
262
        // eslint-disable-next-line camelcase
263
        a11y_advanced_options: true,
264
 
265
        // Add specific rules to the valid elements.
266
        // eslint-disable-next-line camelcase
1441 ariadna 267
        extended_valid_elements: options.extended_valid_elements,
1 efrain 268
 
269
        // Disable XSS Sanitisation.
270
        // We do this in PHP.
271
        // https://www.tiny.cloud/docs/tinymce/6/security/#turning-dompurify-off
272
        // Note: This feature has been backported from TinyMCE 6.4.0.
273
        // eslint-disable-next-line camelcase
274
        xss_sanitization: false,
275
 
276
        // Disable quickbars entirely.
277
        // The UI is not ideal and we'll wait for it to improve in future before we enable it in Moodle.
278
        // eslint-disable-next-line camelcase
279
        quickbars_insert_toolbar: '',
280
 
11 efrain 281
        // If the target element is too small, disable the quickbars selection toolbar.
282
        // The quickbars selection toolbar is not displayed correctly if the target element is too small.
283
        // See: https://github.com/tinymce/tinymce/issues/9693.
284
        quickbars_selection_toolbar: target.rows > 5 ? getDefaultQuickbarsSelectionToolbar() : false,
285
 
1 efrain 286
        // Override the standard block formats property (removing h1 & h2).
287
        // https://www.tiny.cloud/docs/tinymce/6/user-formatting-options/#block_formats
288
        // eslint-disable-next-line camelcase
1441 ariadna 289
        block_formats: 'Paragraph=p;Heading 3=h3;Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre;Blockquote=blockquote',
1 efrain 290
 
291
        // The list of plugins to include in the instance.
292
        // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins
293
        plugins: [
294
            ...plugins,
295
        ],
296
 
297
        // Skins
298
        skin: 'oxide',
299
 
300
        // Do not show the help link in the status bar.
301
        // https://www.tiny.cloud/docs/tinymce/latest/accessibility/#help_accessibility
302
        // eslint-disable-next-line camelcase
303
        help_accessibility: false,
304
 
305
        // Remove the "Upgrade" link for Tiny.
306
        // https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/
307
        promotion: false,
308
 
309
        // Allow the administrator to disable branding.
310
        // https://www.tiny.cloud/docs/tinymce/6/statusbar-configuration-options/#branding
311
        branding: options.branding,
312
 
313
        // Put th cells in a thead element.
314
        // https://www.tiny.cloud/docs/tinymce/6/table-options/#table_header_type
315
        // eslint-disable-next-line camelcase
316
        table_header_type: 'sectionCells',
317
 
318
        // Stored text in non-entity form.
319
        // https://www.tiny.cloud/docs/tinymce/6/content-filtering/#entity_encoding
320
        // eslint-disable-next-line camelcase
321
        entity_encoding: "raw",
322
 
323
        // Enable support for editors in scrollable containers.
324
        // https://www.tiny.cloud/docs/tinymce/6/ui-mode-configuration-options/#ui_mode
325
        // eslint-disable-next-line camelcase
326
        ui_mode: 'split',
327
 
328
        // Enable browser-supported spell checking.
329
        // https://www.tiny.cloud/docs/tinymce/latest/spelling/
330
        // eslint-disable-next-line camelcase
331
        browser_spellcheck: true,
332
 
1441 ariadna 333
        // Set the license_key to gpl.
334
        // https://www.tiny.cloud/docs/tinymce/latest/license-key/
335
        // eslint-disable-next-line camelcase
336
        license_key: 'gpl',
337
 
338
        // Disable the Alt+F12 shortcut.
339
        // This was introduced in Tiny 7.1 to focus notifications, but it conflicts with the German keyboard layout
340
        // which uses Alt+F12 to access the open curly brace.
341
        // This is an upstream problem with TinyMCE and should be fixed in a future release.
342
        // The recommended workaround is to disable the shortcut.
343
        // See MDL-83257 for further information.
344
        // eslint-disable-next-line camelcase
345
        init_instance_callback: (editor) => {
346
            editor.shortcuts.remove('alt+f12');
347
        },
348
 
1 efrain 349
        setup: (editor) => {
350
            Options.register(editor, options);
351
 
352
            editor.on('PreInit', function() {
353
                // Work around a bug in TinyMCE with Firefox.
354
                // When an editor is removed, and replaced with an identically attributed editor (same ID),
355
                // and the Firefox window is freshly opened (e.g. Behat, Private browsing), the wrong contentWindow
356
                // is assigned to the editor instance leading to an NS_ERROR_UNEXPECTED error in Firefox.
357
                // This is a workaround for that issue.
358
                this.contentWindow = this.iframeElement.contentWindow;
359
            });
360
            editor.on('init', function() {
361
                // Hide justify alignment sub-menu.
362
                removeSubmenuItem(editor, 'align', 'tiny:justify');
363
                // Adjust the editor size.
364
                adjustEditorSize(editor, target);
365
            });
366
 
1441 ariadna 367
            addMathMLSupport(editor);
368
            addSVGSupport(editor);
369
 
1 efrain 370
            target.addEventListener('form:editorUpdated', function() {
371
                updateEditorState(editor, target);
372
            });
373
 
374
            target.dispatchEvent(new Event('form:editorUpdated'));
375
        },
376
    });
377
 
378
    config.toolbar = addToolbarSection(config.toolbar, 'content', 'formatting', true);
379
    config.toolbar = addToolbarButton(config.toolbar, 'content', 'link');
380
 
381
    // Add directionality plugins, always.
382
    config.toolbar = addToolbarSection(config.toolbar, 'directionality', 'alignment', true);
383
    config.toolbar = addToolbarButtons(config.toolbar, 'directionality', ['ltr', 'rtl']);
384
 
385
    // Remove the align justify button from the toolbar.
386
    config.toolbar = removeToolbarButton(config.toolbar, 'alignment', 'alignjustify');
387
 
388
    return config;
389
};
390
 
391
/**
392
 * Fetch the TinyMCE configuration for this editor instance.
393
 *
394
 * @param {HTMLElement} target
395
 * @param {TinyMCE} tinyMCE The TinyMCE API
396
 * @param {Object} options The editor plugin configuration
397
 * @param {object} pluginValues
398
 * @param {object} pluginValues.pluginConfig The list of plugin configuration
399
 * @param {object} pluginValues.pluginNames The list of plugins to load
400
 * @returns {object} The TinyMCE Configuration
401
 */
402
const getEditorConfiguration = (target, tinyMCE, options, pluginValues) => {
403
    const {
404
        pluginNames,
405
        pluginConfig,
406
    } = pluginValues;
407
 
408
    // Allow plugins to modify the configuration.
409
    // This seems a little strange, but we must double-process the config slightly.
410
 
411
    // First we fetch the standard configuration.
412
    const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);
413
 
414
    // Next we make any standard changes.
415
    // Here we remove the file menu, as it doesn't offer any useful functionality.
416
    // We only empty the items list so that a plugin may choose to add to it themselves later if they wish.
417
    if (instanceConfig.menu.file) {
418
        instanceConfig.menu.file.items = '';
419
    }
420
 
421
    // We disable the styles, backcolor, and forecolor plugins from the format menu.
422
    // These are not useful for Moodle and we don't want to encourage their use.
423
    if (instanceConfig.menu.format) {
424
        instanceConfig.menu.format.items = instanceConfig.menu.format.items
425
            // Remove forecolor and backcolor.
426
            .replace(/forecolor ?/, '')
427
            .replace(/backcolor ?/, '')
428
 
429
            // Remove fontfamily for now.
430
            .replace(/fontfamily ?/, '')
431
 
432
            // Remove fontsize for now.
433
            .replace(/fontsize ?/, '')
434
 
435
            // Remove styles - it just duplicates the format menu in a way which does not respect configuration
436
            .replace(/styles ?/, '')
437
 
438
            // Remove any duplicate separators.
439
            .replaceAll(/\| *\|/g, '|');
440
    }
441
 
11 efrain 442
    if (instanceConfig.quickbars_selection_toolbar !== false) {
443
        // eslint-disable-next-line camelcase
444
        instanceConfig.quickbars_selection_toolbar = instanceConfig.quickbars_selection_toolbar.replace('h2 h3', 'h3 h4 h5 h6');
445
    }
1 efrain 446
 
447
    // Next we call the `configure` function for any plugin which defines it.
448
    // We pass the current instanceConfig in here, to allow them to make certain changes to the global configuration.
449
    // For example, to add themselves to any menu, toolbar, and so on.
450
    // Any plugin which wishes to have configuration options must register those options here.
451
    pluginConfig.filter((pluginConfig) => typeof pluginConfig.configure === 'function').forEach((pluginConfig) => {
452
        const pluginInstanceOverride = pluginConfig.configure(instanceConfig, options);
453
        Object.assign(instanceConfig, pluginInstanceOverride);
454
    });
455
 
456
    // Next we convert the plugin configuration into a format that TinyMCE understands.
457
    Object.assign(instanceConfig, Options.getInitialPluginConfiguration(options));
458
 
459
    return instanceConfig;
460
};
461
 
462
/**
463
 * Check if the target for TinyMCE is in a modal or not.
464
 *
465
 * @param {HTMLElement} target Target to check
466
 * @returns {boolean} True if the target is in a modal form.
467
 */
468
const isModalMode = (target) => {
469
    return !!target.closest('[data-region="modal"]');
470
};
471
 
472
/**
473
 * Set up TinyMCE for the HTML Element.
474
 *
475
 * @param {HTMLElement} target
476
 * @param {Object} [options={}] The editor plugin configuration
477
 * @return {Promise<TinyMCE>} The TinyMCE instance
478
 */
479
export const setupForTarget = async(target, options = {}) => {
480
    const instance = getInstanceForElement(target);
481
    if (instance) {
482
        return Promise.resolve(instance);
483
    }
484
 
485
    // Register a new pending promise to ensure that Behat waits for the editor setup to complete before continuing.
486
    const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');
487
 
488
    // Get the list of plugins.
489
    const plugins = getPlugins(options);
490
 
491
    // Fetch the tinyMCE API, and instantiate the plugins.
492
    const [tinyMCE, pluginValues] = await Promise.all([
493
        getTinyMCE(),
494
        importPluginList(Object.keys(plugins)),
495
    ]);
496
 
497
    // TinyMCE uses the element ID as a map key internally, even if the target has changed.
498
    // In the case where we have an editor in a modal form which has been detached from the DOM, but the editor not removed,
499
    // we need to manually destroy the editor.
500
    // We could theoretically do this with a Mutation Observer, but in some cases the Node may be moved,
501
    // or added back elsewhere in the DOM.
502
 
503
    // First remove any detached editors.
504
    tinyMCE.get().filter((editor) => !editor.getElement().isConnected).forEach((editor) => {
505
        editor.remove();
506
    });
507
 
508
    // Now check for any existing editor which shares the same ID.
509
    const existingEditor = tinyMCE.EditorManager.get(target.id);
510
    if (existingEditor) {
511
        if (existingEditor.getElement() === target) {
512
            pendingPromise.resolve();
513
            return Promise.resolve(existingEditor);
514
        } else {
515
            pendingPromise.resolve();
516
            throw new Error('TinyMCE instance already exists for different target with same ID');
517
        }
518
    }
519
 
520
    // Get the editor configuration for this editor.
521
    const instanceConfig = getEditorConfiguration(target, tinyMCE, options, pluginValues);
522
 
523
    // Initialise the editor instance for the given configuration.
524
    // At this point any plugin which has configuration options registered will have them applied for this instance.
525
    const [editor] = await tinyMCE.init(instanceConfig);
526
 
527
    // Update the textarea when the editor to set the field type for Behat.
528
    target.dataset.fieldtype = 'editor';
529
 
530
    // Store the editor instance in the instanceMap and register a listener on removal to remove it from the map.
531
    instanceMap.set(target, editor);
532
    editor.on('remove', ({target}) => {
533
        // Handle removal of the editor from the map on destruction.
534
        instanceMap.delete(target.targetElm);
535
        target.targetElm.dataset.fieldtype = null;
536
    });
537
 
538
    // If the editor is part of a form, also listen to the jQuery submit event.
539
    // The jQuery submit event will not trigger the native submit event, and therefore the content will not be saved.
540
    // We cannot rely on listening to the bubbled submit event on the document because other events on child nodes may
541
    // consume the data before it is saved.
542
    if (target.form) {
543
        jQuery(target.form).on('submit', () => {
544
            editor.save();
545
        });
546
    }
547
 
548
    // Save the editor content to the textarea when the editor is blurred.
549
    editor.on('blur', () => {
550
        editor.save();
551
    });
552
 
553
    // If the editor is in a modal, we need to hide the modal when window editor's window is opened.
554
    editor.on('OpenWindow', () => {
555
        const modals = document.querySelectorAll('[data-region="modal"]');
556
        if (modals) {
557
            modals.forEach((modal) => {
558
                if (!modal.classList.contains('hide')) {
559
                    modal.classList.add('hide');
560
                }
561
            });
562
        }
563
    });
564
 
565
    // If the editor's window is closed, we need to show the hidden modal back.
566
    editor.on('CloseWindow', () => {
567
        if (isModalMode(target)) {
568
            const modals = document.querySelectorAll('[data-region="modal"]');
569
            if (modals) {
570
                modals.forEach((modal) => {
571
                    if (modal.classList.contains('hide')) {
572
                        modal.classList.remove('hide');
573
                    }
574
                });
575
            }
576
        }
577
    });
578
 
579
    pendingPromise.resolve();
580
    return editor;
581
};
582
 
583
/**
584
 * Set the default editor configuration.
585
 *
586
 * This configuration is used when an editor is initialised without any configuration.
587
 *
588
 * @param {object} [options={}]
589
 */
590
export const configureDefaultEditor = (options = {}) => {
591
    defaultOptions = options;
592
};