Files
buzz-sheet/components/rich-text-editor.tsx
T
gunshiz a4ecdf4fff
CI / Verify (push) Successful in 1m1s
CI / Build immutable images and deploy (push) Successful in 2m19s
feat(extra) : add catalog items and image export
2026-09-05 12:36:00 +07:00

306 lines
9.9 KiB
TypeScript

"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;
showBubbleMenu?: 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,
showBubbleMenu = true,
}: 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>
{showBubbleMenu ? (
<BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md">
<FormatControls editor={editor} onLink={openLinkDialog} />
</BubbleMenu>
) : null}
<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>
</>
);
}