sistema-de-chamados/web/src/components/ui/rich-text-editor.tsx
esdrasrenan ea60c3b841 feat(rich-text, types): Tiptap editor, SSR-safe, comments + description; stricter typing (no any) across app
- Add Tiptap editor + toolbar and rich content rendering with sanitize-html
- Fix SSR hydration (immediatelyRender: false) and setContent options
- Comments: rich text + visibility selector, typed attachments (Id<_storage>)
- New Ticket: description rich text; attachments typed; queues typed
- Convex: server-side filters using indexes; priority order rename; stronger Doc/Id typing; remove helper with any
- Schemas/Mappers: zod v4 record typing; event payload record typing; customFields typed
- UI: replace any in header/play/list/timeline/fields; improve select typings
- Build passes; only non-blocking lint warnings remain
2025-10-04 14:25:10 -03:00

218 lines
6.2 KiB
TypeScript

"use client"
import { useEffect } 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 {
Bold,
Italic,
Strikethrough,
List,
ListOrdered,
Quote,
Undo,
Redo,
Link as LinkIcon,
} 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,
})
// 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])
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>
<ToolbarButton
onClick={() => {
const prev = editor.getAttributes("link").href as string | undefined
const url = window.prompt("URL do link:", prev || "https://")
if (url === null) return
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
}}
active={editor.isActive("link")}
ariaLabel="Inserir link"
>
<LinkIcon className="size-4" />
</ToolbarButton>
<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>
)
}
function ToolbarButton({
onClick,
active,
ariaLabel,
children,
}: {
onClick: () => void
active?: boolean
ariaLabel?: string
children: React.ReactNode
}) {
return (
<Button
type="button"
variant={active ? "default" : "ghost"}
size="icon"
className="h-7 w-7"
onClick={onClick}
aria-label={ariaLabel}
>
{children}
</Button>
)
}
// 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) }}
/>
)
}