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

@ -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

View file

@ -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)