rich-text-editor
Version:
Rich text editor
296 lines (295 loc) • 14.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = useEditorState;
exports.EditorStateProvider = EditorStateProvider;
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const history_1 = __importDefault(require("./history"));
const math_editor_1 = __importDefault(require("../components/math-editor"));
const create_math_stub_1 = require("../utils/create-math-stub");
const FI_1 = __importDefault(require("../../FI"));
const SV_1 = __importDefault(require("../../SV"));
const react_dom_1 = require("react-dom");
const utility_1 = require("../utility");
const editorCtx = (0, react_1.createContext)(null);
function useEditorState() {
const ctx = (0, react_1.useContext)(editorCtx);
if (!ctx)
throw Error('Tried to use Editor State Context outside a Provider');
return ctx;
}
let nextKey = 0;
const getNextKey = () => {
const next = nextKey;
nextKey = next + 1;
return next;
};
const defaultPasteSource = (file) => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (evt) => resolve(reader.result);
reader.readAsDataURL(file);
});
const defaultOnLatexUpdate = (baseUrl) => (img, latex) => {
img.setAttribute('src', `${baseUrl}/math.svg?latex=${encodeURIComponent(latex)}`);
img.setAttribute('alt', latex);
};
const setCursorAroundElement = (element, position = 'after') => {
const selection = window.getSelection();
const range = document.createRange();
if (position === 'before') {
range.setStartBefore(element);
}
else {
range.setStartAfter(element);
}
range.collapse(true);
selection === null || selection === void 0 ? void 0 : selection.removeAllRanges();
selection === null || selection === void 0 ? void 0 : selection.addRange(range);
};
function EditorStateProvider({ children, language = 'FI', getPasteSource = defaultPasteSource, allowedFileTypes = ['image/png', 'image/jpeg'], invalidImageSelector = 'img:not(img[src^="data"], img[src^="/math.svg?latex="], img[src^="/screenshot/"])', onValueChange = () => { }, initialValue = '', baseUrl = '', onLatexUpdate = defaultOnLatexUpdate(baseUrl), }) {
var _a;
const [isToolbarOpen, setIsToolbarOpen] = (0, react_1.useState)(false);
const [isMathToolbarOpen, setIsMathToolbarOpen] = (0, react_1.useState)(false);
const [isToolbarExpanded, setIsToolbarExpanded] = (0, react_1.useState)(false);
const [isHelpDialogOpen, setIsHelpDialogOpen] = (0, react_1.useState)(false);
const [activeMathEditor, setActiveMathEditor] = (0, react_1.useState)(null);
const [hasBeenInitialized, setHasBeenInitialized] = (0, react_1.useState)(false);
/** url search parameter for dev use
* `?forceToolbars=1` to force basic toolbar to stay open
* `?forceToolbars=2` to force basic and math toolbars to stay open
* NOTE: Using this will likely cause things to break, as this is for debug/dev reasons
* */
const forceToolbarsOpen = (_a = new URL(window.location.href).searchParams.get('forceToolbars')) !== null && _a !== void 0 ? _a : '0';
const [mathEditorPortal, setMathEditorPortal] = (0, react_1.useState)(null);
const equationEditorHistory = (0, history_1.default)();
const mainTextAreaHistory = (0, history_1.default)();
const mainTextAreaRef = (0, react_1.useRef)(null);
const t = { FI: FI_1.default, SV: SV_1.default }[language];
/* Sometimes blur events cause multiple onBlur calls (at least in Chrome). Removing elements can then
* cause uncaught errors, which require no actions to be taken.*/
function safeRemove(e) {
try {
e === null || e === void 0 ? void 0 : e.remove();
}
catch (_) {
/* No action needed.*/
}
}
function spawnMathEditor(stub, image, props) {
// This is called both on the creation of the component and each time the equation is opened after that
function onOpen(handle) {
equationEditorHistory.clear();
setActiveMathEditor(handle);
setIsToolbarOpen(true);
setIsMathToolbarOpen(true);
image.classList.add('active');
}
function onBlur(latex, forceCursorPosition) {
equationEditorHistory.clear();
setActiveMathEditor(null);
setIsMathToolbarOpen(false);
setIsToolbarOpen(false);
onAnswerChange((0, utility_1.getCaretPositionAfterElement)(mainTextAreaRef.current, image), true, true);
if (forceCursorPosition) {
setCursorAroundElement(image, forceCursorPosition);
}
safeRemove(stub);
if (!latex) {
safeRemove(image);
}
else
image.classList.remove('active');
}
function onChange(latex) {
equationEditorHistory.write(latex);
onAnswerChange((0, utility_1.getCaretPositionAfterElement)(mainTextAreaRef.current, image), false, false);
}
function onEnter(latex) {
if (image) {
spawnMathEditorInNewLine(image);
}
onBlur(latex);
}
const portal = (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)(math_editor_1.default, Object.assign({ onOpen: onOpen, onBlur: onBlur, onChange: onChange, onEnter: onEnter, errorText: t.editor.render_error }, props)), stub);
setMathEditorPortal([stub, portal]);
}
function onMathImageClick(img, e) {
var _a;
const parent = img.parentElement;
e.stopPropagation();
e.preventDefault();
const stub = (0, create_math_stub_1.createMathStub)(getNextKey());
if (parent) {
parent.insertBefore(stub, img.nextSibling);
}
else {
(_a = mainTextAreaRef.current) === null || _a === void 0 ? void 0 : _a.appendChild(stub);
}
spawnMathEditor(stub, img, {
initialLatex: img.getAttribute('alt'),
onLatexUpdate: (latex) => onLatexUpdate(img, latex),
});
}
function createMathImage() {
const mathImage = document.createElement('img');
mathImage.addEventListener('click', (e) => onMathImageClick(mathImage, e));
mathImage.setAttribute('initialized', '');
mathImage.setAttribute('src', ''); // Browsers add a border to images without a source attribute
mathImage.classList.add('equation');
return mathImage;
}
function spawnMathEditorAtCursor() {
const mathImage = createMathImage();
spawnMathEditor((0, create_math_stub_1.createMathStub)(getNextKey(), true, mathImage), mathImage, {
onLatexUpdate: (latex) => onLatexUpdate(mathImage, latex),
});
}
function spawnMathEditorInNewLine(afterElement) {
// Find the closest div. This is necessary because the browser sometimes wraps lines in <div>s automatically,
// so we don't know whether the parent is the main text area or a random div inserted by the browser.
const parent = afterElement.closest('div');
const mathImage = createMathImage();
const newStub = (0, create_math_stub_1.createMathStub)(getNextKey(), false, mathImage);
const nextSibling = afterElement.nextSibling;
// Ensure mainTextAreaRef exists before inserting
if (parent) {
parent.insertBefore(document.createElement('br'), nextSibling);
parent.insertBefore(mathImage, nextSibling);
parent.insertBefore(newStub, nextSibling);
spawnMathEditor(newStub, mathImage, { onLatexUpdate: (latex) => onLatexUpdate(mathImage, latex) });
}
else {
console.error('parent element not found for math editor');
}
}
function initMathImages() {
if (mainTextAreaRef.current) {
Array.from(mainTextAreaRef.current.querySelectorAll('img[src*="/math.svg?"][alt]:not([initialized])')).forEach((oldImage) => {
var _a;
const mathImage = createMathImage();
const src = oldImage.getAttribute('src');
if (src) {
const { origin, pathname, search } = new URL(src, baseUrl || document.location.toString());
if (origin !== baseUrl) {
mathImage.setAttribute('src', `${baseUrl}${pathname}${search}`);
}
else {
mathImage.setAttribute('src', src);
}
}
mathImage.setAttribute('alt', (_a = oldImage.getAttribute('alt')) !== null && _a !== void 0 ? _a : '');
oldImage.replaceWith(mathImage);
});
}
}
function persistValidImages() {
var _a, _b, _c;
(_a = mainTextAreaRef.current) === null || _a === void 0 ? void 0 : _a.querySelectorAll(invalidImageSelector).forEach((e) => e.remove());
const images = Array.from((_c = (_b = mainTextAreaRef.current) === null || _b === void 0 ? void 0 : _b.querySelectorAll('img[src^="data:image/')) !== null && _c !== void 0 ? _c : []);
const imagesWithFile = images.flatMap((e) => {
const src = e.getAttribute('src');
if (src) {
const file = (0, utility_1.decodeBase64Image)(src);
if (file) {
return [{ file, element: e }];
}
}
return [];
});
imagesWithFile.forEach(async (img) => {
if (img.element instanceof HTMLImageElement &&
(0, utility_1.isForbiddenInlineImage)(img.file.type, img.element, allowedFileTypes)) {
img.element.remove();
}
img.element.setAttribute('src', utility_1.loadingImage);
const url = await getPasteSource(new File([img.file.data], 'image', { type: img.file.type }));
img.element.setAttribute('src', url);
});
}
const updateAnswerHistory = (content, caretPositionBefore) => {
mainTextAreaHistory.write(content, caretPositionBefore, (0, utility_1.getCaretPosition)(mainTextAreaRef.current));
};
const updateAnswerHistoryDebounced = (0, utility_1.debounceAnswerSave)((content, caretPositionBefore) => updateAnswerHistory(content, caretPositionBefore), 500);
/**
* @param shouldUpdateHistory - Whether to update the answer history (defaults to true)
* @param shouldUpdateHistoryImmediately - Whether to update the answer history immediately, without debouncing (defaults to false)
*/
function onAnswerChange(caretPosition = 0, shouldUpdateHistory = true, shouldUpdateHistoryImmediately = false) {
const fn = () => {
var _a;
const content = (_a = mainTextAreaRef.current) === null || _a === void 0 ? void 0 : _a.innerHTML;
if (content !== undefined) {
const answer = (0, utility_1.getAnswer)(content);
onValueChange(answer);
if (shouldUpdateHistory) {
if (shouldUpdateHistoryImmediately) {
updateAnswerHistory(answer.answerHtml, caretPosition);
}
else {
updateAnswerHistoryDebounced(answer.answerHtml, caretPosition);
}
}
}
};
/** This 0ms timeout is crucial - it essentially moves the callback into the next event loop,
* after pending DOM changes have been made in the current loop (in practice,
* this is needed because closing a math editor changes the HTML of the answer,
* but doesn't trigger the text area's onInput event. With this hack we can get the answer HTML with
* the changes already included.)
*/
setTimeout(fn, 0);
}
/** Initialization */
(0, react_1.useEffect)(() => {
if (!hasBeenInitialized && mainTextAreaRef.current !== null) {
if (initialValue) {
mainTextAreaRef.current.innerHTML = initialValue;
}
setTimeout(() => {
initMathImages();
setHasBeenInitialized(true);
if (mainTextAreaRef.current) {
mainTextAreaHistory.write(mainTextAreaRef.current.innerHTML);
}
}, 0);
}
}, [hasBeenInitialized, initialValue, mainTextAreaRef.current, mainTextAreaHistory]);
return ((0, jsx_runtime_1.jsx)(editorCtx.Provider, { value: {
isToolbarOpen: forceToolbarsOpen !== '0' || isToolbarOpen,
isMathToolbarOpen: forceToolbarsOpen === '2' || isMathToolbarOpen,
showToolbar: () => setIsToolbarOpen(true),
hideToolbar: () => setIsToolbarOpen(false),
isToolbarExpanded,
expandToolbar: () => setIsToolbarExpanded(true),
collapseToolbar: () => setIsToolbarExpanded(false),
isHelpDialogOpen,
showHelpDialog: () => setIsHelpDialogOpen(true),
hideHelpDialog: () => setIsHelpDialogOpen(false),
activeMathEditor: activeMathEditor,
setActiveMathEditor: setActiveMathEditor,
ref: mainTextAreaRef,
mathEditorPortal: mathEditorPortal,
spawnMathEditorAtCursor: spawnMathEditorAtCursor,
spawnMathEditorInNewLine: spawnMathEditorInNewLine,
initMathImages,
persistValidImages,
canUndoEquation: equationEditorHistory.canUndo,
canRedoEquation: equationEditorHistory.canRedo,
undoEquation: () => { var _a; return (_a = equationEditorHistory.undo()) === null || _a === void 0 ? void 0 : _a.content; },
redoEquation: () => { var _a; return (_a = equationEditorHistory.redo()) === null || _a === void 0 ? void 0 : _a.content; },
canUndoEditor: mainTextAreaHistory.canUndo,
canRedoEditor: mainTextAreaHistory.canRedo,
undoEditor: mainTextAreaHistory.undo,
redoEditor: mainTextAreaHistory.redo,
t,
handlePastedImage: getPasteSource,
allowedFileTypes,
invalidImageSelector,
onAnswerChange,
initialValue,
baseUrl,
}, children: children }));
}