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(/
${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/