Normalize ticket mentions in editor and server
This commit is contained in:
parent
cf11ac9bcb
commit
296e02cf0c
5 changed files with 447 additions and 40 deletions
|
|
@ -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<string, unknown>
|
||||
|
||||
const TICKET_MENTION_FALLBACK_STATUS = "PENDING"
|
||||
const TICKET_MENTION_FALLBACK_PRIORITY = "MEDIUM"
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Em andamento",
|
||||
|
|
@ -91,6 +129,183 @@ const statusTone: Record<string, string> = {
|
|||
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, """)
|
||||
.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 `<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",
|
||||
|
|
@ -231,13 +446,15 @@ function TicketMentionList({ items, command, onRegister }: TicketMentionSuggesti
|
|||
<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 flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<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>• {item.companyName}</span> : null}
|
||||
{item.assigneeName ? <span>• {item.assigneeName}</span> : null}
|
||||
{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>
|
||||
)
|
||||
|
|
@ -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<string, unknown> }) {
|
||||
|
|
@ -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<TippyProps> | 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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue