diff --git a/package-lock.json b/package-lock.json index 2b01add39..7c4615945 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "5.90.2", - "@tinymce/tinymce-react": "^3.14.0", + "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", "codemirror": "^6.0.0", "email-validator": "2.0.4", @@ -8373,17 +8373,22 @@ } }, "node_modules/@tinymce/tinymce-react": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-3.14.0.tgz", - "integrity": "sha512-1X3Kl4DNVG/XNttlniQHvb9awX2MrD7XaFO2nWZ9SJrionIqWqKMLVl5GnJ8Br6KehNl97amxO8t3+5eLvfgxg==", - "license": "Apache-2.0", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz", + "integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==", + "license": "MIT", "dependencies": { - "prop-types": "^15.6.2", - "tinymce": "^5.5.1" + "prop-types": "^15.6.2" }, "peerDependencies": { - "react": "^18.0.0 || ^17.0.1 || ^16.7.0", - "react-dom": "^18.0.0 || ^17.0.1 || ^16.7.0" + "react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1" + }, + "peerDependenciesMeta": { + "tinymce": { + "optional": true + } } }, "node_modules/@tokens-studio/sd-transforms": { @@ -27363,7 +27368,8 @@ "version": "5.10.9", "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", "integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", - "license": "LGPL-2.1" + "license": "LGPL-2.1", + "peer": true }, "node_modules/tmp": { "version": "0.0.33", diff --git a/package.json b/package.json index 702388367..009650299 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "5.90.2", - "@tinymce/tinymce-react": "^3.14.0", + "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", "codemirror": "^6.0.0", "email-validator": "2.0.4", diff --git a/src/editors/sharedComponents/ImageUploadModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/index.jsx index e72f4a56d..07353c484 100644 --- a/src/editors/sharedComponents/ImageUploadModal/index.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/index.jsx @@ -1,7 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl } from '@edx/frontend-platform/i18n'; import * as tinyMCEKeys from '../../data/constants/tinyMCE'; import ImageSettingsModal from './ImageSettingsModal'; import SelectImageModal from './SelectImageModal'; @@ -204,4 +203,4 @@ ImageUploadModal.propTypes = { }; export const ImageUploadModalInternal = ImageUploadModal; // For testing only -export default injectIntl(ImageUploadModal); +export default ImageUploadModal; diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.ts similarity index 92% rename from src/editors/sharedComponents/TinyMceWidget/hooks.js rename to src/editors/sharedComponents/TinyMceWidget/hooks.ts index 3730e809b..f89f1320a 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.ts @@ -11,11 +11,7 @@ import { isEmpty } from 'lodash'; import tinyMCEStyles from '../../data/constants/tinyMCEStyles'; import { StrictDict } from '../../utils'; import pluginConfig from './pluginConfig'; -// This 'module' self-import hack enables mocking during tests. -// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested -// should be re-thought and cleaned up to avoid this pattern. -// eslint-disable-next-line import/no-self-import -import * as module from './hooks'; + import * as tinyMCE from '../../data/constants/tinyMCE'; import { getRelativeUrl, getStaticUrl, parseAssetName } from './utils'; import { isLibraryKey } from '../../../generic/key-utils'; @@ -31,9 +27,39 @@ export const state = StrictDict({ refReady: (val) => useState(val), }); +/** + * const imageMatchRegex + * + * Image urls and ids used in the TinyMceEditor vary wildly, with different base urls, + * different lengths and constituent parts, and replacement of some "/" with "@". + * Common are the keys "asset-v1", "type", and "block", each holding a value after some separator. + * This regex captures only the values for these keys using capture groups, which can be used for matching. + */ +export const imageMatchRegex = /asset-v1.(.*).type.(.*).block.(.*)/; + +/** + * function matchImageStringsByIdentifiers + * + * matches two strings by comparing their regex capture groups using the `imageMatchRegex` + */ +export const matchImageStringsByIdentifiers = (a, b) => { + if (!a || !b || !(typeof a === 'string') || !(typeof b === 'string')) { return null; } + const matchA = JSON.stringify(a.match(imageMatchRegex)?.slice?.(1)); + const matchB = JSON.stringify(b.match(imageMatchRegex)?.slice?.(1)); + return matchA && matchA === matchB; +}; + +export const stringToFragment = (htmlString) => document.createRange().createContextualFragment(htmlString); + +export function getImageFromHtmlString(htmlString, imageSrc) { + const images = stringToFragment(htmlString)?.querySelectorAll('img') || []; + + return Array.from(images).find((img) => matchImageStringsByIdentifiers(img.src || '', imageSrc)); +} + export const addImagesAndDimensionsToRef = ({ imagesRef, images, editorContentHtml }) => { - const imagesWithDimensions = Object.values(images).map((image) => { - const imageFragment = module.getImageFromHtmlString(editorContentHtml, image.url); + const imagesWithDimensions = Object.values(images).map((image: any) => { + const imageFragment = getImageFromHtmlString(editorContentHtml, image.url); return { ...image, width: imageFragment?.width, height: imageFragment?.height }; }); // eslint-disable-next-line no-param-reassign @@ -44,7 +70,7 @@ export const useImages = ({ images, editorContentHtml }) => { const imagesRef = useRef([]); useEffect(() => { - module.addImagesAndDimensionsToRef({ imagesRef, images, editorContentHtml }); + addImagesAndDimensionsToRef({ imagesRef, images, editorContentHtml }); }, [images]); return { imagesRef }; @@ -130,13 +156,44 @@ export const replaceStaticWithAsset = ({ return false; }; +/** + * function updateImageDimensions + * + * Updates one images' dimensions in an array by identifying one image via a url string match + * that includes asset-v1, type, and block. Returns a new array. + * + * @param {Object[]} images - [{ id, ...other }] + * @param {string} url + * @param {number} width + * @param {number} height + * + * @returns {Object} { result, foundMatch } + */ +export function updateImageDimensions({ + images, url, width, height, +}) { + let foundMatch = false; + + const result = images.map((image) => { + const imageIdentifier = image.id || image.url || image.src || image.externalUrl; + const isMatch = matchImageStringsByIdentifiers(imageIdentifier, url); + if (isMatch) { + foundMatch = true; + return { ...image, width, height }; + } + return image; + }); + + return { result, foundMatch }; +} + export const getImageResizeHandler = ({ editor, imagesRef, setImage }) => () => { const { src, alt, width, height, } = editor.selection.getNode(); // eslint-disable-next-line no-param-reassign - imagesRef.current = module.updateImageDimensions({ + imagesRef.current = updateImageDimensions({ images: imagesRef.current, url: src, width, height, }).result; @@ -182,6 +239,41 @@ export const reparentTinyMceModals = /* istanbul ignore next */ () => { } }; +export const detectImageMatchingError = ({ matchingImages, tinyMceHTML }) => { + if (!matchingImages.length) { return true; } + if (matchingImages.length > 1) { return true; } + + if (!matchImageStringsByIdentifiers(matchingImages[0].id, tinyMceHTML.src)) { return true; } + if (!matchingImages[0].width || !matchingImages[0].height) { return true; } + if (matchingImages[0].width !== tinyMceHTML.width) { return true; } + if (matchingImages[0].height !== tinyMceHTML.height) { return true; } + + return false; +}; + +export const openModalWithSelectedImage = ({ + editor, images, setImage, openImgModal, +}) => () => { + const tinyMceHTML = editor.selection.getNode(); + const { src: mceSrc } = tinyMceHTML; + + const matchingImages = images.current.filter(image => matchImageStringsByIdentifiers(image.id, mceSrc)); + + const imageMatchingErrorDetected = detectImageMatchingError({ tinyMceHTML, matchingImages }); + + const width = imageMatchingErrorDetected ? null : matchingImages[0]?.width; + const height = imageMatchingErrorDetected ? null : matchingImages[0]?.height; + + setImage({ + externalUrl: tinyMceHTML.src, + altText: tinyMceHTML.alt, + width, + height, + }); + + openImgModal(); +}; + export const setupCustomBehavior = ({ updateContent, openImgModal, @@ -202,7 +294,7 @@ export const setupCustomBehavior = ({ editor.ui.registry.addButton(tinyMCE.buttons.editImageSettings, { icon: 'image', tooltip: 'Edit Image Settings', - onAction: module.openModalWithSelectedImage({ + onAction: openModalWithSelectedImage({ editor, images, setImage, openImgModal, }), }); @@ -244,7 +336,7 @@ export const setupCustomBehavior = ({ if (editorType === 'expandable') { editor.on('init', () => { const initialContent = editor.getContent(); - const newContent = module.replaceStaticWithAsset({ + const newContent = replaceStaticWithAsset({ initialContent, editorType, lmsEndpointUrl, @@ -273,7 +365,8 @@ export const setupCustomBehavior = ({ editor.on('ExecCommand', /* istanbul ignore next */ (e) => { if (editorType === 'text' && e.command === 'mceFocus') { const initialContent = editor.getContent(); - const newContent = module.replaceStaticWithAsset({ + // @ts-ignore Some parameters like 'lmsEndpointUrl' were missing here. Fix me? + const newContent = replaceStaticWithAsset({ initialContent, learningContextId, }); @@ -323,7 +416,7 @@ export const editorConfig = ({ } = pluginConfig({ placeholder, editorType, enableImageUpload }); const isLocaleRtl = isRtl(getLocale()); return { - onInit: (evt, editor) => { + onInit: (_evt, editor) => { setEditorRef(editor); if (editorType === 'text') { initializeEditor(); @@ -338,18 +431,19 @@ export const editorConfig = ({ min_height: minHeight, max_height: maxHeight, contextmenu: 'link table', - directionality: isLocaleRtl ? 'rtl' : 'ltr', + directionality: isLocaleRtl ? 'rtl' as const : 'ltr' as const, document_base_url: baseURL, imagetools_cors_hosts: [removeProtocolFromUrl(lmsEndpointUrl), removeProtocolFromUrl(studioEndpointUrl)], imagetools_toolbar: imageToolbar, formats: { label: { inline: 'label' } }, - setup: module.setupCustomBehavior({ + setup: setupCustomBehavior({ editorType, updateContent, openImgModal, openSourceCodeModal, lmsEndpointUrl, setImage: setSelection, + // @ts-ignore FIXME: 'content' is not an accepted parameter of setupCustomBehavior() content, images, learningContextId, @@ -361,7 +455,8 @@ export const editorConfig = ({ plugins, valid_children: '+body[style]', valid_elements: '*[*]', - entity_encoding: 'utf-8', + // FIXME: this is passing 'utf-8', which is not a valid entity_encoding value. It should be 'named' etc. + entity_encoding: 'utf-8' as any, }, }; }; @@ -373,14 +468,14 @@ export const prepareEditorRef = () => { const setEditorRef = useCallback((ref) => { editorRef.current = ref; }, []); - const [refReady, setRefReady] = module.state.refReady(false); + const [refReady, setRefReady] = state.refReady(false); // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => setRefReady(true), []); return { editorRef, refReady, setEditorRef }; }; export const imgModalToggle = () => { - const [isImgOpen, setIsOpen] = module.state.isImageModalOpen(false); + const [isImgOpen, setIsOpen] = state.isImageModalOpen(false); return { isImgOpen, openImgModal: () => setIsOpen(true), @@ -389,7 +484,7 @@ export const imgModalToggle = () => { }; export const sourceCodeModalToggle = (editorRef) => { - const [isSourceCodeOpen, setIsOpen] = module.state.isSourceCodeModalOpen(false); + const [isSourceCodeOpen, setIsOpen] = state.isSourceCodeModalOpen(false); return { isSourceCodeOpen, openSourceCodeModal: () => setIsOpen(true), @@ -400,71 +495,6 @@ export const sourceCodeModalToggle = (editorRef) => { }; }; -/** - * const imageMatchRegex - * - * Image urls and ids used in the TinyMceEditor vary wildly, with different base urls, - * different lengths and constituent parts, and replacement of some "/" with "@". - * Common are the keys "asset-v1", "type", and "block", each holding a value after some separator. - * This regex captures only the values for these keys using capture groups, which can be used for matching. - */ -export const imageMatchRegex = /asset-v1.(.*).type.(.*).block.(.*)/; - -/** - * function matchImageStringsByIdentifiers - * - * matches two strings by comparing their regex capture groups using the `imageMatchRegex` - */ -export const matchImageStringsByIdentifiers = (a, b) => { - if (!a || !b || !(typeof a === 'string') || !(typeof b === 'string')) { return null; } - const matchA = JSON.stringify(a.match(imageMatchRegex)?.slice?.(1)); - const matchB = JSON.stringify(b.match(imageMatchRegex)?.slice?.(1)); - return matchA && matchA === matchB; -}; - -export const stringToFragment = (htmlString) => document.createRange().createContextualFragment(htmlString); - -export const getImageFromHtmlString = (htmlString, imageSrc) => { - const images = stringToFragment(htmlString)?.querySelectorAll('img') || []; - - return Array.from(images).find((img) => matchImageStringsByIdentifiers(img.src || '', imageSrc)); -}; - -export const detectImageMatchingError = ({ matchingImages, tinyMceHTML }) => { - if (!matchingImages.length) { return true; } - if (matchingImages.length > 1) { return true; } - - if (!matchImageStringsByIdentifiers(matchingImages[0].id, tinyMceHTML.src)) { return true; } - if (!matchingImages[0].width || !matchingImages[0].height) { return true; } - if (matchingImages[0].width !== tinyMceHTML.width) { return true; } - if (matchingImages[0].height !== tinyMceHTML.height) { return true; } - - return false; -}; - -export const openModalWithSelectedImage = ({ - editor, images, setImage, openImgModal, -}) => () => { - const tinyMceHTML = editor.selection.getNode(); - const { src: mceSrc } = tinyMceHTML; - - const matchingImages = images.current.filter(image => matchImageStringsByIdentifiers(image.id, mceSrc)); - - const imageMatchingErrorDetected = detectImageMatchingError({ tinyMceHTML, matchingImages }); - - const width = imageMatchingErrorDetected ? null : matchingImages[0]?.width; - const height = imageMatchingErrorDetected ? null : matchingImages[0]?.height; - - setImage({ - externalUrl: tinyMceHTML.src, - altText: tinyMceHTML.alt, - width, - height, - }); - - openImgModal(); -}; - export const setAssetToStaticUrl = ({ editorValue, lmsEndpointUrl }) => { /* For assets to remain usable across course instances, we convert their url to be course-agnostic. * For example, /assets/course//filename gets converted to /static/filename. This is @@ -486,7 +516,7 @@ export const setAssetToStaticUrl = ({ editorValue, lmsEndpointUrl }) => { content = updatedContent; }); - const updatedStaticUrls = []; + const updatedStaticUrls: string[] = []; assetSrcs.filter(src => src.startsWith('static/')).forEach(src => { // Before storing assets we make sure that library static assets points again to // `/static/dummy.jpg` instead of using the relative url `static/dummy.jpg` @@ -507,41 +537,10 @@ export const setAssetToStaticUrl = ({ editorValue, lmsEndpointUrl }) => { }; export const selectedImage = (val) => { - const [selection, setSelection] = module.state.imageSelection(val); + const [selection, setSelection] = state.imageSelection(val); return { clearSelection: () => setSelection(null), selection, setSelection, }; }; - -/** - * function updateImageDimensions - * - * Updates one images' dimensions in an array by identifying one image via a url string match - * that includes asset-v1, type, and block. Returns a new array. - * - * @param {Object[]} images - [{ id, ...other }] - * @param {string} url - * @param {number} width - * @param {number} height - * - * @returns {Object} { result, foundMatch } - */ -export const updateImageDimensions = ({ - images, url, width, height, -}) => { - let foundMatch = false; - - const result = images.map((image) => { - const imageIdentifier = image.id || image.url || image.src || image.externalUrl; - const isMatch = matchImageStringsByIdentifiers(imageIdentifier, url); - if (isMatch) { - foundMatch = true; - return { ...image, width, height }; - } - return image; - }); - - return { result, foundMatch }; -}; diff --git a/src/editors/sharedComponents/TinyMceWidget/index.jsx b/src/editors/sharedComponents/TinyMceWidget/index.tsx similarity index 94% rename from src/editors/sharedComponents/TinyMceWidget/index.jsx rename to src/editors/sharedComponents/TinyMceWidget/index.tsx index e82000a4b..66bef9832 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.jsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.tsx @@ -80,10 +80,12 @@ const TinyMceWidget = ({ disabled={disabled} onEditorChange={onChange} { + // @ts-ignore FIXME: this will have type errors until `editorConfig` gets proper type definitions. ...hooks.editorConfig({ openImgModal, openSourceCodeModal, editorType, + // @ts-ignore FIXME: 'editorRef' is not an accepted parameter of editorConfig() editorRef, enableImageUpload: isLibraryV1Key(learningContextId) ? false : enableImageUpload, learningContextId, @@ -108,7 +110,6 @@ TinyMceWidget.defaultProps = { id: null, disabled: false, editorContentHtml: undefined, - updateContent: undefined, enableImageUpload: true, onChange: () => ({}), ...editorConfigDefaultProps, @@ -124,7 +125,6 @@ TinyMceWidget.propTypes = { id: PropTypes.string, disabled: PropTypes.bool, editorContentHtml: PropTypes.string, - updateContent: PropTypes.func, enableImageUpload: PropTypes.bool, onChange: PropTypes.func, ...editorConfigPropTypes, diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js index b9aa7452a..b216c0fe2 100644 --- a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js +++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js @@ -85,7 +85,7 @@ const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => { branding: false, height: '100%', menubar: false, - toolbar_mode: 'sliding', + toolbar_mode: /** @type {'sliding'} */('sliding'), toolbar_sticky: true, toolbar_sticky_offset: hasStudioHeader ? 0 : 76, relative_urls: true,