Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1441 ariadna 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
 * Tiny media plugin embed handler class.
18
 *
19
 * This handles anything that embed requires like:
20
 * - Calling the media preview in embedPreview.
21
 * - Loading the embed insert.
22
 * - Getting selected media data.
23
 * - Handles url and repository uploads.
24
 * - Reset embed insert when embed preview is deleted.
25
 * - Handles media embedding into tiny and etc.
26
 *
27
 * @module      tiny_media/embed/embedhandler
28
 * @copyright   2024 Stevani Andolo <stevani@hotmail.com.au>
29
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
30
 */
31
 
32
import Selectors from "../selectors";
33
import {EmbedInsert} from './embedinsert';
34
import {
35
    body,
36
    footer,
37
    setPropertiesFromData,
38
    isValidUrl,
39
    stopMediaLoading,
40
    startMediaLoading,
41
} from '../helpers';
42
import * as ModalEvents from 'core/modal_events';
43
import {displayFilepicker} from 'editor_tiny/utils';
44
import {
45
    insertMediaTemplateContext,
46
    getHelpStrings,
47
    prepareMoodleLang,
48
    getMoodleLangObj,
49
    hasAudioVideoAttr,
50
    insertMediaThumbnailTemplateContext,
51
} from "./embedhelpers";
52
import Templates from 'core/templates';
53
import {EmbedThumbnailInsert} from './embedthumbnailinsert';
54
 
55
export class EmbedHandler {
56
 
57
    constructor(data) {
58
        setPropertiesFromData(this, data); // Creates dynamic properties based on "data" param.
59
    }
60
 
61
    /**
62
     * Load the media insert dialogue.
63
     *
64
     * @param {object} templateContext Object template context
65
     */
66
    loadTemplatePromise = (templateContext) => {
67
        templateContext.elementid = this.editor.id;
68
        templateContext.bodyTemplate = Selectors.EMBED.template.body.insertMediaBody;
69
        templateContext.footerTemplate = Selectors.EMBED.template.footer.insertMediaFooter;
70
        templateContext.selector = Selectors.EMBED.type;
71
 
72
        Promise.all([body(templateContext, this.root), footer(templateContext, this.root)])
73
            .then(() => {
74
                (new EmbedInsert(this)).init();
75
                return;
76
            })
77
            .catch(error => {
78
                window.console.log(error);
79
            });
80
    };
81
 
82
    /**
83
     * Load the media thumbnail insert dialogue.
84
     *
85
     * @param {object} templateContext Object template context
86
     * @param {HTMLElement} root
87
     * @param {object} mediaData
88
     */
89
    loadInsertThumbnailTemplatePromise = async(templateContext, root, mediaData) => {
90
        Promise.all([body(templateContext, root.root), footer(templateContext, root.root)])
91
            .then(() => {
92
                if (!this.currentModal.insertMediaModal) {
93
                    this.currentModal.insertMediaModal = this.currentModal;
94
                }
95
 
96
                if (root.uploadThumbnailModal) {
97
                    this.currentModal.uploadThumbnailModal = root.uploadThumbnailModal;
98
                }
99
 
100
                this.thumbnailModalRoot = root.root;
101
                (new EmbedThumbnailInsert(this)).init(mediaData);
102
                return;
103
            })
104
            .catch(error => {
105
                window.console.log(error);
106
            });
107
    };
108
 
109
    /**
110
     * Loads the media preview dialogue.
111
     *
112
     * @param {object} embedPreview Object of embedPreview
113
     * @param {object} templateContext Object of template context
114
     */
115
    loadMediaDetails = async(embedPreview, templateContext) => {
116
        Promise.all([body(templateContext, this.root), footer(templateContext, this.root)])
117
            .then(() => {
118
                embedPreview.init();
119
                return;
120
            })
121
            .catch(error => {
122
                stopMediaLoading(this.root, Selectors.EMBED.type);
123
                window.console.log(error);
124
            });
125
    };
126
 
127
    /**
128
     * Reset the media/thumbnail insert modal form.
129
     *
130
     * @param {boolean} isMediaInsert Is current state media insert or thumbnail insert?
131
     */
132
    resetUploadForm = (isMediaInsert = true) => {
133
        if (isMediaInsert) {
134
            this.newMediaLink = false;
135
            this.fetchedMediaLinkTitle = null;
136
            this.resetCurrentMediaData();
137
            this.loadTemplatePromise(insertMediaTemplateContext(this));
138
        } else {
139
            this.loadInsertThumbnailTemplatePromise(
140
                insertMediaThumbnailTemplateContext(this), // Get template context for creating media thumbnail.
141
                {root: this.thumbnailModalRoot}, // Required root elements.
142
                this.mediaData // Get current media data.
143
            );
144
        }
145
    };
146
 
147
    /**
148
     * Get selected media data.
149
     *
150
     * @returns {null|object}
151
     */
152
    getMediaProperties = () => {
153
        const media = this.selectedMedia;
154
        if (!media) {
155
            return null;
156
        }
157
 
158
        const tracks = {
159
            subtitles: [],
160
            captions: [],
161
            descriptions: [],
162
            chapters: [],
163
            metadata: []
164
        };
165
        const sources = [];
166
 
167
        media.querySelectorAll('track').forEach((track) => {
168
            tracks[track.getAttribute('kind')].push({
169
                src: track.getAttribute('src'),
170
                srclang: track.getAttribute('srclang'),
171
                label: track.getAttribute('label'),
172
                defaultTrack: hasAudioVideoAttr(track, 'default')
173
            });
174
        });
175
 
176
        media.querySelectorAll('source').forEach((source) => {
177
            sources.push(source.src);
178
        });
179
        const title = media.getAttribute('title') ?? media.textContent;
180
 
181
        return {
182
            type: this.mediaType,
183
            sources,
184
            poster: media.getAttribute('poster'),
185
            title: title ? title.trim() : false,
186
            width: media.getAttribute('width'),
187
            height: media.getAttribute('height'),
188
            autoplay: hasAudioVideoAttr(media, 'autoplay'),
189
            loop: hasAudioVideoAttr(media, 'loop'),
190
            muted: hasAudioVideoAttr(media, 'muted'),
191
            controls: hasAudioVideoAttr(media, 'controls'),
192
            tracks,
193
        };
194
    };
195
 
196
    /**
197
     * Get selected media data.
198
     *
199
     * @returns {object}
200
     */
201
    getCurrentEmbedData = () => {
202
        const properties = this.getMediaProperties();
203
        if (!properties || this.newMediaLink) {
204
            return {media: {}};
205
        }
206
 
207
        const processedProperties = {};
208
        processedProperties.media = properties;
209
        processedProperties.link = false;
210
 
211
        return processedProperties;
212
    };
213
 
214
    /**
215
     * Get help strings for media subtitles and captions.
216
     *
217
     * @returns {null|object}
218
     */
219
    getHelpStrings = async() => {
220
        if (!this.helpStrings) {
221
            this.helpStrings = await getHelpStrings();
222
        }
223
 
224
        return this.helpStrings;
225
    };
226
 
227
    /**
228
     * Set template context for insert media dialogue.
229
     *
230
     * @param {object} data Object of media data
231
     * @returns {object}
232
     */
233
    getTemplateContext = async(data) => {
234
        const languages = prepareMoodleLang(this.editor);
235
        const helpIcons = Array.from(Object.entries(await this.getHelpStrings())).forEach(([key, text]) => {
236
            data[`${key.toLowerCase()}helpicon`] = {text};
237
        });
238
 
239
        return Object.assign({}, {
240
            elementid: this.editor.getElement().id,
241
            showFilePickerTrack: this.canShowFilePickerTrack,
242
            langsInstalled: languages.installed,
243
            langsAvailable: languages.available,
244
            media: true,
245
            isUpdating: this.isUpdating,
246
        }, data, helpIcons);
247
    };
248
 
249
    /**
250
     * Set and get media template context.
251
     *
252
     * @param {null|object} data Null or object of media data
253
     * @returns {Promise<object>} A promise that resolves template context.
254
     */
255
    getMediaTemplateContext = async(data = null) => {
256
        if (!data) {
257
            data = Object.assign({}, this.getCurrentEmbedData());
258
        } else {
259
            if (data.hasOwnProperty('isUpdating')) {
260
                this.isUpdating = data.isUpdating;
261
            } else {
262
                this.isUpdating = Object.keys(data).length > 1;
263
            }
264
        }
265
        return await this.getTemplateContext(data);
266
    };
267
 
268
    /**
269
     * Handles changes in the media URL input field and loads a preview of the media if the URL has changed.
270
     */
271
    urlChanged() {
272
        const url = this.root.querySelector(Selectors.EMBED.elements.fromUrl).value;
273
        if (url && url !== this.currentUrl) {
274
            // Set to null on new url change.
275
            this.mediaType = null;
276
 
277
            // Flag as new media link insert.
278
            this.newMediaLink = true;
279
            this.loadMediaPreview(url);
280
        }
281
    }
282
 
283
    /**
284
     * Load the media preview dialogue.
285
     *
286
     * @param {string} url String of media url
287
     */
288
    loadMediaPreview = (url) => {
289
        (new EmbedInsert(this)).loadMediaPreview(url);
290
    };
291
 
292
    /**
293
     * Callback for file picker that previews the media or add the captions and subtitles.
294
     *
295
     * @param {object} params Object of media url and etc
296
     * @param {html} element Selected element.
297
     * @param {string} fpType Caption type.
298
     */
299
    trackFilePickerCallback(params, element, fpType) {
300
        if (params.url !== '') {
301
            const tabPane = element.closest('.tab-pane');
302
            if (tabPane) {
303
                element.closest(Selectors.EMBED.elements.source).querySelector(Selectors.EMBED.elements.url).value = params.url;
304
 
305
                if (fpType === 'subtitle') {
306
                    // If the file is subtitle file. We need to match the language and label for that file.
307
                    const subtitleLang = params.file.split('.vtt')[0].split('-').slice(-1)[0];
308
                    const langObj = getMoodleLangObj(subtitleLang, this.editor);
309
                    if (langObj) {
310
                        const track = element.closest(Selectors.EMBED.elements.track);
311
                        track.querySelector(Selectors.EMBED.elements.trackLabel).value = langObj.lang.trim();
312
                        track.querySelector(Selectors.EMBED.elements.trackLang).value = langObj.code;
313
                    }
314
                }
315
            } else {
316
                // Flag as new file upload.
317
                this.newFileUpload = true;
318
                this.resetCurrentMediaData();
319
                this.loadMediaPreview(params.url);
320
            }
321
        }
322
    }
323
 
324
    /**
325
     * Reset current media data.
326
     */
327
    resetCurrentMediaData = () => {
328
        // Reset the value of the following props.
329
        this.media = {};
330
        this.mediaType = null;
331
        this.selectedMedia = null;
332
    };
333
 
334
    /**
335
     * Add new html track element.
336
     *
337
     * @param {html} element
338
     */
339
    addTrackComponent(element) {
340
        const trackElement = element.closest(Selectors.EMBED.elements.track);
341
        const clone = trackElement.cloneNode(true);
342
 
343
        trackElement.querySelector('.removecomponent-wrapper').classList.remove('hidden');
344
        trackElement.querySelector('.addcomponent-wrapper').classList.add('hidden');
345
        trackElement.parentNode.insertBefore(clone, trackElement.nextSibling);
346
    }
347
 
348
    /**
349
     * Remove added html track element.
350
     *
351
     * @param {html} element
352
     */
353
    removeTrackComponent(element) {
354
        const sourceElement = element.closest(Selectors.EMBED.elements.track);
355
        sourceElement.remove();
356
    }
357
 
358
    /**
359
     * Get picker type based on the selected element.
360
     *
361
     * @param {html} element Selected element
362
     * @returns {string}
363
     */
364
    getFilePickerTypeFromElement = (element) => {
365
        if (element.closest(Selectors.EMBED.elements.posterSource)) {
366
            return 'image';
367
        }
368
        if (element.closest(Selectors.EMBED.elements.trackSource)) {
369
            return 'subtitle';
370
        }
371
 
372
        return 'media';
373
    };
374
 
375
    /**
376
     * Get captions/subtitles type.
377
     *
378
     * @param {html} tabPane
379
     * @returns {string}
380
     */
381
    getTrackTypeFromTabPane = (tabPane) => {
382
        return tabPane.getAttribute('data-track-kind');
383
    };
384
 
385
    /**
386
     * Handle click events.
387
     *
388
     * @param {html} e Selected element
389
     */
390
    clickHandler = async(e) => {
391
        const element = e.target;
392
 
393
        // Handle repository browsing.
394
        const mediaBrowser = element.closest(Selectors.EMBED.actions.mediaBrowser);
395
        if (mediaBrowser) {
396
            e.preventDefault();
397
            const fpType = this.getFilePickerTypeFromElement(element);
398
            const params = await displayFilepicker(this.editor, fpType);
399
            this.trackFilePickerCallback(params, element, fpType);
400
        }
401
 
402
        // Handles add media url.
403
        const addUrlEle = e.target.closest(Selectors.EMBED.actions.addUrl);
404
        if (addUrlEle) {
405
            startMediaLoading(this.root, Selectors.EMBED.type);
406
            this.urlChanged();
407
        }
408
 
409
        // Handles adding tracks.
410
        const addComponentTrackAction = element.closest(Selectors.EMBED.elements.track + ' .addcomponent');
411
        if (addComponentTrackAction) {
412
            e.preventDefault();
413
            this.addTrackComponent(element);
414
        }
415
 
416
        // Handles removing added tracks.
417
        const removeComponentTrackAction = element.closest(Selectors.EMBED.elements.track + ' .removecomponent');
418
        if (removeComponentTrackAction) {
419
            e.preventDefault();
420
            this.removeTrackComponent(element);
421
        }
422
 
423
        // Only allow one track per tab to be selected as "default".
424
        const trackDefaultAction = element.closest(Selectors.EMBED.elements.trackDefault);
425
        if (trackDefaultAction && trackDefaultAction.checked) {
426
            const getKind = (el) => this.getTrackTypeFromTabPane(el.parentElement.closest('.tab-pane'));
427
 
428
            element.parentElement
429
                .closest('.tab-content')
430
                .querySelectorAll(Selectors.EMBED.elements.trackDefault)
431
                .forEach((select) => {
432
                    if (select !== element && getKind(element) === getKind(select)) {
433
                        select.checked = false;
434
                    }
435
                });
436
        }
437
    };
438
 
439
    /**
440
     * Enables or disables the URL-related buttons in the footer based on the current URL and input value.
441
     *
442
     * @param {html} input Url input field
443
     * @param {object} root
444
     */
445
    toggleUrlButton(input, root) {
446
        const url = input.value;
447
        const addUrl = root.querySelector(Selectors.EMBED.actions.addUrl);
448
        addUrl.disabled = !(url !== "" && isValidUrl(url));
449
    }
450
 
451
    /**
452
     * Get media html to be inserted or updated into tiny.
453
     *
454
     * @param {html} form Selected element
455
     * @returns {string} String of html
456
     */
457
    getMediaHTML = (form) => {
458
        this.mediaType = this.root.querySelector(Selectors.EMBED.elements.mediaPreviewContainer).dataset.mediaType;
459
        const tabContent = form.querySelector('.tab-content');
460
        const callback = 'getMediaHTML' + this.mediaType[0].toUpperCase() + this.mediaType.substr(1);
461
        return this[callback](tabContent);
462
    };
463
 
464
    /**
465
     * Get media as link.
466
     *
467
     * @returns {string} String of html.
468
     */
469
    getMediaHTMLLink() {
470
        const mediaPreviewContainer = document.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
471
        const context = {
472
            name: document.querySelector(Selectors.EMBED.elements.title).value ?? mediaPreviewContainer.dataset.originalUrl,
473
            url: mediaPreviewContainer.dataset.originalUrl || false
474
        };
475
 
476
        return context.url ? Templates.renderForPromise('tiny_media/embed/embed_media_link', context) : '';
477
    }
478
 
479
    /**
480
     * Get media as video.
481
     *
482
     * @param {html} tab Selected element
483
     * @returns {string} String of html.
484
     */
485
    getMediaHTMLVideo = (tab) => {
486
        const details = document.querySelector(Selectors.EMBED.elements.mediaDetailsBody);
487
        const context = this.getContextForMediaHTML(tab, details);
488
        context.width = details.querySelector(Selectors.EMBED.elements.width).value || false;
489
        context.height = details.querySelector(Selectors.EMBED.elements.height).value || false;
490
 
491
        const mediaPreviewContainer = details.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
492
        context.poster = mediaPreviewContainer.dataset.mediaPoster || false;
493
        return context.sources ? Templates.renderForPromise('tiny_media/embed/embed_media_video', context) : '';
494
    };
495
 
496
    /**
497
     * Get media as audio.
498
     *
499
     * @param {html} tab Selected element
500
     * @returns {string} String of html.
501
     */
502
    getMediaHTMLAudio = (tab) => {
503
        const details = document.querySelector(Selectors.EMBED.elements.mediaDetailsBody);
504
        const context = this.getContextForMediaHTML(tab, details);
505
        return context.sources.length ? Templates.renderForPromise('tiny_media/embed/embed_media_audio', context) : '';
506
    };
507
 
508
    /**
509
     * Get previewed media data.
510
     *
511
     * @param {html} tab Selected element
512
     * @param {html} details Selected element
513
     * @returns {object}
514
     */
515
    getContextForMediaHTML = (tab, details) => {
516
        const tracks = Array.from(tab.querySelectorAll(Selectors.EMBED.elements.track)).map(track => {
517
            const langTrack = track.querySelector(Selectors.EMBED.elements.trackLang);
518
            const selectedLangTrack = langTrack.options[langTrack.selectedIndex];
519
 
520
            return {
521
                track: track.querySelector(Selectors.EMBED.elements.trackSource + ' ' + Selectors.EMBED.elements.url).value,
522
                kind: this.getTrackTypeFromTabPane(track.closest('.tab-pane')),
523
                label: track.querySelector(Selectors.EMBED.elements.trackLabel).value || langTrack.value,
524
                srclang: selectedLangTrack.dataset.languageCode ?? false,
525
                defaultTrack: track.querySelector(Selectors.EMBED.elements.trackDefault).checked ? "true" : null
526
            };
527
        }).filter((track) => !!track.track);
528
 
529
        const mediaPreviewContainer = details.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
530
        let sources = mediaPreviewContainer.dataset.originalUrl ?? null;
531
 
532
        // Let's check if media has more than one sources.
533
        if (this.alternativeSources) {
534
            // Always update the first item in this.alternativeSources to the new one.
535
            this.alternativeSources[0] = sources;
536
            // Override the sources to have all the updated sources.
537
            sources = this.alternativeSources;
538
        }
539
 
540
        const title = details.querySelector(Selectors.EMBED.elements.title).value;
541
        // Remove data-original-url attribute once it's extracted.
542
        mediaPreviewContainer.removeAttribute('data-original-url');
543
 
544
        const templateContext = {
545
            sources,
546
            tracks,
547
            showControls: details.querySelector(Selectors.EMBED.elements.mediaControl).checked,
548
            autoplay: details.querySelector(Selectors.EMBED.elements.mediaAutoplay).checked,
549
            muted: details.querySelector(Selectors.EMBED.elements.mediaMute).checked,
550
            loop: details.querySelector(Selectors.EMBED.elements.mediaLoop).checked,
551
            title: title !== '' ? title.trim() : false,
552
        };
553
 
554
        // Add description prop to templateContext if media type is "link".
555
        if (this.mediaType === 'link') {
556
            // Let's form an alternative title.
557
            templateContext.description = Array.isArray(sources) ? sources[0] : sources;
558
        }
559
 
560
        return templateContext;
561
    };
562
 
563
    /**
564
     * Handle the insert/update media in tiny editor.
565
     *
566
     * @param {event} event
567
     * @param {object} modal Object of current modal
568
     */
569
    handleDialogueSubmission = async(event, modal) => {
570
        const {html} = await this.getMediaHTML(modal.getRoot()[0]);
571
        if (html) {
572
            if (this.isUpdating) {
573
                this.selectedMedia.outerHTML = html;
574
                this.isUpdating = false;
575
            } else {
576
                this.editor.insertContent(html);
577
            }
578
        }
579
    };
580
 
581
    /**
582
     * Register insert media modal elements' events.
583
     */
584
    registerEventListeners = async() => {
585
        // Handles click events for insert media modal.
586
        if (this.canShowFilePickerTrack) {
587
            this.root.addEventListener('click', this.clickHandler.bind(this));
588
        }
589
 
590
        // Handles media adding using url input.
591
        this.root.addEventListener('input', (e) => {
592
            const urlEle = e.target.closest(Selectors.EMBED.elements.fromUrl);
593
            if (urlEle) {
594
                this.toggleUrlButton(urlEle, this.root);
595
            }
596
        });
597
 
598
        // Destroy created modal when it's closed.
599
        this.modalRoot.on(ModalEvents.hidden, () => {
600
            this.currentModal.destroy();
601
        });
602
 
603
        // Handles media insert to editor.
604
        this.modalRoot.on(ModalEvents.save, this.handleDialogueSubmission.bind(this));
605
    };
606
}