Changes
This commit is contained in:
@@ -6,12 +6,16 @@ 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
|
||||
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
|
||||
Maximize2, Minimize2
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
@@ -20,7 +24,37 @@ interface RichTextEditorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Custom Image extension with resize support
|
||||
const ResizableImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('width') || element.style.width?.replace('px', ''),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.width) return {};
|
||||
return { width: attributes.width, style: `width: ${attributes.width}px` };
|
||||
},
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('height') || element.style.height?.replace('px', ''),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.height) return {};
|
||||
return { height: attributes.height, style: `height: ${attributes.height}px` };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null);
|
||||
const [imageWidth, setImageWidth] = useState('');
|
||||
const [imageHeight, setImageHeight] = useState('');
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -30,9 +64,9 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
class: 'text-primary underline',
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
ResizableImage.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'max-w-full h-auto rounded-md',
|
||||
class: 'max-w-full h-auto rounded-md cursor-pointer',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
@@ -43,9 +77,25 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
// Check if an image is selected
|
||||
const { selection } = editor.state;
|
||||
const node = editor.state.doc.nodeAt(selection.from);
|
||||
if (node?.type.name === 'image') {
|
||||
setSelectedImage({
|
||||
src: node.attrs.src,
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
});
|
||||
setImageWidth(node.attrs.width?.toString() || '');
|
||||
setImageHeight(node.attrs.height?.toString() || '');
|
||||
} else {
|
||||
setSelectedImage(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Sync content when it changes externally (e.g., when editing different items)
|
||||
// Sync content when it changes externally
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content || '');
|
||||
@@ -60,18 +110,65 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const uploadImageToStorage = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${fileExt}`;
|
||||
const filePath = `editor-images/${fileName}`;
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('content')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Storage upload error:', error);
|
||||
// Fall back to base64 if storage fails
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
|
||||
return urlData.publicUrl;
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const convertToDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
setUploading(true);
|
||||
|
||||
// Try to upload to storage first
|
||||
let imageUrl = await uploadImageToStorage(file);
|
||||
|
||||
// Fall back to base64 if storage upload fails
|
||||
if (!imageUrl) {
|
||||
toast({
|
||||
title: 'Info',
|
||||
description: 'Menggunakan penyimpanan lokal untuk gambar',
|
||||
});
|
||||
imageUrl = await convertToDataUrl(file);
|
||||
}
|
||||
|
||||
editor.chain().focus().setImage({ src: imageUrl }).run();
|
||||
setUploading(false);
|
||||
|
||||
// Reset input
|
||||
e.target.value = '';
|
||||
}, [editor]);
|
||||
|
||||
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
||||
@@ -84,17 +181,47 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
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);
|
||||
setUploading(true);
|
||||
|
||||
let imageUrl = await uploadImageToStorage(file);
|
||||
if (!imageUrl) {
|
||||
imageUrl = await convertToDataUrl(file);
|
||||
}
|
||||
|
||||
editor.chain().focus().setImage({ src: imageUrl }).run();
|
||||
setUploading(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const updateImageSize = useCallback(() => {
|
||||
if (!editor || !selectedImage) return;
|
||||
|
||||
const width = imageWidth ? parseInt(imageWidth) : null;
|
||||
const height = imageHeight ? parseInt(imageHeight) : null;
|
||||
|
||||
editor.chain().focus().updateAttributes('image', { width, height }).run();
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Ukuran gambar diperbarui' });
|
||||
}, [editor, selectedImage, imageWidth, imageHeight]);
|
||||
|
||||
const setPresetSize = useCallback((size: 'small' | 'medium' | 'large' | 'full') => {
|
||||
if (!editor) return;
|
||||
|
||||
const sizes = {
|
||||
small: { width: 200, height: null },
|
||||
medium: { width: 400, height: null },
|
||||
large: { width: 600, height: null },
|
||||
full: { width: null, height: null },
|
||||
};
|
||||
|
||||
const { width, height } = sizes[size];
|
||||
editor.chain().focus().updateAttributes('image', { width, height }).run();
|
||||
setImageWidth(width?.toString() || '');
|
||||
setImageHeight(height?.toString() || '');
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
@@ -173,8 +300,8 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<label>
|
||||
<Button type="button" variant="ghost" size="sm" asChild>
|
||||
<span>
|
||||
<Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
|
||||
<span className={uploading ? 'opacity-50' : ''}>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</span>
|
||||
</Button>
|
||||
@@ -183,8 +310,57 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Image resize popover */}
|
||||
{selectedImage && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" className="bg-accent">
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Ukuran Gambar</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('small')}>S</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('medium')}>M</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('large')}>L</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('full')}>Full</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Lebar (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={imageWidth}
|
||||
onChange={(e) => setImageWidth(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Tinggi (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={imageHeight}
|
||||
onChange={(e) => setImageHeight(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={updateImageSize} className="w-full">
|
||||
Terapkan
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -208,9 +384,14 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
<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]"
|
||||
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
{uploading && (
|
||||
<div className="p-2 text-sm text-muted-foreground text-center border-t border-border">
|
||||
Mengunggah gambar...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user