217 lines
6.6 KiB
TypeScript
217 lines
6.6 KiB
TypeScript
import { useEditor, EditorContent } from '@tiptap/react';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import Link from '@tiptap/extension-link';
|
|
import Image from '@tiptap/extension-image';
|
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
|
|
Image as ImageIcon, Heading1, Heading2, Undo, Redo
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useCallback, useEffect } from 'react';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { toast } from '@/hooks/use-toast';
|
|
|
|
interface RichTextEditorProps {
|
|
content: string;
|
|
onChange: (html: string) => void;
|
|
placeholder?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: {
|
|
class: 'text-primary underline',
|
|
},
|
|
}),
|
|
Image.configure({
|
|
HTMLAttributes: {
|
|
class: 'max-w-full h-auto rounded-md',
|
|
},
|
|
}),
|
|
Placeholder.configure({
|
|
placeholder,
|
|
}),
|
|
],
|
|
content,
|
|
onUpdate: ({ editor }) => {
|
|
onChange(editor.getHTML());
|
|
},
|
|
});
|
|
|
|
// Sync content when it changes externally (e.g., when editing different items)
|
|
useEffect(() => {
|
|
if (editor && content !== editor.getHTML()) {
|
|
editor.commands.setContent(content || '');
|
|
}
|
|
}, [content, editor]);
|
|
|
|
const addLink = useCallback(() => {
|
|
if (!editor) return;
|
|
const url = window.prompt('Masukkan URL:');
|
|
if (url) {
|
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
}
|
|
}, [editor]);
|
|
|
|
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file || !editor) return;
|
|
|
|
// For now, convert to base64 data URL since storage bucket may not be configured
|
|
// In production, you would upload to Supabase Storage
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
editor.chain().focus().setImage({ src: dataUrl }).run();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}, [editor]);
|
|
|
|
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items || !editor) return;
|
|
|
|
for (const item of Array.from(items)) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
editor.chain().focus().setImage({ src: dataUrl }).run();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
break;
|
|
}
|
|
}
|
|
}, [editor]);
|
|
|
|
if (!editor) return null;
|
|
|
|
return (
|
|
<div className={cn("border-2 border-border rounded-md", className)}>
|
|
<div className="flex flex-wrap gap-1 p-2 border-b-2 border-border bg-muted">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
className={editor.isActive('bold') ? 'bg-accent' : ''}
|
|
>
|
|
<Bold className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
className={editor.isActive('italic') ? 'bg-accent' : ''}
|
|
>
|
|
<Italic className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
className={editor.isActive('heading', { level: 1 }) ? 'bg-accent' : ''}
|
|
>
|
|
<Heading1 className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
className={editor.isActive('heading', { level: 2 }) ? 'bg-accent' : ''}
|
|
>
|
|
<Heading2 className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
className={editor.isActive('bulletList') ? 'bg-accent' : ''}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
className={editor.isActive('orderedList') ? 'bg-accent' : ''}
|
|
>
|
|
<ListOrdered className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
className={editor.isActive('blockquote') ? 'bg-accent' : ''}
|
|
>
|
|
<Quote className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={addLink}
|
|
className={editor.isActive('link') ? 'bg-accent' : ''}
|
|
>
|
|
<LinkIcon className="w-4 h-4" />
|
|
</Button>
|
|
<label>
|
|
<Button type="button" variant="ghost" size="sm" asChild>
|
|
<span>
|
|
<ImageIcon className="w-4 h-4" />
|
|
</span>
|
|
</Button>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleImageUpload}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
<div className="flex-1" />
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
disabled={!editor.can().undo()}
|
|
>
|
|
<Undo className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
disabled={!editor.can().redo()}
|
|
>
|
|
<Redo className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<div onPaste={handlePaste}>
|
|
<EditorContent
|
|
editor={editor}
|
|
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|