Files
neuron/packages/app/src/components/prompt-input/attachments.ts
T

229 lines
6.7 KiB
TypeScript

import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const idx = value.indexOf(",")
if (idx === -1) {
resolve(value)
return
}
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
})
reader.readAsDataURL(file)
})
}
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
type PromptAttachmentsCoreInput = {
capture: () => PromptTarget
editor: () => HTMLDivElement | undefined
focusEditor?: () => void
addPart?: (part: ContentPart) => boolean
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const capture = (): AttachmentTarget | undefined => {
const prompt = input.capture()
const editor = input.editor()
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture()) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
if (toast) input.warn?.()
return false
}
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
dataUrl: url,
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true, target = capture()) => {
let found = false
for (const file of files) {
const ok = await add(file, false, target)
if (ok) found = true
}
if (!found && files.length > 0 && toast) input.warn?.()
return found
}
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
const file = await pending
if (!file) return false
return add(file, true, target)
}
const removeAttachment = (id: string) => {
const target = input.capture()
const current = target.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
target.set(next, target.cursor())
}
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") ?? ""
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
if (input.readClipboardImage && !plainText) {
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
}
if (!plainText) return
const text = normalizePaste(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 }) ?? false
}
if (pasteMode(text) === "manual") {
put()
return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
if (inserted) return
put()
}
return {
addAttachment,
addAttachments,
addClipboardAttachment,
removeAttachment,
handlePaste,
}
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const attachments = createPromptAttachmentsCore({
...input,
capture: input.prompt.capture,
warn: () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
},
})
const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
const hasText = event.dataTransfer?.types.includes("text/plain")
if (hasFiles) {
input.setDraggingType("image")
} else if (hasText) {
input.setDraggingType("@mention")
}
}
const handleGlobalDragLeave = (event: DragEvent) => {
if (input.isDialogActive()) return
if (!event.relatedTarget) {
input.setDraggingType(null)
}
}
const handleGlobalDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
input.setDraggingType(null)
const plainText = event.dataTransfer?.getData("text/plain")
const filePrefix = "file:"
if (plainText?.startsWith(filePrefix)) {
const filePath = plainText.slice(filePrefix.length)
input.focusEditor()
input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 })
return
}
const dropped = event.dataTransfer?.files
if (!dropped) return
await attachments.addAttachments(Array.from(dropped))
}
onMount(() => {
makeEventListener(document, "dragover", handleGlobalDragOver)
makeEventListener(document, "dragleave", handleGlobalDragLeave)
makeEventListener(document, "drop", handleGlobalDrop)
})
return attachments
}