- 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
218 lines
6.2 KiB
TypeScript
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) }}
|
|
/>
|
|
)
|
|
}
|