MDL-78096 tiny_media: Add a new details image

This commit is contained in:
meirzamoodle 2024-02-06 23:50:51 +07:00
parent 4d3c8e895e
commit 7fda4d6f63
19 changed files with 1077 additions and 15 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,6 @@ define("tiny_media/imagehelpers",["exports","core/templates"],(function(_exports
* @module tiny_media/imagehelpers
* @copyright 2024 Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.showElements=_exports.hideElements=_exports.footerImageInsert=_exports.bodyImageInsert=void 0,_templates=(obj=_templates)&&obj.__esModule?obj:{default:obj};_exports.bodyImageInsert=async(templateContext,root)=>_templates.default.renderForPromise("tiny_media/insert_image_modal_insert",{...templateContext}).then((_ref=>{let{html:html,js:js}=_ref;_templates.default.replaceNodeContents(root.querySelector(".tiny_image_body_template"),html,js)})).catch((error=>{window.console.log(error)}));_exports.footerImageInsert=async(templateContext,root)=>_templates.default.renderForPromise("tiny_media/insert_image_modal_insert_footer",{...templateContext}).then((_ref2=>{let{html:html,js:js}=_ref2;_templates.default.replaceNodeContents(root.querySelector(".tiny_image_footer_template"),html,js)})).catch((error=>{window.console.log(error)}));_exports.showElements=(elements,root)=>{if(elements instanceof Array)elements.forEach((elementSelector=>{const element=root.querySelector(elementSelector);element&&element.classList.remove("d-none")}));else{const element=root.querySelector(elements);element&&element.classList.remove("d-none")}};_exports.hideElements=(elements,root)=>{if(elements instanceof Array)elements.forEach((elementSelector=>{const element=root.querySelector(elementSelector);element&&element.classList.add("d-none")}));else{const element=root.querySelector(elements);element&&element.classList.add("d-none")}}}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.showElements=_exports.isPercentageValue=_exports.hideElements=_exports.footerImageInsert=_exports.footerImageDetails=_exports.bodyImageInsert=_exports.bodyImageDetails=void 0,_templates=(obj=_templates)&&obj.__esModule?obj:{default:obj};_exports.bodyImageInsert=async(templateContext,root)=>_templates.default.renderForPromise("tiny_media/insert_image_modal_insert",{...templateContext}).then((_ref=>{let{html:html,js:js}=_ref;_templates.default.replaceNodeContents(root.querySelector(".tiny_image_body_template"),html,js)})).catch((error=>{window.console.log(error)}));_exports.footerImageInsert=async(templateContext,root)=>_templates.default.renderForPromise("tiny_media/insert_image_modal_insert_footer",{...templateContext}).then((_ref2=>{let{html:html,js:js}=_ref2;_templates.default.replaceNodeContents(root.querySelector(".tiny_image_footer_template"),html,js)})).catch((error=>{window.console.log(error)}));_exports.bodyImageDetails=async(templateContext,root)=>_templates.default.renderForPromise("tiny_media/insert_image_modal_details",{...templateContext}).then((_ref3=>{let{html:html,js:js}=_ref3;_templates.default.replaceNodeContents(root.querySelector(".tiny_image_body_template"),html,js)})).catch((error=>{window.console.log(error)}));_exports.footerImageDetails=async(templateContext,root)=>_templates.default.renderForPromise("tiny_media/insert_image_modal_details_footer",{...templateContext}).then((_ref4=>{let{html:html,js:js}=_ref4;_templates.default.replaceNodeContents(root.querySelector(".tiny_image_footer_template"),html,js)})).catch((error=>{window.console.log(error)}));_exports.showElements=(elements,root)=>{if(elements instanceof Array)elements.forEach((elementSelector=>{const element=root.querySelector(elementSelector);element&&element.classList.remove("d-none")}));else{const element=root.querySelector(elements);element&&element.classList.remove("d-none")}};_exports.hideElements=(elements,root)=>{if(elements instanceof Array)elements.forEach((elementSelector=>{const element=root.querySelector(elementSelector);element&&element.classList.add("d-none")}));else{const element=root.querySelector(elements);element&&element.classList.add("d-none")}};_exports.isPercentageValue=value=>value.match(/\d+%/)}));
//# sourceMappingURL=imagehelpers.min.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,3 @@
define("tiny_media/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={IMAGE:{actions:{submit:".tiny_image_urlentrysubmit",imageBrowser:".openimagebrowser",addUrl:".tiny_image_addurl"},elements:{form:"form.tiny_image_form",alignSettings:".tiny_image_button",alt:".tiny_image_altentry",altWarning:".tiny_image_altwarning",height:".tiny_image_heightentry",width:".tiny_image_widthentry",url:".tiny_image_urlentry",urlWarning:".tiny_image_urlwarning",size:".tiny_image_size",presentation:".tiny_image_presentation",constrain:".tiny_image_constrain",customStyle:".tiny_image_customstyle",preview:".tiny_image_preview",previewBox:".tiny_image_preview_box",loaderIcon:".tiny_image_loader",loaderIconContainer:".tiny_image_loader_container",insertImage:".tiny_image_insert_image",modalFooter:".modal-footer",dropzoneContainer:".tiny_image_dropzone_container",fileInput:"#tiny_image_fileinput"},styles:{responsive:"img-fluid"}},EMBED:{actions:{submit:".tiny_media_submit",mediaBrowser:".openmediabrowser"},elements:{form:"form.tiny_media_form",source:".tiny_media_source",track:".tiny_media_track",mediaSource:".tiny_media_media_source",linkSource:".tiny_media_link_source",linkSize:".tiny_media_link_size",posterSource:".tiny_media_poster_source",posterSize:".tiny_media_poster_size",displayOptions:".tiny_media_display_options",name:".tiny_media_name_entry",title:".tiny_media_title_entry",url:".tiny_media_url_entry",width:".tiny_media_width_entry",height:".tiny_media_height_entry",trackSource:".tiny_media_track_source",trackKind:".tiny_media_track_kind_entry",trackLabel:".tiny_media_track_label_entry",trackLang:".tiny_media_track_lang_entry",trackDefault:".tiny_media_track_default",mediaControl:".tiny_media_controls",mediaAutoplay:".tiny_media_autoplay",mediaMute:".tiny_media_mute",mediaLoop:".tiny_media_loop",advancedSettings:".tiny_media_advancedsettings",linkTab:'li[data-medium-type="link"]',videoTab:'li[data-medium-type="video"]',audioTab:'li[data-medium-type="audio"]',linkPane:'.tab-pane[data-medium-type="link"]',videoPane:'.tab-pane[data-medium-type="video"]',audioPane:'.tab-pane[data-medium-type="audio"]',trackSubtitlesTab:'li[data-track-kind="subtitles"]',trackCaptionsTab:'li[data-track-kind="captions"]',trackDescriptionsTab:'li[data-track-kind="descriptions"]',trackChaptersTab:'li[data-track-kind="chapters"]',trackMetadataTab:'li[data-track-kind="metadata"]',trackSubtitlesPane:'.tab-pane[data-track-kind="subtitles"]',trackCaptionsPane:'.tab-pane[data-track-kind="captions"]',trackDescriptionsPane:'.tab-pane[data-track-kind="descriptions"]',trackChaptersPane:'.tab-pane[data-track-kind="chapters"]',trackMetadataPane:'.tab-pane[data-track-kind="metadata"]'},mediaTypes:{link:"LINK",video:"VIDEO",audio:"AUDIO"},trackKinds:{subtitles:"SUBTITLES",captions:"CAPTIONS",descriptions:"DESCRIPTIONS",chapters:"CHAPTERS",metadata:"METADATA"}}},_exports.default}));
define("tiny_media/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={IMAGE:{actions:{submit:".tiny_image_urlentrysubmit",imageBrowser:".openimagebrowser",addUrl:".tiny_image_addurl",deleteImage:".tiny_image_deleteicon"},elements:{form:"form.tiny_image_form",alignSettings:".tiny_image_button",alt:".tiny_image_altentry",altWarning:".tiny_image_altwarning",height:".tiny_image_heightentry",width:".tiny_image_widthentry",url:".tiny_image_urlentry",urlWarning:".tiny_image_urlwarning",size:".tiny_image_size",presentation:".tiny_image_presentation",constrain:".tiny_image_constrain",customStyle:".tiny_image_customstyle",preview:".tiny_image_preview",previewBox:".tiny_image_preview_box",loaderIcon:".tiny_image_loader",loaderIconContainer:".tiny_image_loader_container",insertImage:".tiny_image_insert_image",modalFooter:".modal-footer",dropzoneContainer:".tiny_image_dropzone_container",fileInput:"#tiny_image_fileinput",fileNameLabel:".tiny_image_filename",sizeOriginal:".tiny_image_sizeoriginal",sizeCustom:".tiny_image_sizecustom",properties:".tiny_image_properties"},styles:{responsive:"img-fluid"}},EMBED:{actions:{submit:".tiny_media_submit",mediaBrowser:".openmediabrowser"},elements:{form:"form.tiny_media_form",source:".tiny_media_source",track:".tiny_media_track",mediaSource:".tiny_media_media_source",linkSource:".tiny_media_link_source",linkSize:".tiny_media_link_size",posterSource:".tiny_media_poster_source",posterSize:".tiny_media_poster_size",displayOptions:".tiny_media_display_options",name:".tiny_media_name_entry",title:".tiny_media_title_entry",url:".tiny_media_url_entry",width:".tiny_media_width_entry",height:".tiny_media_height_entry",trackSource:".tiny_media_track_source",trackKind:".tiny_media_track_kind_entry",trackLabel:".tiny_media_track_label_entry",trackLang:".tiny_media_track_lang_entry",trackDefault:".tiny_media_track_default",mediaControl:".tiny_media_controls",mediaAutoplay:".tiny_media_autoplay",mediaMute:".tiny_media_mute",mediaLoop:".tiny_media_loop",advancedSettings:".tiny_media_advancedsettings",linkTab:'li[data-medium-type="link"]',videoTab:'li[data-medium-type="video"]',audioTab:'li[data-medium-type="audio"]',linkPane:'.tab-pane[data-medium-type="link"]',videoPane:'.tab-pane[data-medium-type="video"]',audioPane:'.tab-pane[data-medium-type="audio"]',trackSubtitlesTab:'li[data-track-kind="subtitles"]',trackCaptionsTab:'li[data-track-kind="captions"]',trackDescriptionsTab:'li[data-track-kind="descriptions"]',trackChaptersTab:'li[data-track-kind="chapters"]',trackMetadataTab:'li[data-track-kind="metadata"]',trackSubtitlesPane:'.tab-pane[data-track-kind="subtitles"]',trackCaptionsPane:'.tab-pane[data-track-kind="captions"]',trackDescriptionsPane:'.tab-pane[data-track-kind="descriptions"]',trackChaptersPane:'.tab-pane[data-track-kind="chapters"]',trackMetadataPane:'.tab-pane[data-track-kind="metadata"]'},mediaTypes:{link:"LINK",video:"VIDEO",audio:"AUDIO"},trackKinds:{subtitles:"SUBTITLES",captions:"CAPTIONS",descriptions:"DESCRIPTIONS",chapters:"CHAPTERS",metadata:"METADATA"}}},_exports.default}));
//# sourceMappingURL=selectors.min.js.map

File diff suppressed because one or more lines are too long

View File

@ -21,13 +21,21 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Selectors from './selectors';
import ImageModal from './imagemodal';
import {getImagePermissions} from './options';
import {getFilePicker} from 'editor_tiny/options';
import {ImageInsert} from 'tiny_media/imageinsert';
import {ImageDetails} from 'tiny_media/imagedetails';
import {getString} from 'core/str';
import {
bodyImageInsert,
footerImageInsert,
bodyImageDetails,
footerImageDetails,
showElements,
hideElements,
isPercentageValue,
} from 'tiny_media/imagehelpers';
export default class MediaImage {
@ -53,9 +61,14 @@ export default class MediaImage {
}
async displayDialogue() {
const currentImageData = await this.getCurrentImageData();
this.currentModal = await ImageModal.create();
this.root = this.currentModal.getRoot()[0];
this.loadInsertImage();
if (currentImageData && currentImageData.src) {
this.loadPreviewImage(currentImageData.src);
} else {
this.loadInsertImage();
}
}
/**
@ -86,4 +99,168 @@ export default class MediaImage {
window.console.log(error);
});
};
async getTemplateContext(data) {
return {
elementid: this.editor.id,
showfilepicker: this.canShowFilePicker,
...data,
};
}
async getCurrentImageData() {
const selectedImageProperties = this.getSelectedImageProperties();
if (!selectedImageProperties) {
return {};
}
const properties = {...selectedImageProperties};
if (properties.src) {
properties.haspreview = true;
}
if (!properties.alt) {
properties.presentation = true;
}
return properties;
}
/**
* Asynchronously loads and previews an image from the provided URL.
*
* @param {string} url - The URL of the image to load and preview.
* @returns {Promise<void>}
*/
loadPreviewImage = async function(url) {
this.startImageLoading();
const image = new Image();
image.src = url;
image.addEventListener('error', () => {
const urlWarningLabelEle = this.root.querySelector(Selectors.IMAGE.elements.urlWarning);
urlWarningLabelEle.innerHTML = this.langStrings.imageurlrequired;
showElements(Selectors.IMAGE.elements.urlWarning, this.root);
this.stopImageLoading();
});
image.addEventListener('load', async() => {
const currentImageData = await this.getCurrentImageData();
let templateContext = await this.getTemplateContext(currentImageData);
templateContext.sizecustomhelpicon = {text: await getString('sizecustom_help', 'tiny_media')};
Promise.all([bodyImageDetails(templateContext, this.root), footerImageDetails(templateContext, this.root)])
.then(() => {
this.stopImageLoading();
return;
})
.then(() => {
const imagedetails = new ImageDetails(
this.root,
this.editor,
this.currentModal,
this.canShowFilePicker,
this.canShowDropZone,
url,
image,
);
imagedetails.init();
return;
})
.catch(error => {
window.console.log(error);
});
});
};
getSelectedImageProperties() {
const image = this.getSelectedImage();
if (!image) {
this.selectedImage = null;
return null;
}
const properties = {
src: null,
alt: null,
width: null,
height: null,
presentation: false,
customStyle: '', // Custom CSS styles applied to the image.
};
const getImageHeight = (image) => {
if (!isPercentageValue(String(image.height))) {
return parseInt(image.height, 10);
}
return image.height;
};
const getImageWidth = (image) => {
if (!isPercentageValue(String(image.width))) {
return parseInt(image.width, 10);
}
return image.width;
};
// Get the current selection.
this.selectedImage = image;
properties.customStyle = image.style.cssText;
const width = getImageWidth(image);
if (width !== 0) {
properties.width = width;
}
const height = getImageHeight(image);
if (height !== 0) {
properties.height = height;
}
properties.src = image.getAttribute('src');
properties.alt = image.getAttribute('alt') || '';
properties.presentation = (image.getAttribute('role') === 'presentation');
return properties;
}
getSelectedImage() {
const imgElm = this.editor.selection.getNode();
const figureElm = this.editor.dom.getParent(imgElm, 'figure.image');
if (figureElm) {
return this.editor.dom.select('img', figureElm)[0];
}
if (imgElm && (imgElm.nodeName.toUpperCase() !== 'IMG' || this.isPlaceholderImage(imgElm))) {
return null;
}
return imgElm;
}
isPlaceholderImage(imgElm) {
if (imgElm.nodeName.toUpperCase() !== 'IMG') {
return false;
}
return (imgElm.hasAttribute('data-mce-object') || imgElm.hasAttribute('data-mce-placeholder'));
}
/**
* Displays the upload loader and disables UI elements while loading a file.
*/
startImageLoading() {
showElements(Selectors.IMAGE.elements.loaderIcon, this.root);
hideElements(Selectors.IMAGE.elements.insertImage, this.root);
}
/**
* Displays the upload loader and disables UI elements while loading a file.
*/
stopImageLoading() {
hideElements(Selectors.IMAGE.elements.loaderIcon, this.root);
showElements(Selectors.IMAGE.elements.insertImage, this.root);
}
}

View File

@ -0,0 +1,614 @@
// 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 {
bodyImageInsert,
footerImageInsert,
showElements,
hideElements,
isPercentageValue,
} from 'tiny_media/imagehelpers';
export class ImageDetails {
DEFAULTS = {
WIDTH: 160,
HEIGHT: 160,
};
rawImageDimensions = null;
constructor(
root,
editor,
currentModal,
canShowFilePicker,
canShowDropZone,
currentUrl,
image,
) {
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,
};
Promise.all([bodyImageInsert(templateContext, this.root), footerImageInsert(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.rawImageDimensions = {
width: image.width || this.DEFAULTS.WIDTH,
height: image.height || this.DEFAULTS.HEIGHT,
};
const getCurrentWidth = (element) => {
if (element.value === '') {
element.value = this.rawImageDimensions.width;
}
return element.value;
};
const getCurrentHeight = (element) => {
if (element.value === '') {
element.value = this.rawImageDimensions.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.rawImageDimensions.width === currentWidth &&
this.rawImageDimensions.height === currentHeight
) {
this.currentWidth = this.rawImageDimensions.width;
this.currentHeight = this.rawImageDimensions.height;
this.sizeChecked('original');
} else {
this.currentWidth = currentWidth;
this.currentHeight = currentHeight;
this.sizeChecked('custom');
}
};
setSelectedSize(Number(currentWidth), Number(currentHeight));
}
/**
* Handles the selection of image size options and updates the form inputs accordingly.
*
* @param {string} option - The selected image size option ("original" or "custom").
*/
sizeChecked(option) {
const widthInput = this.root.querySelector(Selectors.IMAGE.elements.width);
const heightInput = this.root.querySelector(Selectors.IMAGE.elements.height);
if (option === "original") {
this.sizeOriginalChecked();
widthInput.value = this.rawImageDimensions.width;
heightInput.value = this.rawImageDimensions.height;
} else if (option === "custom") {
this.sizeCustomChecked();
widthInput.value = this.currentWidth;
heightInput.value = this.currentHeight;
// If the current size is equal to the original size, then check the Keep proportion checkbox.
if (this.currentWidth === this.rawImageDimensions.width && this.currentHeight === this.rawImageDimensions.height) {
const constrainField = this.root.querySelector(Selectors.IMAGE.elements.constrain);
constrainField.checked = true;
}
}
this.autoAdjustSize();
}
autoAdjustSize(forceHeight = false) {
// If we do not know the image size, do not do anything.
if (!this.rawImageDimensions) {
return;
}
const widthField = this.root.querySelector(Selectors.IMAGE.elements.width);
const heightField = this.root.querySelector(Selectors.IMAGE.elements.height);
const normalizeFieldData = (fieldData) => {
fieldData.isPercentageValue = !!isPercentageValue(fieldData.field.value);
if (fieldData.isPercentageValue) {
fieldData.percentValue = parseInt(fieldData.field.value, 10);
fieldData.pixelSize = this.rawImageDimensions[fieldData.type] / 100 * fieldData.percentValue;
} else {
fieldData.pixelSize = parseInt(fieldData.field.value, 10);
fieldData.percentValue = fieldData.pixelSize / this.rawImageDimensions[fieldData.type] * 100;
}
return fieldData;
};
const getKeyField = () => {
const getValue = () => {
if (forceHeight) {
return {
field: heightField,
type: 'height',
};
} else {
return {
field: widthField,
type: 'width',
};
}
};
const currentValue = getValue();
if (currentValue.field.value === '') {
currentValue.field.value = this.rawImageDimensions[currentValue.type];
}
return normalizeFieldData(currentValue);
};
const getRelativeField = () => {
if (forceHeight) {
return normalizeFieldData({
field: widthField,
type: 'width',
});
} else {
return normalizeFieldData({
field: heightField,
type: 'height',
});
}
};
// Now update with the new values.
const constrainField = this.root.querySelector(Selectors.IMAGE.elements.constrain);
if (constrainField.checked) {
const keyField = getKeyField();
const relativeField = getRelativeField();
// We are keeping the image in proportion.
// Calculate the size for the relative field.
if (keyField.isPercentageValue) {
// In proportion, so the percentages are the same.
relativeField.field.value = keyField.field.value;
relativeField.percentValue = keyField.percentValue;
} else {
relativeField.pixelSize = Math.round(
keyField.pixelSize / this.rawImageDimensions[keyField.type] * this.rawImageDimensions[relativeField.type]
);
relativeField.field.value = relativeField.pixelSize;
}
}
// Store the custom width and height to reuse.
this.currentWidth = Number(widthField.value) !== this.rawImageDimensions.width ? widthField.value : this.currentWidth;
this.currentHeight = Number(heightField.value) !== this.rawImageDimensions.height ? heightField.value : this.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 the selection of the "Original Size" option and updates the form elements accordingly.
*/
sizeOriginalChecked() {
this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked = true;
this.root.querySelector(Selectors.IMAGE.elements.sizeCustom).checked = false;
hideElements(Selectors.IMAGE.elements.properties, this.root);
}
/**
* Handles the selection of the "Custom Size" option and updates the form elements accordingly.
*/
sizeCustomChecked() {
this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked = false;
this.root.querySelector(Selectors.IMAGE.elements.sizeCustom).checked = true;
showElements(Selectors.IMAGE.elements.properties, this.root);
}
/**
* 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.urlWaaltWarningrning, 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,
};
};
}

View File

@ -59,6 +59,41 @@ export const footerImageInsert = async(templateContext, root) => {
});
};
/**
* Renders and inserts the body template for displaying image details in the modal.
*
* @param {object} templateContext - The context for rendering the template.
* @param {HTMLElement} root - The root element where the template will be inserted.
* @returns {Promise<void>}
*/
export const bodyImageDetails = async(templateContext, root) => {
return Templates.renderForPromise('tiny_media/insert_image_modal_details', {...templateContext})
.then(({html, js}) => {
Templates.replaceNodeContents(root.querySelector('.tiny_image_body_template'), html, js);
return;
})
.catch(error => {
window.console.log(error);
});
};
/**
* Renders and inserts the footer template for displaying image details in the modal.
* @param {object} templateContext - The context for rendering the template.
* @param {HTMLElement} root - The root element where the template will be inserted.
* @returns {Promise<void>}
*/
export const footerImageDetails = async(templateContext, root) => {
return Templates.renderForPromise('tiny_media/insert_image_modal_details_footer', {...templateContext})
.then(({html, js}) => {
Templates.replaceNodeContents(root.querySelector('.tiny_image_footer_template'), html, js);
return;
})
.catch(error => {
window.console.log(error);
});
};
/**
* Show the element(s).
*
@ -102,3 +137,13 @@ export const hideElements = (elements, root) => {
}
}
};
/**
* Checks if the given value is a percentage value.
*
* @param {string} value - The value to check.
* @returns {boolean} True if the value is a percentage value, false otherwise.
*/
export const isPercentageValue = (value) => {
return value.match(/\d+%/);
};

View File

@ -28,9 +28,12 @@ import {prefetchStrings} from 'core/prefetch';
import {getStrings} from 'core/str';
import {component} from "./common";
import {displayFilepicker} from 'editor_tiny/utils';
import {ImageDetails} from 'tiny_media/imagedetails';
import {
showElements,
hideElements,
bodyImageDetails,
footerImageDetails,
} from 'tiny_media/imagehelpers';
prefetchStrings('tiny_media', [
@ -41,6 +44,7 @@ prefetchStrings('tiny_media', [
'uploading',
'loading',
'addfilesdrop',
'sizecustom_help',
]);
export class ImageInsert {
@ -69,6 +73,7 @@ export class ImageInsert {
'uploading',
'loading',
'addfilesdrop',
'sizecustom_help',
];
const langStringvalues = await getStrings([...langStringKeys].map((key) => ({key, component})));
@ -144,8 +149,28 @@ export class ImageInsert {
});
image.addEventListener('load', () => {
window.console.log(this.currentUrl);
this.stopImageLoading();
let templateContext = {};
templateContext.sizecustomhelpicon = {text: this.langStrings.sizecustom_help};
Promise.all([bodyImageDetails(templateContext, this.root), footerImageDetails(templateContext, this.root)])
.then(() => {
const imagedetails = new ImageDetails(
this.root,
this.editor,
this.currentModal,
this.canShowFilePicker,
this.canShowDropZone,
this.currentUrl,
image,
);
imagedetails.init();
return;
}).then(() => {
this.stopImageLoading();
return;
})
.catch(error => {
window.console.log(error);
});
});
};

View File

@ -27,6 +27,7 @@ export default {
submit: '.tiny_image_urlentrysubmit',
imageBrowser: '.openimagebrowser',
addUrl: '.tiny_image_addurl',
deleteImage: '.tiny_image_deleteicon',
},
elements: {
form: 'form.tiny_image_form',
@ -49,6 +50,10 @@ export default {
modalFooter: '.modal-footer',
dropzoneContainer: '.tiny_image_dropzone_container',
fileInput: '#tiny_image_fileinput',
fileNameLabel: '.tiny_image_filename',
sizeOriginal: '.tiny_image_sizeoriginal',
sizeCustom: '.tiny_image_sizecustom',
properties: '.tiny_image_properties',
},
styles: {
responsive: 'img-fluid',

View File

@ -43,16 +43,18 @@ $string['captionssourcelabel'] = 'Caption track URL';
$string['chapters_help'] = 'Chapter titles may be provided for use in navigating the media resource.';
$string['chapters'] = 'Chapters';
$string['chapterssourcelabel'] = 'Chapter track URL';
$string['constrain'] = 'Auto size';
$string['constrain'] = 'Keep proportion';
$string['controls'] = 'Show controls';
$string['createmedia'] = 'Insert media';
$string['default'] = 'Default';
$string['deleteimage'] = 'Delete image';
$string['deleteimagewarning'] = 'Are you sure you want to remove the image?';
$string['deleteselected'] = 'Delete selected files';
$string['descriptions_help'] = 'Audio descriptions may be used to provide a narration which explains visual details not apparent from the audio alone.';
$string['descriptions'] = 'Descriptions';
$string['descriptionssourcelabel'] = 'Description track URL';
$string['displayoptions'] = 'Display options';
$string['enteralt'] = 'Describe this image for someone who cannot see it';
$string['enteralt'] = 'How would you describe this image to someone who can\'t see it:';
$string['entername'] = 'Name';
$string['entersource'] = 'Source URL';
$string['entertitle'] = 'Title';
@ -63,6 +65,7 @@ $string['hasmissingfiles'] = 'Warning! The following files that are referenced i
$string['height'] = 'Height';
$string['helplinktext'] = 'Media helper';
$string['imagebuttontitle'] = 'Image';
$string['imagedetails'] = 'Image details';
$string['imageproperties'] = 'Image properties';
$string['imageurlrequired'] = 'An image must have a valid URL.';
$string['insertimage'] = 'Insert image';
@ -87,8 +90,11 @@ $string['presentationoraltrequired'] = 'An image must have a description, unless
$string['privacy:metadata'] = 'The media plugin for TinyMCE does not store any personal data.';
$string['remove'] = 'Remove';
$string['repositorynotpermitted'] = 'Paste an image link in the field below.';
$string['saveimage'] = 'Save image';
$string['saveimage'] = 'Save';
$string['size'] = 'Width x height (in pixels)';
$string['sizecustom'] = 'Custom size';
$string['sizecustom_help'] = 'This image is just a preview.<br>Changes to its size will be<br>visible after you save it.';
$string['sizeoriginal'] = 'Original size';
$string['srclang'] = 'Language';
$string['subtitles_help'] = 'Subtitles may be used to provide a transcription or translation of the dialogue.';
$string['subtitles'] = 'Subtitles';
@ -112,4 +118,4 @@ $string['alignment_bottom'] = 'Bottom';
$string['alignment_left'] = 'Left';
$string['alignment_middle'] = 'Middle';
$string['alignment_right'] = 'Right';
$string['alignment_top'] = 'Top';
$string['alignment_top'] = 'Top';

View File

@ -45,3 +45,35 @@ iframe.mm_iframe {
.tiny_image_form .tiny_image_loader_container {
height: 200px;
}
.tiny_image_form .tiny_image_preview_box {
height: 300px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.tiny_image_form .tiny_image_deleteicon {
position: absolute;
top: 5px;
right: 5px;
cursor: pointer;
z-index: 1;
width: 30px;
height: 30px;
background: rgba(255, 255, 255, 1);
border-radius: 50%;
padding: 4px 5px 5px 9px;
}
.tiny_image_form .tiny_image_deleteicon .fa-trash {
color: #1d2125;
}
@media (max-width: 767px) {
.tiny_image_form .tiny_image_properties_col {
padding: 0;
}
}

View File

@ -0,0 +1,112 @@
{{!
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/>.
}}
{{!
@template tiny_media/insert_image_modal_details
Insert image details body template.
Example context (json):
{
"elementid": "exampleId",
"alt": "Image description",
"presentation": true,
"width": 600,
"height": 400,
"customStyle": "",
"sizecustomhelpicon": {
"text": "Help text"
}
}
}}
<div class="tiny_image_image_details">
<div class="container">
<div class="row">
<!-- Column 1: Image Preview and Description -->
<div class="tiny_image_preview_col col-lg-7 p-0">
<input type="hidden" class="tiny_image_customstyle" value="{{customStyle}}">
<!-- Row 1: Image preview -->
<div class="tiny_image_preview_box border rounded">
<!-- Delete image icon -->
<div class="tiny_image_deleteicon" tabindex="0" title="{{#str}} deleteimage, tiny_media {{/str}}">
<i class="fa fa-trash-o" title="{{#str}} deleteimage, tiny_media {{/str}}"></i>
</div>
<!-- Image placeholder -->
<img class="tiny_image_preview" src="data:," alt>
</div>
<!-- Row 2: Image description -->
<div class="form-group mt-3">
<label for="{{elementid}}_tiny_image_altentry">{{#str}} enteralt, tiny_media {{/str}}</label>
<textarea class="tiny_image_altentry form-control fullwidth" id="{{elementid}}_tiny_image_altentry" name="altentry" maxlength="125">{{alt}}</textarea>
<!-- Character counter -->
<div id="the-count" class="d-flex justify-content-end small">
<span id="currentcount">0</span>
<span id="maximumcount"> / 125</span>
</div>
</div>
</div>
<!-- Column 2: Checkbox and Radio Buttons -->
<div class="tiny_image_properties_col col-lg-5">
<!-- Row 1: Image presentation role -->
<div class="form-check mb-2">
<input type="checkbox" class="tiny_image_presentation form-check-input" id="{{elementid}}_tiny_image_presentation" {{# presentation }}checked{{/ presentation }}>
<label class="form-check-label" for="{{elementid}}_tiny_image_presentation">{{#str}} presentation, tiny_media {{/str}}</label>
</div>
<!-- Row 2: Original size radiobutton -->
<div class="form-check mb-2 pl-0">
<input type="radio" class="tiny_image_sizeoriginal" id="{{elementid}}_tiny_image_sizeoriginal" name="radioOptions">
<label class="form-check-label" for="{{elementid}}_tiny_image_sizeoriginal">{{#str}} sizeoriginal, tiny_media {{/str}}</label>
</div>
<!-- Row 3: Custom size radiobutton -->
<div class="form-check pl-0 mb-2">
<input type="radio" class="tiny_image_sizecustom" id="{{elementid}}_tiny_image_sizecustom" name="radioOptions">
<label class="form-check-label" for="{{elementid}}_tiny_image_sizecustom">{{#str}} sizecustom, tiny_media {{/str}}</label>
</div>
<!-- Row 4: Image size -->
<div class="tiny_image_properties mb-2">
<!-- Row 1: Image width and height -->
<div id="{{elementid}}_tiny_image_size" class="tiny_image_size container ml-1">
<div class="d-flex justify-content-start">
<!-- Column 1: Width Input -->
<div class="flex-item mr-2">
<div class="form-group mb-0">
<input type="number" min="0" class="tiny_image_widthentry form-control mr-1 input-mini" id="{{elementid}}_tiny_image_widthentry" value="{{width}}">
<label for="{{elementid}}_tiny_image_widthentry" class="ml-1">{{#str}} width, tiny_media {{/str}}</label>
</div>
</div>
<!-- Column 2: "X" Text -->
<div class="flex-item mr-1 mt-2">X</div>
<!-- Column 3: Height Input -->
<div class="flex-item mr-1">
<div class="form-group mb-0">
<input type="number" min="0" class="tiny_image_heightentry form-control ml-1 input-mini" id="{{elementid}}_tiny_image_heightentry" value="{{height}}">
<label for="{{elementid}}_tiny_image_heightentry" class="ml-1">{{#str}} height, tiny_media {{/str}}</label>
</div>
</div>
<div class="tiny_image_customhelpicon flex-item ml-1">{{#sizecustomhelpicon}}{{> core/help_icon }}{{/sizecustomhelpicon}}</div>
</div>
</div>
<!-- Row 2: Keep proportion -->
<div class="form-check mb-2">
<input type="checkbox" class="tiny_image_constrain form-check-input" id="{{elementid}}_tiny_image_constrain">
<label class="form-check-label" for="{{elementid}}_tiny_image_constrain">{{#str}} constrain, tiny_media {{/str}}</label>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
{{!
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/>.
}}
{{!
@template tiny_media/insert_image_modal_details_footer
Insert image details footer template.
Example context (json):
{
"elementid": "exampleId"
}
}}
<div class="row">
<!-- First Column -->
<div class="col-md-6 d-flex align-items-center p-0">
<!-- Row 1: URL related label -->
<label for="{{elementid}}_tiny_image_filename" title="{{#str}} filename, tiny_media {{/str}}" class="tiny_image_filename text-truncate mr-1">{{#str}} filename, tiny_media {{/str}}</label>
</div>
<!-- Column 2: Saving, canceling, browsing repositories buttons -->
<div class="col-md-6 text-right mt-2 md-0 p-0">
<!-- Row 1: Cancel button -->
<button type="button" class="btn btn-secondary" data-action="cancel">{{#str}} cancel, moodle {{/str}}</button>
<!-- Row 2: Save button -->
<button class="tiny_image_urlentrysubmit btn btn-primary" type="submit">{{#str}} saveimage, tiny_media {{/str}}</button>
</div>
</div>