diff --git a/convex/tickets.ts b/convex/tickets.ts index 4958139..9838d4d 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -84,13 +84,29 @@ function truncateSubject(subject: string) { return `${subject.slice(0, 57)}…` } +const TICKET_MENTION_ANCHOR_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_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" +const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full" +const TICKET_MENTION_STATUS_TONE: Record = { + PENDING: "bg-amber-400", + AWAITING_ATTENDANCE: "bg-sky-500", + PAUSED: "bg-violet-500", + RESOLVED: "bg-emerald-500", +} + function buildTicketMentionAnchor(ticket: Doc<"tickets">): string { const reference = ticket.reference const subject = escapeHtml(ticket.subject ?? "") const truncated = truncateSubject(subject) const status = (ticket.status ?? "PENDING").toString().toUpperCase() const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase() - return `#${reference}${truncated}` + const normalizedStatus = normalizeStatus(status) + const dotTone = TICKET_MENTION_STATUS_TONE[normalizedStatus] ?? "bg-slate-400" + const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotTone}` + return `#${reference}${truncated}` } function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) { @@ -111,11 +127,11 @@ async function normalizeTicketMentions( viewer: { user: Doc<"users">; role: string }, tenantId: string, ): Promise { - if (!html || html.indexOf("data-ticket-mention") === -1) { + if (!html || (html.indexOf("data-ticket-mention") === -1 && html.indexOf("ticket-mention") === -1)) { return html } - const mentionPattern = /]*data-ticket-mention="true"[^>]*>[\s\S]*?<\/a>/gi + const mentionPattern = /]*(?:data-ticket-mention="true"|class="[^"]*ticket-mention[^"]*")[^>]*>[\s\S]*?<\/a>/gi const matches = Array.from(html.matchAll(mentionPattern)) if (!matches.length) { return html @@ -123,10 +139,23 @@ async function normalizeTicketMentions( let output = html + const attributePattern = /(data-[\w-]+|class|href)="([^"]*)"/gi + for (const match of matches) { const full = match[0] - const idMatch = /data-ticket-id="([^"]+)"/i.exec(full) - const ticketIdRaw = idMatch?.[1] + attributePattern.lastIndex = 0 + const attributes: Record = {} + let attrMatch: RegExpExecArray | null + while ((attrMatch = attributePattern.exec(full)) !== null) { + attributes[attrMatch[1]] = attrMatch[2] + } + + let ticketIdRaw: string | null = attributes["data-ticket-id"] ?? null + if (!ticketIdRaw && attributes.href) { + const hrefPath = attributes.href.split("?")[0] + const segments = hrefPath.split("/").filter(Boolean) + ticketIdRaw = segments.pop() ?? null + } let replacement = "" if (ticketIdRaw) { diff --git a/src/app/globals.css b/src/app/globals.css index 2469273..444fd80 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -171,21 +171,20 @@ .rich-text .ProseMirror .ticket-mention { @apply 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; } - /* While editing, TipTap renders the mention as a span with data-type="ticketMention". - Mirror the same chip style so the layout stays consistent during edits. */ - .rich-text .ProseMirror [data-type="ticketMention"] { + /* Fallback for legacy editing markup before the custom node view hydrates. */ + .rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"]) { @apply 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 align-middle; position: relative; } - .rich-text .ProseMirror [data-type="ticketMention"]::before { + .rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"])::before { content: ""; @apply inline-block size-2 rounded-full bg-slate-400; margin-right: 0.375rem; /* ~gap-1.5 */ } - .rich-text .ProseMirror [data-type="ticketMention"][status="PENDING"]::before { @apply bg-amber-400; } - .rich-text .ProseMirror [data-type="ticketMention"][status="AWAITING_ATTENDANCE"]::before { @apply bg-sky-500; } - .rich-text .ProseMirror [data-type="ticketMention"][status="PAUSED"]::before { @apply bg-violet-500; } - .rich-text .ProseMirror [data-type="ticketMention"][status="RESOLVED"]::before { @apply bg-emerald-500; } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="PENDING"]:not([data-ticket-mention="true"])::before { @apply bg-amber-400; } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="AWAITING_ATTENDANCE"]:not([data-ticket-mention="true"])::before { @apply bg-sky-500; } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="PAUSED"]:not([data-ticket-mention="true"])::before { @apply bg-violet-500; } + .rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="RESOLVED"]:not([data-ticket-mention="true"])::before { @apply bg-emerald-500; } .rich-text [data-ticket-mention="true"] .ticket-mention-dot, .rich-text .ticket-mention .ticket-mention-dot { @apply inline-flex size-2 rounded-full bg-slate-400; diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx index 4b29558..72554fb 100644 --- a/src/components/tickets/ticket-comments.rich.tsx +++ b/src/components/tickets/ticket-comments.rich.tsx @@ -16,7 +16,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { toast } from "sonner" import { Dropzone } from "@/components/ui/dropzone" -import { RichTextEditor, RichTextContent, sanitizeEditorHtml, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor" +import { + RichTextEditor, + RichTextContent, + sanitizeEditorHtml, + stripLeadingEmptyParagraphs, + normalizeTicketMentionHtml, +} from "@/components/ui/rich-text-editor" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" @@ -82,7 +88,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) { } const startEditingComment = useCallback((commentId: string, currentBody: string) => { - setEditingComment({ id: commentId, value: currentBody || "" }) + const normalized = normalizeTicketMentionHtml(currentBody || "") + setEditingComment({ id: commentId, value: normalized }) }, []) const cancelEditingComment = useCallback(() => { @@ -96,7 +103,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) { if (commentId.startsWith("temp-")) return const sanitized = sanitizeEditorHtml(editingComment.value) - if (sanitized === originalBody) { + const normalized = normalizeTicketMentionHtml(sanitized) + if (normalized === originalBody) { setEditingComment(null) return } @@ -109,9 +117,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) { ticketId: ticket.id as Id<"tickets">, commentId: commentId as unknown as Id<"ticketComments">, actorId: convexUserId as Id<"users">, - body: sanitized, + body: normalized, }) - setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized })) + setLocalBodies((prev) => ({ ...prev, [commentId]: normalized })) setEditingComment(null) toast.success("Comentário atualizado!", { id: toastId }) } catch (error) { @@ -133,7 +141,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) { if (!convexUserId) return // Enforce generous max length for comment plain text const sanitized = sanitizeEditorHtml(body) - const plain = sanitized.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim() + const normalized = normalizeTicketMentionHtml(sanitized) + const plain = normalized.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim() const MAX_COMMENT_CHARS = 20000 if (plain.length > MAX_COMMENT_CHARS) { toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "comment" }) @@ -149,7 +158,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { id: `temp-${now.getTime()}`, author: ticket.requester, visibility: selectedVisibility, - body: sanitized, + body: normalized, attachments: attachments.map((attachment) => ({ id: attachment.storageId, name: attachment.name, @@ -176,7 +185,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { ticketId: ticket.id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: selectedVisibility, - body: optimistic.body, + body: normalized, attachments: payload, }) setPending([]) diff --git a/src/components/ui/rich-text-editor.tsx b/src/components/ui/rich-text-editor.tsx index 7235c7f..1ec8516 100644 --- a/src/components/ui/rich-text-editor.tsx +++ b/src/components/ui/rich-text-editor.tsx @@ -70,13 +70,51 @@ type TicketMentionSuggestionProps = { onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void } -const TICKET_MENTION_CLASS = "ticket-mention" +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 + +const TICKET_MENTION_FALLBACK_STATUS = "PENDING" +const TICKET_MENTION_FALLBACK_PRIORITY = "MEDIUM" + const statusLabels: Record = { PENDING: "Pendente", AWAITING_ATTENDANCE: "Em andamento", @@ -91,6 +129,183 @@ const statusTone: Record = { RESOLVED: "bg-emerald-500", } +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 statusToneClass = statusTone[normalized.status] ?? "bg-slate-400" + + root.className = cn(TICKET_MENTION_BASE_CLASSES, additionalClass) + root.dataset.ticketMention = "true" + dotEl.className = cn(TICKET_MENTION_DOT_BASE_CLASSES, statusToneClass) + 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, "'") +} + +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 dotToneClass = statusTone[status] ?? "bg-slate-400" + const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotToneClass}` + const title = formattedSubject ? `#${displayedReference} • ${formattedSubject}` : `#${displayedReference}` + + return `#${escapeHtml(displayedReference)}${escapeHtml(formattedSubject)}` +} + +export function normalizeTicketMentionHtml(html: string): string { + if (!html || html.indexOf("ticket-mention") === -1) { + return html + } + const mentionPattern = /]*class="[^"]*ticket-mention[^"]*"[^>]*>[\s\S]*?<\/a>/gi + const attributePattern = /(data-[\w-]+|href|status)="([^"]*)"/gi + + return html.replace(mentionPattern, (match) => { + const attributes: Record = {} + 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( + "[data-radix-dialog-content],[data-radix-drawer-content],[data-radix-popover-content],[data-radix-dropdown-menu-content],[data-radix-sheet-content]" + ) ?? viewElement.closest("[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 = { LOW: "Baixa", MEDIUM: "Média", @@ -231,13 +446,15 @@ function TicketMentionList({ items, command, onRegister }: TicketMentionSuggesti {priority}
{formatMentionSubject(item.subject)}
-
- +
+ {status} - {item.companyName ? • {item.companyName} : null} - {item.assigneeName ? • {item.assigneeName} : null} + {item.companyName ? {item.companyName} : null} + {item.assigneeName ? ( + Responsável • {item.assigneeName} + ) : null}
) @@ -326,6 +543,29 @@ const TicketMentionExtension = Mention.extend({ 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 }) { @@ -338,36 +578,41 @@ const TicketMentionExtension = Mention.extend({ 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 statusToneClass = statusTone[status] ?? "bg-slate-400" + const dotClass = cn(TICKET_MENTION_DOT_BASE_CLASSES, statusToneClass) 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 ?? "", - class: TICKET_MENTION_CLASS, + status, + class: anchorClass, }, [ "span", - { class: "ticket-mention-dot" }, + { class: dotClass }, ], [ "span", - { class: "ticket-mention-ref" }, + { class: TICKET_MENTION_REF_CLASSES }, `#${reference ?? ""}`, ], [ "span", - { class: "ticket-mention-sep" }, + { class: TICKET_MENTION_SEP_CLASSES }, "•", ], [ "span", - { class: "ticket-mention-subject" }, + { class: TICKET_MENTION_SUBJECT_CLASSES }, formatMentionSubject(subject ?? ""), ], ] @@ -380,6 +625,68 @@ const TicketMentionExtension = Mention.extend({ 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: "@", @@ -389,6 +696,7 @@ const TicketMentionExtension = Mention.extend({ let component: ReactRenderer | null = null let popup: Instance | null = null let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null + let appendTarget: HTMLElement | null = null const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => { keydownHandler = handler } @@ -403,10 +711,13 @@ const TicketMentionExtension = Mention.extend({ props: listProps, editor: props.editor, }) + const container = resolveMentionPopupContainer(props.editor) + appendTarget = container ?? document.body if (!props.clientRect) return - popup = tippy(document.body, { - getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), - appendTo: () => document.body, + const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget) + popup = tippy(appendTarget, { + getReferenceClientRect: () => computeRect(), + appendTo: () => appendTarget ?? document.body, content: component.element, showOnCreate: true, interactive: true, @@ -423,8 +734,9 @@ const TicketMentionExtension = Mention.extend({ onRegister: registerHandler, }) if (!props.clientRect) return + const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget) popup?.setProps({ - getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), + getReferenceClientRect: () => computeRect(), }) }, onKeyDown(props) { @@ -441,6 +753,7 @@ const TicketMentionExtension = Mention.extend({ popup?.destroy() component?.destroy() keydownHandler = null + appendTarget = null }, } }, @@ -459,6 +772,13 @@ export function RichTextEditor({ 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({ @@ -487,9 +807,11 @@ export function RichTextEditor({ "prose prose-sm max-w-none focus:outline-none text-foreground", }, }, - content: value || "", + content: normalizedInitialContent, onUpdate({ editor }) { - onChange?.(editor.getHTML()) + const html = editor.getHTML() + const normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(html) : html + onChange?.(normalized) }, editable: !disabled, // Avoid SSR hydration mismatches per Tiptap recommendation @@ -555,11 +877,12 @@ export function RichTextEditor({ // Keep external value in sync when it changes useEffect(() => { if (!editor) return + const normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(value || "") : value || "" const current = editor.getHTML() - if ((value ?? "") !== current) { - editor.commands.setContent(value || "", { emitUpdate: false }) + if (normalized !== current) { + editor.commands.setContent(normalized, { emitUpdate: false }) } - }, [value, editor]) + }, [value, editor, ticketMention?.enabled]) // Reflect disabled prop changes after initialization useEffect(() => { diff --git a/tests/ticket-mention-html.test.ts b/tests/ticket-mention-html.test.ts new file mode 100644 index 0000000..f08827c --- /dev/null +++ b/tests/ticket-mention-html.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from "vitest" + +import { normalizeTicketMentionHtml } from "@/components/ui/rich-text-editor" + +function getAnchor(html: string): HTMLAnchorElement | null { + const template = document.createElement("template") + template.innerHTML = html + return template.content.querySelector("a.ticket-mention") +} + +describe("normalizeTicketMentionHtml", () => { + it("returns original html when there is no ticket mention", () => { + const input = "

Sem menções aqui

" + expect(normalizeTicketMentionHtml(input)).toBe(input) + }) + + it("upgrades legacy mention markup with flattened text", () => { + const input = + '

#41002•Integração ERP parada

' + const output = normalizeTicketMentionHtml(input) + const anchor = getAnchor(output) + expect(anchor).not.toBeNull() + expect(anchor?.dataset.ticketMention).toBe("true") + expect(anchor?.dataset.ticketId).toBe("abc123") + expect(anchor?.dataset.ticketReference).toBe("41002") + expect(anchor?.dataset.ticketStatus).toBe("PENDING") + expect(anchor?.dataset.ticketPriority).toBe("MEDIUM") + expect(anchor?.querySelector(".ticket-mention-dot")).not.toBeNull() + expect(anchor?.querySelector(".ticket-mention-ref")?.textContent).toBe("#41002") + expect(anchor?.querySelector(".ticket-mention-subject")?.textContent).toBe("Integração ERP parada") + }) + + it("preserves existing structured markup while refreshing classes", () => { + const input = + '

#123Teste

' + const output = normalizeTicketMentionHtml(input) + const anchor = getAnchor(output) + expect(anchor).not.toBeNull() + expect(anchor?.dataset.ticketStatus).toBe("RESOLVED") + expect(anchor?.dataset.ticketPriority).toBe("LOW") + expect(anchor?.className).toContain("ticket-mention") + expect(anchor?.querySelector(".ticket-mention-dot")?.className).toContain("bg-emerald-500") + }) +})