Files
erika/components/rich-text-editor.tsx
2026-08-10 13:19:09 +07:00

265 lines
8.7 KiB
TypeScript

"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import { useState } from "react";
import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Unlink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { sanitizeHtml, sanitizeUrl } from "@/lib/sanitize-html";
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function RichTextEditor({ value, onChange, className }: RichTextEditorProps) {
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [linkText, setLinkText] = useState("");
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: "text-primary hover:underline underline-offset-4",
target: "_blank",
rel: "noopener noreferrer"
},
}),
],
content: value,
onUpdate: ({ editor }) => {
// Get HTML output. If empty, maybe output empty string to avoid empty p tags.
let html = editor.getHTML();
if (html === "<p></p>") html = "";
onChange(sanitizeHtml(html));
},
editorProps: {
attributes: {
class: "prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[40px] px-3 py-2 [&>p]:m-0 leading-snug",
},
},
immediatelyRender: false,
});
if (!editor) {
return null;
}
const toggleLink = () => {
const previousUrl = editor.getAttributes("link").href;
if (previousUrl) {
editor.chain().focus().unsetLink().run();
return;
}
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
setLinkText(selectedText || "");
setLinkUrl("");
setIsLinkDialogOpen(true);
};
const saveLink = () => {
if (linkUrl === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
const safeUrl = sanitizeUrl(linkUrl);
if (!safeUrl) {
return;
}
if (linkText) {
editor.chain().focus().insertContent({
type: "text",
text: linkText,
marks: [{ type: "link", attrs: { href: safeUrl } }],
}).run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: safeUrl }).run();
}
}
setIsLinkDialogOpen(false);
setLinkUrl("");
setLinkText("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
saveLink();
}
};
return (
<div className={`border focus-within:ring-1 focus-within:ring-ring ${className || ""}`}>
<div className="flex flex-wrap items-center gap-1 border-b bg-muted/50 p-1">
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("bold") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold"
aria-label="Bold"
aria-pressed={editor.isActive("bold")}
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("italic") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic"
aria-label="Italic"
aria-pressed={editor.isActive("italic")}
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("underline") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Underline"
aria-label="Underline"
aria-pressed={editor.isActive("underline")}
>
<UnderlineIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("strike") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
aria-label="Strikethrough"
aria-pressed={editor.isActive("strike")}
>
<Strikethrough className="h-4 w-4" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("link") ? "bg-muted" : ""}`}
onClick={toggleLink}
title={editor.isActive("link") ? "Remove Link" : "Add Link"}
aria-label={editor.isActive("link") ? "Remove link" : "Add link"}
>
{editor.isActive("link") ? <Unlink className="h-4 w-4" /> : <LinkIcon className="h-4 w-4" />}
</Button>
</div>
{editor && (
<BubbleMenu
editor={editor}
className="flex items-center gap-1 border bg-background p-1"
>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("bold") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold"
aria-label="Bold"
aria-pressed={editor.isActive("bold")}
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("italic") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic"
aria-label="Italic"
aria-pressed={editor.isActive("italic")}
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("underline") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Underline"
aria-label="Underline"
aria-pressed={editor.isActive("underline")}
>
<UnderlineIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${editor.isActive("strike") ? "bg-muted" : ""}`}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
aria-label="Strikethrough"
aria-pressed={editor.isActive("strike")}
>
<Strikethrough className="h-4 w-4" />
</Button>
</BubbleMenu>
)}
<EditorContent editor={editor} className="bg-background rounded-b-md" />
<Dialog open={isLinkDialogOpen} onOpenChange={setIsLinkDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="link-text" className="text-sm font-medium">Text</label>
<Input
id="link-text"
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Click here"
autoFocus
/>
</div>
<div className="grid gap-2">
<label htmlFor="link-url" className="text-sm font-medium">URL</label>
<Input
id="link-url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="https://example.com"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsLinkDialogOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={saveLink}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}