UNPKG

custom-rich-text-editor

Version:

A customizable React rich text editor with icon-based toolbar

293 lines (292 loc) 14.3 kB
"use client"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useRef, useState, useEffect } from "react"; import { FaBold, FaItalic, FaListUl, FaListOl, FaLink, FaParagraph, FaCode, FaUnderline, FaCaretDown, FaCircle, FaRegCircle, FaSquare, } from "react-icons/fa"; import { LuHeading1, LuHeading2 } from "react-icons/lu"; const Editor = ({ initialContent = "", onChange }) => { const editorRef = useRef(null); const [showCode, setShowCode] = useState(false); const [htmlCode, setHtmlCode] = useState(initialContent); const [showLinkDialog, setShowLinkDialog] = useState(false); const [linkText, setLinkText] = useState(""); const [linkUrl, setLinkUrl] = useState("https://"); const linkUrlInputRef = useRef(null); const selectionRef = useRef(null); const [showListMenu, setShowListMenu] = useState(false); const listMenuRef = useRef(null); const listButtonRef = useRef(null); // Save selection position const saveSelection = () => { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; return selection.getRangeAt(0); }; // Restore selection position const restoreSelection = (range) => { if (!range) return; const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(range); } }; // Handle paste events to prevent cursor jumping const handlePaste = (event) => { event.preventDefault(); const range = saveSelection(); const text = event.clipboardData.getData("text/plain"); document.execCommand("insertText", false, text); if (range) { restoreSelection(range); } }; useEffect(() => { if (editorRef.current && editorRef.current.innerHTML !== initialContent) { editorRef.current.innerHTML = initialContent; } setHtmlCode(initialContent); }, [initialContent]); useEffect(() => { var _a, _b; if (showLinkDialog) { (_a = linkUrlInputRef.current) === null || _a === void 0 ? void 0 : _a.focus(); (_b = linkUrlInputRef.current) === null || _b === void 0 ? void 0 : _b.select(); } }, [showLinkDialog]); useEffect(() => { if (!showListMenu) return; const handleOutsideClick = (event) => { var _a, _b; const target = event.target; if ((_a = listMenuRef.current) === null || _a === void 0 ? void 0 : _a.contains(target)) return; if ((_b = listButtonRef.current) === null || _b === void 0 ? void 0 : _b.contains(target)) return; setShowListMenu(false); }; document.addEventListener("mousedown", handleOutsideClick); return () => { document.removeEventListener("mousedown", handleOutsideClick); }; }, [showListMenu]); const handleEditorInput = () => { if (editorRef.current) { const content = editorRef.current.innerHTML; if (content !== htmlCode) { setHtmlCode(content); onChange === null || onChange === void 0 ? void 0 : onChange(content); } } }; const handleCodeInput = (e) => { const html = e.target.value; setHtmlCode(html); if (editorRef.current) { editorRef.current.innerHTML = html; } onChange === null || onChange === void 0 ? void 0 : onChange(html); }; const format = (command, value) => { document.execCommand(command, false, value); }; const createLinkHtml = (url, text) => { const anchor = document.createElement("a"); anchor.href = url; anchor.textContent = text; return anchor.outerHTML; }; const openLinkDialog = () => { var _a; const selection = window.getSelection(); const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; selectionRef.current = range; setLinkText((_a = selection === null || selection === void 0 ? void 0 : selection.toString()) !== null && _a !== void 0 ? _a : ""); setLinkUrl("https://"); setShowLinkDialog(true); }; const closeLinkDialog = () => { setShowLinkDialog(false); }; const handleInsertLink = () => { const url = linkUrl.trim(); if (!url) return; if (editorRef.current) { editorRef.current.focus(); } const range = selectionRef.current; if (range) { restoreSelection(range); } const selectedText = range ? range.toString() : ""; const finalText = linkText.trim() || selectedText || url; if (range && selectedText && linkText.trim() === "") { format("createLink", url); } else { format("insertHTML", createLinkHtml(url, finalText)); } setShowLinkDialog(false); setLinkText(""); setLinkUrl("https://"); handleEditorInput(); }; const getClosestUnorderedList = () => { var _a, _b; const selection = window.getSelection(); const anchorNode = (_b = (_a = selection === null || selection === void 0 ? void 0 : selection.anchorNode) !== null && _a !== void 0 ? _a : selection === null || selection === void 0 ? void 0 : selection.focusNode) !== null && _b !== void 0 ? _b : null; const element = anchorNode instanceof Element ? anchorNode : anchorNode === null || anchorNode === void 0 ? void 0 : anchorNode.parentElement; return element === null || element === void 0 ? void 0 : element.closest("ul"); }; const applyUnorderedListStyle = (style) => { let list = getClosestUnorderedList(); if (!list) { format("insertUnorderedList"); list = getClosestUnorderedList(); } if (!list) { setShowListMenu(false); return; } if (style === ">>") { list.style.listStyleType = '">> "'; } else if (style === "->") { list.style.listStyleType = '"-> "'; } else { list.style.listStyleType = style; } setShowListMenu(false); handleEditorInput(); }; const buttonStyle = { backgroundColor: "#f4f4f4", border: "none", borderRadius: "8px", padding: "10px", margin: "5px", cursor: "pointer", boxShadow: "0 2px 5px rgba(0, 0, 0, 0.1)", }; const listMenuWrapperStyle = { position: "relative", display: "inline-flex", }; const listDropdownButtonStyle = Object.assign(Object.assign({}, buttonStyle), { display: "inline-flex", alignItems: "center", gap: "6px" }); const listCaretStyle = { fontSize: "0.65rem", }; const listMenuStyle = { position: "absolute", top: "44px", left: "0", backgroundColor: "#fff", borderRadius: "8px", border: "1px solid #d0d7de", boxShadow: "0 10px 20px rgba(0, 0, 0, 0.18)", padding: "8px", display: "grid", gridTemplateColumns: "repeat(3, 32px)", gap: "6px", zIndex: 200, }; const listMenuButtonStyle = { width: "32px", height: "32px", borderRadius: "6px", border: "1px solid #e5e7eb", backgroundColor: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", }; const listMenuTextStyle = { fontSize: "12px", fontWeight: 600, }; const linkDialogOverlayStyle = { position: "fixed", inset: 0, backgroundColor: "rgba(0, 0, 0, 0.06)", display: "flex", justifyContent: "center", alignItems: "flex-start", paddingTop: "70px", zIndex: 1000, }; const linkDialogStyle = { width: "320px", backgroundColor: "#fff", borderRadius: "8px", border: "1px solid #d0d7de", boxShadow: "0 12px 30px rgba(0, 0, 0, 0.18)", overflow: "hidden", }; const linkDialogHeaderStyle = { padding: "10px 12px", fontWeight: 600, fontSize: "14px", borderBottom: "1px solid #e5e7eb", }; const linkDialogBodyStyle = { padding: "12px", display: "grid", gap: "8px", }; const linkLabelStyle = { fontSize: "11px", color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em", }; const linkInputStyle = { padding: "8px 10px", borderRadius: "6px", border: "1px solid #cbd5e1", fontSize: "14px", outline: "none", }; const linkRowStyle = { display: "flex", gap: "8px", alignItems: "center", }; const linkInsertButtonStyle = { backgroundColor: "#2e7d32", color: "#fff", border: "none", borderRadius: "6px", padding: "8px 12px", fontWeight: 600, cursor: "pointer", }; return (_jsx("div", { className: "main_layout", children: _jsxs("div", { className: "text_editor", children: [_jsxs("div", { className: "icon_bar", style: { marginBottom: "1rem", }, children: [_jsx("button", { onClick: () => format("formatBlock", "<p>"), style: buttonStyle, children: _jsx(FaParagraph, {}) }), _jsx("button", { onClick: () => format("bold"), style: buttonStyle, children: _jsx(FaBold, {}) }), _jsx("button", { onClick: () => format("underline"), style: buttonStyle, children: _jsx(FaUnderline, {}) }), _jsx("button", { onClick: () => format("italic"), style: buttonStyle, children: _jsx(FaItalic, {}) }), _jsxs("div", { style: listMenuWrapperStyle, children: [_jsxs("button", { onClick: () => setShowListMenu((prev) => !prev), style: listDropdownButtonStyle, ref: listButtonRef, children: [_jsx(FaListUl, {}), _jsx(FaCaretDown, { style: listCaretStyle })] }), showListMenu && (_jsxs("div", { style: listMenuStyle, ref: listMenuRef, children: [_jsx("button", { type: "button", onClick: () => applyUnorderedListStyle("disc"), style: listMenuButtonStyle, "aria-label": "Disc bullets", children: _jsx(FaCircle, { size: 12 }) }), _jsx("button", { type: "button", onClick: () => applyUnorderedListStyle("circle"), style: listMenuButtonStyle, "aria-label": "Hollow bullets", children: _jsx(FaRegCircle, { size: 12 }) }), _jsx("button", { type: "button", onClick: () => applyUnorderedListStyle("square"), style: listMenuButtonStyle, "aria-label": "Square bullets", children: _jsx(FaSquare, { size: 12 }) }), _jsx("button", { type: "button", onClick: () => applyUnorderedListStyle(">>"), style: listMenuButtonStyle, "aria-label": "Double angle bullets", children: _jsx("span", { style: listMenuTextStyle, children: ">>" }) }), _jsx("button", { type: "button", onClick: () => applyUnorderedListStyle("->"), style: listMenuButtonStyle, "aria-label": "Arrow bullets", children: _jsx("span", { style: listMenuTextStyle, children: "->" }) })] }))] }), _jsx("button", { onClick: () => format("insertOrderedList"), style: buttonStyle, children: _jsx(FaListOl, {}) }), _jsx("button", { onClick: () => format("formatBlock", "<h1>"), style: buttonStyle, children: _jsx(LuHeading1, {}) }), _jsx("button", { onClick: () => format("formatBlock", "<h2>"), style: buttonStyle, children: _jsx(LuHeading2, {}) }), _jsx("button", { onClick: openLinkDialog, style: buttonStyle, children: _jsx(FaLink, {}) }), _jsx("button", { onClick: () => setShowCode(!showCode), style: buttonStyle, children: _jsx(FaCode, {}) })] }), showLinkDialog && (_jsx("div", { style: linkDialogOverlayStyle, onClick: closeLinkDialog, children: _jsxs("div", { style: linkDialogStyle, onClick: (event) => event.stopPropagation(), children: [_jsx("div", { style: linkDialogHeaderStyle, children: "Link" }), _jsxs("div", { style: linkDialogBodyStyle, children: [_jsx("label", { style: linkLabelStyle, htmlFor: "link-display-text", children: "Display text" }), _jsx("input", { id: "link-display-text", type: "text", value: linkText, onChange: (event) => setLinkText(event.target.value), style: linkInputStyle }), _jsx("label", { style: linkLabelStyle, htmlFor: "link-url", children: "Link URL" }), _jsxs("div", { style: linkRowStyle, children: [_jsx("input", { id: "link-url", type: "text", value: linkUrl, ref: linkUrlInputRef, onChange: (event) => setLinkUrl(event.target.value), onKeyDown: (event) => { if (event.key === "Enter") { event.preventDefault(); handleInsertLink(); } }, style: linkInputStyle }), _jsx("button", { type: "button", onClick: handleInsertLink, style: linkInsertButtonStyle, children: "Insert" })] })] })] }) })), _jsx("div", { ref: editorRef, contentEditable: true, suppressContentEditableWarning: true, onInput: handleEditorInput, onPaste: handlePaste, style: { border: "1px solid #ccc", minHeight: "200px", padding: "1rem", borderRadius: "8px", outline: "none", marginBottom: "1rem", } }), showCode && (_jsx("div", { className: "html_code_editor", children: _jsx("textarea", { value: htmlCode, onChange: handleCodeInput, placeholder: "Write or paste HTML here...", style: { width: "100%", minHeight: "200px", padding: "1rem", borderRadius: "8px", border: "1px solid #ccc", fontFamily: "monospace", backgroundColor: "#fdfdfd", } }) }))] }) })); }; export default Editor;