lib/editor/tiny/plugins/media/amd/src/imageinsert.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * Tiny media plugin image insertion class for Moodle.
  17. *
  18. * @module tiny_media/imageinsert
  19. * @copyright 2024 Meirza <meirza.arson@moodle.com>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import Selectors from './selectors';
  23. import Dropzone from 'core/dropzone';
  24. import uploadFile from 'editor_tiny/uploader';
  25. import {prefetchStrings} from 'core/prefetch';
  26. import {getStrings} from 'core/str';
  27. import {component} from "./common";
  28. import {getFilePicker} from 'editor_tiny/options';
  29. import {displayFilepicker} from 'editor_tiny/utils';
  30. import {ImageDetails} from 'tiny_media/imagedetails';
  31. import {
  32. showElements,
  33. hideElements,
  34. bodyImageDetails,
  35. footerImageDetails,
  36. } from 'tiny_media/imagehelpers';
  37. prefetchStrings('tiny_media', [
  38. 'insertimage',
  39. 'enterurl',
  40. 'enterurlor',
  41. 'imageurlrequired',
  42. 'uploading',
  43. 'loading',
  44. 'addfilesdrop',
  45. 'sizecustom_help',
  46. ]);
  47. export class ImageInsert {
  48. constructor(
  49. root,
  50. editor,
  51. currentModal,
  52. canShowFilePicker,
  53. canShowDropZone,
  54. ) {
  55. this.root = root;
  56. this.editor = editor;
  57. this.currentModal = currentModal;
  58. this.canShowFilePicker = canShowFilePicker;
  59. this.canShowDropZone = canShowDropZone;
  60. }
  61. init = async function() {
  62. // Get the localization lang strings and turn them into object.
  63. const langStringKeys = [
  64. 'insertimage',
  65. 'enterurl',
  66. 'enterurlor',
  67. 'imageurlrequired',
  68. 'uploading',
  69. 'loading',
  70. 'addfilesdrop',
  71. 'sizecustom_help',
  72. ];
  73. const langStringvalues = await getStrings([...langStringKeys].map((key) => ({key, component})));
  74. // Convert array to object.
  75. this.langStrings = Object.fromEntries(langStringKeys.map((key, index) => [key, langStringvalues[index]]));
  76. this.currentModal.setTitle(this.langStrings.insertimage);
  77. if (this.canShowDropZone) {
  78. const dropZoneEle = document.querySelector(Selectors.IMAGE.elements.dropzoneContainer);
  79. // Accepted types can be either a string or an array.
  80. let acceptedTypes = getFilePicker(this.editor, 'image').accepted_types;
  81. if (Array.isArray(acceptedTypes)) {
  82. acceptedTypes = acceptedTypes.join(',');
  83. }
  84. const dropZone = new Dropzone(
  85. dropZoneEle,
  86. acceptedTypes,
  87. files => {
  88. this.handleUploadedFile(files);
  89. }
  90. );
  91. dropZone.setLabel(this.langStrings.addfilesdrop);
  92. dropZone.init();
  93. }
  94. await this.registerEventListeners();
  95. };
  96. /**
  97. * Enables or disables the URL-related buttons in the footer based on the current URL and input value.
  98. */
  99. toggleUrlButton() {
  100. const urlInput = this.root.querySelector(Selectors.IMAGE.elements.url);
  101. const url = urlInput.value;
  102. const addUrl = this.root.querySelector(Selectors.IMAGE.actions.addUrl);
  103. addUrl.disabled = !(url !== "" && this.isValidUrl(url));
  104. }
  105. /**
  106. * Check if given string is a valid URL.
  107. *
  108. * @param {String} urlString URL the link will point to.
  109. * @returns {boolean} True is valid, otherwise false.
  110. */
  111. isValidUrl = urlString => {
  112. const urlPattern = new RegExp('^(https?:\\/\\/)?' + // Protocol.
  113. '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // Domain name.
  114. '((\\d{1,3}\\.){3}\\d{1,3})|localhost)' + // OR ip (v4) address, localhost.
  115. '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'); // Port and path.
  116. return !!urlPattern.test(urlString);
  117. };
  118. /**
  119. * Handles changes in the image URL input field and loads a preview of the image if the URL has changed.
  120. */
  121. urlChanged() {
  122. hideElements(Selectors.IMAGE.elements.urlWarning, this.root);
  123. const input = this.root.querySelector(Selectors.IMAGE.elements.url);
  124. if (input.value && input.value !== this.currentUrl) {
  125. this.loadPreviewImage(input.value);
  126. }
  127. }
  128. /**
  129. * Loads and displays a preview image based on the provided URL, and handles image loading events.
  130. *
  131. * @param {string} url - The URL of the image to load and display.
  132. */
  133. loadPreviewImage = function(url) {
  134. this.startImageLoading();
  135. this.currentUrl = url;
  136. const image = new Image();
  137. image.src = url;
  138. image.addEventListener('error', () => {
  139. const urlWarningLabelEle = this.root.querySelector(Selectors.IMAGE.elements.urlWarning);
  140. urlWarningLabelEle.innerHTML = this.langStrings.imageurlrequired;
  141. showElements(Selectors.IMAGE.elements.urlWarning, this.root);
  142. this.currentUrl = "";
  143. this.stopImageLoading();
  144. });
  145. image.addEventListener('load', () => {
  146. let templateContext = {};
  147. templateContext.sizecustomhelpicon = {text: this.langStrings.sizecustom_help};
  148. Promise.all([bodyImageDetails(templateContext, this.root), footerImageDetails(templateContext, this.root)])
  149. .then(() => {
  150. const imagedetails = new ImageDetails(
  151. this.root,
  152. this.editor,
  153. this.currentModal,
  154. this.canShowFilePicker,
  155. this.canShowDropZone,
  156. this.currentUrl,
  157. image,
  158. );
  159. imagedetails.init();
  160. return;
  161. }).then(() => {
  162. this.stopImageLoading();
  163. return;
  164. })
  165. .catch(error => {
  166. window.console.log(error);
  167. });
  168. });
  169. };
  170. /**
  171. * Displays the upload loader and disables UI elements while loading a file.
  172. */
  173. startImageLoading() {
  174. showElements(Selectors.IMAGE.elements.loaderIcon, this.root);
  175. const elementsToHide = [
  176. Selectors.IMAGE.elements.insertImage,
  177. Selectors.IMAGE.elements.urlWarning,
  178. Selectors.IMAGE.elements.modalFooter,
  179. ];
  180. hideElements(elementsToHide, this.root);
  181. }
  182. /**
  183. * Displays the upload loader and disables UI elements while loading a file.
  184. */
  185. stopImageLoading() {
  186. hideElements(Selectors.IMAGE.elements.loaderIcon, this.root);
  187. const elementsToShow = [
  188. Selectors.IMAGE.elements.insertImage,
  189. Selectors.IMAGE.elements.modalFooter,
  190. ];
  191. showElements(elementsToShow, this.root);
  192. }
  193. filePickerCallback(params) {
  194. if (params.url) {
  195. this.loadPreviewImage(params.url);
  196. }
  197. }
  198. /**
  199. * Updates the content of the loader icon.
  200. *
  201. * @param {HTMLElement} root - The root element containing the loader icon.
  202. * @param {object} langStrings - An object containing language strings.
  203. * @param {number|null} progress - The progress percentage (optional).
  204. * @returns {void}
  205. */
  206. updateLoaderIcon = (root, langStrings, progress = null) => {
  207. const loaderIcon = root.querySelector(Selectors.IMAGE.elements.loaderIconContainer + ' div');
  208. loaderIcon.innerHTML = progress !== null ? `${langStrings.uploading} ${Math.round(progress)}%` : langStrings.loading;
  209. };
  210. /**
  211. * Handles the uploaded file, initiates the upload process, and updates the UI during the upload.
  212. *
  213. * @param {FileList} files - The list of files to upload (usually from a file input field).
  214. * @returns {Promise<void>} A promise that resolves when the file is uploaded and processed.
  215. */
  216. handleUploadedFile = async(files) => {
  217. try {
  218. this.startImageLoading();
  219. const fileURL = await uploadFile(this.editor, 'image', files[0], files[0].name, (progress) => {
  220. this.updateLoaderIcon(this.root, this.langStrings, progress);
  221. });
  222. // Set the loader icon content to "loading" after the file upload completes.
  223. this.updateLoaderIcon(this.root, this.langStrings);
  224. this.filePickerCallback({url: fileURL});
  225. } catch (error) {
  226. // Handle the error.
  227. const urlWarningLabelEle = this.root.querySelector(Selectors.IMAGE.elements.urlWarning);
  228. urlWarningLabelEle.innerHTML = error.error !== undefined ? error.error : error;
  229. showElements(Selectors.IMAGE.elements.urlWarning, this.root);
  230. this.stopImageLoading();
  231. }
  232. };
  233. registerEventListeners() {
  234. this.root.addEventListener('click', async(e) => {
  235. const addUrlEle = e.target.closest(Selectors.IMAGE.actions.addUrl);
  236. if (addUrlEle) {
  237. this.urlChanged();
  238. }
  239. const imageBrowserAction = e.target.closest(Selectors.IMAGE.actions.imageBrowser);
  240. if (imageBrowserAction && this.canShowFilePicker) {
  241. e.preventDefault();
  242. const params = await displayFilepicker(this.editor, 'image');
  243. this.filePickerCallback(params);
  244. }
  245. });
  246. this.root.addEventListener('input', (e) => {
  247. const urlEle = e.target.closest(Selectors.IMAGE.elements.url);
  248. if (urlEle) {
  249. this.toggleUrlButton();
  250. }
  251. });
  252. const fileInput = this.root.querySelector(Selectors.IMAGE.elements.fileInput);
  253. if (fileInput) {
  254. fileInput.addEventListener('change', () => {
  255. this.handleUploadedFile(fileInput.files);
  256. });
  257. }
  258. }
  259. }