"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 (
{ const changed = formats.find((format) => active.includes(format) !== next.includes(format)); if (changed) toggleFormat(changed); }} >
); } 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}
เพิ่มลิงก์ ข้อความ setLinkText(event.target.value)} /> URL { setLinkUrl(event.target.value); setLinkInvalid(false); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); saveLink(); } }} /> {linkInvalid ? ( ใช้ลิงก์ http, https หรือ mailto เท่านั้น ) : null} ); }