rich-text-editor-lib
Version:
A reusable and responsive rich text editor React component.
272 lines (270 loc) • 8.69 kB
JavaScript
// src/RichTextEditor.tsx
import { useRef, useEffect, useState } from "react";
import {
Bold as LucideBold,
Italic as LucideItalic,
Underline as LucideUnderline,
Strikethrough as LucideStrikethrough,
List,
ListOrdered,
Link2
} from "lucide-react";
import { jsx, jsxs } from "react/jsx-runtime";
var toolbarButtons = [
{ cmd: "bold", icon: /* @__PURE__ */ jsx(LucideBold, { size: 18 }), label: "Bold" },
{ cmd: "italic", icon: /* @__PURE__ */ jsx(LucideItalic, { size: 18 }), label: "Italic" },
{ cmd: "underline", icon: /* @__PURE__ */ jsx(LucideUnderline, { size: 18 }), label: "Underline" },
{
cmd: "strikeThrough",
icon: /* @__PURE__ */ jsx(LucideStrikethrough, { size: 18 }),
label: "Strikethrough"
},
{
cmd: "insertUnorderedList",
icon: /* @__PURE__ */ jsx(List, { size: 18 }),
// Bullet List
label: "Bullet List"
},
{
cmd: "insertOrderedList",
icon: /* @__PURE__ */ jsx(ListOrdered, { size: 18 }),
// Numbered List
label: "Numbered List"
},
{ cmd: "createLink", icon: /* @__PURE__ */ jsx(Link2, { size: 18 }), label: "Link" }
// Image upload intentionally omitted
];
var RichTextEditor = ({
name,
value,
onChange,
onBlur,
placeholder,
error,
className,
style
}) => {
const editorRef = useRef(null);
const [activeStates, setActiveStates] = useState(
{}
);
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [linkText, setLinkText] = useState("");
const savedRangeRef = useRef(null);
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value || "";
}
}, [value]);
useEffect(() => {
const updateToolbarState = () => {
const newStates = {};
toolbarButtons.forEach((btn) => {
try {
newStates[btn.cmd] = document.queryCommandState(btn.cmd);
} catch {
newStates[btn.cmd] = false;
}
});
setActiveStates(newStates);
};
document.addEventListener("selectionchange", updateToolbarState);
return () => document.removeEventListener("selectionchange", updateToolbarState);
}, []);
const handleCommand = (cmd) => {
if (editorRef.current) {
editorRef.current.focus();
}
if (cmd === "createLink") {
let selectedText = "";
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
selectedText = selection.toString();
}
if (selection && selection.rangeCount > 0) {
savedRangeRef.current = selection.getRangeAt(0).cloneRange();
} else {
savedRangeRef.current = null;
}
setLinkText(selectedText);
setShowLinkInput(true);
setLinkUrl("");
return;
} else if (cmd === "insertUnorderedList" || cmd === "insertOrderedList") {
document.execCommand(cmd, false, "");
} else {
document.execCommand(cmd, false);
}
if (editorRef.current) {
onChange(editorRef.current.innerHTML);
}
setTimeout(() => {
const newStates = {};
toolbarButtons.forEach((btn) => {
try {
newStates[btn.cmd] = document.queryCommandState(btn.cmd);
} catch {
newStates[btn.cmd] = false;
}
});
setActiveStates(newStates);
}, 0);
};
const handleInsertLink = () => {
if (editorRef.current) {
editorRef.current.focus();
const selection = window.getSelection();
if (savedRangeRef.current && selection) {
selection.removeAllRanges();
selection.addRange(savedRangeRef.current);
}
if (selection && !selection.isCollapsed) {
document.execCommand("createLink", false, linkUrl);
} else if (selection) {
const a = document.createElement("a");
a.href = linkUrl;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = linkText || linkUrl;
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
if (range) {
range.insertNode(a);
range.setStartAfter(a);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
onChange(editorRef.current.innerHTML);
}
setShowLinkInput(false);
setLinkUrl("");
setLinkText("");
savedRangeRef.current = null;
};
const handleCancelLink = () => {
setShowLinkInput(false);
setLinkUrl("");
if (editorRef.current) {
editorRef.current.focus();
}
};
const handleInput = (e) => {
onChange(e.currentTarget.innerHTML);
};
const handleBlur = (e) => {
if (onBlur) onBlur(e);
onChange(e.currentTarget.innerHTML);
};
const handleEditorClick = (e) => {
const target = e.target;
if (target.tagName === "A") {
const anchor = target;
if (anchor.href) {
e.preventDefault();
window.open(anchor.href, "_blank", "noopener,noreferrer");
}
}
};
return /* @__PURE__ */ jsxs(
"div",
{
className: `rte-root${className ? ` ${className}` : ""}`,
style,
children: [
/* @__PURE__ */ jsxs("div", { className: "rte-editor-container", children: [
/* @__PURE__ */ jsx("div", { className: "rte-toolbar", children: toolbarButtons.map((btn) => /* @__PURE__ */ jsx(
"button",
{
type: "button",
"aria-label": btn.label,
className: `rte-toolbar-btn${activeStates[btn.cmd] ? " rte-active" : ""}`,
onMouseDown: (e) => e.preventDefault(),
onClick: () => handleCommand(btn.cmd),
children: btn.icon
},
btn.cmd
)) }),
/* @__PURE__ */ jsxs("div", { style: { position: "relative" }, className: "w-full max-w-full", children: [
showLinkInput && /* @__PURE__ */ jsxs("div", { className: "rte-link-popup", children: [
/* @__PURE__ */ jsx(
"input",
{
type: "text",
className: "",
placeholder: "Text",
value: linkText,
onChange: (e) => setLinkText(e.target.value),
autoFocus: true,
onKeyDown: (e) => {
if (e.key === "Enter") handleInsertLink();
if (e.key === "Escape") handleCancelLink();
}
}
),
/* @__PURE__ */ jsx(
"input",
{
type: "url",
className: "",
placeholder: "Enter URL",
value: linkUrl,
onChange: (e) => setLinkUrl(e.target.value),
onKeyDown: (e) => {
if (e.key === "Enter") handleInsertLink();
if (e.key === "Escape") handleCancelLink();
}
}
),
/* @__PURE__ */ jsx(
"button",
{
type: "button",
className: "rte-insert-btn",
onClick: handleInsertLink,
disabled: !linkUrl || !linkText,
children: "Insert"
}
),
/* @__PURE__ */ jsx(
"button",
{
type: "button",
className: "rte-cancel-btn",
onClick: handleCancelLink,
children: "Cancel"
}
)
] }),
/* @__PURE__ */ jsx(
"div",
{
ref: editorRef,
className: `rte-editor-box ${error ? " border-red-500" : ""}`,
contentEditable: true,
dir: "ltr",
spellCheck: true,
"data-placeholder": placeholder,
onInput: handleInput,
onBlur: handleBlur,
"aria-label": name,
"aria-invalid": !!error,
style: { whiteSpace: "pre-wrap", textAlign: "left" },
onClick: handleEditorClick
}
),
(!value || value === "<br>") && /* @__PURE__ */ jsx("div", { className: "rte-placeholder", children: placeholder })
] })
] }),
error && /* @__PURE__ */ jsx("div", { className: "rte-error", children: error })
]
}
);
};
var RichTextEditor_default = RichTextEditor;
// src/index.ts
var index_default = RichTextEditor_default;
export {
index_default as default
};