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 (
);
}
function ToolbarDivider() {
return
;
}
export default function TiptapEditor({
value = '',
onChange,
name = 'content',
placeholder = 'Tulis konten di sini...',
collection = 'content',
error,
}: TiptapEditorProps) {
const fileInputRef = useRef(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) => {
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 (
editor.chain().focus().toggleBold().run()
}
isActive={editor.isActive('bold')}
title="Bold"
>
editor.chain().focus().toggleItalic().run()
}
isActive={editor.isActive('italic')}
title="Italic"
>
editor.chain().focus().toggleUnderline().run()
}
isActive={editor.isActive('underline')}
title="Underline"
>
editor
.chain()
.focus()
.toggleHeading({ level: 2 })
.run()
}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
editor
.chain()
.focus()
.toggleHeading({ level: 3 })
.run()
}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
editor.chain().focus().toggleBulletList().run()
}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
editor.chain().focus().toggleOrderedList().run()
}
isActive={editor.isActive('orderedList')}
title="Ordered List"
>
editor.chain().focus().setTextAlign('left').run()
}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
editor.chain().focus().setTextAlign('center').run()
}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
editor.chain().focus().setTextAlign('right').run()
}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
{
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
} else {
setShowLinkInput(true);
}
}}
isActive={editor.isActive('link')}
title="Insert Link"
>
fileInputRef.current?.click()}
disabled={uploading}
title="Insert Image"
>
{uploading ? (
) : (
)}
editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo"
>
editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo"
>
{showLinkInput && (
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
/>
)}
{error &&
{error}
}
);
}