siakad-itm/resources/js/components/rich-text-editor.tsx

468 lines
15 KiB
TypeScript

import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import Underline from '@tiptap/extension-underline';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Heading2,
Heading3,
ImagePlus,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Loader2,
Redo2,
Underline as UnderlineIcon,
Undo2,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { richTextContentClass } from '@/lib/rich-text-content';
import { cn } from '@/lib/utils';
import { store as uploadMedia } from '@/routes/media/uploads';
type MediaItem = {
id: number;
name: string;
file_name: string;
mime_type: string;
size: number;
url: string;
};
type TiptapEditorProps = {
value?: string;
onChange?: (value: string) => void;
name?: string;
placeholder?: string;
collection?: string;
error?: string;
};
function ToolbarButton({
onClick,
isActive = false,
disabled = false,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
'hover:bg-muted hover:text-muted-foreground',
'disabled:pointer-events-none disabled:opacity-50',
isActive && 'bg-accent text-accent-foreground',
)}
>
{children}
</button>
);
}
function ToolbarDivider() {
return <div className="h-6 w-px bg-border" />;
}
export default function TiptapEditor({
value = '',
onChange,
name = 'content',
placeholder = 'Tulis konten di sini...',
collection = 'content',
error,
}: TiptapEditorProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [2, 3],
},
}),
Image.configure({
inline: false,
allowBase64: true,
}),
Placeholder.configure({
placeholder,
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline cursor-pointer',
},
}),
],
content: value,
onUpdate: ({ editor: e }) => {
onChange?.(e.getHTML());
},
editorProps: {
attributes: {
class: cn(
'prose prose-sm sm:prose-base max-w-none',
'min-h-[300px] w-full rounded-b-md px-3 py-2',
'focus:outline-none',
richTextContentClass,
'placeholder:text-muted-foreground',
),
},
},
});
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value);
}
}, [value, editor]);
const getCsrfToken = () =>
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') ?? '';
const handleImageUpload = useCallback(
async (file: File) => {
if (!editor) {
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('collection', collection);
const res = await fetch(uploadMedia.url(), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
},
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const message =
body?.errors?.file?.[0] ??
body?.message ??
'Gagal mengunggah gambar.';
throw new Error(message);
}
const media: MediaItem = await res.json();
editor
.chain()
.focus()
.setImage({ src: media.url, alt: file.name })
.run();
} catch (err) {
const message =
err instanceof Error
? err.message
: 'Gagal mengunggah gambar.';
toast.error(message);
console.error('Image upload error:', err);
} finally {
setUploading(false);
}
},
[editor, collection],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleImageUpload(file);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[handleImageUpload],
);
const handleSetLink = useCallback(() => {
if (!editor) {
return;
}
if (linkUrl) {
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: linkUrl })
.run();
} else {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}
setShowLinkInput(false);
setLinkUrl('');
}, [editor, linkUrl]);
if (!editor) {
return null;
}
return (
<div className="grid gap-2">
<div className="rounded-md border border-input">
<div className="flex flex-wrap items-center gap-0.5 border-b border-border p-1">
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleBold().run()
}
isActive={editor.isActive('bold')}
title="Bold"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleItalic().run()
}
isActive={editor.isActive('italic')}
title="Italic"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleUnderline().run()
}
isActive={editor.isActive('underline')}
title="Underline"
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor
.chain()
.focus()
.toggleHeading({ level: 2 })
.run()
}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor
.chain()
.focus()
.toggleHeading({ level: 3 })
.run()
}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleBulletList().run()
}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleOrderedList().run()
}
isActive={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('left').run()
}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('center').run()
}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('right').run()
}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() => {
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
} else {
setShowLinkInput(true);
}
}}
isActive={editor.isActive('link')}
title="Insert Link"
>
<LinkIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
title="Insert Image"
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
</ToolbarButton>
<div className="ml-auto flex items-center gap-0.5">
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo"
>
<Undo2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo"
>
<Redo2 className="h-4 w-4" />
</ToolbarButton>
</div>
</div>
{showLinkInput && (
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="flex-1 rounded-md border border-input bg-transparent px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSetLink();
}
if (e.key === 'Escape') {
setShowLinkInput(false);
setLinkUrl('');
}
}}
autoFocus
/>
<button
type="button"
onClick={handleSetLink}
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
>
Set
</button>
<button
type="button"
onClick={() => {
setShowLinkInput(false);
setLinkUrl('');
}}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<EditorContent editor={editor} />
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<input type="hidden" name={name} value={editor.getHTML()} />
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}