This is a PR enabling eslint "rules-of-hooks". This lint rule catches some very annoying bugs and enforces you to use correct naming for custom hooks (prepend with "use"), which is a mandatory react rule and important for a number of reasons. I added eslint-disable statements wherever the rules are broken, and if this is merged, I would expect new code not to break the rules any longer. I strongly suggest that the much better way to extract and reuse hooks and logic from components is the way the community does it and the React docs suggest. The new React docs are really fantastic in this regard and you can use the patterns found there very well to have an excellent application. https://react.dev/learn/reusing-logic-with-custom-hooks
348 lines
11 KiB
JavaScript
348 lines
11 KiB
JavaScript
import {
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useEffect,
|
|
} from 'react';
|
|
import tinyMCEStyles from '../../data/constants/tinyMCEStyles';
|
|
import { StrictDict } from '../../utils';
|
|
import pluginConfig from './pluginConfig';
|
|
import * as module from './hooks';
|
|
import tinyMCE from '../../data/constants/tinyMCE';
|
|
|
|
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 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 = ({
|
|
editor,
|
|
imageUrls,
|
|
editorType,
|
|
lmsEndpointUrl,
|
|
updateContent,
|
|
}) => {
|
|
let content = editor.getContent();
|
|
const imageSrcs = content.split('src="');
|
|
imageSrcs.forEach(src => {
|
|
const currentContent = content;
|
|
let staticFullUrl;
|
|
const isStatic = src.startsWith('/static/');
|
|
const isExpandableAsset = src.startsWith('/assets/') && editorType === 'expandable';
|
|
if ((isStatic || isExpandableAsset) && imageUrls.length > 0) {
|
|
const assetSrc = src.substring(0, src.indexOf('"'));
|
|
const assetName = assetSrc.replace(/\/assets\/.+[^/]\//g, '');
|
|
const staticName = assetSrc.substring(8);
|
|
imageUrls.forEach((url) => {
|
|
if (isExpandableAsset && assetName === url.displayName) {
|
|
staticFullUrl = `${lmsEndpointUrl}${url.staticFullUrl}`;
|
|
} else if (staticName === url.displayName) {
|
|
staticFullUrl = url.staticFullUrl;
|
|
if (isExpandableAsset) {
|
|
staticFullUrl = `${lmsEndpointUrl}${url.staticFullUrl}`;
|
|
}
|
|
}
|
|
});
|
|
if (staticFullUrl) {
|
|
const currentSrc = src.substring(0, src.indexOf('"'));
|
|
content = currentContent.replace(currentSrc, staticFullUrl);
|
|
if (editorType === 'expandable') {
|
|
updateContent(content);
|
|
} else {
|
|
editor.setContent(content);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
export const setupCustomBehavior = ({
|
|
updateContent,
|
|
openImgModal,
|
|
openSourceCodeModal,
|
|
setImage,
|
|
editorType,
|
|
imageUrls,
|
|
lmsEndpointUrl,
|
|
}) => (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, 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', () => {
|
|
module.replaceStaticwithAsset({
|
|
editor,
|
|
imageUrls,
|
|
editorType,
|
|
lmsEndpointUrl,
|
|
updateContent,
|
|
});
|
|
});
|
|
}
|
|
editor.on('ExecCommand', (e) => {
|
|
if (editorType === 'text' && e.command === 'mceFocus') {
|
|
module.replaceStaticwithAsset({ editor, imageUrls });
|
|
}
|
|
if (e.command === 'RemoveFormat') {
|
|
editor.formatter.remove('blockquote');
|
|
editor.formatter.remove('label');
|
|
}
|
|
});
|
|
};
|
|
|
|
// imagetools_cors_hosts needs a protocol-sanatized url
|
|
export const removeProtocolFromUrl = (url) => url.replace(/^https?:\/\//, '');
|
|
|
|
export const editorConfig = ({
|
|
editorType,
|
|
setEditorRef,
|
|
textValue,
|
|
images,
|
|
lmsEndpointUrl,
|
|
studioEndpointUrl,
|
|
isLibrary,
|
|
placeholder,
|
|
initializeEditor,
|
|
openImgModal,
|
|
openSourceCodeModal,
|
|
setSelection,
|
|
updateContent,
|
|
minHeight,
|
|
}) => {
|
|
const {
|
|
toolbar,
|
|
config,
|
|
plugins,
|
|
imageToolbar,
|
|
quickbarsInsertToolbar,
|
|
quickbarsSelectionToolbar,
|
|
} = pluginConfig({ isLibrary, placeholder, editorType });
|
|
return {
|
|
onInit: (evt, editor) => {
|
|
setEditorRef(editor);
|
|
if (editorType === 'text') {
|
|
initializeEditor();
|
|
}
|
|
},
|
|
initialValue: textValue || '',
|
|
init: {
|
|
...config,
|
|
skin: false,
|
|
content_css: false,
|
|
content_style: tinyMCEStyles,
|
|
min_height: minHeight,
|
|
contextmenu: 'link table',
|
|
document_base_url: lmsEndpointUrl,
|
|
imagetools_cors_hosts: [removeProtocolFromUrl(lmsEndpointUrl), removeProtocolFromUrl(studioEndpointUrl)],
|
|
imagetools_toolbar: imageToolbar,
|
|
formats: { label: { inline: 'label' } },
|
|
setup: module.setupCustomBehavior({
|
|
editorType,
|
|
updateContent,
|
|
openImgModal,
|
|
openSourceCodeModal,
|
|
lmsEndpointUrl,
|
|
setImage: setSelection,
|
|
imageUrls: module.fetchImageUrls(images),
|
|
}),
|
|
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();
|
|
},
|
|
};
|
|
};
|
|
|
|
export const openModalWithSelectedImage = ({ editor, setImage, openImgModal }) => () => {
|
|
const imgHTML = editor.selection.getNode();
|
|
setImage({
|
|
externalUrl: imgHTML.src,
|
|
altText: imgHTML.alt,
|
|
width: imgHTML.width,
|
|
height: imgHTML.height,
|
|
});
|
|
openImgModal();
|
|
};
|
|
|
|
export const filterAssets = ({ assets }) => {
|
|
let images = [];
|
|
const assetsList = Object.values(assets);
|
|
if (assetsList.length > 0) {
|
|
images = assetsList.filter(asset => asset?.contentType?.startsWith('image/'));
|
|
}
|
|
return images;
|
|
};
|
|
|
|
export const setAssetToStaticUrl = ({ editorValue, assets, 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 assetUrls = [];
|
|
const assetsList = Object.values(assets);
|
|
assetsList.forEach(asset => {
|
|
assetUrls.push({ portableUrl: asset.portableUrl, displayName: asset.displayName });
|
|
});
|
|
const assetSrcs = typeof content === 'string' ? content.split(/(src="|src="|href="|href=")/g) : [];
|
|
assetSrcs.forEach(src => {
|
|
if (src.startsWith('/asset') && assetUrls.length > 0) {
|
|
const assetBlockName = src.substring(src.indexOf('@') + 1, src.search(/("|")/));
|
|
const nameFromEditorSrc = assetBlockName.substring(assetBlockName.indexOf('@') + 1);
|
|
const nameFromStudioSrc = assetBlockName.substring(assetBlockName.indexOf('/') + 1);
|
|
let portableUrl;
|
|
assetUrls.forEach((url) => {
|
|
const displayName = url.displayName.replace(/\s/g, '_');
|
|
if (displayName === nameFromEditorSrc || displayName === nameFromStudioSrc) {
|
|
portableUrl = url.portableUrl;
|
|
}
|
|
});
|
|
if (portableUrl) {
|
|
const currentSrc = src.substring(0, src.search(/("|")/));
|
|
const updatedContent = content.replace(currentSrc, portableUrl);
|
|
content = updatedContent;
|
|
}
|
|
}
|
|
});
|
|
return content;
|
|
};
|
|
|
|
export const fetchImageUrls = (images) => {
|
|
const imageUrls = [];
|
|
images.forEach(image => {
|
|
imageUrls.push({ staticFullUrl: image.staticFullUrl, displayName: image.displayName });
|
|
});
|
|
return imageUrls;
|
|
};
|
|
|
|
export const selectedImage = (val) => {
|
|
const [selection, setSelection] = module.state.imageSelection(val);
|
|
return {
|
|
clearSelection: () => setSelection(null),
|
|
selection,
|
|
setSelection,
|
|
};
|
|
};
|