"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 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, "'") } 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 `#${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", 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() let mentionAbortController: AbortController | null = null async function fetchTicketMentions(query: string): Promise { 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 (
Nenhum chamado encontrado com esse termo.
) } return (
{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 ( ) })}
) } TicketMentionList.displayName = "TicketMentionList" const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => ( ) 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 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 }) { 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 } }) { 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 | 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(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 (
editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} ariaLabel="Negrito" > editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} ariaLabel="Itálico" > editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} ariaLabel="Tachado" > editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} ariaLabel="Lista" > editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} ariaLabel="Lista ordenada" > editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} ariaLabel="Citação" > { if (open) { openLinkPopover() } else { closeLinkPopover() } }} >
setLinkUrl(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault() applyLink() } else if (event.key === "Escape") { event.preventDefault() closeLinkPopover() } }} />
editor.chain().focus().undo().run()} ariaLabel="Desfazer"> editor.chain().focus().redo().run()} ariaLabel="Refazer">
) } type ToolbarButtonProps = { onClick?: () => void active?: boolean ariaLabel?: string children: ReactNode } const ToolbarButton = forwardRef( ({ onClick, active, ariaLabel, children }, ref) => { return ( ) } ) ToolbarButton.displayName = "ToolbarButton" // Utilitário simples para renderização segura do HTML do editor. // Remove tags