custom-rich-text-editor
Version:
A customizable React rich text editor with icon-based toolbar
293 lines (292 loc) • 14.3 kB
JavaScript
"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;