feat: implement Tiptap rich text editor and integrate it into admin question management
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"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";
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RichTextEditor({ value, onChange, placeholder, 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(html);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[40px] px-3 py-2",
|
||||
},
|
||||
},
|
||||
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 {
|
||||
if (linkText) {
|
||||
editor.chain().focus().insertContent(`<a href="${linkUrl}">${linkText}</a>`).run();
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl }).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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user