"use client" import { forwardRef, useCallback, useEffect, useRef, useState } from "react" import type { ReactNode } from "react" import { useEditor, EditorContent } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" import Link from "@tiptap/extension-link" import Placeholder from "@tiptap/extension-placeholder" import { cn } from "@/lib/utils" import sanitize from "sanitize-html" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Bold, Italic, Strikethrough, List, ListOrdered, Quote, Undo, Redo, Link as LinkIcon, Check, Link2Off, } from "lucide-react" type RichTextEditorProps = { value?: string onChange?: (html: string) => void className?: string placeholder?: string disabled?: boolean minHeight?: number } export function RichTextEditor({ value, onChange, className, placeholder = "Escreva aqui...", disabled, minHeight = 120, }: RichTextEditorProps) { const editor = useEditor({ extensions: [ StarterKit.configure({ bulletList: { keepMarks: true }, orderedList: { keepMarks: true }, }), Link.configure({ openOnClick: true, autolink: true, protocols: ["http", "https", "mailto"], HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" }, }), Placeholder.configure({ placeholder }), ], editorProps: { attributes: { class: "prose prose-sm max-w-none focus:outline-none text-foreground", }, }, content: value || "", onUpdate({ editor }) { onChange?.(editor.getHTML()) }, editable: !disabled, // Avoid SSR hydration mismatches per Tiptap recommendation immediatelyRender: false, }) const [linkPopoverOpen, setLinkPopoverOpen] = useState(false) const [linkUrl, setLinkUrl] = useState("") const linkInputRef = useRef(null) const closeLinkPopover = useCallback(() => { setLinkPopoverOpen(false) requestAnimationFrame(() => { editor?.commands.focus() }) }, [editor]) const openLinkPopover = useCallback(() => { if (!editor) return editor.chain().focus() const prev = (editor.getAttributes("link").href as string | undefined) ?? "" setLinkUrl(prev) setLinkPopoverOpen(true) requestAnimationFrame(() => { linkInputRef.current?.focus() if (prev) { linkInputRef.current?.select() } }) }, [editor]) const applyLink = useCallback(() => { if (!editor) return const trimmed = linkUrl.trim() if (!trimmed) { editor.chain().focus().extendMarkRange("link").unsetLink().run() closeLinkPopover() return } const normalized = /^(https?:\/\/|mailto:)/i.test(trimmed) ? trimmed : `https://${trimmed}` editor.chain().focus().extendMarkRange("link").setLink({ href: normalized }).run() closeLinkPopover() }, [closeLinkPopover, editor, linkUrl]) const removeLink = useCallback(() => { if (!editor) return editor.chain().focus().extendMarkRange("link").unsetLink().run() closeLinkPopover() }, [closeLinkPopover, editor]) useEffect(() => { if (!editor || !linkPopoverOpen) return const handler = () => { const prev = (editor.getAttributes("link").href as string | undefined) ?? "" setLinkUrl(prev) } editor.on("selectionUpdate", handler) return () => { editor.off("selectionUpdate", handler) } }, [editor, linkPopoverOpen]) // Keep external value in sync when it changes useEffect(() => { if (!editor) return const current = editor.getHTML() if ((value ?? "") !== current) { editor.commands.setContent(value || "", { emitUpdate: false }) } }, [value, editor]) // Reflect disabled prop changes after initialization useEffect(() => { if (!editor) return editor.setEditable(!disabled) }, [disabled, editor]) if (!editor) return null return (
editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} ariaLabel="Negrito" > editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} ariaLabel="Itálico" > editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} ariaLabel="Tachado" > editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} ariaLabel="Lista" > editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} ariaLabel="Lista ordenada" > editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} ariaLabel="Citação" > { if (open) { openLinkPopover() } else { closeLinkPopover() } }} >
setLinkUrl(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault() applyLink() } else if (event.key === "Escape") { event.preventDefault() closeLinkPopover() } }} />
editor.chain().focus().undo().run()} ariaLabel="Desfazer"> editor.chain().focus().redo().run()} ariaLabel="Refazer">
) } type ToolbarButtonProps = { onClick?: () => void active?: boolean ariaLabel?: string children: ReactNode } const ToolbarButton = forwardRef( ({ onClick, active, ariaLabel, children }, ref) => { return ( ) } ) ToolbarButton.displayName = "ToolbarButton" // Utilitário simples para renderização segura do HTML do editor. // Remove tags