arcadia-suite-sv/client/src/components/RichTextEditor.tsx

343 lines
11 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 Youtube from '@tiptap/extension-youtube';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useState, useCallback } from 'react';
import {
Bold,
Italic,
Strikethrough,
Code,
List,
ListOrdered,
Quote,
Link as LinkIcon,
Image as ImageIcon,
Youtube as YoutubeIcon,
Undo,
Redo,
Heading1,
Heading2,
Heading3,
} from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
placeholder?: string;
className?: string;
}
export function RichTextEditor({ content, onChange, placeholder, className }: RichTextEditorProps) {
const [linkUrl, setLinkUrl] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [youtubeUrl, setYoutubeUrl] = useState('');
const [showLinkPopover, setShowLinkPopover] = useState(false);
const [showImagePopover, setShowImagePopover] = useState(false);
const [showYoutubePopover, setShowYoutubePopover] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
class: 'text-primary underline cursor-pointer',
},
}),
Image.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-lg my-2',
},
}),
Youtube.configure({
HTMLAttributes: {
class: 'w-full aspect-video rounded-lg my-2',
},
}),
],
content,
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[150px] p-3',
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
const addLink = useCallback(() => {
if (linkUrl && editor) {
editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkPopover(false);
}
}, [editor, linkUrl]);
const addImage = useCallback(() => {
if (imageUrl && editor) {
editor.chain().focus().setImage({ src: imageUrl }).run();
setImageUrl('');
setShowImagePopover(false);
}
}, [editor, imageUrl]);
const addYoutubeVideo = useCallback(() => {
if (youtubeUrl && editor) {
editor.chain().focus().setYoutubeVideo({ src: youtubeUrl }).run();
setYoutubeUrl('');
setShowYoutubePopover(false);
}
}, [editor, youtubeUrl]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items || !editor) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target?.result as string;
editor.chain().focus().setImage({ src: base64 }).run();
};
reader.readAsDataURL(blob);
}
return;
}
}
}, [editor]);
if (!editor) return null;
return (
<div className={`border rounded-lg overflow-hidden ${className}`}>
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'bg-muted' : ''}
data-testid="btn-bold"
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'bg-muted' : ''}
data-testid="btn-italic"
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'bg-muted' : ''}
data-testid="btn-strike"
>
<Strikethrough className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive('code') ? 'bg-muted' : ''}
data-testid="btn-code"
>
<Code className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
data-testid="btn-h1"
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
data-testid="btn-h2"
>
<Heading2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive('heading', { level: 3 }) ? 'bg-muted' : ''}
data-testid="btn-h3"
>
<Heading3 className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
data-testid="btn-bullet-list"
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
data-testid="btn-ordered-list"
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
data-testid="btn-quote"
>
<Quote className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Popover open={showLinkPopover} onOpenChange={setShowLinkPopover}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={editor.isActive('link') ? 'bg-muted' : ''}
data-testid="btn-add-link"
>
<LinkIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<div className="space-y-2">
<Input
placeholder="Cole a URL aqui..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
data-testid="input-link-url"
/>
<Button size="sm" onClick={addLink} className="w-full" data-testid="btn-confirm-link">
Adicionar Link
</Button>
</div>
</PopoverContent>
</Popover>
<Popover open={showImagePopover} onOpenChange={setShowImagePopover}>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" data-testid="btn-add-image">
<ImageIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<div className="space-y-2">
<Input
placeholder="Cole a URL da imagem..."
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
data-testid="input-image-url"
/>
<Button size="sm" onClick={addImage} className="w-full" data-testid="btn-confirm-image">
Adicionar Imagem
</Button>
<p className="text-xs text-muted-foreground text-center">
Ou cole uma imagem diretamente (Ctrl+V)
</p>
</div>
</PopoverContent>
</Popover>
<Popover open={showYoutubePopover} onOpenChange={setShowYoutubePopover}>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" data-testid="btn-add-youtube">
<YoutubeIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<div className="space-y-2">
<Input
placeholder="Cole a URL do YouTube..."
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
data-testid="input-youtube-url"
/>
<Button size="sm" onClick={addYoutubeVideo} className="w-full" data-testid="btn-confirm-youtube">
Adicionar Vídeo
</Button>
</div>
</PopoverContent>
</Popover>
<div className="flex-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
data-testid="btn-undo"
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
data-testid="btn-redo"
>
<Redo className="h-4 w-4" />
</Button>
</div>
<div onPaste={handlePaste}>
<EditorContent editor={editor} />
</div>
</div>
);
}