lib/editor/tiny/plugins/media/amd/src/embed/embedhandler.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/>.

/**
 * Tiny media plugin embed handler class.
 *
 * This handles anything that embed requires like:
 * - Calling the media preview in embedPreview.
 * - Loading the embed insert.
 * - Getting selected media data.
 * - Handles url and repository uploads.
 * - Reset embed insert when embed preview is deleted.
 * - Handles media embedding into tiny and etc.
 *
 * @module      tiny_media/embed/embedhandler
 * @copyright   2024 Stevani Andolo <stevani@hotmail.com.au>
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import Selectors from "../selectors";
import {EmbedInsert} from './embedinsert';
import {
    body,
    footer,
    setPropertiesFromData,
    isValidUrl,
    stopMediaLoading,
    startMediaLoading,
} from '../helpers';
import * as ModalEvents from 'core/modal_events';
import {displayFilepicker} from 'editor_tiny/utils';
import {
    insertMediaTemplateContext,
    getHelpStrings,
    prepareMoodleLang,
    getMoodleLangObj,
    hasAudioVideoAttr,
    insertMediaThumbnailTemplateContext,
} from "./embedhelpers";
import Templates from 'core/templates';
import {EmbedThumbnailInsert} from './embedthumbnailinsert';

export class EmbedHandler {

    constructor(data) {
        setPropertiesFromData(this, data); // Creates dynamic properties based on "data" param.
    }

    /**
     * Load the media insert dialogue.
     *
     * @param {object} templateContext Object template context
     */
    loadTemplatePromise = (templateContext) => {
        templateContext.elementid = this.editor.id;
        templateContext.bodyTemplate = Selectors.EMBED.template.body.insertMediaBody;
        templateContext.footerTemplate = Selectors.EMBED.template.footer.insertMediaFooter;
        templateContext.selector = Selectors.EMBED.type;

        Promise.all([body(templateContext, this.root), footer(templateContext, this.root)])
            .then(() => {
                (new EmbedInsert(this)).init();
                return;
            })
            .catch(error => {
                window.console.log(error);
            });
    };

    /**
     * Load the media thumbnail insert dialogue.
     *
     * @param {object} templateContext Object template context
     * @param {HTMLElement} root
     * @param {object} mediaData
     */
    loadInsertThumbnailTemplatePromise = async(templateContext, root, mediaData) => {
        Promise.all([body(templateContext, root.root), footer(templateContext, root.root)])
            .then(() => {
                if (!this.currentModal.insertMediaModal) {
                    this.currentModal.insertMediaModal = this.currentModal;
                }

                if (root.uploadThumbnailModal) {
                    this.currentModal.uploadThumbnailModal = root.uploadThumbnailModal;
                }

                this.thumbnailModalRoot = root.root;
                (new EmbedThumbnailInsert(this)).init(mediaData);
                return;
            })
            .catch(error => {
                window.console.log(error);
            });
    };

    /**
     * Loads the media preview dialogue.
     *
     * @param {object} embedPreview Object of embedPreview
     * @param {object} templateContext Object of template context
     */
    loadMediaDetails = async(embedPreview, templateContext) => {
        Promise.all([body(templateContext, this.root), footer(templateContext, this.root)])
            .then(() => {
                embedPreview.init();
                return;
            })
            .catch(error => {
                stopMediaLoading(this.root, Selectors.EMBED.type);
                window.console.log(error);
            });
    };

    /**
     * Reset the media/thumbnail insert modal form.
     *
     * @param {boolean} isMediaInsert Is current state media insert or thumbnail insert?
     */
    resetUploadForm = (isMediaInsert = true) => {
        if (isMediaInsert) {
            this.newMediaLink = false;
            this.fetchedMediaLinkTitle = null;
            this.resetCurrentMediaData();
            this.loadTemplatePromise(insertMediaTemplateContext(this));
        } else {
            this.loadInsertThumbnailTemplatePromise(
                insertMediaThumbnailTemplateContext(this), // Get template context for creating media thumbnail.
                {root: this.thumbnailModalRoot}, // Required root elements.
                this.mediaData // Get current media data.
            );
        }
    };

    /**
     * Get selected media data.
     *
     * @returns {null|object}
     */
    getMediaProperties = () => {
        const media = this.selectedMedia;
        if (!media) {
            return null;
        }

        const tracks = {
            subtitles: [],
            captions: [],
            descriptions: [],
            chapters: [],
            metadata: []
        };
        const sources = [];

        media.querySelectorAll('track').forEach((track) => {
            tracks[track.getAttribute('kind')].push({
                src: track.getAttribute('src'),
                srclang: track.getAttribute('srclang'),
                label: track.getAttribute('label'),
                defaultTrack: hasAudioVideoAttr(track, 'default')
            });
        });

        media.querySelectorAll('source').forEach((source) => {
            sources.push(source.src);
        });
        const title = media.getAttribute('title') ?? media.textContent;

        return {
            type: this.mediaType,
            sources,
            poster: media.getAttribute('poster'),
            title: title ? title.trim() : false,
            width: media.getAttribute('width'),
            height: media.getAttribute('height'),
            autoplay: hasAudioVideoAttr(media, 'autoplay'),
            loop: hasAudioVideoAttr(media, 'loop'),
            muted: hasAudioVideoAttr(media, 'muted'),
            controls: hasAudioVideoAttr(media, 'controls'),
            tracks,
        };
    };

    /**
     * Get selected media data.
     *
     * @returns {object}
     */
    getCurrentEmbedData = () => {
        const properties = this.getMediaProperties();
        if (!properties || this.newMediaLink) {
            return {media: {}};
        }

        const processedProperties = {};
        processedProperties.media = properties;
        processedProperties.link = false;

        return processedProperties;
    };

    /**
     * Get help strings for media subtitles and captions.
     *
     * @returns {null|object}
     */
    getHelpStrings = async() => {
        if (!this.helpStrings) {
            this.helpStrings = await getHelpStrings();
        }

        return this.helpStrings;
    };

    /**
     * Set template context for insert media dialogue.
     *
     * @param {object} data Object of media data
     * @returns {object}
     */
    getTemplateContext = async(data) => {
        const languages = prepareMoodleLang(this.editor);
        const helpIcons = Array.from(Object.entries(await this.getHelpStrings())).forEach(([key, text]) => {
            data[`${key.toLowerCase()}helpicon`] = {text};
        });

        return Object.assign({}, {
            elementid: this.editor.getElement().id,
            showFilePickerTrack: this.canShowFilePickerTrack,
            langsInstalled: languages.installed,
            langsAvailable: languages.available,
            media: true,
            isUpdating: this.isUpdating,
        }, data, helpIcons);
    };

    /**
     * Set and get media template context.
     *
     * @param {null|object} data Null or object of media data
     * @returns {Promise<object>} A promise that resolves template context.
     */
    getMediaTemplateContext = async(data = null) => {
        if (!data) {
            data = Object.assign({}, this.getCurrentEmbedData());
        } else {
            if (data.hasOwnProperty('isUpdating')) {
                this.isUpdating = data.isUpdating;
            } else {
                this.isUpdating = Object.keys(data).length > 1;
            }
        }
        return await this.getTemplateContext(data);
    };

    /**
     * Handles changes in the media URL input field and loads a preview of the media if the URL has changed.
     */
    urlChanged() {
        const url = this.root.querySelector(Selectors.EMBED.elements.fromUrl).value;
        if (url && url !== this.currentUrl) {
            // Set to null on new url change.
            this.mediaType = null;

            // Flag as new media link insert.
            this.newMediaLink = true;
            this.loadMediaPreview(url);
        }
    }

    /**
     * Load the media preview dialogue.
     *
     * @param {string} url String of media url
     */
    loadMediaPreview = (url) => {
        (new EmbedInsert(this)).loadMediaPreview(url);
    };

    /**
     * Callback for file picker that previews the media or add the captions and subtitles.
     *
     * @param {object} params Object of media url and etc
     * @param {html} element Selected element.
     * @param {string} fpType Caption type.
     */
    trackFilePickerCallback(params, element, fpType) {
        if (params.url !== '') {
            const tabPane = element.closest('.tab-pane');
            if (tabPane) {
                element.closest(Selectors.EMBED.elements.source).querySelector(Selectors.EMBED.elements.url).value = params.url;

                if (fpType === 'subtitle') {
                    // If the file is subtitle file. We need to match the language and label for that file.
                    const subtitleLang = params.file.split('.vtt')[0].split('-').slice(-1)[0];
                    const langObj = getMoodleLangObj(subtitleLang, this.editor);
                    if (langObj) {
                        const track = element.closest(Selectors.EMBED.elements.track);
                        track.querySelector(Selectors.EMBED.elements.trackLabel).value = langObj.lang.trim();
                        track.querySelector(Selectors.EMBED.elements.trackLang).value = langObj.code;
                    }
                }
            } else {
                // Flag as new file upload.
                this.newFileUpload = true;
                this.resetCurrentMediaData();
                this.loadMediaPreview(params.url);
            }
        }
    }

    /**
     * Reset current media data.
     */
    resetCurrentMediaData = () => {
        // Reset the value of the following props.
        this.media = {};
        this.mediaType = null;
        this.selectedMedia = null;
    };

    /**
     * Add new html track element.
     *
     * @param {html} element
     */
    addTrackComponent(element) {
        const trackElement = element.closest(Selectors.EMBED.elements.track);
        const clone = trackElement.cloneNode(true);

        trackElement.querySelector('.removecomponent-wrapper').classList.remove('hidden');
        trackElement.querySelector('.addcomponent-wrapper').classList.add('hidden');
        trackElement.parentNode.insertBefore(clone, trackElement.nextSibling);
    }

    /**
     * Remove added html track element.
     *
     * @param {html} element
     */
    removeTrackComponent(element) {
        const sourceElement = element.closest(Selectors.EMBED.elements.track);
        sourceElement.remove();
    }

    /**
     * Get picker type based on the selected element.
     *
     * @param {html} element Selected element
     * @returns {string}
     */
    getFilePickerTypeFromElement = (element) => {
        if (element.closest(Selectors.EMBED.elements.posterSource)) {
            return 'image';
        }
        if (element.closest(Selectors.EMBED.elements.trackSource)) {
            return 'subtitle';
        }

        return 'media';
    };

    /**
     * Get captions/subtitles type.
     *
     * @param {html} tabPane
     * @returns {string}
     */
    getTrackTypeFromTabPane = (tabPane) => {
        return tabPane.getAttribute('data-track-kind');
    };

    /**
     * Handle click events.
     *
     * @param {html} e Selected element
     */
    clickHandler = async(e) => {
        const element = e.target;

        // Handle repository browsing.
        const mediaBrowser = element.closest(Selectors.EMBED.actions.mediaBrowser);
        if (mediaBrowser) {
            e.preventDefault();
            const fpType = this.getFilePickerTypeFromElement(element);
            const params = await displayFilepicker(this.editor, fpType);
            this.trackFilePickerCallback(params, element, fpType);
        }

        // Handles add media url.
        const addUrlEle = e.target.closest(Selectors.EMBED.actions.addUrl);
        if (addUrlEle) {
            startMediaLoading(this.root, Selectors.EMBED.type);
            this.urlChanged();
        }

        // Handles adding tracks.
        const addComponentTrackAction = element.closest(Selectors.EMBED.elements.track + ' .addcomponent');
        if (addComponentTrackAction) {
            e.preventDefault();
            this.addTrackComponent(element);
        }

        // Handles removing added tracks.
        const removeComponentTrackAction = element.closest(Selectors.EMBED.elements.track + ' .removecomponent');
        if (removeComponentTrackAction) {
            e.preventDefault();
            this.removeTrackComponent(element);
        }

        // Only allow one track per tab to be selected as "default".
        const trackDefaultAction = element.closest(Selectors.EMBED.elements.trackDefault);
        if (trackDefaultAction && trackDefaultAction.checked) {
            const getKind = (el) => this.getTrackTypeFromTabPane(el.parentElement.closest('.tab-pane'));

            element.parentElement
                .closest('.tab-content')
                .querySelectorAll(Selectors.EMBED.elements.trackDefault)
                .forEach((select) => {
                    if (select !== element && getKind(element) === getKind(select)) {
                        select.checked = false;
                    }
                });
        }
    };

    /**
     * Enables or disables the URL-related buttons in the footer based on the current URL and input value.
     *
     * @param {html} input Url input field
     * @param {object} root
     */
    toggleUrlButton(input, root) {
        const url = input.value;
        const addUrl = root.querySelector(Selectors.EMBED.actions.addUrl);
        addUrl.disabled = !(url !== "" && isValidUrl(url));
    }

    /**
     * Get media html to be inserted or updated into tiny.
     *
     * @param {html} form Selected element
     * @returns {string} String of html
     */
    getMediaHTML = (form) => {
        this.mediaType = this.root.querySelector(Selectors.EMBED.elements.mediaPreviewContainer).dataset.mediaType;
        const tabContent = form.querySelector('.tab-content');
        const callback = 'getMediaHTML' + this.mediaType[0].toUpperCase() + this.mediaType.substr(1);
        return this[callback](tabContent);
    };

    /**
     * Get media as link.
     *
     * @returns {string} String of html.
     */
    getMediaHTMLLink() {
        const mediaPreviewContainer = document.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
        const context = {
            name: document.querySelector(Selectors.EMBED.elements.title).value ?? mediaPreviewContainer.dataset.originalUrl,
            url: mediaPreviewContainer.dataset.originalUrl || false
        };

        return context.url ? Templates.renderForPromise('tiny_media/embed/embed_media_link', context) : '';
    }

    /**
     * Get media as video.
     *
     * @param {html} tab Selected element
     * @returns {string} String of html.
     */
    getMediaHTMLVideo = (tab) => {
        const details = document.querySelector(Selectors.EMBED.elements.mediaDetailsBody);
        const context = this.getContextForMediaHTML(tab, details);
        context.width = details.querySelector(Selectors.EMBED.elements.width).value || false;
        context.height = details.querySelector(Selectors.EMBED.elements.height).value || false;

        const mediaPreviewContainer = details.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
        context.poster = mediaPreviewContainer.dataset.mediaPoster || false;
        return context.sources ? Templates.renderForPromise('tiny_media/embed/embed_media_video', context) : '';
    };

    /**
     * Get media as audio.
     *
     * @param {html} tab Selected element
     * @returns {string} String of html.
     */
    getMediaHTMLAudio = (tab) => {
        const details = document.querySelector(Selectors.EMBED.elements.mediaDetailsBody);
        const context = this.getContextForMediaHTML(tab, details);
        return context.sources.length ? Templates.renderForPromise('tiny_media/embed/embed_media_audio', context) : '';
    };

    /**
     * Get previewed media data.
     *
     * @param {html} tab Selected element
     * @param {html} details Selected element
     * @returns {object}
     */
    getContextForMediaHTML = (tab, details) => {
        const tracks = Array.from(tab.querySelectorAll(Selectors.EMBED.elements.track)).map(track => ({
            track: track.querySelector(Selectors.EMBED.elements.trackSource + ' ' + Selectors.EMBED.elements.url).value,
            kind: this.getTrackTypeFromTabPane(track.closest('.tab-pane')),
            label: track.querySelector(Selectors.EMBED.elements.trackLabel).value ||
                track.querySelector(Selectors.EMBED.elements.trackLang).value,
            srclang: track.querySelector(Selectors.EMBED.elements.trackLang).value,
            defaultTrack: track.querySelector(Selectors.EMBED.elements.trackDefault).checked ? "true" : null
        })).filter((track) => !!track.track);

        const mediaPreviewContainer = details.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
        let sources = mediaPreviewContainer.dataset.originalUrl ?? null;

        // Let's check if media has more than one sources.
        if (this.alternativeSources) {
            // Always update the first item in this.alternativeSources to the new one.
            this.alternativeSources[0] = sources;
            // Override the sources to have all the updated sources.
            sources = this.alternativeSources;
        }

        const title = details.querySelector(Selectors.EMBED.elements.title).value;
        // Remove data-original-url attribute once it's extracted.
        mediaPreviewContainer.removeAttribute('data-original-url');

        const templateContext = {
            sources,
            tracks,
            showControls: details.querySelector(Selectors.EMBED.elements.mediaControl).checked,
            autoplay: details.querySelector(Selectors.EMBED.elements.mediaAutoplay).checked,
            muted: details.querySelector(Selectors.EMBED.elements.mediaMute).checked,
            loop: details.querySelector(Selectors.EMBED.elements.mediaLoop).checked,
            title: title !== '' ? title.trim() : false,
        };

        // Add description prop to templateContext if media type is "link".
        if (this.mediaType === 'link') {
            // Let's form an alternative title.
            templateContext.description = Array.isArray(sources) ? sources[0] : sources;
        }

        return templateContext;
    };

    /**
     * Handle the insert/update media in tiny editor.
     *
     * @param {event} event
     * @param {object} modal Object of current modal
     */
    handleDialogueSubmission = async(event, modal) => {
        const {html} = await this.getMediaHTML(modal.getRoot()[0]);
        if (html) {
            if (this.isUpdating) {
                this.selectedMedia.outerHTML = html;
                this.isUpdating = false;
            } else {
                this.editor.insertContent(html);
            }
        }
    };

    /**
     * Register insert media modal elements' events.
     */
    registerEventListeners = async() => {
        // Handles click events for insert media modal.
        if (this.canShowFilePickerTrack) {
            this.root.addEventListener('click', this.clickHandler.bind(this));
        }

        // Handles media adding using url input.
        this.root.addEventListener('input', (e) => {
            const urlEle = e.target.closest(Selectors.EMBED.elements.fromUrl);
            if (urlEle) {
                this.toggleUrlButton(urlEle, this.root);
            }
        });

        // Destroy created modal when it's closed.
        this.modalRoot.on(ModalEvents.hidden, () => {
            this.currentModal.destroy();
        });

        // Handles media insert to editor.
        this.modalRoot.on(ModalEvents.save, this.handleDialogueSubmission.bind(this));
    };
}