UNPKG

@churchapps/apphelper-markdown

Version:
295 lines (294 loc) 13.9 kB
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); }