sistema-de-chamados/src/components/ui/rich-text-editor.tsx
codex-bot 7ed7775c05 fix(editor): reativar edição ao atribuir responsável
- Atualiza RichTextEditor para refletir mudanças de disabled (setEditable)
- Corrige bug onde editor permanecia travado até F5 após atribuição
2025-10-20 15:06:30 -03:00

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) }}
/>
)
}