lib/editor/tiny/plugins/media/amd/src/embed/embedpreview.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 embed preview and details class.
  17. *
  18. * This handles the embed file/url preview before embedding them into tiny editor.
  19. *
  20. * @module tiny_media/embed/embedpreview
  21. * @copyright 2024 Stevani Andolo <stevani@hotmail.com.au>
  22. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23. */
  24. import Selectors from '../selectors';
  25. import {component} from '../common';
  26. import {getString} from 'core/str';
  27. import {
  28. sourceTypeChecked,
  29. getFileName,
  30. setPropertiesFromData,
  31. showElements,
  32. stopMediaLoading,
  33. hideElements,
  34. } from '../helpers';
  35. import {EmbedHandler} from './embedhandler';
  36. import {MediaBase} from '../mediabase';
  37. import Notification from 'core/notification';
  38. import EmbedModal from '../embedmodal';
  39. import {
  40. getEmbeddedMediaDetails,
  41. insertMediaThumbnailTemplateContext,
  42. fetchPreview,
  43. } from './embedhelpers';
  44. import {notifyFilterContentUpdated} from 'core_filters/events';
  45. export class EmbedPreview extends MediaBase {
  46. // Selector type for "EMBED".
  47. selectorType = Selectors.EMBED.type;
  48. // Fixed aspect ratio used for external media providers.
  49. linkMediaAspectRatio = 1.78;
  50. constructor(data) {
  51. super();
  52. setPropertiesFromData(this, data); // Creates dynamic properties based on "data" param.
  53. }
  54. /**
  55. * Init the media details preview.
  56. */
  57. init = async() => {
  58. this.currentModal.setTitle(getString('mediadetails', component));
  59. sourceTypeChecked({
  60. fetchedTitle: this.fetchedMediaLinkTitle ?? null,
  61. source: this.originalUrl,
  62. root: this.root,
  63. urlSelector: Selectors.EMBED.elements.fromUrl,
  64. fileNameSelector: Selectors.EMBED.elements.fileNameLabel,
  65. });
  66. this.setMediaSourceAndPoster();
  67. this.registerMediaDetailsEventListeners(this.currentModal);
  68. };
  69. /**
  70. * Sets media source and thumbnail for the video.
  71. */
  72. setMediaSourceAndPoster = async() => {
  73. const box = this.root.querySelector(Selectors.EMBED.elements.previewBox);
  74. const previewArea = document.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
  75. previewArea.setAttribute('data-original-url', this.originalUrl);
  76. // Previewing existing media could be a link one.
  77. // Or, new media added using url input and mediaType is neither video or audio.
  78. if (this.mediaType === 'link' || (this.newMediaLink && !['video', 'audio'].includes(this.mediaType))) {
  79. previewArea.setAttribute('data-media-type', 'link');
  80. previewArea.innerHTML = await fetchPreview(this.originalUrl, this.contextId);
  81. notifyFilterContentUpdated(previewArea);
  82. } else if (this.mediaType === 'video') {
  83. const video = document.createElement('video');
  84. video.src = this.originalUrl;
  85. // Media url can be played using html video.
  86. video.addEventListener('loadedmetadata', () => {
  87. const videoHeight = video.videoHeight;
  88. const videoWidth = video.videoWidth;
  89. const widthProportion = (videoWidth - videoHeight);
  90. const isLandscape = widthProportion > 0;
  91. // Store dimensions of the raw video.
  92. this.mediaDimensions = {
  93. width: videoWidth,
  94. height: videoHeight,
  95. };
  96. // Set the media preview based on the media dimensions.
  97. if (isLandscape) {
  98. video.width = box.offsetWidth;
  99. } else {
  100. video.height = box.offsetHeight;
  101. }
  102. const height = this.root.querySelector(Selectors.EMBED.elements.height);
  103. const width = this.root.querySelector(Selectors.EMBED.elements.width);
  104. if (height.value === '' && width.value === '') {
  105. height.value = videoHeight;
  106. width.value = videoWidth;
  107. }
  108. // Size checking and adjustment.
  109. if (videoHeight === parseInt(height.value) && videoWidth === parseInt(width.value)) {
  110. this.currentWidth = this.mediaDimensions.width;
  111. this.currentHeight = this.mediaDimensions.height;
  112. this.sizeChecked('original');
  113. } else {
  114. this.currentWidth = parseInt(width.value);
  115. this.currentHeight = parseInt(height.value);
  116. this.sizeChecked('custom');
  117. }
  118. });
  119. video.controls = true;
  120. if (this.media.poster) {
  121. previewArea.setAttribute('data-media-poster', this.media.poster);
  122. if (!video.classList.contains('w-100')) {
  123. video.classList.add('w-100');
  124. }
  125. video.poster = this.media.poster;
  126. }
  127. video.load();
  128. previewArea.setAttribute('data-media-type', 'video');
  129. previewArea.innerHTML = video.outerHTML;
  130. notifyFilterContentUpdated(previewArea);
  131. } else if (this.mediaType === 'audio') {
  132. const audio = document.createElement('audio');
  133. audio.src = this.originalUrl;
  134. audio.controls = true;
  135. audio.load();
  136. previewArea.setAttribute('data-media-type', 'audio');
  137. previewArea.innerHTML = audio.outerHTML;
  138. notifyFilterContentUpdated(previewArea);
  139. } else {
  140. // Show warning notification.
  141. const urlWarningLabelEle = this.root.querySelector(Selectors.EMBED.elements.urlWarning);
  142. urlWarningLabelEle.innerHTML = await getString('medianotavailabledesc', component, this.originalUrl);
  143. showElements(Selectors.EMBED.elements.urlWarning, this.root);
  144. // Stop the spinner.
  145. stopMediaLoading(this.root, Selectors.EMBED.type);
  146. // Reset the upload form.
  147. (new EmbedHandler(this)).resetUploadForm();
  148. return;
  149. }
  150. // Stop the loader and display back the body template when the media is loaded.
  151. stopMediaLoading(this.root, Selectors.EMBED.type);
  152. showElements(Selectors.EMBED.elements.mediaDetailsBody, this.root);
  153. // Set the media name/title.
  154. this.root.querySelector(Selectors.EMBED.elements.title).value = this.setMediaTitle();
  155. };
  156. /**
  157. * Set media name/title.
  158. *
  159. * @returns {string}
  160. */
  161. setMediaTitle = () => {
  162. // Getting and setting up media title/name.
  163. let fileName = null;
  164. if (['video', 'audio'].includes(this.mediaType)) {
  165. fileName = getFileName(this.originalUrl); // Get original filename.
  166. } else if (this.fetchedMediaLinkTitle) {
  167. fileName = this.fetchedMediaLinkTitle;
  168. } else {
  169. fileName = this.originalUrl;
  170. }
  171. if (this.isUpdating) {
  172. if (!this.newMediaLink) {
  173. fileName = this.mediaTitle; // Title from the selected media.
  174. }
  175. }
  176. return fileName;
  177. };
  178. /**
  179. * Deletes the media after confirming with the user and loads the insert media page.
  180. */
  181. deleteMedia = () => {
  182. Notification.deleteCancelPromise(
  183. getString('deletemedia', component),
  184. getString('deletemediawarning', component),
  185. ).then(() => {
  186. // Reset media upload form.
  187. (new EmbedHandler(this)).resetUploadForm();
  188. // Delete any selected media mediaData.
  189. delete this.mediaData;
  190. return;
  191. }).catch(error => {
  192. window.console.log(error);
  193. });
  194. };
  195. /**
  196. * Delete embedded media thumbnail.
  197. */
  198. deleteEmbeddedThumbnail = () => {
  199. Notification.deleteCancelPromise(
  200. getString('deleteembeddedthumbnail', component),
  201. getString('deleteembeddedthumbnailwarning', component),
  202. ).then(async() => {
  203. if (this.mediaType === 'video') {
  204. const video = this.root.querySelector('video');
  205. if (video) {
  206. video.removeAttribute('poster');
  207. const preview = this.root.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
  208. preview.removeAttribute('data-media-poster');
  209. }
  210. }
  211. const deleteCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.deleteCustomThumbnail);
  212. deleteCustomThumbnail.remove();
  213. const uploadCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.uploadCustomThumbnail);
  214. uploadCustomThumbnail.textContent = await getString('uploadthumbnail', component);
  215. return;
  216. }).catch(error => {
  217. window.console.log(error);
  218. });
  219. };
  220. /**
  221. * Shows the insert thumbnail dialogue.
  222. */
  223. showUploadThumbnail = async() => {
  224. const uploadThumbnailModal = await EmbedModal.create({
  225. large: true,
  226. templateContext: {elementid: this.editor.getElement().id},
  227. });
  228. const root = uploadThumbnailModal.getRoot()[0];
  229. // Get selected media metadata.
  230. const mediaData = getEmbeddedMediaDetails(this);
  231. mediaData.isUpdating = this.isUpdating;
  232. const embedHandler = new EmbedHandler(this);
  233. embedHandler.loadInsertThumbnailTemplatePromise(
  234. insertMediaThumbnailTemplateContext(this), // Get template context for creating media thumbnail.
  235. {root, uploadThumbnailModal}, // Required root elements.
  236. await embedHandler.getMediaTemplateContext(mediaData) // Get current media data.
  237. );
  238. };
  239. /**
  240. * Only registers event listeners for new loaded elements in embed preview modal.
  241. */
  242. registerMediaDetailsEventListeners = async() => {
  243. // Handle the original size when selected.
  244. const sizeOriginalEle = this.root.querySelector(Selectors.EMBED.elements.sizeOriginal);
  245. if (sizeOriginalEle) {
  246. sizeOriginalEle.addEventListener('change', () => {
  247. this.sizeChecked('original');
  248. });
  249. }
  250. // Handle the custom size when selected.
  251. const sizeCustomEle = this.root.querySelector(Selectors.EMBED.elements.sizeCustom);
  252. if (sizeCustomEle) {
  253. sizeCustomEle.addEventListener('change', () => {
  254. this.sizeChecked('custom');
  255. });
  256. }
  257. const widthEle = this.root.querySelector(Selectors.EMBED.elements.width);
  258. const heightEle = this.root.querySelector(Selectors.EMBED.elements.height);
  259. // Handle the custom with size when inputted.
  260. if (widthEle) {
  261. widthEle.addEventListener('input', () => {
  262. if (this.mediaType === 'link') {
  263. // Let's apply the 16:9 aspect ratio if it's a link media type.
  264. heightEle.value = Math.round(widthEle.value / this.linkMediaAspectRatio);
  265. } else {
  266. // Avoid empty value.
  267. widthEle.value = widthEle.value === "" ? 0 : Number(widthEle.value);
  268. this.autoAdjustSize();
  269. }
  270. });
  271. }
  272. // Handle the custom height size when inputted.
  273. if (heightEle) {
  274. heightEle.addEventListener('input', () => {
  275. if (this.mediaType === 'link') {
  276. // Let's apply the 16:9 aspect ratio if it's a link media type.
  277. widthEle.value = Math.round(heightEle.value * this.linkMediaAspectRatio);
  278. } else {
  279. // Avoid empty value.
  280. heightEle.value = heightEle.value === "" ? 0 : Number(heightEle.value);
  281. this.autoAdjustSize(true);
  282. }
  283. });
  284. }
  285. // Handle media preview delete.
  286. const deleteMedia = this.root.querySelector(Selectors.EMBED.actions.deleteMedia);
  287. if (deleteMedia) {
  288. deleteMedia.addEventListener('click', (e) => {
  289. e.preventDefault();
  290. this.deleteMedia();
  291. });
  292. }
  293. // Show subtitles and captions settings.
  294. const showSubtitleCaption = this.root.querySelector(Selectors.EMBED.actions.showSubtitleCaption);
  295. if (showSubtitleCaption) {
  296. showSubtitleCaption.addEventListener('click', (e) => {
  297. e.preventDefault();
  298. hideElements([
  299. Selectors.EMBED.actions.showSubtitleCaption,
  300. Selectors.EMBED.actions.cancelMediaDetails,
  301. Selectors.EMBED.elements.mediaDetailsBody,
  302. ], this.root);
  303. showElements([
  304. Selectors.EMBED.actions.backToMediaDetails,
  305. Selectors.EMBED.elements.mediaSubtitleCaptionBody,
  306. ], this.root);
  307. });
  308. }
  309. // Back to media preview.
  310. const backToMediaDetails = this.root.querySelector(Selectors.EMBED.actions.backToMediaDetails);
  311. if (backToMediaDetails) {
  312. backToMediaDetails.addEventListener('click', () => {
  313. hideElements([
  314. Selectors.EMBED.actions.backToMediaDetails,
  315. Selectors.EMBED.elements.mediaSubtitleCaptionBody,
  316. ], this.root);
  317. showElements([
  318. Selectors.EMBED.actions.showSubtitleCaption,
  319. Selectors.EMBED.actions.cancelMediaDetails,
  320. Selectors.EMBED.elements.mediaDetailsBody,
  321. ], this.root);
  322. });
  323. }
  324. // Handles upload media thumbnail.
  325. const uploadCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.uploadCustomThumbnail);
  326. if (uploadCustomThumbnail) {
  327. uploadCustomThumbnail.addEventListener('click', () => {
  328. this.showUploadThumbnail();
  329. });
  330. }
  331. // Handles delete media thumbnail.
  332. const deleteCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.deleteCustomThumbnail);
  333. if (deleteCustomThumbnail) {
  334. deleteCustomThumbnail.addEventListener('click', () => {
  335. this.deleteEmbeddedThumbnail();
  336. });
  337. }
  338. // Handles language track selection.
  339. const langTracks = this.root.querySelectorAll(Selectors.EMBED.elements.trackLang);
  340. if (langTracks) {
  341. langTracks.forEach((dropdown) => {
  342. const defaultVal = dropdown.getAttribute('data-value');
  343. if (defaultVal) {
  344. // ISO 639-1: 2-letter codes (e.g., en for English, fr for French).
  345. // Most widely used in applications like web development (lang="en" in HTML).
  346. // Let's check if the value of srclang is language code or language name.
  347. if (defaultVal.length === 2) {
  348. const options = dropdown.options;
  349. for (let i = 0; i < options.length; i++) {
  350. if (options[i].dataset.languageCode === defaultVal) {
  351. dropdown.value = options[i].value;
  352. break;
  353. }
  354. }
  355. } else {
  356. // It means the value of srclang in track is a full language name like "English (en)",
  357. // which has been like this before this patch.
  358. dropdown.value = defaultVal;
  359. }
  360. }
  361. });
  362. }
  363. };
  364. }