- Atualiza RichTextEditor para refletir mudanças de disabled (setEditable) - Corrige bug onde editor permanecia travado até F5 após atribuição
328 lines
9.9 KiB
TypeScript
328 lines
9.9 KiB
TypeScript
"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<HTMLInputElement>(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 (
|
|
<div className={cn("rounded-md border bg-background", className)}>
|
|
<div className="flex flex-wrap items-center gap-1 border-b px-2 py-1">
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
active={editor.isActive("bold")}
|
|
ariaLabel="Negrito"
|
|
>
|
|
<Bold className="size-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
active={editor.isActive("italic")}
|
|
ariaLabel="Itálico"
|
|
>
|
|
<Italic className="size-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
active={editor.isActive("strike")}
|
|
ariaLabel="Tachado"
|
|
>
|
|
<Strikethrough className="size-4" />
|
|
</ToolbarButton>
|
|
<Separator orientation="vertical" className="mx-1 h-5" />
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
active={editor.isActive("bulletList")}
|
|
ariaLabel="Lista"
|
|
>
|
|
<List className="size-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
active={editor.isActive("orderedList")}
|
|
ariaLabel="Lista ordenada"
|
|
>
|
|
<ListOrdered className="size-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
active={editor.isActive("blockquote")}
|
|
ariaLabel="Citação"
|
|
>
|
|
<Quote className="size-4" />
|
|
</ToolbarButton>
|
|
<Popover
|
|
open={linkPopoverOpen}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
openLinkPopover()
|
|
} else {
|
|
closeLinkPopover()
|
|
}
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<ToolbarButton active={editor.isActive("link")} ariaLabel="Inserir link">
|
|
<LinkIcon className="size-4" />
|
|
</ToolbarButton>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 space-y-3" align="start">
|
|
<div className="space-y-1">
|
|
<label htmlFor="rich-text-editor-link" className="text-xs font-semibold text-neutral-600">
|
|
URL do link
|
|
</label>
|
|
<Input
|
|
id="rich-text-editor-link"
|
|
ref={linkInputRef}
|
|
placeholder="https://"
|
|
value={linkUrl}
|
|
onChange={(event) => setLinkUrl(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault()
|
|
applyLink()
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault()
|
|
closeLinkPopover()
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="inline-flex items-center gap-2"
|
|
onClick={removeLink}
|
|
disabled={!editor.isActive("link")}
|
|
>
|
|
<Link2Off className="size-4" />
|
|
Remover
|
|
</Button>
|
|
<Button type="button" size="sm" className="inline-flex items-center gap-2" onClick={applyLink}>
|
|
<Check className="size-4" />
|
|
Aplicar
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<div className="ms-auto flex items-center gap-1">
|
|
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
|
|
<Undo className="size-4" />
|
|
</ToolbarButton>
|
|
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} ariaLabel="Refazer">
|
|
<Redo className="size-4" />
|
|
</ToolbarButton>
|
|
</div>
|
|
</div>
|
|
<div style={{ minHeight }} className="rich-text p-3">
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type ToolbarButtonProps = {
|
|
onClick?: () => void
|
|
active?: boolean
|
|
ariaLabel?: string
|
|
children: ReactNode
|
|
}
|
|
|
|
const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
|
({ onClick, active, ariaLabel, children }, ref) => {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant={active ? "default" : "ghost"}
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={onClick}
|
|
aria-label={ariaLabel}
|
|
ref={ref}
|
|
>
|
|
{children}
|
|
</Button>
|
|
)
|
|
}
|
|
)
|
|
|
|
ToolbarButton.displayName = "ToolbarButton"
|
|
|
|
// Utilitário simples para renderização segura do HTML do editor.
|
|
// Remove tags <script>/<style> e atributos on*.
|
|
export function sanitizeEditorHtml(html: string): string {
|
|
try {
|
|
return sanitize(html || "", {
|
|
allowedTags: [
|
|
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
|
|
],
|
|
allowedAttributes: {
|
|
a: ["href","name","target","rel"],
|
|
span: ["style"],
|
|
code: ["class"],
|
|
pre: ["class"],
|
|
},
|
|
allowedSchemes: ["http","https","mailto"],
|
|
// prevent target=_self phishing
|
|
transformTags: {
|
|
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
|
|
},
|
|
// disallow inline event handlers
|
|
allowVulnerableTags: false,
|
|
})
|
|
} catch {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
export function RichTextContent({ html, className }: { html: string; className?: string }) {
|
|
return (
|
|
<div
|
|
className={cn("rich-text text-sm leading-relaxed", className)}
|
|
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(html) }}
|
|
/>
|
|
)
|
|
}
|