348 lines
11 KiB
TypeScript
348 lines
11 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 {
|
|
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 === "<p></p>" ? "" : 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 (
|
|
<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) => {
|
|
if (formatWholeContent) editor.commands.selectAll();
|
|
editor.chain().focus().setColor(event.target.value).run();
|
|
onFormatChange?.({ color: event.target.value });
|
|
}}
|
|
/>
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="ล้างสีข้อความ"
|
|
title="ล้างสีข้อความ"
|
|
onClick={() => {
|
|
if (formatWholeContent) editor.commands.selectAll();
|
|
editor.chain().focus().unsetColor().run();
|
|
onFormatChange?.({ color: null });
|
|
}}
|
|
>
|
|
<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,
|
|
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 (
|
|
<>
|
|
<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}
|
|
formatWholeContent={formatWholeContent}
|
|
onFormatChange={onFormatChange}
|
|
/>
|
|
</div>
|
|
{showBubbleMenu ? (
|
|
<BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md">
|
|
<FormatControls
|
|
editor={editor}
|
|
onLink={openLinkDialog}
|
|
formatWholeContent={formatWholeContent}
|
|
onFormatChange={onFormatChange}
|
|
/>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|