UNPKG

kabulmark

Version:

A React-based rich text editor built as a wrapper over Meta's Lexical library.

223 lines (222 loc) 15.1 kB
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $getSelection, $isElementNode, $isRangeSelection, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, REDO_COMMAND, UNDO_COMMAND } from "lexical"; import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from "@lexical/rich-text"; import { $createParagraphNode } from "lexical"; import { TOGGLE_LINK_COMMAND } from "@lexical/link"; import { $isListItemNode, $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND } from "@lexical/list"; import { $setBlocksType } from "@lexical/selection"; import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, Italic, Link2, List, ListOrdered, Redo, Underline, Undo } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import Divider from "../shared/Divider"; const Toolbar = ({ toolbarConfig }) => { const [editor] = useLexicalComposerContext(); const [activeFormats, setActiveFormats] = useState(new Set()); const [activeAlignment, setActiveAlignment] = useState(""); const [activeDirection, setActiveDirection] = useState(null); const [isInList, setIsInList] = useState({ ordered: false, unordered: false }); const [blockType, setBlockType] = useState("paragraph"); const [isLinkActive, setIsLinkActive] = useState(false); const [currentLinkUrl, setCurrentLinkUrl] = useState(""); const [modalOpen, setModalOpen] = useState(false); const [url, setUrl] = useState(""); const inputRef = useRef(null); useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { // Track text formatting const formats = new Set(); if (selection.hasFormat("bold")) formats.add("bold"); if (selection.hasFormat("italic")) formats.add("italic"); if (selection.hasFormat("underline")) formats.add("underline"); setActiveFormats(formats); // Track element alignment const element = selection.getNodes()[0]?.getTopLevelElement(); if (element && $isElementNode(element)) { const formatType = element.getFormatType(); setActiveAlignment(formatType); const direction = element.getDirection() ?? null; setActiveDirection(direction); } // Track list state const anchorNode = selection.anchor.getNode(); let listNode = null; let currentNode = anchorNode; while (currentNode && !listNode) { if ($isListNode(currentNode)) { listNode = currentNode; } else if ($isListItemNode(currentNode)) { const parent = currentNode.getParent(); if ($isListNode(parent)) { listNode = parent; } else { currentNode = parent; } } else { currentNode = currentNode.getParent(); } } setIsInList({ ordered: listNode ? listNode.getListType() === "number" : false, unordered: listNode ? listNode.getListType() === "bullet" : false }); // Track block type const blockElement = anchorNode.getTopLevelElement(); let newBlockType = "paragraph"; if (blockElement && $isElementNode(blockElement)) { if ($isHeadingNode(blockElement)) { newBlockType = blockElement.getTag(); } else if ($isQuoteNode(blockElement)) { newBlockType = "quote"; } } setBlockType(newBlockType); } }); }); }, [editor]); const handleBold = () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"); }; const handleItalic = () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"); }; const handleUnderline = () => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"); }; const handleUndo = () => { editor.dispatchCommand(UNDO_COMMAND, undefined); }; const handleRedo = () => { editor.dispatchCommand(REDO_COMMAND, undefined); }; const handleAlignLeft = () => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left"); }; const handleAlignCenter = () => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "center"); }; const handleAlignRight = () => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "right"); }; const handleAlignJustify = () => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "justify"); }; const handleBulletList = () => { if (isInList.unordered) { editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); } else { editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); } }; const handleNumberedList = () => { if (isInList.ordered) { editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); } else { editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); } }; const formatBlock = (newType) => { if (newType === blockType) return; editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => { if (newType === "quote") { return $createQuoteNode(); } if (newType.startsWith("h")) { return $createHeadingNode(newType); } return $createParagraphNode(); }); } }); }; const setBlocksDirection = (selection, dir) => { const selectedNodes = selection.getNodes(); const topLevelElements = new Set(); for (const node of selectedNodes) { const topLevel = node.getTopLevelElement(); if (topLevel && $isElementNode(topLevel)) { topLevelElements.add(topLevel); } } for (const element of topLevelElements) { element.setDirection(dir); } }; const handleLTR = () => { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { setBlocksDirection(selection, activeDirection === "ltr" ? null : "ltr"); } }); }; const handleRTL = () => { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { setBlocksDirection(selection, activeDirection === "rtl" ? null : "rtl"); } }); }; const handleLinkButton = () => { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { if (!selection.isCollapsed() || isLinkActive) { setUrl(currentLinkUrl || ""); setModalOpen(true); } else { console.warn("Please select some text or place cursor in a link."); } } }); }; const handleSubmit = () => { editor.dispatchCommand(TOGGLE_LINK_COMMAND, url.trim() || null); setModalOpen(false); }; useEffect(() => { if (modalOpen && inputRef.current) { inputRef.current.focus(); } }, [modalOpen]); const getButtonClass = (isActive) => `toolbar-button ${isActive ? "active" : ""}`; const { showRedo, showUndo, showBold, showItalic, showUnderline, showLink, showAlignLeft, showAlignCenter, showAlignRight, showAlignJustify, showList, showNumberedList, showBulletList, showHeadingSelect, showDirectionButtons } = toolbarConfig || {}; return (_jsxs("div", { className: "editor-toolbar flex flex-wrap items-center gap-2 p-3 border-b border-editor-border bg-gray-50 flex-grow", role: "toolbar", "aria-label": "Editor toolbar", children: [showUndo && (_jsx("button", { className: "toolbar-button", title: "Undo (Ctrl+Z)", "aria-label": "Undo", onClick: handleUndo, type: "button", children: _jsx(Undo, { className: "w-4 h-4" }) })), showRedo && (_jsx("button", { className: "toolbar-button", title: "Redo (Ctrl+Y)", "aria-label": "Redo", onClick: handleRedo, type: "button", children: _jsx(Redo, { className: "w-4 h-4" }) })), (showUndo || showRedo) && (showBold || showItalic || showUnderline || showLink) && _jsx(Divider, {}), showBold && (_jsx("button", { className: getButtonClass(activeFormats.has("bold")), title: "Bold (Ctrl+B)", "aria-label": "Bold", onClick: handleBold, type: "button", children: _jsx(Bold, { className: "w-4 h-4" }) })), showItalic && (_jsx("button", { className: getButtonClass(activeFormats.has("italic")), title: "Italic (Ctrl+I)", "aria-label": "Italic", onClick: handleItalic, type: "button", children: _jsx(Italic, { className: "w-4 h-4" }) })), showUnderline && (_jsx("button", { className: getButtonClass(activeFormats.has("underline")), title: "Underline (Ctrl+U)", "aria-label": "Underline", onClick: handleUnderline, type: "button", children: _jsx(Underline, { className: "w-4 h-4" }) })), showLink && (_jsx("button", { className: getButtonClass(isLinkActive), title: "Insert Link", "aria-label": "Insert link", onClick: handleLinkButton, type: "button", children: _jsx(Link2, { className: "w-4 h-4" }) })), (showBold || showItalic || showUnderline || showLink) && (showAlignLeft || showAlignCenter || showAlignRight || showAlignJustify || showDirectionButtons) && _jsx(Divider, {}), showAlignLeft && (_jsx("button", { className: getButtonClass(activeAlignment === "left" || activeAlignment === ""), title: "Align Left", "aria-label": "Align left", onClick: handleAlignLeft, type: "button", children: _jsx(AlignLeft, { className: "w-4 h-4" }) })), showAlignCenter && (_jsx("button", { className: getButtonClass(activeAlignment === "center"), title: "Align Center", "aria-label": "Align center", onClick: handleAlignCenter, type: "button", children: _jsx(AlignCenter, { className: "w-4 h-4" }) })), showAlignRight && (_jsx("button", { className: getButtonClass(activeAlignment === "right"), title: "Align Right", "aria-label": "Align right", onClick: handleAlignRight, type: "button", children: _jsx(AlignRight, { className: "w-4 h-4" }) })), showAlignJustify && (_jsx("button", { className: getButtonClass(activeAlignment === "justify"), title: "Align Justify", "aria-label": "Align justify", onClick: handleAlignJustify, type: "button", children: _jsx(AlignJustify, { className: "w-4 h-4" }) })), showDirectionButtons && (_jsxs(_Fragment, { children: [_jsx("button", { className: getButtonClass(activeDirection === "ltr"), title: "Left-to-right direction", "aria-label": "Set left-to-right direction", onClick: handleLTR, type: "button", children: _jsx("span", { className: "text-xs font-bold", children: "LTR" }) }), _jsx("button", { className: getButtonClass(activeDirection === "rtl"), title: "Right-to-left direction", "aria-label": "Set right-to-left direction", onClick: handleRTL, type: "button", children: _jsx("span", { className: "text-xs font-bold", children: "RTL" }) })] })), (showAlignLeft || showAlignCenter || showAlignRight || showAlignJustify || showDirectionButtons) && (showBulletList || showNumberedList || showList) && _jsx(Divider, {}), showBulletList && (_jsx("button", { className: getButtonClass(isInList.unordered), title: "Bullet List", "aria-label": "Bullet list", onClick: handleBulletList, type: "button", children: _jsx(List, { className: "w-4 h-4" }) })), showNumberedList && (_jsx("button", { className: getButtonClass(isInList.ordered), title: "Numbered List", "aria-label": "Numbered list", onClick: handleNumberedList, type: "button", children: _jsx(ListOrdered, { className: "w-4 h-4" }) })), (showBulletList || showNumberedList || showList) && showHeadingSelect && _jsx(Divider, {}), showHeadingSelect && (_jsxs("select", { value: blockType, onChange: (e) => formatBlock(e.target.value), className: "px-2 py-1 bg-white border border-gray-200 rounded-md text-sm", "aria-label": "Block type", children: [_jsx("option", { value: "paragraph", children: "Paragraph" }), _jsx("option", { value: "h1", children: "Heading 1" }), _jsx("option", { value: "h2", children: "Heading 2" }), _jsx("option", { value: "h3", children: "Heading 3" }), _jsx("option", { value: "h4", children: "Heading 4" }), _jsx("option", { value: "h5", children: "Heading 5" }), _jsx("option", { value: "h6", children: "Heading 6" }), _jsx("option", { value: "quote", children: "Blockquote" })] })), modalOpen && (_jsx("div", { className: "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50", children: _jsxs("div", { className: "bg-white p-4 rounded-md shadow-lg w-96", children: [_jsx("h3", { className: "font-bold mb-2", children: isLinkActive ? "Edit Link" : "Insert Link" }), _jsx("input", { ref: inputRef, type: "url", value: url, onChange: (e) => setUrl(e.target.value), placeholder: "Enter URL (e.g., https://example.com)", className: "border p-2 w-full mb-2 rounded" }), _jsxs("div", { className: "flex justify-end gap-2", children: [isLinkActive && (_jsx("button", { onClick: () => { editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); setModalOpen(false); }, className: "px-4 py-2 bg-red-500 text-white rounded", children: "Remove" })), _jsx("button", { onClick: () => setModalOpen(false), className: "px-4 py-2 bg-gray-200 rounded", children: "Cancel" }), _jsx("button", { onClick: handleSubmit, className: "px-4 py-2 bg-blue-500 text-white rounded", children: "OK" })] })] }) }))] })); }; export default Toolbar;