sistema-de-chamados/src/components/ui/rich-text-editor.tsx
2025-10-24 16:48:24 -03:00

1101 lines
37 KiB
TypeScript

"use client"
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import type { Editor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import Mention from "@tiptap/extension-mention"
import { ReactRenderer } from "@tiptap/react"
import tippy, { type Instance, type Props as TippyProps } from "tippy.js"
// Nota: o CSS do Tippy não é obrigatório, mas melhora muito a renderização
// do popover de sugestões. O componente aplica um z-index alto e estratégia
// "fixed" para evitar problemas de sobreposição/scrolling mesmo sem CSS global.
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 { getTicketStatusDotClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
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
ticketMention?: {
enabled?: boolean
}
}
type TicketMentionItem = {
id: string
reference: number
subject: string
status: string
priority: string
requesterName: string | null
assigneeName: string | null
companyName: string | null
url: string
updatedAt: string
}
type TicketMentionSuggestionProps = {
command: (item: TicketMentionItem) => void
items: TicketMentionItem[]
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
}
const TICKET_MENTION_BASE_CLASSES =
"ticket-mention inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200"
const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full bg-slate-400"
const TICKET_MENTION_REF_CLASSES = "ticket-mention-ref text-neutral-900"
const TICKET_MENTION_SEP_CLASSES = "ticket-mention-sep text-neutral-400"
const TICKET_MENTION_SUBJECT_CLASSES = "ticket-mention-subject max-w-[220px] truncate text-neutral-700"
function formatMentionSubject(subject: string) {
if (!subject) return ""
return subject.length > 60 ? `${subject.slice(0, 57)}` : subject
}
function extractTicketIdFromHref(href: string | null | undefined): string {
if (!href) return ""
try {
const normalized = href.split("?")[0]
const segments = normalized.split("/").filter(Boolean)
return segments.pop() ?? ""
} catch {
return ""
}
}
function parseTicketMentionText(text: string | null | undefined) {
const fallback = { reference: "", subject: "" }
if (!text) return fallback
const trimmed = text.replace(/\s+/g, " ").trim()
if (!trimmed) return fallback
const bulletIndex = trimmed.indexOf("•")
if (bulletIndex === -1) {
const hashIndex = trimmed.indexOf("#")
const ref = hashIndex === -1 ? trimmed : trimmed.slice(hashIndex + 1)
return { reference: ref.trim(), subject: "" }
}
const referencePart = trimmed.slice(0, bulletIndex)
const subjectPart = trimmed.slice(bulletIndex + 1)
const ref = referencePart.replace(/^#/, "").trim()
return { reference: ref, subject: subjectPart.trim() }
}
type TicketMentionAttributes = Record<string, unknown>
const TICKET_MENTION_FALLBACK_STATUS = "PENDING"
const TICKET_MENTION_FALLBACK_PRIORITY = "MEDIUM"
function toPlainString(value: unknown): string {
if (value === null || value === undefined) return ""
return String(value)
}
function normalizeTicketMentionAttributes(attrs: TicketMentionAttributes) {
const id = toPlainString(attrs.id ?? attrs["data-ticket-id"])
const referenceRaw = attrs.reference ?? attrs["data-ticket-reference"] ?? attrs.id ?? ""
const reference = toPlainString(referenceRaw)
const subject = toPlainString(attrs.subject ?? attrs["data-ticket-subject"])
const statusRaw = toPlainString(attrs.status ?? attrs["data-ticket-status"] ?? TICKET_MENTION_FALLBACK_STATUS)
const status = statusRaw ? statusRaw.toUpperCase() : TICKET_MENTION_FALLBACK_STATUS
const priorityRaw = toPlainString(attrs.priority ?? attrs["data-ticket-priority"] ?? TICKET_MENTION_FALLBACK_PRIORITY)
const priority = priorityRaw ? priorityRaw.toUpperCase() : TICKET_MENTION_FALLBACK_PRIORITY
const url = toPlainString(attrs.url ?? attrs["data-ticket-url"] ?? attrs.href ?? "")
return { id, reference, subject, status, priority, url }
}
type TicketMentionNodeElements = {
root: HTMLAnchorElement
dotEl: HTMLSpanElement
referenceEl: HTMLSpanElement
subjectEl: HTMLSpanElement
separatorEl: HTMLSpanElement
}
function updateTicketMentionNodeElements(elements: TicketMentionNodeElements, attrs: TicketMentionAttributes) {
const { root, dotEl, referenceEl, subjectEl, separatorEl } = elements
const normalized = normalizeTicketMentionAttributes(attrs)
const referenceLabel = normalized.reference || normalized.id
const displayedReference = referenceLabel ? `#${referenceLabel}` : "#"
const formattedSubject = normalized.subject ? formatMentionSubject(normalized.subject) : ""
const additionalClass = toPlainString(attrs.class ?? attrs.className)
const dotClass = getTicketStatusDotClass(normalized.status)
root.className = cn(TICKET_MENTION_BASE_CLASSES, additionalClass)
root.dataset.ticketMention = "true"
dotEl.className = cn(TICKET_MENTION_DOT_BASE_CLASSES, dotClass)
referenceEl.className = TICKET_MENTION_REF_CLASSES
subjectEl.className = TICKET_MENTION_SUBJECT_CLASSES
separatorEl.className = TICKET_MENTION_SEP_CLASSES
referenceEl.textContent = displayedReference
subjectEl.textContent = formattedSubject
separatorEl.style.display = formattedSubject ? "" : "none"
if (normalized.id) {
root.dataset.ticketId = normalized.id
} else {
delete root.dataset.ticketId
}
if (normalized.reference) {
root.dataset.ticketReference = normalized.reference
} else {
delete root.dataset.ticketReference
}
if (normalized.status) {
root.dataset.ticketStatus = normalized.status
root.setAttribute("status", normalized.status)
} else {
root.dataset.ticketStatus = TICKET_MENTION_FALLBACK_STATUS
root.setAttribute("status", TICKET_MENTION_FALLBACK_STATUS)
}
if (normalized.priority) {
root.dataset.ticketPriority = normalized.priority
} else {
delete root.dataset.ticketPriority
}
if (normalized.subject) {
root.dataset.ticketSubject = normalized.subject
} else {
delete root.dataset.ticketSubject
}
if (normalized.url) {
root.dataset.ticketUrl = normalized.url
} else {
delete root.dataset.ticketUrl
}
if (normalized.url) {
root.href = normalized.url
root.rel = "noopener noreferrer"
root.target = "_self"
} else {
root.removeAttribute("href")
}
root.title = formattedSubject ? `${displayedReference}${formattedSubject}` : displayedReference
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function buildTicketMentionAnchorHtml(attrs: {
id: string
reference: string
subject: string
status: string
priority: string
url: string
}) {
const id = attrs.id ?? ""
const reference = attrs.reference ?? ""
const subject = attrs.subject ?? ""
const status = (attrs.status || TICKET_MENTION_FALLBACK_STATUS).toUpperCase()
const priority = (attrs.priority || TICKET_MENTION_FALLBACK_PRIORITY).toUpperCase()
const url = attrs.url ?? ""
const displayedReference = reference || id
const formattedSubject = subject ? formatMentionSubject(subject) : ""
const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${getTicketStatusDotClass(status)}`
const title = formattedSubject ? `#${displayedReference}${formattedSubject}` : `#${displayedReference}`
return `<a data-ticket-mention="true" data-ticket-id="${escapeHtml(id)}" data-ticket-reference="${escapeHtml(reference)}" data-ticket-status="${escapeHtml(status)}" data-ticket-priority="${escapeHtml(priority)}" data-ticket-subject="${escapeHtml(subject)}" status="${escapeHtml(status)}" href="${escapeHtml(url)}" class="${escapeHtml(TICKET_MENTION_BASE_CLASSES)}" rel="noopener noreferrer" target="_self" title="${escapeHtml(title)}"><span class="${escapeHtml(dotClass)}"></span><span class="${escapeHtml(TICKET_MENTION_REF_CLASSES)}">#${escapeHtml(displayedReference)}</span><span class="${escapeHtml(TICKET_MENTION_SEP_CLASSES)}">•</span><span class="${escapeHtml(TICKET_MENTION_SUBJECT_CLASSES)}">${escapeHtml(formattedSubject)}</span></a>`
}
export function normalizeTicketMentionHtml(html: string): string {
if (!html || html.indexOf("ticket-mention") === -1) {
return html
}
const mentionPattern = /<a\b[^>]*class="[^"]*ticket-mention[^"]*"[^>]*>[\s\S]*?<\/a>/gi
const attributePattern = /(data-[\w-]+|href|status)="([^"]*)"/gi
return html.replace(mentionPattern, (match) => {
const attributes: Record<string, string> = {}
let attrMatch: RegExpExecArray | null
attributePattern.lastIndex = 0
while ((attrMatch = attributePattern.exec(match)) !== null) {
attributes[attrMatch[1]] = attrMatch[2]
}
const href = attributes.href ?? ""
const parsedText = parseTicketMentionText(match.replace(/<[^>]*>/g, " "))
const id = attributes["data-ticket-id"] || extractTicketIdFromHref(href)
const reference = attributes["data-ticket-reference"] || parsedText.reference || id
const subject = attributes["data-ticket-subject"] || parsedText.subject || ""
const status = attributes["data-ticket-status"] || attributes.status || TICKET_MENTION_FALLBACK_STATUS
const priority = attributes["data-ticket-priority"] || TICKET_MENTION_FALLBACK_PRIORITY
return buildTicketMentionAnchorHtml({ id, reference, subject, status, priority, url: href })
})
}
function resolveMentionPopupContainer(editor: Editor): HTMLElement | null {
if (typeof document === "undefined") {
return null
}
const viewElement = editor.view.dom as HTMLElement | null
if (!viewElement) {
return document.body
}
const container =
viewElement.closest<HTMLElement>(
"[data-radix-dialog-content],[data-radix-drawer-content],[data-radix-popover-content],[data-radix-dropdown-menu-content],[data-radix-sheet-content]"
) ?? viewElement.closest<HTMLElement>("[role=\"dialog\"],[role=\"menu\"]")
return container ?? document.body
}
function toRelativeClientRect(rect: DOMRect | undefined, target: HTMLElement | null): DOMRect {
const baseRect = rect ?? new DOMRect()
if (typeof document === "undefined" || !target || target === document.body) {
return baseRect
}
const containerRect = target.getBoundingClientRect()
return new DOMRect(
baseRect.x - containerRect.x,
baseRect.y - containerRect.y,
baseRect.width,
baseRect.height
)
}
const priorityLabels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
// Cache simples para resultados de menções.
// Obs.: não armazenamos resultados vazios para permitir que
// consultas que antes falharam (ex.: endpoint indisponível)
// sejam revalidadas em tentativas subsequentes.
const mentionCache = new Map<string, TicketMentionItem[]>()
let mentionAbortController: AbortController | null = null
async function fetchTicketMentions(query: string): Promise<TicketMentionItem[]> {
const cacheKey = query.trim().toLowerCase()
if (mentionCache.has(cacheKey)) {
return mentionCache.get(cacheKey) ?? []
}
try {
mentionAbortController?.abort()
mentionAbortController = new AbortController()
const response = await fetch(`/api/tickets/mentions?q=${encodeURIComponent(query)}`, {
signal: mentionAbortController.signal,
})
if (!response.ok) {
return []
}
const json = (await response.json()) as { items?: TicketMentionItem[] }
const items = Array.isArray(json.items) ? json.items : []
if (items.length > 0) {
mentionCache.set(cacheKey, items)
} else {
mentionCache.delete(cacheKey)
}
return items
} catch (error) {
if ((error as Error).name === "AbortError") {
return []
}
return []
}
}
function TicketMentionList({ items, command, onRegister }: TicketMentionSuggestionProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = useCallback(
(index: number) => {
const item = items[index]
if (item) {
command(item)
}
},
[command, items]
)
const upHandler = useCallback(() => {
setSelectedIndex((prev) => {
const nextIndex = (prev + items.length - 1) % items.length
return nextIndex
})
}, [items.length])
const downHandler = useCallback(() => {
setSelectedIndex((prev) => {
const nextIndex = (prev + 1) % items.length
return nextIndex
})
}, [items.length])
const enterHandler = useCallback(() => {
selectItem(selectedIndex)
}, [selectItem, selectedIndex])
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
upHandler()
return true
}
if (event.key === "ArrowDown") {
downHandler()
return true
}
if (event.key === "Enter") {
enterHandler()
return true
}
return false
},
[downHandler, enterHandler, upHandler]
)
useEffect(() => {
onRegister?.(handleKeyDown)
return () => {
onRegister?.(null)
}
}, [handleKeyDown, onRegister])
useEffect(() => {
setSelectedIndex(0)
}, [items])
if (!items.length) {
return (
<div className="min-w-[260px] p-3 text-sm text-muted-foreground">
Nenhum chamado encontrado com esse termo.
</div>
)
}
return (
<div className="max-h-72 min-w-[320px] space-y-1 overflow-y-auto p-2">
{items.map((item, index) => {
const isActive = index === selectedIndex
const status = getTicketStatusLabel(item.status)
const statusDot = getTicketStatusDotClass(item.status)
const priority = priorityLabels[item.priority] ?? item.priority
return (
<button
key={item.id}
type="button"
className={cn(
"w-full rounded-lg border border-transparent px-3 py-2 text-left transition",
isActive ? "border-slate-200 bg-slate-100" : "hover:bg-slate-50"
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(event) => {
event.preventDefault()
selectItem(index)
}}
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-900">#{item.reference}</span>
<span className="text-xs font-medium uppercase tracking-wide text-neutral-500">{priority}</span>
</div>
<div className="mt-1 line-clamp-1 text-sm text-neutral-700">{formatMentionSubject(item.subject)}</div>
<div className="mt-1 space-y-1 text-xs text-neutral-500">
<span className="inline-flex items-center gap-1 text-neutral-500">
<span className={cn("inline-flex size-2 rounded-full", statusDot)} />
{status}
</span>
{item.companyName ? <span className="block text-neutral-500">{item.companyName}</span> : null}
{item.assigneeName ? (
<span className="block text-neutral-400">Responsável {item.assigneeName}</span>
) : null}
</div>
</button>
)
})}
</div>
)
}
TicketMentionList.displayName = "TicketMentionList"
const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
<TicketMentionList {...props} />
)
const TicketMentionExtension = Mention.extend({
name: "ticketMention",
group: "inline",
inline: true,
draggable: false,
selectable: true,
addAttributes() {
return {
id: { default: null },
reference: { default: null },
subject: { default: null },
status: { default: null },
priority: { default: null },
url: { default: null },
}
},
addKeyboardShortcuts() {
// Reuse base Mention keyboard shortcuts when extending the extension.
const parentShortcuts = (this as unknown as {
parent?: () => Record<string, (args: { editor: Editor }) => boolean>
}).parent?.()
const parent = parentShortcuts ?? {}
return {
...parent,
Backspace: ({ editor }: { editor: Editor }) => {
const { state } = editor
const { selection } = state
if (selection.empty) {
const { $from } = selection
const nodeBefore = $from.nodeBefore
if (nodeBefore?.type?.name === this.name) {
const from = $from.pos - nodeBefore.nodeSize
const to = $from.pos
editor.chain().focus().deleteRange({ from, to }).run()
return true
}
}
return parent.Backspace ? parent.Backspace({ editor }) : false
},
Delete: ({ editor }: { editor: Editor }) => {
const { state } = editor
const { selection } = state
if (selection.empty) {
const { $from } = selection
const nodeAfter = $from.nodeAfter
if (nodeAfter?.type?.name === this.name) {
const from = $from.pos
const to = $from.pos + nodeAfter.nodeSize
editor.chain().focus().deleteRange({ from, to }).run()
return true
}
}
return parent.Delete ? parent.Delete({ editor }) : false
},
}
},
parseHTML() {
return [
{
tag: `a[data-ticket-mention="true"]`,
getAttrs: (dom: HTMLElement | string) => {
if (dom instanceof HTMLElement) {
return {
id: dom.dataset.ticketId ?? null,
reference: dom.dataset.ticketReference ?? null,
status: dom.dataset.ticketStatus ?? null,
priority: dom.dataset.ticketPriority ?? null,
subject: dom.getAttribute("data-ticket-subject") ?? dom.textContent ?? null,
url: dom.getAttribute("href") ?? null,
}
}
return {}
},
},
{
tag: "a.ticket-mention",
getAttrs: (dom: HTMLElement | string) => {
if (dom instanceof HTMLElement) {
const href = dom.getAttribute("href") ?? null
const id = dom.dataset.ticketId ?? extractTicketIdFromHref(href)
const parsed = parseTicketMentionText(dom.textContent)
const reference = dom.dataset.ticketReference ?? parsed.reference ?? null
const subject = dom.dataset.ticketSubject ?? parsed.subject ?? null
const status = dom.dataset.ticketStatus ?? dom.getAttribute("status") ?? null
const priority = dom.dataset.ticketPriority ?? null
return {
id,
reference,
status,
priority,
subject,
url: href,
}
}
return {}
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
const referenceValue =
HTMLAttributes.reference ?? HTMLAttributes["data-ticket-reference"] ?? ""
const reference = String(referenceValue ?? "")
const subjectValue =
HTMLAttributes.subject ?? HTMLAttributes["data-ticket-subject"] ?? ""
const subject = String(subjectValue ?? "")
const status = String(HTMLAttributes.status ?? HTMLAttributes["data-ticket-status"] ?? "PENDING").toUpperCase()
const priority = String(HTMLAttributes.priority ?? HTMLAttributes["data-ticket-priority"] ?? "MEDIUM").toUpperCase()
const href = String(HTMLAttributes.url ?? HTMLAttributes.href ?? "#")
const anchorClass = cn(TICKET_MENTION_BASE_CLASSES, toPlainString(HTMLAttributes.class ?? HTMLAttributes.className))
const dotClass = cn(TICKET_MENTION_DOT_BASE_CLASSES, getTicketStatusDotClass(status))
return [
"a",
{
...HTMLAttributes,
href,
"data-type": HTMLAttributes["data-type"] ?? this.name,
"data-ticket-mention": "true",
"data-ticket-id": HTMLAttributes.id ?? HTMLAttributes["data-ticket-id"] ?? "",
"data-ticket-reference": reference ?? "",
"data-ticket-status": status,
"data-ticket-priority": priority,
"data-ticket-subject": subject ?? "",
status,
class: anchorClass,
},
[
"span",
{ class: dotClass },
],
[
"span",
{ class: TICKET_MENTION_REF_CLASSES },
`#${reference ?? ""}`,
],
[
"span",
{ class: TICKET_MENTION_SEP_CLASSES },
"•",
],
[
"span",
{ class: TICKET_MENTION_SUBJECT_CLASSES },
formatMentionSubject(subject ?? ""),
],
]
},
renderLabel({ node }: { node: { attrs: Record<string, unknown> } }) {
const subjectAttr = node.attrs.subject ?? node.attrs["data-ticket-subject"] ?? ""
const displayedSubject = typeof subjectAttr === "string" ? subjectAttr : String(subjectAttr ?? "")
const refAttr = node.attrs.reference ?? node.attrs.id ?? node.attrs["data-ticket-reference"] ?? ""
const reference = typeof refAttr === "string" ? refAttr : String(refAttr ?? "")
const subjectPart = displayedSubject ? `${formatMentionSubject(displayedSubject)}` : ""
return `#${reference}${subjectPart}`
},
addNodeView() {
const extensionName = this.name
return ({ node }: { node: { attrs: TicketMentionAttributes; type: { name: string } } }) => {
if (typeof document === "undefined") {
return {}
}
const root = document.createElement("a")
root.dataset.ticketMention = "true"
root.setAttribute("contenteditable", "false")
root.setAttribute("data-type", extensionName)
root.setAttribute("draggable", "false")
root.setAttribute("spellcheck", "false")
root.tabIndex = -1
const dotEl = document.createElement("span")
dotEl.className = TICKET_MENTION_DOT_BASE_CLASSES
dotEl.setAttribute("aria-hidden", "true")
const referenceEl = document.createElement("span")
referenceEl.className = TICKET_MENTION_REF_CLASSES
const separatorEl = document.createElement("span")
separatorEl.className = TICKET_MENTION_SEP_CLASSES
separatorEl.textContent = "•"
const subjectEl = document.createElement("span")
subjectEl.className = TICKET_MENTION_SUBJECT_CLASSES
root.append(dotEl, referenceEl, separatorEl, subjectEl)
const elements: TicketMentionNodeElements = {
root,
dotEl,
referenceEl,
subjectEl,
separatorEl,
}
updateTicketMentionNodeElements(elements, node.attrs ?? {})
const preventDefaultClick = (event: MouseEvent) => {
event.preventDefault()
}
root.addEventListener("click", preventDefaultClick)
return {
dom: root,
update: (updatedNode: { attrs: TicketMentionAttributes; type: { name: string } }) => {
if (updatedNode.type.name !== extensionName) {
return false
}
updateTicketMentionNodeElements(elements, updatedNode.attrs ?? {})
return true
},
ignoreMutation: () => true,
destroy: () => {
root.removeEventListener("click", preventDefaultClick)
},
}
}
},
}).configure({
suggestion: {
char: "@",
startOfLine: false,
allowSpaces: false,
render: () => {
let component: ReactRenderer | null = null
let popup: Instance<TippyProps> | null = null
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
let appendTarget: HTMLElement | null = null
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
keydownHandler = handler
}
return {
onStart: (props) => {
const listProps: TicketMentionSuggestionProps = {
command: props.command,
items: props.items,
onRegister: registerHandler,
}
component = new ReactRenderer(TicketMentionListComponent, {
props: listProps,
editor: props.editor,
})
const container = resolveMentionPopupContainer(props.editor)
appendTarget = container ?? document.body
if (!props.clientRect) return
const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget)
popup = tippy(appendTarget, {
getReferenceClientRect: () => computeRect(),
appendTo: () => appendTarget ?? document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
zIndex: 99999,
popperOptions: { strategy: "fixed" },
})
},
onUpdate(props) {
component?.updateProps({
command: props.command,
items: props.items,
onRegister: registerHandler,
})
if (!props.clientRect) return
const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget)
popup?.setProps({
getReferenceClientRect: () => computeRect(),
})
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.hide()
return true
}
if (keydownHandler && keydownHandler(props.event)) {
return true
}
return false
},
onExit() {
popup?.destroy()
component?.destroy()
keydownHandler = null
appendTarget = null
},
}
},
items: async ({ query }) => {
return fetchTicketMentions(query)
},
},
})
export function RichTextEditor({
value,
onChange,
className,
placeholder = "Escreva aqui...",
disabled,
minHeight = 120,
ticketMention,
}: RichTextEditorProps) {
const normalizedInitialContent = useMemo(() => {
if (!ticketMention?.enabled) {
return value || ""
}
return normalizeTicketMentionHtml(value || "")
}, [ticketMention?.enabled, value])
const extensions = useMemo(() => {
const baseExtensions = [
StarterKit.configure({
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
// Configure built-in link from StarterKit to avoid duplicate extension
link: {
openOnClick: true,
autolink: true,
protocols: ["http", "https", "mailto"],
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
},
}),
Placeholder.configure({ placeholder }),
]
return ticketMention?.enabled ? [...baseExtensions, TicketMentionExtension] : baseExtensions
}, [placeholder, ticketMention?.enabled])
const editor = useEditor({
extensions: [
...extensions,
],
editorProps: {
attributes: {
class:
"prose prose-sm max-w-none focus:outline-none text-foreground",
},
},
content: normalizedInitialContent,
onUpdate({ editor }) {
const html = editor.getHTML()
const normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(html) : html
onChange?.(normalized)
},
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 normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(value || "") : value || ""
const current = editor.getHTML()
if (normalized !== current) {
editor.commands.setContent(normalized, { emitUpdate: false })
}
}, [value, editor, ticketMention?.enabled])
// 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",
"class",
"data-ticket-mention",
"data-ticket-id",
"data-ticket-reference",
"data-ticket-status",
"data-ticket-priority",
"data-ticket-subject",
"title",
],
span: [
"style",
"class",
"data-ticket-mention",
"data-ticket-id",
"data-ticket-reference",
"data-ticket-status",
"data-ticket-priority",
"data-ticket-subject",
],
code: ["class"],
pre: ["class"],
},
allowedSchemes: ["http","https","mailto"],
// prevent target=_self phishing
transformTags: {
a: (tagName, attribs) => {
const isMention = attribs["data-ticket-mention"] === "true"
const nextAttribs = {
...attribs,
rel: attribs.rel ?? "noopener noreferrer",
target: isMention ? "_self" : "_blank",
}
return {
tagName,
attribs: nextAttribs,
}
},
},
// disallow inline event handlers
allowVulnerableTags: false,
})
} catch {
return ""
}
}
export function stripLeadingEmptyParagraphs(html: string): string {
if (!html) return ""
return html.replace(
/^(?:\s|&nbsp;|&#160;|<br\s*\/?>|<p>(?:\s|&nbsp;|&#160;|<br\s*\/?>)*<\/p>)+/gi,
""
)
}
export function RichTextContent({ html, className }: { html: string; className?: string }) {
return (
<div
className={cn("rich-text text-sm leading-relaxed", className)}
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(html) }}
/>
)
}