feat : update
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
"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 {
|
||||
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;
|
||||
}
|
||||
|
||||
type Format = "bold" | "italic" | "underline" | "strike";
|
||||
|
||||
function normalizedEditorHtml(editor: Editor): string {
|
||||
const html = sanitizeRichTextHtml(editor.getHTML());
|
||||
return html === "<p></p>" ? "" : html;
|
||||
}
|
||||
|
||||
function FormatControls({
|
||||
editor,
|
||||
onLink,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onLink: () => 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) => {
|
||||
const chain = editor.chain().focus();
|
||||
if (format === "bold") chain.toggleBold().run();
|
||||
if (format === "italic") chain.toggleItalic().run();
|
||||
if (format === "underline") chain.toggleUnderline().run();
|
||||
if (format === "strike") chain.toggleStrike().run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<ToggleGroup
|
||||
multiple
|
||||
value={active}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
aria-label="รูปแบบข้อความ"
|
||||
onValueChange={(next) => {
|
||||
const changed = formats.find((format) => active.includes(format) !== next.includes(format));
|
||||
if (changed) toggleFormat(changed);
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="bold" aria-label="ตัวหนา" title="ตัวหนา">
|
||||
<BoldIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="ตัวเอียง" title="ตัวเอียง">
|
||||
<ItalicIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="ขีดเส้นใต้" title="ขีดเส้นใต้">
|
||||
<UnderlineIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="strike" aria-label="ขีดฆ่า" title="ขีดฆ่า">
|
||||
<StrikethroughIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
<label className="relative grid size-8 cursor-pointer place-items-center rounded-lg border border-input hover:bg-muted" title="สีข้อความ">
|
||||
<PaletteIcon aria-hidden="true" />
|
||||
<span className="sr-only">สีข้อความ</span>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
onChange={(event) => editor.chain().focus().setColor(event.target.value).run()}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="ล้างสีข้อความ"
|
||||
title="ล้างสีข้อความ"
|
||||
onClick={() => editor.chain().focus().unsetColor().run()}
|
||||
>
|
||||
<RemoveFormattingIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={editor.isActive("link") ? "ลบลิงก์" : "เพิ่มลิงก์"}
|
||||
title={editor.isActive("link") ? "ลบลิงก์" : "เพิ่มลิงก์"}
|
||||
onClick={onLink}
|
||||
>
|
||||
{editor.isActive("link") ? <UnlinkIcon /> : <LinkIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "เขียนข้อความ...",
|
||||
className,
|
||||
invalid = false,
|
||||
}: 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 (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-background focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
|
||||
invalid && "border-destructive ring-3 ring-destructive/20",
|
||||
className,
|
||||
)}
|
||||
aria-invalid={invalid || undefined}
|
||||
>
|
||||
<div className="border-b bg-muted/50 p-1">
|
||||
<FormatControls editor={editor} onLink={openLinkDialog} />
|
||||
</div>
|
||||
<BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md">
|
||||
<FormatControls editor={editor} onLink={openLinkDialog} />
|
||||
</BubbleMenu>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
<Dialog open={linkOpen} onOpenChange={setLinkOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>เพิ่มลิงก์</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rich-text-link-text">ข้อความ</FieldLabel>
|
||||
<Input
|
||||
id="rich-text-link-text"
|
||||
value={linkText}
|
||||
placeholder="ข้อความลิงก์"
|
||||
onChange={(event) => setLinkText(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field data-invalid={linkInvalid || undefined}>
|
||||
<FieldLabel htmlFor="rich-text-link-url">URL</FieldLabel>
|
||||
<Input
|
||||
id="rich-text-link-url"
|
||||
value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
aria-invalid={linkInvalid}
|
||||
autoFocus
|
||||
onChange={(event) => {
|
||||
setLinkUrl(event.target.value);
|
||||
setLinkInvalid(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
saveLink();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{linkInvalid ? (
|
||||
<FieldDescription>ใช้ลิงก์ http, https หรือ mailto เท่านั้น</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setLinkOpen(false)}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type="button" onClick={saveLink}>
|
||||
บันทึกลิงก์
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user