265 lines
8.8 KiB
TypeScript
265 lines
8.8 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 rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ${className || ""}`}>
|
|
<div className="flex flex-wrap items-center gap-1 border-b p-1 bg-muted/50 rounded-t-md">
|
|
<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 p-1 bg-background border rounded-md shadow-md"
|
|
>
|
|
<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>
|
|
);
|
|
}
|