feat: upgrade tiptap and handle clipboard uploads
This commit is contained in:
parent
fa9efdb5af
commit
281ecd5f6f
5 changed files with 487 additions and 219 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export function Dropzone({
|
|||
currentFileCount = 0,
|
||||
currentTotalBytes = 0,
|
||||
disabled = false,
|
||||
onRegisterUploadHandler,
|
||||
}: {
|
||||
onUploaded?: (files: Uploaded[]) => void;
|
||||
maxFiles?: number;
|
||||
|
|
@ -28,6 +29,7 @@ export function Dropzone({
|
|||
currentFileCount?: number;
|
||||
currentTotalBytes?: number;
|
||||
disabled?: boolean;
|
||||
onRegisterUploadHandler?: (handler: ((files: File[]) => Promise<void>) | null) => void;
|
||||
}) {
|
||||
const generateUrl = useAction(api.files.generateUploadUrl);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -122,6 +124,15 @@ export function Dropzone({
|
|||
if (uploaded.length) onUploaded?.(uploaded);
|
||||
}, [disabled, generateUrl, maxFiles, maxSize, normalizedFileCount, onUploaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onRegisterUploadHandler) return;
|
||||
const handler = (files: File[]) => startUpload(files);
|
||||
onRegisterUploadHandler(handler);
|
||||
return () => {
|
||||
onRegisterUploadHandler(null);
|
||||
};
|
||||
}, [onRegisterUploadHandler, startUpload]);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import StarterKit from "@tiptap/starter-kit"
|
|||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import Mention from "@tiptap/extension-mention"
|
||||
import TiptapLink from "@tiptap/extension-link"
|
||||
import { Markdown } from "@tiptap/markdown"
|
||||
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
|
||||
|
|
@ -51,6 +52,8 @@ type RichTextEditorProps = {
|
|||
ticketMention?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
onEditorReady?: (editor: Editor | null) => void
|
||||
onPasteFiles?: (files: File[]) => void
|
||||
}
|
||||
|
||||
type TicketMentionItem = {
|
||||
|
|
@ -117,6 +120,24 @@ type TicketMentionAttributes = Record<string, unknown>
|
|||
const TICKET_MENTION_FALLBACK_STATUS = "PENDING"
|
||||
const TICKET_MENTION_FALLBACK_PRIORITY = "MEDIUM"
|
||||
|
||||
const MAX_PASTED_IMAGE_FILES = 10
|
||||
|
||||
function extractImageFilesFromClipboard(event: ClipboardEvent): File[] {
|
||||
const clipboard = event.clipboardData
|
||||
if (!clipboard) return []
|
||||
|
||||
const filesFromItems = Array.from(clipboard.items ?? [])
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file && file.type.startsWith("image/")))
|
||||
|
||||
if (filesFromItems.length > 0) {
|
||||
return filesFromItems
|
||||
}
|
||||
|
||||
return Array.from(clipboard.files ?? []).filter((file) => file.type.startsWith("image/"))
|
||||
}
|
||||
|
||||
function toPlainString(value: unknown): string {
|
||||
if (value === null || value === undefined) return ""
|
||||
return String(value)
|
||||
|
|
@ -767,6 +788,8 @@ export function RichTextEditor({
|
|||
disabled,
|
||||
minHeight = 120,
|
||||
ticketMention,
|
||||
onEditorReady,
|
||||
onPasteFiles,
|
||||
}: RichTextEditorProps) {
|
||||
const normalizedInitialContent = useMemo(() => {
|
||||
if (!ticketMention?.enabled) {
|
||||
|
|
@ -790,6 +813,7 @@ export function RichTextEditor({
|
|||
protocols: ["http", "https", "mailto"],
|
||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({ placeholder }),
|
||||
]
|
||||
|
||||
|
|
@ -797,14 +821,21 @@ export function RichTextEditor({
|
|||
}, [placeholder, ticketMention?.enabled])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
...extensions,
|
||||
],
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none text-foreground",
|
||||
},
|
||||
handlePaste(_, event) {
|
||||
if (!onPasteFiles) return false
|
||||
const imageFiles = extractImageFilesFromClipboard(event)
|
||||
if (imageFiles.length === 0) return false
|
||||
event.preventDefault()
|
||||
const limited = imageFiles.slice(0, MAX_PASTED_IMAGE_FILES)
|
||||
onPasteFiles(limited)
|
||||
return true
|
||||
},
|
||||
},
|
||||
content: normalizedInitialContent,
|
||||
onUpdate({ editor }) {
|
||||
|
|
@ -817,6 +848,14 @@ export function RichTextEditor({
|
|||
immediatelyRender: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
onEditorReady?.(editor)
|
||||
return () => {
|
||||
onEditorReady?.(null)
|
||||
}
|
||||
}, [editor, onEditorReady])
|
||||
|
||||
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState("")
|
||||
const linkInputRef = useRef<HTMLInputElement>(null)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue