ข้ามไปเนื้อหา
WhereUI

ค้นหาในเอกสาร

ค้นหาชื่อ component หรือหน้าเอกสาร

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

proptypeค่าเริ่มต้นคำอธิบาย
valuestring—เนื้อหาเป็น HTML string ("" = ว่าง) เปลี่ยนค่าจากภายนอกได้
onChange(html: string) => void—ส่ง HTML ปัจจุบันกลับทุกครั้งที่พิมพ์ (ว่าง = "")
disabledboolean—ปิดแถบเครื่องมือและการแก้ไข

ข้อควรรู้

  • เครื่องมือทั้งหมดมาจาก @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 }