diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e68369f7c..034071eec 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,6 +18,7 @@ "height": 700, "minWidth": 360, "minHeight": 600, + "dragDropEnabled": false, "titleBarStyle": "Overlay", "hiddenTitle": true, "transparent": true, diff --git a/src/features/composer/components/ComposerAttachments.tsx b/src/features/composer/components/ComposerAttachments.tsx index 55a70035a..e9496be4e 100644 --- a/src/features/composer/components/ComposerAttachments.tsx +++ b/src/features/composer/components/ComposerAttachments.tsx @@ -1,4 +1,4 @@ -import { X } from "lucide-react"; +import { Image, X } from "lucide-react"; type ComposerAttachmentsProps = { attachments: string[]; @@ -33,7 +33,14 @@ export function ComposerAttachments({ const title = fileTitle(path); const titleAttr = path.startsWith("data:") ? "Pasted image" : path; return ( -
+
+ + + {title} -
); diff --git a/src/features/composer/hooks/useComposerImageDrop.ts b/src/features/composer/hooks/useComposerImageDrop.ts index 5e871dcdb..12c1fc371 100644 --- a/src/features/composer/hooks/useComposerImageDrop.ts +++ b/src/features/composer/hooks/useComposerImageDrop.ts @@ -17,6 +17,32 @@ function isImagePath(path: string) { return imageExtensions.some((ext) => lower.endsWith(ext)); } +function isDragFileTransfer(types: readonly string[] | undefined) { + if (!types || types.length === 0) { + return false; + } + return ( + types.includes("Files") || + types.includes("public.file-url") || + types.includes("application/x-moz-file") + ); +} + +function readFilesAsDataUrls(files: File[]) { + return Promise.all( + files.map( + (file) => + new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => + resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = () => resolve(""); + reader.readAsDataURL(file); + }), + ), + ).then((items) => items.filter(Boolean)); +} + type UseComposerImageDropArgs = { disabled: boolean; onAttachImages?: (paths: string[]) => void; @@ -86,7 +112,7 @@ export function useComposerImageDrop({ if (disabled) { return; } - if (event.dataTransfer?.types?.includes("Files")) { + if (isDragFileTransfer(event.dataTransfer?.types)) { event.preventDefault(); setIsDragOver(true); } @@ -102,24 +128,35 @@ export function useComposerImageDrop({ } }; - const handleDrop = (event: React.DragEvent) => { + const handleDrop = async (event: React.DragEvent) => { if (disabled) { return; } event.preventDefault(); setIsDragOver(false); const files = Array.from(event.dataTransfer?.files ?? []); - if (!files.length) { - return; - } - const imagePaths = files - .map((file) => { - const candidate = (file as File & { path?: string }).path ?? ""; - return candidate && isImagePath(candidate) ? candidate : null; - }) - .filter((path): path is string => Boolean(path)); + const items = Array.from(event.dataTransfer?.items ?? []); + const itemFiles = items + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + const filePaths = [...files, ...itemFiles] + .map((file) => (file as File & { path?: string }).path ?? "") + .filter(Boolean); + const imagePaths = filePaths.filter(isImagePath); if (imagePaths.length > 0) { onAttachImages?.(imagePaths); + return; + } + const fileImages = [...files, ...itemFiles].filter((file) => + file.type.startsWith("image/"), + ); + if (fileImages.length === 0) { + return; + } + const dataUrls = await readFilesAsDataUrls(fileImages); + if (dataUrls.length > 0) { + onAttachImages?.(dataUrls); } }; diff --git a/src/features/composer/hooks/useComposerImages.ts b/src/features/composer/hooks/useComposerImages.ts index 71b8844d2..b0c9445e3 100644 --- a/src/features/composer/hooks/useComposerImages.ts +++ b/src/features/composer/hooks/useComposerImages.ts @@ -26,10 +26,7 @@ export function useComposerImages({ } setImagesByThread((prev) => { const existing = prev[draftKey] ?? []; - const merged = [ - ...existing, - ...paths.filter((path) => !existing.includes(path)), - ]; + const merged = Array.from(new Set([...existing, ...paths])); return { ...prev, [draftKey]: merged }; }); }, diff --git a/src/styles/composer.css b/src/styles/composer.css index 82e0e8778..f64384a74 100644 --- a/src/styles/composer.css +++ b/src/styles/composer.css @@ -66,7 +66,7 @@ .composer-input { display: grid; - grid-template-columns: 1fr auto auto; + grid-template-columns: 1fr auto; gap: 12px; align-items: center; } @@ -182,7 +182,7 @@ outline: none; } -.composer-send { +.composer-action { border: 1px solid rgba(255, 255, 255, 0.5); background: transparent; color: #ffffff; @@ -197,41 +197,33 @@ justify-content: center; } -.composer-send:hover { - background: var(--surface-control-hover); - color: #ffffff; -} - -.composer-send svg { - width: 12px; - height: 12px; +.composer-action.is-stop { + border-color: rgba(255, 107, 107, 0.6); + background: rgba(255, 107, 107, 0.12); } -.composer-stop { - border: 1px solid rgba(255, 107, 107, 0.6); - background: rgba(255, 107, 107, 0.12); +.composer-action:hover { + background: var(--surface-control-hover); color: #ffffff; - padding: 0; - border-radius: 999px; - cursor: pointer; - width: 24px; - height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; } -.composer-stop:hover { +.composer-action.is-stop:hover { background: rgba(255, 107, 107, 0.2); } -.composer-stop-square { +.composer-action svg { + width: 12px; + height: 12px; +} + +.composer-action-stop-square { width: 6px; height: 6px; border-radius: 2px; background: currentColor; } + .composer.is-disabled { opacity: 0.7; } @@ -242,7 +234,7 @@ color: var(--text-fainter); } -.composer-send:disabled { +.composer-action:disabled { opacity: 0.5; cursor: not-allowed; background: var(--surface-control-disabled); @@ -250,13 +242,14 @@ border-color: var(--border-subtle); } -.composer-stop:disabled { +.composer-action.is-stop:disabled { opacity: 0.4; cursor: not-allowed; background: rgba(255, 107, 107, 0.08); color: rgba(255, 196, 196, 0.4); } + .composer-select:disabled { cursor: not-allowed; color: var(--text-fainter);