Sets a max_height=500px for the TinyMCE editor when editing a Text/Html component.
This prevents the autoresize plugin from expanding the editor textarea beyond the bounds of the editor modal.
⚠️ Because the max height can only be a numeric pixel value, we can't use clever settings like vh or %, and so we're forced to limit the height of the editor to a fixed size for all screen sizes in order to address this issue.
(cherry picked from commit c5f7d0cf3b)
548 lines
19 KiB
JavaScript
548 lines
19 KiB
JavaScript
import {
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useEffect,
|
|
} from 'react';
|
|
import { getConfig } from '@edx/frontend-platform';
|
|
import { getLocale, isRtl } from '@edx/frontend-platform/i18n';
|
|
import { a11ycheckerCss } from 'frontend-components-tinymce-advanced-plugins';
|
|
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';
|
|
|
|
export const state = StrictDict({
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
isImageModalOpen: (val) => useState(val),
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
isSourceCodeModalOpen: (val) => useState(val),
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
imageSelection: (val) => useState(val),
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
refReady: (val) => useState(val),
|
|
});
|
|
|
|
export const addImagesAndDimensionsToRef = ({ imagesRef, images, editorContentHtml }) => {
|
|
const imagesWithDimensions = Object.values(images).map((image) => {
|
|
const imageFragment = module.getImageFromHtmlString(editorContentHtml, image.url);
|
|
return { ...image, width: imageFragment?.width, height: imageFragment?.height };
|
|
});
|
|
// eslint-disable-next-line no-param-reassign
|
|
imagesRef.current = imagesWithDimensions;
|
|
};
|
|
|
|
export const useImages = ({ images, editorContentHtml }) => {
|
|
const imagesRef = useRef([]);
|
|
|
|
useEffect(() => {
|
|
module.addImagesAndDimensionsToRef({ imagesRef, images, editorContentHtml });
|
|
}, [images]);
|
|
|
|
return { imagesRef };
|
|
};
|
|
|
|
export const parseContentForLabels = ({ editor, updateContent }) => {
|
|
let content = editor.getContent();
|
|
if (content && content?.length > 0) {
|
|
const parsedLabels = content.split(/<label>|<\/label>/gm);
|
|
let updatedContent;
|
|
parsedLabels.forEach((label, i) => {
|
|
if (!label.startsWith('<') && !label.endsWith('>')) {
|
|
let previousLabel = parsedLabels[i - 1];
|
|
let nextLabel = parsedLabels[i + 1];
|
|
if (!previousLabel.endsWith('<p>')) {
|
|
previousLabel = `${previousLabel}</p><p>`;
|
|
updatedContent = content.replace(parsedLabels[i - 1], previousLabel);
|
|
content = updatedContent;
|
|
updateContent(content);
|
|
}
|
|
if (!nextLabel.startsWith('</p>')) {
|
|
nextLabel = `</p><p>${nextLabel}`;
|
|
updatedContent = content.replace(parsedLabels[i + 1], nextLabel);
|
|
content = updatedContent;
|
|
updateContent(content);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
updateContent(content);
|
|
}
|
|
};
|
|
|
|
export const replaceStaticWithAsset = ({
|
|
initialContent,
|
|
learningContextId,
|
|
editorType,
|
|
lmsEndpointUrl,
|
|
}) => {
|
|
let content = initialContent;
|
|
let hasChanges = false;
|
|
const srcs = content.split(/(src="|src="|href="|href=")/g).filter(
|
|
src => src.startsWith('/static') || src.startsWith('/asset'),
|
|
);
|
|
if (!isEmpty(srcs)) {
|
|
srcs.forEach(src => {
|
|
const currentContent = content;
|
|
let staticFullUrl;
|
|
const isStatic = src.startsWith('/static/');
|
|
const assetSrc = src.substring(0, src.indexOf('"'));
|
|
const staticName = assetSrc.substring(8);
|
|
const assetName = parseAssetName(src);
|
|
const displayName = isStatic ? staticName : assetName;
|
|
const isCorrectAssetFormat = assetSrc.startsWith('/asset') && assetSrc.match(/\/asset-v1:\S+[+]\S+[@]\S+[+]\S+[@]/g)?.length >= 1;
|
|
|
|
// assets in expandable text areas do not support relative urls so all assets must have the lms
|
|
// endpoint prepended to the relative url
|
|
if (isLibraryKey(learningContextId)) {
|
|
// We are removing the initial "/" in a "/static/foo.png" link, and then
|
|
// set the base URL to an endpoint serving the draft version of an asset by
|
|
// its path.
|
|
/* istanbul ignore next */
|
|
if (isStatic) {
|
|
staticFullUrl = assetSrc.substring(1);
|
|
}
|
|
} else if (editorType === 'expandable') {
|
|
if (isCorrectAssetFormat) {
|
|
staticFullUrl = `${lmsEndpointUrl}${assetSrc}`;
|
|
} else {
|
|
staticFullUrl = `${lmsEndpointUrl}${getRelativeUrl({ courseId: learningContextId, displayName })}`;
|
|
}
|
|
} else if (!isCorrectAssetFormat) {
|
|
staticFullUrl = getRelativeUrl({ courseId: learningContextId, displayName });
|
|
}
|
|
if (staticFullUrl) {
|
|
const currentSrc = src.substring(0, src.indexOf('"'));
|
|
content = currentContent.replace(currentSrc, staticFullUrl);
|
|
hasChanges = true;
|
|
}
|
|
});
|
|
if (hasChanges) { return content; }
|
|
}
|
|
return false;
|
|
};
|
|
|
|
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({
|
|
images: imagesRef.current, url: src, width, height,
|
|
}).result;
|
|
|
|
setImage({
|
|
externalUrl: src,
|
|
altText: alt,
|
|
width,
|
|
height,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Fix TinyMCE editors used in Paragon modals, by re-parenting their modal <div>
|
|
* from the body to the Paragon modal container.
|
|
*
|
|
* This fixes a problem where clicking on any modal/popup within TinyMCE (e.g.
|
|
* the emoji inserter, the link inserter, the floating format toolbar -
|
|
* quickbars, etc.) would cause the parent Paragon modal to close, because
|
|
* Paragon sees it as a "click outside" event. Also fixes some hover effects by
|
|
* ensuring the layering of the divs is correct.
|
|
*
|
|
* This could potentially cause problems if there are TinyMCE editors being used
|
|
* both on the parent page and inside a Paragon modal popup, but I don't think
|
|
* we have that situation.
|
|
*
|
|
* Note: we can't just do this on init, because the quickbars plugin used by
|
|
* ExpandableTextEditors creates its modal DIVs later. Ideally we could listen
|
|
* for some kind of "modal open" event, but I haven't been able to find anything
|
|
* like that so for now we do this quite frequently, every time there is a
|
|
* "selectionchange" event (which is pretty often).
|
|
*/
|
|
export const reparentTinyMceModals = /* istanbul ignore next */ () => {
|
|
const modalLayer = document.querySelector('.pgn__modal-layer');
|
|
if (!modalLayer) {
|
|
return;
|
|
}
|
|
const tinymceAuxDivs = document.querySelectorAll('.tox.tox-tinymce-aux');
|
|
for (const tinymceAux of tinymceAuxDivs) {
|
|
if (tinymceAux.parentElement !== modalLayer) {
|
|
// Move this tinyMCE modal div into the paragon modal layer.
|
|
modalLayer.appendChild(tinymceAux);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const setupCustomBehavior = ({
|
|
updateContent,
|
|
openImgModal,
|
|
openSourceCodeModal,
|
|
editorType,
|
|
images,
|
|
setImage,
|
|
lmsEndpointUrl,
|
|
learningContextId,
|
|
}) => (editor) => {
|
|
// image upload button
|
|
editor.ui.registry.addButton(tinyMCE.buttons.imageUploadButton, {
|
|
icon: 'image',
|
|
tooltip: 'Add Image',
|
|
onAction: openImgModal,
|
|
});
|
|
// editing an existing image
|
|
editor.ui.registry.addButton(tinyMCE.buttons.editImageSettings, {
|
|
icon: 'image',
|
|
tooltip: 'Edit Image Settings',
|
|
onAction: module.openModalWithSelectedImage({
|
|
editor, images, setImage, openImgModal,
|
|
}),
|
|
});
|
|
// overriding the code plugin's icon with 'HTML' text
|
|
editor.ui.registry.addButton(tinyMCE.buttons.code, {
|
|
text: 'HTML',
|
|
tooltip: 'Source code',
|
|
onAction: openSourceCodeModal,
|
|
});
|
|
// add a custom simple inline code block formatter.
|
|
const setupCodeFormatting = (api) => {
|
|
editor.formatter.formatChanged(
|
|
'code',
|
|
(active) => api.setActive(active),
|
|
);
|
|
};
|
|
const toggleCodeFormatting = () => {
|
|
editor.formatter.toggle('code');
|
|
editor.undoManager.add();
|
|
editor.focus();
|
|
};
|
|
editor.ui.registry.addToggleButton(tinyMCE.buttons.codeBlock, {
|
|
icon: 'sourcecode',
|
|
tooltip: 'Code Block',
|
|
onAction: toggleCodeFormatting,
|
|
onSetup: setupCodeFormatting,
|
|
});
|
|
// add a custom simple inline label formatter.
|
|
const toggleLabelFormatting = () => {
|
|
editor.execCommand('mceToggleFormat', false, 'label');
|
|
};
|
|
editor.ui.registry.addIcon('textToSpeech', tinyMCE.textToSpeechIcon);
|
|
editor.ui.registry.addButton('customLabelButton', {
|
|
icon: 'textToSpeech',
|
|
text: 'Label',
|
|
tooltip: 'Apply a "Question" label to specific text, recognized by screen readers. Recommended to improve accessibility.',
|
|
onAction: toggleLabelFormatting,
|
|
});
|
|
if (editorType === 'expandable') {
|
|
editor.on('init', () => {
|
|
const initialContent = editor.getContent();
|
|
const newContent = module.replaceStaticWithAsset({
|
|
initialContent,
|
|
editorType,
|
|
lmsEndpointUrl,
|
|
learningContextId,
|
|
});
|
|
// istanbul ignore if
|
|
if (newContent) {
|
|
// update content but mark as not dirty as user did not change anything
|
|
updateContent(newContent, false);
|
|
editor.setDirty(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
editor.on('init', /* istanbul ignore next */ () => {
|
|
// Check if this editor is inside a (Paragon) modal.
|
|
// The way we get the editor's root <div> depends on whether or not this particular editor is using an iframe:
|
|
const editorDiv = editor.bodyElement ?? editor.container;
|
|
if (editorDiv?.closest('.pgn__modal')) {
|
|
// This editor is inside a Paragon modal. Use this hack to avoid interference with TinyMCE's own modal popups:
|
|
reparentTinyMceModals();
|
|
editor.on('selectionchange', reparentTinyMceModals);
|
|
}
|
|
});
|
|
|
|
editor.on('ExecCommand', /* istanbul ignore next */ (e) => {
|
|
if (editorType === 'text' && e.command === 'mceFocus') {
|
|
const initialContent = editor.getContent();
|
|
const newContent = module.replaceStaticWithAsset({
|
|
initialContent,
|
|
learningContextId,
|
|
});
|
|
if (newContent) { editor.setContent(newContent); }
|
|
}
|
|
if (e.command === 'RemoveFormat') {
|
|
editor.formatter.remove('blockquote');
|
|
editor.formatter.remove('label');
|
|
}
|
|
});
|
|
// after resizing an image in the editor, synchronize React state and ref
|
|
editor.on('ObjectResized', getImageResizeHandler({ editor, imagesRef: images, setImage }));
|
|
};
|
|
|
|
// imagetools_cors_hosts needs a protocol-sanatized url
|
|
export const removeProtocolFromUrl = (url) => url.replace(/^https?:\/\//, '');
|
|
|
|
export const editorConfig = ({
|
|
editorType,
|
|
setEditorRef,
|
|
editorContentHtml,
|
|
images,
|
|
placeholder,
|
|
initializeEditor,
|
|
openImgModal,
|
|
openSourceCodeModal,
|
|
setSelection,
|
|
updateContent,
|
|
content,
|
|
minHeight,
|
|
maxHeight,
|
|
learningContextId,
|
|
staticRootUrl,
|
|
enableImageUpload,
|
|
}) => {
|
|
const lmsEndpointUrl = getConfig().LMS_BASE_URL;
|
|
const studioEndpointUrl = getConfig().STUDIO_BASE_URL;
|
|
|
|
const baseURL = staticRootUrl || lmsEndpointUrl;
|
|
const {
|
|
toolbar,
|
|
config,
|
|
plugins,
|
|
imageToolbar,
|
|
quickbarsInsertToolbar,
|
|
quickbarsSelectionToolbar,
|
|
} = pluginConfig({ placeholder, editorType, enableImageUpload });
|
|
const isLocaleRtl = isRtl(getLocale());
|
|
return {
|
|
onInit: (evt, editor) => {
|
|
setEditorRef(editor);
|
|
if (editorType === 'text') {
|
|
initializeEditor();
|
|
}
|
|
},
|
|
initialValue: editorContentHtml || '',
|
|
init: {
|
|
...config,
|
|
skin: false,
|
|
content_css: false,
|
|
content_style: tinyMCEStyles + a11ycheckerCss,
|
|
min_height: minHeight,
|
|
max_height: maxHeight,
|
|
contextmenu: 'link table',
|
|
directionality: isLocaleRtl ? 'rtl' : 'ltr',
|
|
document_base_url: baseURL,
|
|
imagetools_cors_hosts: [removeProtocolFromUrl(lmsEndpointUrl), removeProtocolFromUrl(studioEndpointUrl)],
|
|
imagetools_toolbar: imageToolbar,
|
|
formats: { label: { inline: 'label' } },
|
|
setup: module.setupCustomBehavior({
|
|
editorType,
|
|
updateContent,
|
|
openImgModal,
|
|
openSourceCodeModal,
|
|
lmsEndpointUrl,
|
|
setImage: setSelection,
|
|
content,
|
|
images,
|
|
learningContextId,
|
|
}),
|
|
quickbars_insert_toolbar: quickbarsInsertToolbar,
|
|
quickbars_selection_toolbar: quickbarsSelectionToolbar,
|
|
quickbars_image_toolbar: false,
|
|
toolbar,
|
|
plugins,
|
|
valid_children: '+body[style]',
|
|
valid_elements: '*[*]',
|
|
entity_encoding: 'utf-8',
|
|
},
|
|
};
|
|
};
|
|
|
|
export const prepareEditorRef = () => {
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
const editorRef = useRef(null);
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
const setEditorRef = useCallback((ref) => {
|
|
editorRef.current = ref;
|
|
}, []);
|
|
const [refReady, setRefReady] = module.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);
|
|
return {
|
|
isImgOpen,
|
|
openImgModal: () => setIsOpen(true),
|
|
closeImgModal: () => setIsOpen(false),
|
|
};
|
|
};
|
|
|
|
export const sourceCodeModalToggle = (editorRef) => {
|
|
const [isSourceCodeOpen, setIsOpen] = module.state.isSourceCodeModalOpen(false);
|
|
return {
|
|
isSourceCodeOpen,
|
|
openSourceCodeModal: () => setIsOpen(true),
|
|
closeSourceCodeModal: () => {
|
|
setIsOpen(false);
|
|
editorRef.current.focus();
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* important for rerunning courses and importing/exporting course as the /static/ part of the url
|
|
* allows the asset to be mapped to the new course run.
|
|
*/
|
|
|
|
// TODO: should probably move this to when the assets are being looped through in the off chance that
|
|
// some of the text in the editor contains the lmsEndpointUrl
|
|
const regExLmsEndpointUrl = RegExp(lmsEndpointUrl, 'g');
|
|
let content = editorValue.replace(regExLmsEndpointUrl, '');
|
|
|
|
const assetSrcs = typeof content === 'string' ? content.split(/(src="|src="|href="|href=")/g) : [];
|
|
assetSrcs.filter(src => src.startsWith('/asset')).forEach(src => {
|
|
const nameFromEditorSrc = parseAssetName(src);
|
|
const portableUrl = getStaticUrl({ displayName: nameFromEditorSrc });
|
|
const currentSrc = src.substring(0, src.search(/("|")/));
|
|
const updatedContent = content.replace(currentSrc, portableUrl);
|
|
content = updatedContent;
|
|
});
|
|
|
|
const updatedStaticUrls = [];
|
|
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`
|
|
const nameFromEditorSrc = parseAssetName(src);
|
|
const portableUrl = `/${nameFromEditorSrc}`;
|
|
if (updatedStaticUrls.includes(portableUrl)) {
|
|
// If same image is used multiple times in the same src,
|
|
// replace all occurence once and do not process them again.
|
|
return;
|
|
}
|
|
// track updated urls to process only once.
|
|
updatedStaticUrls.push(portableUrl);
|
|
const currentSrc = src.substring(0, src.search(/("|")/));
|
|
const updatedContent = content.replaceAll(currentSrc, portableUrl);
|
|
content = updatedContent;
|
|
});
|
|
return content;
|
|
};
|
|
|
|
export const selectedImage = (val) => {
|
|
const [selection, setSelection] = module.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 };
|
|
};
|