lib/editor/tiny/plugins/media/amd/src/imagedetails.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 image details class for Moodle.
 *
 * @module      tiny_media/imagedetails
 * @copyright   2024 Meirza <meirza.arson@moodle.com>
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import Config from 'core/config';
import ModalEvents from 'core/modal_events';
import Notification from 'core/notification';
import Pending from 'core/pending';
import Selectors from './selectors';
import Templates from 'core/templates';
import {getString} from 'core/str';
import {ImageInsert} from 'tiny_media/imageinsert';
import {MediaBase} from './mediabase';
import {
    body,
    footer,
    hideElements,
    showElements,
    isPercentageValue,
} from './helpers';

export class ImageDetails extends MediaBase {
    DEFAULTS = {
        WIDTH: 160,
        HEIGHT: 160,
    };

    selectorType = Selectors.IMAGE.type;

    mediaDimensions = null;

    constructor(
        root,
        editor,
        currentModal,
        canShowFilePicker,
        canShowDropZone,
        currentUrl,
        image,
    ) {
        super();
        this.root = root;
        this.editor = editor;
        this.currentModal = currentModal;
        this.canShowFilePicker = canShowFilePicker;
        this.canShowDropZone = canShowDropZone;
        this.currentUrl = currentUrl;
        this.image = image;
    }

    init = function() {
        this.currentModal.setTitle(getString('imagedetails', 'tiny_media'));
        this.imageTypeChecked();
        this.presentationChanged();
        this.storeImageDimensions(this.image);
        this.setImageDimensions();
        this.registerEventListeners();
    };

    /**
     * Loads and displays a preview image based on the provided URL, and handles image loading events.
     */
    loadInsertImage = async function() {
        const templateContext = {
            elementid: this.editor.id,
            showfilepicker: this.canShowFilePicker,
            showdropzone: this.canShowDropZone,
            bodyTemplate: Selectors.IMAGE.template.body.insertImageBody,
            footerTemplate: Selectors.IMAGE.template.footer.insertImageFooter,
            selector: Selectors.IMAGE.type,
        };

        Promise.all([body(templateContext, this.root), footer(templateContext, this.root)])
            .then(() => {
                const imageinsert = new ImageInsert(
                    this.root,
                    this.editor,
                    this.currentModal,
                    this.canShowFilePicker,
                    this.canShowDropZone,
                );
                imageinsert.init();
                return;
            })
            .catch(error => {
                window.console.log(error);
            });
    };

    storeImageDimensions(image) {
        // Store dimensions of the raw image, falling back to defaults for images without dimensions (e.g. SVG).
        this.mediaDimensions = {
            width: image.width || this.DEFAULTS.WIDTH,
            height: image.height || this.DEFAULTS.HEIGHT,
        };

        const getCurrentWidth = (element) => {
            if (element.value === '') {
                element.value = this.mediaDimensions.width;
            }
            return element.value;
        };

        const getCurrentHeight = (element) => {
            if (element.value === '') {
                element.value = this.mediaDimensions.height;
            }
            return element.value;
        };

        const widthInput = this.root.querySelector(Selectors.IMAGE.elements.width);
        const currentWidth = getCurrentWidth(widthInput);

        const heightInput = this.root.querySelector(Selectors.IMAGE.elements.height);
        const currentHeight = getCurrentHeight(heightInput);

        const preview = this.root.querySelector(Selectors.IMAGE.elements.preview);
        preview.setAttribute('src', image.src);
        preview.style.display = '';

        // Ensure the checkbox always in unchecked status when an image loads at first.
        const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain);
        if (isPercentageValue(currentWidth) && isPercentageValue(currentHeight)) {
            constrain.checked = currentWidth === currentHeight;
        } else if (image.width === 0 || image.height === 0) {
            // If we don't have both dimensions of the image, we can't auto-size it, so disable control.
            constrain.disabled = 'disabled';
        } else {
            // This is the same as comparing to 3 decimal places.
            const widthRatio = Math.round(100 * parseInt(currentWidth, 10) / image.width);
            const heightRatio = Math.round(100 * parseInt(currentHeight, 10) / image.height);
            constrain.checked = widthRatio === heightRatio;
        }

        /**
         * Sets the selected size option based on current width and height values.
         *
         * @param {number} currentWidth - The current width value.
         * @param {number} currentHeight - The current height value.
         */
        const setSelectedSize = (currentWidth, currentHeight) => {
            if (this.mediaDimensions.width === currentWidth &&
                this.mediaDimensions.height === currentHeight
            ) {
                this.currentWidth = this.mediaDimensions.width;
                this.currentHeight = this.mediaDimensions.height;
                this.sizeChecked('original');
            } else {
                this.currentWidth = currentWidth;
                this.currentHeight = currentHeight;
                this.sizeChecked('custom');
            }
        };

        setSelectedSize(Number(currentWidth), Number(currentHeight));
    }

    /**
     * Sets the dimensions of the image preview element based on user input and constraints.
     */
    setImageDimensions = () => {
        const imagePreviewBox = this.root.querySelector(Selectors.IMAGE.elements.previewBox);
        const image = this.root.querySelector(Selectors.IMAGE.elements.preview);
        const widthField = this.root.querySelector(Selectors.IMAGE.elements.width);
        const heightField = this.root.querySelector(Selectors.IMAGE.elements.height);

        const updateImageDimensions = () => {
            // Get the latest dimensions of the preview box for responsiveness.
            const boxWidth = imagePreviewBox.clientWidth;
            const boxHeight = imagePreviewBox.clientHeight;
            // Get the new width and height for the image.
            const dimensions = this.fitSquareIntoBox(widthField.value, heightField.value, boxWidth, boxHeight);
            image.style.width = `${dimensions.width}px`;
            image.style.height = `${dimensions.height}px`;
        };
        // If the client size is zero, then get the new dimensions once the modal is shown.
        if (imagePreviewBox.clientWidth === 0) {
            // Call the shown event.
            this.currentModal.getRoot().on(ModalEvents.shown, () => {
                updateImageDimensions();
            });
        } else {
            updateImageDimensions();
        }
    };

    /**
     * Handles changes in the image presentation checkbox and enables/disables the image alt text input accordingly.
     */
    presentationChanged() {
        const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation);
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt);
        alt.disabled = presentation.checked;

        // Counting the image description characters.
        this.handleKeyupCharacterCount();
    }

    /**
     * This function checks whether an image URL is local (within the same website's domain) or external (from an external source).
     * Depending on the result, it dynamically updates the visibility and content of HTML elements in a user interface.
     * If the image is local then we only show it's filename.
     * If the image is external then it will show full URL and it can be updated.
     */
    imageTypeChecked() {
        const regex = new RegExp(`${Config.wwwroot}`);

        // True if the URL is from external, otherwise false.
        const isExternalUrl = regex.test(this.currentUrl) === false;

        // Hide the URL input.
        hideElements(Selectors.IMAGE.elements.url, this.root);

        if (!isExternalUrl) {
            // Split the URL by '/' to get an array of segments.
            const segments = this.currentUrl.split('/');
            // Get the last segment, which should be the filename.
            const filename = segments.pop().split('?')[0];
            // Show the file name.
            this.setFilenameLabel(decodeURI(filename));
        } else {

            this.setFilenameLabel(decodeURI(this.currentUrl));
        }
    }

    /**
     * Set the string for the URL label element.
     *
     * @param {string} label - The label text to set.
     */
    setFilenameLabel(label) {
        const urlLabelEle = this.root.querySelector(Selectors.IMAGE.elements.fileNameLabel);
        if (urlLabelEle) {
            urlLabelEle.innerHTML = label;
            urlLabelEle.setAttribute("title", label);
        }
    }

    toggleAriaInvalid(selectors, predicate) {
        selectors.forEach((selector) => {
            const elements = this.root.querySelectorAll(selector);
            elements.forEach((element) => element.setAttribute('aria-invalid', predicate));
        });
    }

    hasErrorUrlField() {
        const urlError = this.currentUrl === '';
        if (urlError) {
            showElements(Selectors.IMAGE.elements.urlWarning, this.root);
        } else {
            hideElements(Selectors.IMAGE.elements.urlWarning, this.root);
        }
        this.toggleAriaInvalid([Selectors.IMAGE.elements.url], urlError);

        return urlError;
    }

    hasErrorAltField() {
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt).value;
        const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation).checked;
        const imageAltError = alt === '' && !presentation;
        if (imageAltError) {
            showElements(Selectors.IMAGE.elements.altWarning, this.root);
        } else {
            hideElements(Selectors.IMAGE.elements.altWarning, this.root);
        }
        this.toggleAriaInvalid([Selectors.IMAGE.elements.alt, Selectors.IMAGE.elements.presentation], imageAltError);

        return imageAltError;
    }

    updateWarning() {
        const urlError = this.hasErrorUrlField();
        const imageAltError = this.hasErrorAltField();

        return urlError || imageAltError;
    }

    getImageContext() {
        // Check if there are any accessibility issues.
        if (this.updateWarning()) {
            return null;
        }

        const classList = [];
        const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain).checked;
        const sizeOriginal = this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked;
        if (constrain || sizeOriginal) {
            // If the Auto size checkbox is checked or the Original size is checked, then apply the responsive class.
            classList.push(Selectors.IMAGE.styles.responsive);
        } else {
            // Otherwise, remove it.
            classList.pop(Selectors.IMAGE.styles.responsive);
        }

        return {
            url: this.currentUrl,
            alt: this.root.querySelector(Selectors.IMAGE.elements.alt).value,
            width: this.root.querySelector(Selectors.IMAGE.elements.width).value,
            height: this.root.querySelector(Selectors.IMAGE.elements.height).value,
            presentation: this.root.querySelector(Selectors.IMAGE.elements.presentation).checked,
            customStyle: this.root.querySelector(Selectors.IMAGE.elements.customStyle).value,
            classlist: classList.join(' '),
        };
    }

    setImage() {
        const pendingPromise = new Pending('tiny_media:setImage');
        const url = this.currentUrl;
        if (url === '') {
            return;
        }

        // Check if there are any accessibility issues.
        if (this.updateWarning()) {
            pendingPromise.resolve();
            return;
        }

        // Check for invalid width or height.
        const width = this.root.querySelector(Selectors.IMAGE.elements.width).value;
        if (!isPercentageValue(width) && isNaN(parseInt(width, 10))) {
            this.root.querySelector(Selectors.IMAGE.elements.width).focus();
            pendingPromise.resolve();
            return;
        }

        const height = this.root.querySelector(Selectors.IMAGE.elements.height).value;
        if (!isPercentageValue(height) && isNaN(parseInt(height, 10))) {
            this.root.querySelector(Selectors.IMAGE.elements.height).focus();
            pendingPromise.resolve();
            return;
        }

        Templates.render('tiny_media/image', this.getImageContext())
        .then((html) => {
            this.editor.insertContent(html);
            this.currentModal.destroy();
            pendingPromise.resolve();

            return html;
        })
        .catch(error => {
            window.console.log(error);
        });
    }

    /**
     * Deletes the image after confirming with the user and loads the insert image page.
     */
    deleteImage() {
        Notification.deleteCancelPromise(
            getString('deleteimage', 'tiny_media'),
            getString('deleteimagewarning', 'tiny_media'),
        ).then(() => {
            hideElements(Selectors.IMAGE.elements.altWarning, this.root);
            // Removing the image in the preview will bring the user to the insert page.
            this.loadInsertImage();
            return;
        }).catch(error => {
            window.console.log(error);
        });
    }

    registerEventListeners() {
        const submitAction = this.root.querySelector(Selectors.IMAGE.actions.submit);
        submitAction.addEventListener('click', (e) => {
            e.preventDefault();
            this.setImage();
        });

        const deleteImageEle = this.root.querySelector(Selectors.IMAGE.actions.deleteImage);
        deleteImageEle.addEventListener('click', () => {
            this.deleteImage();
        });
        deleteImageEle.addEventListener("keydown", (e) => {
            if (e.key === "Enter") {
                this.deleteImage();
            }
        });

        this.root.addEventListener('change', (e) => {
            const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
            if (presentationEle) {
                this.presentationChanged();
            }

            const constrainEle = e.target.closest(Selectors.IMAGE.elements.constrain);
            if (constrainEle) {
                this.autoAdjustSize();
            }

            const sizeOriginalEle = e.target.closest(Selectors.IMAGE.elements.sizeOriginal);
            if (sizeOriginalEle) {
                this.sizeChecked('original');
            }

            const sizeCustomEle = e.target.closest(Selectors.IMAGE.elements.sizeCustom);
            if (sizeCustomEle) {
                this.sizeChecked('custom');
            }
        });

        this.root.addEventListener('blur', (e) => {
            if (e.target.nodeType === Node.ELEMENT_NODE) {

                const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
                if (presentationEle) {
                    this.presentationChanged();
                }
            }
        }, true);

        // Character count.
        this.root.addEventListener('keyup', (e) => {
            const altEle = e.target.closest(Selectors.IMAGE.elements.alt);
            if (altEle) {
                this.handleKeyupCharacterCount();
            }
        });

        this.root.addEventListener('input', (e) => {
            const widthEle = e.target.closest(Selectors.IMAGE.elements.width);
            if (widthEle) {
                // Avoid empty value.
                widthEle.value = widthEle.value === "" ? 0 : Number(widthEle.value);
                this.autoAdjustSize();
            }

            const heightEle = e.target.closest(Selectors.IMAGE.elements.height);
            if (heightEle) {
                // Avoid empty value.
                heightEle.value = heightEle.value === "" ? 0 : Number(heightEle.value);
                this.autoAdjustSize(true);
            }
        });
    }

    handleKeyupCharacterCount() {
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt).value;
        const current = this.root.querySelector('#currentcount');
        current.innerHTML = alt.length;
    }

    /**
     * Calculates the dimensions to fit a square into a specified box while maintaining aspect ratio.
     *
     * @param {number} squareWidth - The width of the square.
     * @param {number} squareHeight - The height of the square.
     * @param {number} boxWidth - The width of the box.
     * @param {number} boxHeight - The height of the box.
     * @returns {Object} An object with the new width and height of the square to fit in the box.
     */
    fitSquareIntoBox = (squareWidth, squareHeight, boxWidth, boxHeight) => {
        if (squareWidth < boxWidth && squareHeight < boxHeight) {
          // If the square is smaller than the box, keep its dimensions.
          return {
            width: squareWidth,
            height: squareHeight,
          };
        }
        // Calculate the scaling factor based on the minimum scaling required to fit in the box.
        const widthScaleFactor = boxWidth / squareWidth;
        const heightScaleFactor = boxHeight / squareHeight;
        const minScaleFactor = Math.min(widthScaleFactor, heightScaleFactor);
        // Scale the square's dimensions based on the aspect ratio and the minimum scaling factor.
        const newWidth = squareWidth * minScaleFactor;
        const newHeight = squareHeight * minScaleFactor;
        return {
          width: newWidth,
          height: newHeight,
        };
    };
}