lib/editor/tiny/plugins/media/amd/src/imagedetails.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 details class for Moodle.
  17. *
  18. * @module tiny_media/imagedetails
  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 Config from 'core/config';
  23. import ModalEvents from 'core/modal_events';
  24. import Notification from 'core/notification';
  25. import Pending from 'core/pending';
  26. import Selectors from './selectors';
  27. import Templates from 'core/templates';
  28. import {getString} from 'core/str';
  29. import {ImageInsert} from 'tiny_media/imageinsert';
  30. import {MediaBase} from './mediabase';
  31. import {
  32. body,
  33. footer,
  34. hideElements,
  35. showElements,
  36. isPercentageValue,
  37. } from './helpers';
  38. export class ImageDetails extends MediaBase {
  39. DEFAULTS = {
  40. WIDTH: 160,
  41. HEIGHT: 160,
  42. };
  43. selectorType = Selectors.IMAGE.type;
  44. mediaDimensions = null;
  45. constructor(
  46. root,
  47. editor,
  48. currentModal,
  49. canShowFilePicker,
  50. canShowDropZone,
  51. currentUrl,
  52. image,
  53. ) {
  54. super();
  55. this.root = root;
  56. this.editor = editor;
  57. this.currentModal = currentModal;
  58. this.canShowFilePicker = canShowFilePicker;
  59. this.canShowDropZone = canShowDropZone;
  60. this.currentUrl = currentUrl;
  61. this.image = image;
  62. }
  63. init = function() {
  64. this.currentModal.setTitle(getString('imagedetails', 'tiny_media'));
  65. this.imageTypeChecked();
  66. this.presentationChanged();
  67. this.storeImageDimensions(this.image);
  68. this.setImageDimensions();
  69. this.registerEventListeners();
  70. };
  71. /**
  72. * Loads and displays a preview image based on the provided URL, and handles image loading events.
  73. */
  74. loadInsertImage = async function() {
  75. const templateContext = {
  76. elementid: this.editor.id,
  77. showfilepicker: this.canShowFilePicker,
  78. showdropzone: this.canShowDropZone,
  79. bodyTemplate: Selectors.IMAGE.template.body.insertImageBody,
  80. footerTemplate: Selectors.IMAGE.template.footer.insertImageFooter,
  81. selector: Selectors.IMAGE.type,
  82. };
  83. Promise.all([body(templateContext, this.root), footer(templateContext, this.root)])
  84. .then(() => {
  85. const imageinsert = new ImageInsert(
  86. this.root,
  87. this.editor,
  88. this.currentModal,
  89. this.canShowFilePicker,
  90. this.canShowDropZone,
  91. );
  92. imageinsert.init();
  93. return;
  94. })
  95. .catch(error => {
  96. window.console.log(error);
  97. });
  98. };
  99. storeImageDimensions(image) {
  100. // Store dimensions of the raw image, falling back to defaults for images without dimensions (e.g. SVG).
  101. this.mediaDimensions = {
  102. width: image.width || this.DEFAULTS.WIDTH,
  103. height: image.height || this.DEFAULTS.HEIGHT,
  104. };
  105. const getCurrentWidth = (element) => {
  106. if (element.value === '') {
  107. element.value = this.mediaDimensions.width;
  108. }
  109. return element.value;
  110. };
  111. const getCurrentHeight = (element) => {
  112. if (element.value === '') {
  113. element.value = this.mediaDimensions.height;
  114. }
  115. return element.value;
  116. };
  117. const widthInput = this.root.querySelector(Selectors.IMAGE.elements.width);
  118. const currentWidth = getCurrentWidth(widthInput);
  119. const heightInput = this.root.querySelector(Selectors.IMAGE.elements.height);
  120. const currentHeight = getCurrentHeight(heightInput);
  121. const preview = this.root.querySelector(Selectors.IMAGE.elements.preview);
  122. preview.setAttribute('src', image.src);
  123. preview.style.display = '';
  124. // Ensure the checkbox always in unchecked status when an image loads at first.
  125. const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain);
  126. if (isPercentageValue(currentWidth) && isPercentageValue(currentHeight)) {
  127. constrain.checked = currentWidth === currentHeight;
  128. } else if (image.width === 0 || image.height === 0) {
  129. // If we don't have both dimensions of the image, we can't auto-size it, so disable control.
  130. constrain.disabled = 'disabled';
  131. } else {
  132. // This is the same as comparing to 3 decimal places.
  133. const widthRatio = Math.round(100 * parseInt(currentWidth, 10) / image.width);
  134. const heightRatio = Math.round(100 * parseInt(currentHeight, 10) / image.height);
  135. constrain.checked = widthRatio === heightRatio;
  136. }
  137. /**
  138. * Sets the selected size option based on current width and height values.
  139. *
  140. * @param {number} currentWidth - The current width value.
  141. * @param {number} currentHeight - The current height value.
  142. */
  143. const setSelectedSize = (currentWidth, currentHeight) => {
  144. if (this.mediaDimensions.width === currentWidth &&
  145. this.mediaDimensions.height === currentHeight
  146. ) {
  147. this.currentWidth = this.mediaDimensions.width;
  148. this.currentHeight = this.mediaDimensions.height;
  149. this.sizeChecked('original');
  150. } else {
  151. this.currentWidth = currentWidth;
  152. this.currentHeight = currentHeight;
  153. this.sizeChecked('custom');
  154. }
  155. };
  156. setSelectedSize(Number(currentWidth), Number(currentHeight));
  157. }
  158. /**
  159. * Sets the dimensions of the image preview element based on user input and constraints.
  160. */
  161. setImageDimensions = () => {
  162. const imagePreviewBox = this.root.querySelector(Selectors.IMAGE.elements.previewBox);
  163. const image = this.root.querySelector(Selectors.IMAGE.elements.preview);
  164. const widthField = this.root.querySelector(Selectors.IMAGE.elements.width);
  165. const heightField = this.root.querySelector(Selectors.IMAGE.elements.height);
  166. const updateImageDimensions = () => {
  167. // Get the latest dimensions of the preview box for responsiveness.
  168. const boxWidth = imagePreviewBox.clientWidth;
  169. const boxHeight = imagePreviewBox.clientHeight;
  170. // Get the new width and height for the image.
  171. const dimensions = this.fitSquareIntoBox(widthField.value, heightField.value, boxWidth, boxHeight);
  172. image.style.width = `${dimensions.width}px`;
  173. image.style.height = `${dimensions.height}px`;
  174. };
  175. // If the client size is zero, then get the new dimensions once the modal is shown.
  176. if (imagePreviewBox.clientWidth === 0) {
  177. // Call the shown event.
  178. this.currentModal.getRoot().on(ModalEvents.shown, () => {
  179. updateImageDimensions();
  180. });
  181. } else {
  182. updateImageDimensions();
  183. }
  184. };
  185. /**
  186. * Handles changes in the image presentation checkbox and enables/disables the image alt text input accordingly.
  187. */
  188. presentationChanged() {
  189. const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation);
  190. const alt = this.root.querySelector(Selectors.IMAGE.elements.alt);
  191. alt.disabled = presentation.checked;
  192. // Counting the image description characters.
  193. this.handleKeyupCharacterCount();
  194. }
  195. /**
  196. * This function checks whether an image URL is local (within the same website's domain) or external (from an external source).
  197. * Depending on the result, it dynamically updates the visibility and content of HTML elements in a user interface.
  198. * If the image is local then we only show it's filename.
  199. * If the image is external then it will show full URL and it can be updated.
  200. */
  201. imageTypeChecked() {
  202. const regex = new RegExp(`${Config.wwwroot}`);
  203. // True if the URL is from external, otherwise false.
  204. const isExternalUrl = regex.test(this.currentUrl) === false;
  205. // Hide the URL input.
  206. hideElements(Selectors.IMAGE.elements.url, this.root);
  207. if (!isExternalUrl) {
  208. // Split the URL by '/' to get an array of segments.
  209. const segments = this.currentUrl.split('/');
  210. // Get the last segment, which should be the filename.
  211. const filename = segments.pop().split('?')[0];
  212. // Show the file name.
  213. this.setFilenameLabel(decodeURI(filename));
  214. } else {
  215. this.setFilenameLabel(decodeURI(this.currentUrl));
  216. }
  217. }
  218. /**
  219. * Set the string for the URL label element.
  220. *
  221. * @param {string} label - The label text to set.
  222. */
  223. setFilenameLabel(label) {
  224. const urlLabelEle = this.root.querySelector(Selectors.IMAGE.elements.fileNameLabel);
  225. if (urlLabelEle) {
  226. urlLabelEle.innerHTML = label;
  227. urlLabelEle.setAttribute("title", label);
  228. }
  229. }
  230. toggleAriaInvalid(selectors, predicate) {
  231. selectors.forEach((selector) => {
  232. const elements = this.root.querySelectorAll(selector);
  233. elements.forEach((element) => element.setAttribute('aria-invalid', predicate));
  234. });
  235. }
  236. hasErrorUrlField() {
  237. const urlError = this.currentUrl === '';
  238. if (urlError) {
  239. showElements(Selectors.IMAGE.elements.urlWarning, this.root);
  240. } else {
  241. hideElements(Selectors.IMAGE.elements.urlWarning, this.root);
  242. }
  243. this.toggleAriaInvalid([Selectors.IMAGE.elements.url], urlError);
  244. return urlError;
  245. }
  246. hasErrorAltField() {
  247. const alt = this.root.querySelector(Selectors.IMAGE.elements.alt).value;
  248. const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation).checked;
  249. const imageAltError = alt === '' && !presentation;
  250. if (imageAltError) {
  251. showElements(Selectors.IMAGE.elements.altWarning, this.root);
  252. } else {
  253. hideElements(Selectors.IMAGE.elements.altWarning, this.root);
  254. }
  255. this.toggleAriaInvalid([Selectors.IMAGE.elements.alt, Selectors.IMAGE.elements.presentation], imageAltError);
  256. return imageAltError;
  257. }
  258. updateWarning() {
  259. const urlError = this.hasErrorUrlField();
  260. const imageAltError = this.hasErrorAltField();
  261. return urlError || imageAltError;
  262. }
  263. getImageContext() {
  264. // Check if there are any accessibility issues.
  265. if (this.updateWarning()) {
  266. return null;
  267. }
  268. const classList = [];
  269. const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain).checked;
  270. const sizeOriginal = this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked;
  271. if (constrain || sizeOriginal) {
  272. // If the Auto size checkbox is checked or the Original size is checked, then apply the responsive class.
  273. classList.push(Selectors.IMAGE.styles.responsive);
  274. } else {
  275. // Otherwise, remove it.
  276. classList.pop(Selectors.IMAGE.styles.responsive);
  277. }
  278. return {
  279. url: this.currentUrl,
  280. alt: this.root.querySelector(Selectors.IMAGE.elements.alt).value,
  281. width: this.root.querySelector(Selectors.IMAGE.elements.width).value,
  282. height: this.root.querySelector(Selectors.IMAGE.elements.height).value,
  283. presentation: this.root.querySelector(Selectors.IMAGE.elements.presentation).checked,
  284. customStyle: this.root.querySelector(Selectors.IMAGE.elements.customStyle).value,
  285. classlist: classList.join(' '),
  286. };
  287. }
  288. setImage() {
  289. const pendingPromise = new Pending('tiny_media:setImage');
  290. const url = this.currentUrl;
  291. if (url === '') {
  292. return;
  293. }
  294. // Check if there are any accessibility issues.
  295. if (this.updateWarning()) {
  296. pendingPromise.resolve();
  297. return;
  298. }
  299. // Check for invalid width or height.
  300. const width = this.root.querySelector(Selectors.IMAGE.elements.width).value;
  301. if (!isPercentageValue(width) && isNaN(parseInt(width, 10))) {
  302. this.root.querySelector(Selectors.IMAGE.elements.width).focus();
  303. pendingPromise.resolve();
  304. return;
  305. }
  306. const height = this.root.querySelector(Selectors.IMAGE.elements.height).value;
  307. if (!isPercentageValue(height) && isNaN(parseInt(height, 10))) {
  308. this.root.querySelector(Selectors.IMAGE.elements.height).focus();
  309. pendingPromise.resolve();
  310. return;
  311. }
  312. Templates.render('tiny_media/image', this.getImageContext())
  313. .then((html) => {
  314. this.editor.insertContent(html);
  315. this.currentModal.destroy();
  316. pendingPromise.resolve();
  317. return html;
  318. })
  319. .catch(error => {
  320. window.console.log(error);
  321. });
  322. }
  323. /**
  324. * Deletes the image after confirming with the user and loads the insert image page.
  325. */
  326. deleteImage() {
  327. Notification.deleteCancelPromise(
  328. getString('deleteimage', 'tiny_media'),
  329. getString('deleteimagewarning', 'tiny_media'),
  330. ).then(() => {
  331. hideElements(Selectors.IMAGE.elements.altWarning, this.root);
  332. // Removing the image in the preview will bring the user to the insert page.
  333. this.loadInsertImage();
  334. return;
  335. }).catch(error => {
  336. window.console.log(error);
  337. });
  338. }
  339. registerEventListeners() {
  340. const submitAction = this.root.querySelector(Selectors.IMAGE.actions.submit);
  341. submitAction.addEventListener('click', (e) => {
  342. e.preventDefault();
  343. this.setImage();
  344. });
  345. const deleteImageEle = this.root.querySelector(Selectors.IMAGE.actions.deleteImage);
  346. deleteImageEle.addEventListener('click', () => {
  347. this.deleteImage();
  348. });
  349. deleteImageEle.addEventListener("keydown", (e) => {
  350. if (e.key === "Enter") {
  351. this.deleteImage();
  352. }
  353. });
  354. this.root.addEventListener('change', (e) => {
  355. const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
  356. if (presentationEle) {
  357. this.presentationChanged();
  358. }
  359. const constrainEle = e.target.closest(Selectors.IMAGE.elements.constrain);
  360. if (constrainEle) {
  361. this.autoAdjustSize();
  362. }
  363. const sizeOriginalEle = e.target.closest(Selectors.IMAGE.elements.sizeOriginal);
  364. if (sizeOriginalEle) {
  365. this.sizeChecked('original');
  366. }
  367. const sizeCustomEle = e.target.closest(Selectors.IMAGE.elements.sizeCustom);
  368. if (sizeCustomEle) {
  369. this.sizeChecked('custom');
  370. }
  371. });
  372. this.root.addEventListener('blur', (e) => {
  373. if (e.target.nodeType === Node.ELEMENT_NODE) {
  374. const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
  375. if (presentationEle) {
  376. this.presentationChanged();
  377. }
  378. }
  379. }, true);
  380. // Character count.
  381. this.root.addEventListener('keyup', (e) => {
  382. const altEle = e.target.closest(Selectors.IMAGE.elements.alt);
  383. if (altEle) {
  384. this.handleKeyupCharacterCount();
  385. }
  386. });
  387. this.root.addEventListener('input', (e) => {
  388. const widthEle = e.target.closest(Selectors.IMAGE.elements.width);
  389. if (widthEle) {
  390. // Avoid empty value.
  391. widthEle.value = widthEle.value === "" ? 0 : Number(widthEle.value);
  392. this.autoAdjustSize();
  393. }
  394. const heightEle = e.target.closest(Selectors.IMAGE.elements.height);
  395. if (heightEle) {
  396. // Avoid empty value.
  397. heightEle.value = heightEle.value === "" ? 0 : Number(heightEle.value);
  398. this.autoAdjustSize(true);
  399. }
  400. });
  401. }
  402. handleKeyupCharacterCount() {
  403. const alt = this.root.querySelector(Selectors.IMAGE.elements.alt).value;
  404. const current = this.root.querySelector('#currentcount');
  405. current.innerHTML = alt.length;
  406. }
  407. /**
  408. * Calculates the dimensions to fit a square into a specified box while maintaining aspect ratio.
  409. *
  410. * @param {number} squareWidth - The width of the square.
  411. * @param {number} squareHeight - The height of the square.
  412. * @param {number} boxWidth - The width of the box.
  413. * @param {number} boxHeight - The height of the box.
  414. * @returns {Object} An object with the new width and height of the square to fit in the box.
  415. */
  416. fitSquareIntoBox = (squareWidth, squareHeight, boxWidth, boxHeight) => {
  417. if (squareWidth < boxWidth && squareHeight < boxHeight) {
  418. // If the square is smaller than the box, keep its dimensions.
  419. return {
  420. width: squareWidth,
  421. height: squareHeight,
  422. };
  423. }
  424. // Calculate the scaling factor based on the minimum scaling required to fit in the box.
  425. const widthScaleFactor = boxWidth / squareWidth;
  426. const heightScaleFactor = boxHeight / squareHeight;
  427. const minScaleFactor = Math.min(widthScaleFactor, heightScaleFactor);
  428. // Scale the square's dimensions based on the aspect ratio and the minimum scaling factor.
  429. const newWidth = squareWidth * minScaleFactor;
  430. const newHeight = squareHeight * minScaleFactor;
  431. return {
  432. width: newWidth,
  433. height: newHeight,
  434. };
  435. };
  436. }