feat: upgrade tiptap and handle clipboard uploads

This commit is contained in:
Esdras Renan 2025-11-04 21:35:18 -03:00
parent fa9efdb5af
commit 281ecd5f6f
5 changed files with 487 additions and 219 deletions

View file

@ -4,18 +4,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { Download, FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { Download, FileCode, FileIcon, Image as ImageIcon, PencilLine, Trash2, X, ClipboardCopy } from "lucide-react"
import { useAction, useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import type { Editor } from "@tiptap/react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
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 { Textarea } from "@/components/ui/textarea"
import {
RichTextEditor,
RichTextContent,
@ -38,6 +40,9 @@ const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-
const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
const COMMENT_ATTACHMENT_LIMIT = 10
const COMMENT_ATTACHMENT_MAX_FILE_SIZE = 5 * 1024 * 1024
type CommentsOrder = "descending" | "ascending"
export function TicketComments({ ticket }: TicketCommentsProps) {
@ -60,6 +65,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
() => attachmentsToSend.reduce((acc, item) => acc + (item.size ?? 0), 0),
[attachmentsToSend]
)
const editorRef = useRef<Editor | null>(null)
const dropzoneUploadHandlerRef = useRef<((files: File[]) => Promise<void>) | null>(null)
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL")
@ -69,6 +76,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const [commentsOrder, setCommentsOrder] = useState<CommentsOrder>("descending")
const [markdownDialogOpen, setMarkdownDialogOpen] = useState(false)
const [markdownDraft, setMarkdownDraft] = useState("")
const [isEditorReady, setIsEditorReady] = useState(false)
const templateArgs =
convexUserId && isStaff
@ -91,6 +101,119 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
})
}
const handleEditorInstance = useCallback((instance: Editor | null) => {
editorRef.current = instance
setIsEditorReady(Boolean(instance))
}, [setIsEditorReady])
const handleEditorPasteFiles = useCallback(
(files: File[]) => {
if (!files.length) return
if (!canComment) {
toast.error("Você não tem permissão para anexar arquivos neste comentário.")
return
}
const uploader = dropzoneUploadHandlerRef.current
if (!uploader) {
toast.error("Uploader de arquivos indisponível no momento.")
return
}
const remainingSlots = Math.max(0, COMMENT_ATTACHMENT_LIMIT - attachmentsToSend.length)
if (remainingSlots <= 0) {
toast.warning(`Limite de ${COMMENT_ATTACHMENT_LIMIT} anexos atingido.`)
return
}
const sizeLimitMb = Math.round(COMMENT_ATTACHMENT_MAX_FILE_SIZE / (1024 * 1024))
const withinSizeLimit: File[] = []
let oversized = 0
for (const file of files) {
if (file.size <= COMMENT_ATTACHMENT_MAX_FILE_SIZE) {
withinSizeLimit.push(file)
} else {
oversized += 1
}
}
if (oversized > 0) {
toast.warning(`${oversized} imagem(ns) foram ignoradas por excederem ${sizeLimitMb}MB.`)
}
if (!withinSizeLimit.length) {
return
}
const selected = withinSizeLimit.slice(0, remainingSlots)
if (selected.length < withinSizeLimit.length) {
toast.warning("Algumas imagens foram ignoradas para respeitar o limite de anexos.")
}
const toastId = "clipboard-attachments"
toast.loading("Processando imagens coladas...", { id: toastId })
void uploader(selected)
.then(() => {
toast.success("Imagens coladas adicionadas como anexos.", { id: toastId })
})
.catch((error) => {
console.error("Failed to upload clipboard images", error)
toast.error("Não foi possível anexar as imagens coladas.", { id: toastId })
})
},
[attachmentsToSend.length, canComment]
)
const handleCopyMarkdown = useCallback(async () => {
const editor = editorRef.current
if (!editor) return
const markdownStorage = (editor.storage as { markdown?: { getMarkdown?: () => string } } | undefined)?.markdown
const markdown = markdownStorage?.getMarkdown?.()
if (!markdown) {
toast.warning("Não há conteúdo em Markdown para copiar.")
return
}
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
toast.error("Copiar para a área de transferência não é suportado neste navegador.")
return
}
try {
await navigator.clipboard.writeText(markdown)
toast.success("Markdown copiado para a área de transferência.")
} catch (error) {
console.error("Failed to copy markdown", error)
toast.error("Não foi possível copiar o Markdown.")
}
}, [])
const handleOpenMarkdownImport = useCallback(() => {
const editor = editorRef.current
const markdownStorage = (editor?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)?.markdown
const currentMarkdown = markdownStorage?.getMarkdown?.() ?? ""
setMarkdownDraft(currentMarkdown)
setMarkdownDialogOpen(true)
}, [])
const handleApplyMarkdownImport = useCallback(() => {
const editor = editorRef.current
if (!editor) return
const draft = markdownDraft.trim()
if (!draft) {
editor.commands.clearContent(true)
editor.commands.focus("end")
setMarkdownDialogOpen(false)
setMarkdownDraft("")
toast.success("Editor limpo.")
return
}
const toastId = "markdown-import"
toast.loading("Convertendo Markdown...", { id: toastId })
try {
editor.commands.setContent(draft, { contentType: "markdown" })
editor.commands.focus("end")
toast.success("Markdown convertido em rich text.", { id: toastId })
} catch (error) {
console.error("Failed to import markdown", error)
toast.error("Não foi possível converter o Markdown.", { id: toastId })
} finally {
setMarkdownDialogOpen(false)
setMarkdownDraft("")
}
}, [markdownDraft])
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
const normalized = normalizeTicketMentionHtml(currentBody || "")
setEditingComment({ id: commentId, value: normalized })
@ -440,12 +563,18 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
className="rounded-2xl border border-slate-200"
disabled={!canComment}
ticketMention={{ enabled: allowTicketMentions }}
onEditorReady={handleEditorInstance}
onPasteFiles={handleEditorPasteFiles}
/>
<Dropzone
onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])}
currentFileCount={attachmentsToSend.length}
currentTotalBytes={attachmentsToSendTotalBytes}
disabled={!canComment}
maxFiles={COMMENT_ATTACHMENT_LIMIT}
onRegisterUploadHandler={(handler) => {
dropzoneUploadHandlerRef.current = handler ?? null
}}
/>
{attachmentsToSend.length > 0 ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
@ -537,6 +666,28 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 border-slate-200 text-sm text-neutral-700 hover:bg-slate-50"
onClick={handleOpenMarkdownImport}
disabled={!canComment || !isEditorReady}
>
<FileCode className="size-4" />
Colar Markdown
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 border-slate-200 text-sm text-neutral-700 hover:bg-slate-50"
onClick={handleCopyMarkdown}
disabled={!isEditorReady}
>
<ClipboardCopy className="size-4" />
Copiar Markdown
</Button>
<div className="flex items-center gap-2">
Visibilidade:
<Select
@ -562,6 +713,46 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Button>
</div>
</form>
<Dialog
open={markdownDialogOpen}
onOpenChange={(open) => {
setMarkdownDialogOpen(open)
if (!open) {
setMarkdownDraft("")
}
}}
>
<DialogContent className="sm:max-w-xl space-y-4">
<DialogHeader>
<DialogTitle>Colar Markdown</DialogTitle>
<DialogDescription>
Cole conteúdo em Markdown para convertê-lo automaticamente em rich text. Isso substitui o texto atual do editor.
</DialogDescription>
</DialogHeader>
<Textarea
value={markdownDraft}
onChange={(event) => setMarkdownDraft(event.target.value)}
rows={10}
placeholder="Cole aqui o texto em Markdown..."
className="min-h-[200px]"
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setMarkdownDialogOpen(false)
setMarkdownDraft("")
}}
>
Cancelar
</Button>
<Button type="button" onClick={handleApplyMarkdownImport} disabled={!markdownDraft.trim()}>
Converter
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!attachmentToRemove} onOpenChange={(open) => { if (!open && !removingAttachment) setAttachmentToRemove(null) }}>
<DialogContent className="max-w-sm space-y-4">
<DialogHeader>