278 lines
8.4 KiB
TypeScript
278 lines
8.4 KiB
TypeScript
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<unknown>,
|
|
) => Promise<void>
|
|
directory: () => string
|
|
isDialogActive: () => boolean
|
|
warn: () => void
|
|
duplicate: () => void
|
|
onError: (error: unknown) => void
|
|
readClipboardImage?: () => Promise<File | null>
|
|
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
|
|
}
|