@churchapps/apphelper-markdown
Version:
ChurchApps markdown/lexical editor components
295 lines (294 loc) • 13.9 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { $isCodeHighlightNode } from "@lexical/code";
import { $isLinkNode } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRegister } from "@lexical/utils";
import { $convertToMarkdownString } from "@lexical/markdown";
import { $createParagraphNode, $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, FORMAT_TEXT_COMMAND, SELECTION_CHANGE_COMMAND } from "lexical";
import { $createHeadingNode, $isHeadingNode } from "@lexical/rich-text";
import { $wrapNodes } from "@lexical/selection";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Box, styled, IconButton, Icon, Select, MenuItem } from "@mui/material";
import { getDOMRangeRect } from "./getDOMRangeRect";
import { getSelectedNode } from "./getSelectNode";
import { setFloatingElemPosition } from "./setFloatingElemPosition";
import { PLAYGROUND_TRANSFORMERS } from "../MarkdownTransformers";
import { ApiHelper } from "@churchapps/helpers";
export const FloatingDivContainer = styled(Box)({
display: "flex",
background: "#fff",
padding: 4,
verticalAlign: "middle",
position: "absolute",
top: 0,
left: 0,
zIndex: 1400,
opacity: 0,
backgroundColor: "#fff",
boxShadow: "0px 5px 10px rgba(0, 0, 0, 0.3)",
borderRadius: 8,
transition: "opacity 0.5s",
height: 35,
willChange: "transform",
});
function TextFormatFloatingToolbar({ editor, anchorElem, isLink, isBold, isItalic, isUnderline, isCode, isStrikethrough, isSubscript, isSuperscript, blockType, setBlockType }) {
const popupCharStylesEditorRef = useRef(null);
const applyFormatting = (command) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, command);
saveChanges(editor);
};
function mouseMoveListener(e) {
if (popupCharStylesEditorRef?.current &&
(e.buttons === 1 || e.buttons === 3)) {
popupCharStylesEditorRef.current.style.pointerEvents = "none";
}
}
function mouseUpListener(e) {
if (popupCharStylesEditorRef?.current) {
popupCharStylesEditorRef.current.style.pointerEvents = "auto";
}
}
useEffect(() => {
if (popupCharStylesEditorRef?.current) {
document.addEventListener("mousemove", mouseMoveListener);
document.addEventListener("mouseup", mouseUpListener);
return () => {
document.removeEventListener("mousemove", mouseMoveListener);
document.removeEventListener("mouseup", mouseUpListener);
};
}
}, [popupCharStylesEditorRef]);
const updateTextFormatFloatingToolbar = useCallback(() => {
const selection = $getSelection();
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
const nativeSelection = window.getSelection();
if (popupCharStylesEditorElem === null) {
return;
}
const rootElement = editor.getRootElement();
if (selection !== null &&
nativeSelection !== null &&
!nativeSelection.isCollapsed &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)) {
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem);
}
}, [editor, anchorElem]);
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const update = () => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
};
window.addEventListener("resize", update);
if (scrollerElem) {
scrollerElem.addEventListener("scroll", update);
}
return () => {
window.removeEventListener("resize", update);
if (scrollerElem) {
scrollerElem.removeEventListener("scroll", update);
}
};
}, [editor, updateTextFormatFloatingToolbar, anchorElem]);
useEffect(() => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
return mergeRegister(editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateTextFormatFloatingToolbar();
});
}), editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
updateTextFormatFloatingToolbar();
return false;
}, COMMAND_PRIORITY_LOW));
}, [editor, updateTextFormatFloatingToolbar]);
const formatBlock = (type) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () =>
//@ts-ignore
type === "paragraph" ? $createParagraphNode() : $createHeadingNode(type));
}
});
setBlockType(type);
saveChanges(editor);
};
return (_jsx(FloatingDivContainer, { ref: popupCharStylesEditorRef, children: _jsxs(_Fragment, { children: [_jsxs(Select, { value: blockType, onChange: (e) => formatBlock(e.target.value), sx: {
minWidth: 120,
backgroundColor: "#fff",
borderRadius: 2,
marginRight: 0.3,
}, "aria-label": "Text format", children: [_jsx(MenuItem, { value: "paragraph", children: "Normal" }), _jsx(MenuItem, { value: "h1", children: "Heading 1" }), _jsx(MenuItem, { value: "h2", children: "Heading 2" }), _jsx(MenuItem, { value: "h3", children: "Heading 3" }), _jsx(MenuItem, { value: "h4", children: "Heading 4" })] }), _jsx(IconButton, { onClick: () => {
applyFormatting("bold");
}, sx: { backgroundColor: isBold ? "#e0e0e0" : undefined, borderRadius: 2, marginRight: 0.3 }, "aria-label": "Bold", children: _jsx(Icon, { children: "format_bold_outline" }) }), _jsx(IconButton, { onClick: () => {
applyFormatting("italic");
}, sx: { backgroundColor: isItalic ? "#e0e0e0" : undefined, borderRadius: 2, marginRight: 0.3 }, "aria-label": "Italic", children: _jsx(Icon, { children: "format_italic_outline" }) }), _jsx(IconButton, { onClick: () => {
applyFormatting("underline");
}, sx: { backgroundColor: isUnderline ? "#e0e0e0" : undefined, borderRadius: 2, marginRight: 0.3 }, "aria-label": "Underline", children: _jsx(Icon, { children: "format_underlined_outline" }) }), _jsx(IconButton, { onClick: () => {
applyFormatting("code");
}, sx: { backgroundColor: isCode ? "#e0e0e0" : undefined, borderRadius: 2, marginRight: 0.3 }, "aria-label": "Code", children: _jsx(Icon, { children: "code" }) })] }) }));
}
let lastFormattingState = {}; // Track last formatting state
//@ts-ignore
const getFormattingState = (selection) => {
const node = getSelectedNode(selection);
let blockType = "paragraph";
if ($isHeadingNode(node)) {
blockType = node.getTag(); // "h1", "h2", etc.
}
return {
isBold: selection.hasFormat("bold"),
isItalic: selection.hasFormat("italic"),
isUnderline: selection.hasFormat("underline"),
// isStrikethrough: selection.hasFormat("strikethrough"),
isCode: selection.hasFormat("code"),
blockType
};
};
const saveChanges = (editor) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const newFormattingState = getFormattingState(selection); // Get current formatting
// Get the parent block node (ensuring it's not just a text node)
const node = getSelectedNode(selection);
const parentNode = node.getParent(); // Get the parent block-level node
let blockType = "paragraph";
if ($isHeadingNode(parentNode)) {
blockType = parentNode.getTag(); // Get heading type
}
else if ($isHeadingNode(node)) {
blockType = node.getTag();
}
//@ts-ignore
if (JSON.stringify(newFormattingState) !== JSON.stringify(lastFormattingState) || blockType !== lastFormattingState.blockType) {
lastFormattingState = { ...newFormattingState, blockType };
const editorNode = editor.getRootElement();
const elementJSON = editorNode?.dataset?.element;
if (elementJSON) {
const markdown = $convertToMarkdownString(PLAYGROUND_TRANSFORMERS);
const element = JSON.parse(elementJSON);
element.answers.text = markdown;
element.answersJSON = JSON.stringify(element.answers);
ApiHelper.post("/elements", [element], "ContentApi");
}
}
}
});
};
const updateFormattingState = (editor) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
lastFormattingState = getFormattingState(selection);
}
});
};
function useFloatingTextFormatToolbar(editor, anchorElem) {
const [isText, setIsText] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isSubscript, setIsSubscript] = useState(false);
const [isSuperscript, setIsSuperscript] = useState(false);
const [isCode, setIsCode] = useState(false);
const [blockType, setBlockType] = useState("paragraph");
const updatePopup = useCallback(() => {
editor.getEditorState().read(() => {
// Should not to pop up the floating toolbar when using IME input
if (editor.isComposing()) {
return;
}
const selection = $getSelection();
const nativeSelection = window.getSelection();
const rootElement = editor.getRootElement();
if (nativeSelection !== null &&
(!$isRangeSelection(selection) ||
rootElement === null ||
!rootElement.contains(nativeSelection.anchorNode))) {
setIsText(false);
return;
}
if (!$isRangeSelection(selection)) {
return;
}
const node = getSelectedNode(selection);
// Update text format
setIsBold(selection.hasFormat("bold"));
setIsItalic(selection.hasFormat("italic"));
setIsUnderline(selection.hasFormat("underline"));
setIsStrikethrough(selection.hasFormat("strikethrough"));
setIsSubscript(selection.hasFormat("subscript"));
setIsSuperscript(selection.hasFormat("superscript"));
setIsCode(selection.hasFormat("code"));
// Update links
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
}
else {
setIsLink(false);
}
if (!$isCodeHighlightNode(selection.anchor.getNode()) &&
selection.getTextContent() !== "") {
setIsText($isTextNode(node));
}
else {
setIsText(false);
}
const rawTextContent = selection.getTextContent().replace(/\n/g, "");
if (!selection.isCollapsed() && rawTextContent === "") {
setIsText(false);
return;
}
if ($isRangeSelection(selection)) {
const text = selection.getTextContent().trim();
if (text && JSON.stringify(lastFormattingState === "{}")) {
updateFormattingState(editor);
}
}
let type = "paragraph";
if ($isHeadingNode(parent)) {
type = parent.getTag();
}
else if ($isHeadingNode(node)) {
type = node.getTag();
}
setBlockType(type);
});
}, [editor]);
useEffect(() => {
document.addEventListener("selectionchange", updatePopup);
return () => {
document.removeEventListener("selectionchange", updatePopup);
};
}, [updatePopup]);
useEffect(() => {
return mergeRegister(editor.registerUpdateListener(() => {
updatePopup();
}), editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
updatePopup();
return false;
}, COMMAND_PRIORITY_LOW), editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false);
}
}));
}, [editor, updatePopup]);
if (!isText || isLink) {
return null;
}
return createPortal(_jsx(TextFormatFloatingToolbar, { editor: editor, anchorElem: anchorElem, isLink: isLink, isBold: isBold, isItalic: isItalic, isStrikethrough: isStrikethrough, isSubscript: isSubscript, isSuperscript: isSuperscript, isUnderline: isUnderline, isCode: isCode, blockType: blockType, setBlockType: setBlockType }), anchorElem);
}
export default function FloatingTextFormatToolbarPlugin({ anchorElem = document.body, }) {
const [editor] = useLexicalComposerContext();
return useFloatingTextFormatToolbar(editor, anchorElem);
}