343 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|