lib/editor/tiny/plugins/media/amd/src/embed/embedinsert.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 upload class.
 *
 * This handles the embed upload using url, drag-drop and repositories.
 *
 * @module      tiny_media/embed/embedinsert
 * @copyright   2024 Stevani Andolo <stevani@hotmail.com.au>
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import {prefetchStrings} from 'core/prefetch';
import {getStrings} from 'core/str';
import {component} from "../common";
import {
    setPropertiesFromData,
    startMediaLoading,
    stopMediaLoading,
    showElements,
} from '../helpers';
import Selectors from "../selectors";
import Dropzone from 'core/dropzone';
import uploadFile from 'editor_tiny/uploader';
import {EmbedHandler} from './embedhandler';
import {
    getMediaTitle,
    mediaDetailsTemplateContext,
    checkMediaType,
    fetchPreview,
} from './embedhelpers';
import {EmbedPreview} from './embedpreview';

prefetchStrings(component, [
    'insertmedia',
    'addmediafilesdrop',
    'uploading',
    'loadingmedia',
]);

export class EmbedInsert {

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

    /**
     * Init the dropzone and lang strings.
     */
    init = async() => {
        const langStringKeys = [
            'insertmedia',
            'addmediafilesdrop',
            'uploading',
            'loadingmedia',
        ];
        const langStringValues = await getStrings([...langStringKeys].map((key) => ({key, component})));
        this.langStrings = Object.fromEntries(langStringKeys.map((key, index) => [key, langStringValues[index]]));
        this.currentModal.setTitle(this.langStrings.insertmedia);

        // Let's init the dropzone if canShowDropZone is true and mediaType is null.
        if (this.canShowDropZone && !this.mediaType) {
            const dropZoneEle = document.querySelector(Selectors.EMBED.elements.dropzoneContainer);
            const dropZone = new Dropzone(
                dropZoneEle,
                this.acceptedMediaTypes,
                files => {
                    this.handleUploadedFile(files);
                }
            );

            dropZone.setLabel(this.langStrings.addmediafilesdrop);
            dropZone.init();
        }
    };

    /**
     * Loads and displays a preview media based on the provided URL, and handles media loading events.
     *
     * @param {string} url - The URL of the media to load and display.
     */
    loadMediaPreview = async(url) => {
        this.originalUrl = url;
        this.fetchedMediaLinkTitle = await getMediaTitle(url, this);

        if (this.newMediaLink) { // Media added using url input.
            this.filteredContent = await fetchPreview(this.originalUrl, this.contextId);

            if (!this.mediaType) {
                // It means the url points to a physical media file.
                if (this.fetchedMediaLinkTitle) {
                    // Case-insensitive regex for video tag.
                    const videoRegex = /<video[^>]*>.*<\/video>/i;
                    // Case-insensitive regex for audio tag.
                    const audioRegex = /<audio[^>]*>.*<\/audio>/i;

                    if (videoRegex.test(this.filteredContent)) {
                        this.mediaType = 'video';
                    } else if (audioRegex.test(this.filteredContent)) {
                        this.mediaType = 'audio';
                    }
                } else {
                    this.mediaType = 'link';
                }
            }

            // Process the media preview.
            this.processMediaPreview();
        } else { // Media added using dropzone or repositories.
            this.mediaType ??= await checkMediaType(url);

            // Process the media preview.
            this.processMediaPreview();
        }
    };

    /**
     * Process the media preview.
     */
    processMediaPreview = async() => {
        // Let's combine the props.
        setPropertiesFromData(
            this,
            await (new EmbedHandler(this)).getMediaTemplateContext()
        );

        // Construct templateContext for embed preview.
        const templateContext = await mediaDetailsTemplateContext(this);

        if (this.isUpdating && !this.newMediaLink) {
            // Will be used to set the media title if it's in update state.
            this.mediaTitle = templateContext.media.title;
        }

        // Load the media details and preview of the selected media.
        (new EmbedHandler(this)).loadMediaDetails(new EmbedPreview(this), templateContext);
    };

    /**
     * Updates the content of the loader icon.
     *
     * @param {HTMLElement} root - The root element containing the loader icon.
     * @param {object} langStrings - An object containing language strings.
     * @param {number|null} progress - The progress percentage (optional).
     * @returns {void}
     */
    updateLoaderIcon = (root, langStrings, progress = null) => {
        const loaderIconState = root.querySelector(Selectors.EMBED.elements.loaderIconContainer + ' div');
        loaderIconState.innerHTML = (progress !== null) ?
                               `${langStrings.uploading} ${Math.round(progress)}%` :
                               langStrings.loadingmedia;
    };

    /**
     * Handles media preview on file picker callback.
     *
     * @param {object} params Object of uploaded file
     */
    filePickerCallback = (params) => {
        if (params.url) {
            if (this.mediaType) {
                // Set mediaType to "null" if it started with viewing embedded link, otherwise it will not be consistent.
                this.mediaType = null;
            }

            // Flag as new file upload.
            this.newFileUpload = true;

            // Load the media preview.
            this.loadMediaPreview(params.url);
        }
    };

    /**
     * Handles the uploaded file, initiates the upload process, and updates the UI during the upload.
     *
     * @param {FileList} files - The list of files to upload (usually from a file input field).
     * @returns {Promise<void>} A promise that resolves when the file is uploaded and processed.
     */
    handleUploadedFile = async(files) => {
        try {
            startMediaLoading(this.root, Selectors.EMBED.type);
            const fileURL = await uploadFile(this.editor, 'media', files[0], files[0].name, (progress) => {
                this.updateLoaderIcon(this.root, this.langStrings, progress);
            });

            // Set the loader icon content to "loading" after the file upload completes.
            this.updateLoaderIcon(this.root, this.langStrings);
            this.filePickerCallback({url: fileURL});
        } catch (error) {
            // Handle the error.
            const urlWarningLabelEle = this.root.querySelector(Selectors.EMBED.elements.urlWarning);
            urlWarningLabelEle.innerHTML = error.error !== undefined ? error.error : error;
            showElements(Selectors.EMBED.elements.urlWarning, this.root);
            stopMediaLoading(this.root, Selectors.EMBED.type);
        }
    };
}