RichTextEditor
WhereUIช่องพิมพ์ข้อความประกอบรูปแบบได้ (ตัวหนา เอียง หัวข้อ รายการ ลิงก์) สร้างบน Tiptap + StarterKit ค่าที่รับ/ส่งเป็น HTML string
ติดตั้ง
Terminal
pnpm dlx shadcn@latest add @whereui/rich-text-editorต้องผูก registry และติดตั้ง @whereui/theme นำหน้าเสมอเมื่อติดตั้งครั้งแรก
การใช้งาน
import { RichTextEditor } from "@/components/ui/rich-text-editor"const [html, setHtml] = React.useState("")<RichTextEditor value={html} onChange={setHtml} />ตัวอย่างเพิ่มเติม
ค่าที่ได้เป็น HTML string
HTML ที่ได้: (ว่าง)
Props
| prop | type | ค่าเริ่มต้น | คำอธิบาย |
|---|---|---|---|
| value | string | — | เนื้อหาเป็น HTML string ("" = ว่าง) เปลี่ยนค่าจากภายนอกได้ |
| onChange | (html: string) => void | — | ส่ง HTML ปัจจุบันกลับทุกครั้งที่พิมพ์ (ว่าง = "") |
| disabled | boolean | — | ปิดแถบเครื่องมือและการแก้ไข |
ข้อควรรู้
- เครื่องมือทั้งหมดมาจาก @tiptap/starter-kit — ยกเลิก/ทำซ้ำใช้ Ctrl+Z / Ctrl+Shift+Z ได้
- ตอนกดปุ่มลิงก์จะถาม URL ผ่าน prompt ของเบราว์เซอร์ กดปุ่มซ้ำเพื่อถอนลิงก์
- ค่าที่ส่งกลับควรทำความสะอาด (sanitize) ก่อนแสดงผลในหน้าอื่นเพื่อความปลอดภัย
ซอร์สโค้ด
components/ui/rich-text-editor.tsx
"use client"import * as React from "react"import { EditorContent, useEditor, type Editor } from "@tiptap/react"import StarterKit from "@tiptap/starter-kit"import { BoldIcon, Heading1Icon, Heading2Icon, ItalicIcon, Link2Icon, ListIcon, ListOrderedIcon, Redo2Icon, Undo2Icon,} from "lucide-react"import { cn } from "cn"import { Button } from "@/components/ui/button"type Tool = { icon: typeof BoldIcon label: string isActive: (editor: Editor) => boolean run: (editor: Editor) => void}// เครื่องมือทั้งหมดมาจาก StarterKit (ตัวหนา/เอียง/หัวข้อ/รายการ/ลิงก์/ย้อน-ทำซ้ำ)const TOOLS: Tool[] = [ { icon: BoldIcon, label: "ตัวหนา", isActive: (e) => e.isActive("bold"), run: (e) => e.chain().focus().toggleBold().run(), }, { icon: ItalicIcon, label: "ตัวเอียง", isActive: (e) => e.isActive("italic"), run: (e) => e.chain().focus().toggleItalic().run(), }, { icon: Heading1Icon, label: "หัวข้อใหญ่", isActive: (e) => e.isActive("heading", { level: 1 }), run: (e) => e.chain().focus().toggleHeading({ level: 1 }).run(), }, { icon: Heading2Icon, label: "หัวข้อรอง", isActive: (e) => e.isActive("heading", { level: 2 }), run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(), }, { icon: ListIcon, label: "รายการหัวข้อย่อย", isActive: (e) => e.isActive("bulletList"), run: (e) => e.chain().focus().toggleBulletList().run(), }, { icon: ListOrderedIcon, label: "รายการลำดับเลข", isActive: (e) => e.isActive("orderedList"), run: (e) => e.chain().focus().toggleOrderedList().run(), }, { icon: Link2Icon, label: "ลิงก์", isActive: (e) => e.isActive("link"), run: (e) => { if (e.isActive("link")) { e.chain().focus().unsetLink().run() return } const url = window.prompt("ใส่ URL ของลิงก์ (เช่น https://example.com)") if (url) e.chain().focus().setLink({ href: url }).run() }, }, { icon: Undo2Icon, label: "ย้อนกลับ", isActive: () => false, run: (e) => e.chain().focus().undo().run(), }, { icon: Redo2Icon, label: "ทำซ้ำ", isActive: () => false, run: (e) => e.chain().focus().redo().run(), },]function RichTextEditor({ value = "", onChange, disabled, className, ...props}: Omit<React.ComponentProps<"div">, "value" | "onChange"> & { /** เนื้อหาเป็น HTML string ("" = ว่าง) */ value?: string onChange?: (html: string) => void disabled?: boolean}) { const editor = useEditor({ extensions: [StarterKit], content: value || "", editable: !disabled, // สร้าง editor หลัง mount เท่านั้น กัน hydration mismatch ตอน SSR immediatelyRender: false, // ให้ toolbar อัปเดตสถานะ active ตามตำแหน่ง cursor shouldRerenderOnTransaction: true, onUpdate: ({ editor }) => { onChange?.(editor.isEmpty ? "" : editor.getHTML()) }, }) // ปรับ editable ตาม prop โดยไม่สร้าง editor ใหม่ React.useEffect(() => { editor?.setEditable(!disabled) }, [editor, disabled]) // ซิงก์เมื่อค่าจากภายนอกเปลี่ยน (เช่น ฟอร์มรีเซ็ต) React.useEffect(() => { if (editor && value !== (editor.isEmpty ? "" : editor.getHTML())) { editor.commands.setContent(value || "", { emitUpdate: false }) } }, [value, editor]) return ( <div data-slot="rich-text-editor" className={cn( "flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 aria-invalid:border-destructive disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", className )} {...props} > <div role="toolbar" aria-label="เครื่องมือจัดข้อความ" className="flex flex-wrap gap-0.5 border-b border-input p-1" > {TOOLS.map((tool) => ( <Button key={tool.label} type="button" variant="ghost" size="icon-xs" aria-label={tool.label} aria-pressed={editor ? tool.isActive(editor) : false} disabled={disabled || !editor} className={cn(editor && tool.isActive(editor) && "bg-muted")} onClick={() => editor && tool.run(editor)} > <tool.icon aria-hidden="true" /> </Button> ))} </div> <EditorContent editor={editor} className="min-h-28 px-3 py-2 text-sm leading-relaxed [&_.ProseMirror]:min-h-24 [&_.ProseMirror]:outline-none [&_a]:text-primary [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5" /> </div> )}export { RichTextEditor }