import { onMount } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types" const accepted = [ "image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/*", "application/json", "application/ld+json", "application/toml", "application/x-toml", "application/x-yaml", "application/xml", "application/yaml", ".c", ".cc", ".cjs", ".conf", ".cpp", ".css", ".csv", ".cts", ".env", ".go", ".gql", ".graphql", ".h", ".hh", ".hpp", ".htm", ".html", ".ini", ".java", ".js", ".json", ".jsx", ".log", ".md", ".mdx", ".mjs", ".mts", ".py", ".rb", ".rs", ".sass", ".scss", ".sh", ".sql", ".toml", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml", ".zsh", ] type PromptTarget = { current: () => PromptInputV2Prompt cursor: () => number | undefined set: (prompt: PromptInputV2Prompt, cursor?: number) => void } export type PromptInputV2AttachmentConfig = { picker?: ( options: { defaultPath?: string; multiple?: boolean; accept?: string[] }, onFile: (file: File) => Promise, ) => Promise directory: () => string isDialogActive: () => boolean warn: () => void duplicate: () => void onError: (error: unknown) => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string store?: (file: File) => Promise<{ id: string; url: string }> } export function createPromptInputV2Attachments( input: PromptInputV2AttachmentConfig & { capture: () => PromptTarget editor: () => HTMLElement | undefined focusEditor: () => void addPart: (part: PromptInputV2Prompt[number]) => boolean setDraggingType: (type: "image" | "@mention" | null) => void }, ) { const capture = () => { const prompt = input.capture() const editor = input.editor() if (!editor) return return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } } const add = async (file: File, toast = true, target = capture(), clipboard = false) => { if (!target) return false const mime = await attachmentMime(file) if (!mime) { if (toast) input.warn() return false } const blob = input.store ? await input.store(file) : await blobReference(file) const sourcePath = input.getPathForFile?.(file) || undefined // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical // clipboard content is matched on bytes alone. const duplicate = target.prompt .current() .some( (part) => part.type === "image" && part.blob.id === blob.id && (sourcePath ? part.sourcePath === sourcePath : !part.sourcePath && (clipboard || part.filename === file.name)), ) if (duplicate) { input.duplicate() return true } const attachment: PromptInputV2Attachment = { type: "image", id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), filename: file.name, sourcePath, mime, blob, } target.prompt.set([...target.prompt.current(), attachment], target.cursor) return true } const addAttachments = async (files: File[], toast = true, target = capture()) => { const found = await files.reduce(async (result, file) => { const previous = await result return (await add(file, false, target)) || previous }, Promise.resolve(false)) if (!found && files.length > 0 && toast) input.warn() return found } const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData if (!clipboardData) return const target = capture() if (!target) return event.preventDefault() event.stopPropagation() const files = Array.from(clipboardData.items).flatMap((item) => { if (item.kind !== "file") return [] const file = item.getAsFile() return file ? [file] : [] }) if (files.length > 0) { await addAttachments(files, true, target) return } const plainText = clipboardData.getData("text/plain") ?? "" if (input.readClipboardImage && !plainText) { const file = await input.readClipboardImage() if (file && (await add(file, true, target, true))) return } if (!plainText) return const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText const put = () => { if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true input.focusEditor() return input.addPart({ type: "text", content: text, start: 0, end: 0 }) } if (text.includes("\n") || largePaste(text)) { put() return } if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return put() } const handleDrop = async (event: DragEvent) => { if (input.isDialogActive()) return event.preventDefault() input.setDraggingType(null) const plainText = event.dataTransfer?.getData("text/plain") if (plainText?.startsWith("file:")) { const path = plainText.slice("file:".length) input.focusEditor() input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 }) return } const files = event.dataTransfer?.files if (files) await addAttachments(Array.from(files)) } onMount(() => { makeEventListener(document, "dragover", (event) => { if (input.isDialogActive()) return event.preventDefault() if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image") else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention") }) makeEventListener(document, "dragleave", (event) => { if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null) }) makeEventListener(document, "drop", handleDrop) }) return { addAttachments, handlePaste, handleDrop, pick(fallback: () => void) { if (!input.picker) { fallback() return } void input .picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file)) .catch(input.onError) }, } } const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) async function blobReference(file: File) { const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))) .map((byte) => byte.toString(16).padStart(2, "0")) .join("") return { id, url: URL.createObjectURL(file) } } const imageExtensions = new Map([ ["gif", "image/gif"], ["jpeg", "image/jpeg"], ["jpg", "image/jpeg"], ["png", "image/png"], ["webp", "image/webp"], ]) const textMimes = new Set([ "application/json", "application/ld+json", "application/toml", "application/x-toml", "application/x-yaml", "application/xml", "application/yaml", ]) async function attachmentMime(file: File) { const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "" if (imageMimes.has(type) || type === "application/pdf") return type const index = file.name.lastIndexOf(".") const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase() const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) if ((!type || type === "application/octet-stream") && fallback) return fallback if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) { return "text/plain" } const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer()) if (bytes.some((byte) => byte === 0)) return const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length if (bytes.length > 0 && control / bytes.length > 0.3) return return "text/plain" } function cursorPosition(editor: HTMLElement) { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) if (!editor.contains(range.startContainer)) return 0 const before = range.cloneRange() before.selectNodeContents(editor) before.setEnd(range.startContainer, range.startOffset) return before.toString().replace(/\u200B/g, "").length } function largePaste(text: string) { if (text.length >= 8000) return true return text.split("\n").length - 1 >= 120 }