1101 lines
37 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
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| | |<br\s*\/?>|<p>(?:\s| | |<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) }}
|
|
/>
|
|
)
|
|
}
|