"use client";
import { useEffect, useState } from "react";
import type { Editor } from "@tiptap/core";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { Color, TextStyle } from "@tiptap/extension-text-style";
import Underline from "@tiptap/extension-underline";
import { EditorContent, useEditor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import StarterKit from "@tiptap/starter-kit";
import {
BoldIcon,
ItalicIcon,
LinkIcon,
PaletteIcon,
RemoveFormattingIcon,
StrikethroughIcon,
UnderlineIcon,
UnlinkIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
type RichTextStylePatch,
sanitizeRichTextHtml,
sanitizeRichTextUrl,
} from "@/lib/rich-text";
import { cn } from "@/lib/utils";
export interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
invalid?: boolean;
showBubbleMenu?: boolean;
formatWholeContent?: boolean;
onFormatChange?: (patch: RichTextStylePatch) => void;
}
type Format = "bold" | "italic" | "underline" | "strike";
function normalizedEditorHtml(editor: Editor): string {
const html = sanitizeRichTextHtml(editor.getHTML());
return html === "
" ? "" : html;
}
function FormatControls({
editor,
onLink,
formatWholeContent,
onFormatChange,
}: {
editor: Editor;
onLink: () => void;
formatWholeContent: boolean;
onFormatChange?: (patch: RichTextStylePatch) => void;
}) {
const formats: Format[] = ["bold", "italic", "underline", "strike"];
const active = formats.filter((format) => editor.isActive(format));
const color = editor.getAttributes("textStyle").color ?? "#FF3B30";
const toggleFormat = (format: Format) => {
if (formatWholeContent) editor.commands.selectAll();
const enabled = !editor.isActive(format);
const chain = editor.chain().focus();
if (format === "bold") {
if (enabled) chain.setBold().run();
else chain.unsetBold().run();
}
if (format === "italic") {
if (enabled) chain.setItalic().run();
else chain.unsetItalic().run();
}
if (format === "underline") {
if (enabled) chain.setUnderline().run();
else chain.unsetUnderline().run();
}
if (format === "strike") {
if (enabled) chain.setStrike().run();
else chain.unsetStrike().run();
}
onFormatChange?.({ [format]: enabled });
};
return (
);
}
export function RichTextEditor({
value,
onChange,
placeholder = "เขียนข้อความ...",
className,
invalid = false,
showBubbleMenu = true,
formatWholeContent = false,
onFormatChange,
}: RichTextEditorProps) {
const [linkOpen, setLinkOpen] = useState(false);
const [linkText, setLinkText] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [linkInvalid, setLinkInvalid] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
blockquote: false,
codeBlock: false,
heading: false,
horizontalRule: false,
link: false,
underline: false,
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: "text-primary underline underline-offset-4",
target: "_blank",
rel: "noopener noreferrer",
},
isAllowedUri: (url) => Boolean(sanitizeRichTextUrl(url)),
}),
TextStyle,
Color,
Placeholder.configure({ placeholder }),
],
content: value,
immediatelyRender: false,
onUpdate: ({ editor: currentEditor }) => onChange(normalizedEditorHtml(currentEditor)),
editorProps: {
attributes: {
class: "prose prose-sm prose-invert min-h-28 max-w-none px-3 py-2 leading-snug outline-none [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&>p]:m-0",
},
},
});
useEffect(() => {
if (!editor) return;
const next = sanitizeRichTextHtml(value);
if (normalizedEditorHtml(editor) !== next) {
editor.commands.setContent(next, { emitUpdate: false });
}
}, [editor, value]);
if (!editor) return null;
const openLinkDialog = () => {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
}
const { from, to } = editor.state.selection;
setLinkText(editor.state.doc.textBetween(from, to, " "));
setLinkUrl("");
setLinkInvalid(false);
setLinkOpen(true);
};
const saveLink = () => {
const safeUrl = sanitizeRichTextUrl(linkUrl);
if (!safeUrl) {
setLinkInvalid(true);
return;
}
const chain = editor.chain().focus();
if (linkText) {
chain.insertContent({
type: "text",
text: linkText,
marks: [{ type: "link", attrs: { href: safeUrl } }],
}).run();
} else {
chain.extendMarkRange("link").setLink({ href: safeUrl }).run();
}
setLinkOpen(false);
setLinkText("");
setLinkUrl("");
setLinkInvalid(false);
};
return (
<>
{showBubbleMenu ? (
) : null}
>
);
}