fix(deps): update dependency @tinymce/tinymce-react to v6 (#2536)

* fix(deps): update dependency @tinymce/tinymce-react to v6

* refactor: validating typing of tinymce-react usage (no runtime changes)

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
This commit is contained in:
renovate[bot]
2025-10-16 22:07:27 +00:00
committed by GitHub
parent 77215eeb5e
commit a56faf8ca7
6 changed files with 137 additions and 133 deletions

26
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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/<asset hash>/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 };
};

View File

@@ -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,

View File

@@ -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,