From 3f77304f721d37af98b21272331809829af39a4c Mon Sep 17 00:00:00 2001 From: yi111 <153097222+Yi-111-a@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:03:56 +0800 Subject: [PATCH] fix(generate): accept image drops from Explorer --- electron/main/model-download-preload.test.mjs | 6 ++- .../preload/artifact-registry-preload.test.ts | 26 +++++++++- electron/preload/electron-api.ts | 6 ++- electron/preload/index.ts | 4 +- .../generate/components/WorkflowPanel.tsx | 47 +++++++++++++------ src/shared/types/electron.d.ts | 1 + 6 files changed, 71 insertions(+), 19 deletions(-) diff --git a/electron/main/model-download-preload.test.mjs b/electron/main/model-download-preload.test.mjs index 8d9a16b7..d75eae0f 100644 --- a/electron/main/model-download-preload.test.mjs +++ b/electron/main/model-download-preload.test.mjs @@ -29,7 +29,11 @@ test('renderer model actions send only the model node id', async () => { on: () => {}, removeAllListeners: () => {}, } - const api = createElectronApi(ipc, { setZoomFactor: () => {} }) + const api = createElectronApi( + ipc, + { setZoomFactor: () => {} }, + { getPathForFile: () => '' }, + ) await api.model.isDownloaded('pixal3d/generate') await api.model.hasLocalData('pixal3d/generate') diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts index 511589e4..9dec46fd 100644 --- a/electron/preload/artifact-registry-preload.test.ts +++ b/electron/preload/artifact-registry-preload.test.ts @@ -13,7 +13,7 @@ test('preload exposes scoped workspace library list/read/open methods', async () send: () => undefined, on: () => undefined, removeAllListeners: () => undefined, - }, { setZoomFactor: () => undefined }) + }, { setZoomFactor: () => undefined }, { getPathForFile: () => '' }) await api.workspace.library.list() await api.workspace.library.read({ @@ -43,3 +43,27 @@ test('preload exposes scoped workspace library list/read/open methods', async () }, ]) }) + +test('preload resolves dropped file paths through Electron webUtils', () => { + const files: unknown[] = [] + const calls: string[] = [] + const api = createElectronApi({ + invoke: async (channel: string) => { + calls.push(channel) + return null + }, + send: () => undefined, + on: () => undefined, + removeAllListeners: () => undefined, + }, { setZoomFactor: () => undefined }, { + getPathForFile: (file) => { + files.push(file) + return 'C:\\images\\input.png' + }, + }) + const file = {} as Parameters[0] + + assert.equal(api.fs.getPathForFile(file), 'C:\\images\\input.png') + assert.deepEqual(files, [file]) + assert.deepEqual(calls, []) +}) diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index af0b1265..e9ee6334 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -17,7 +17,9 @@ export interface WebFrameLike { setZoomFactor(factor: number): void } -export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFrameLike) { +export type WebUtilsLike = Pick + +export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFrameLike, webUtils: WebUtilsLike) { return { // Window controls window: { @@ -73,6 +75,8 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra // File system dialogs + local file reading fs: { + getPathForFile: (file: Parameters[0]): string => + webUtils.getPathForFile(file), selectImage: (): Promise => ipcRenderer.invoke('fs:selectImage') as Promise, selectMeshFile: (): Promise => diff --git a/electron/preload/index.ts b/electron/preload/index.ts index ec34ebf2..03b80363 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,6 +1,6 @@ -import { contextBridge, ipcRenderer, webFrame } from 'electron' +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { createElectronApi } from './electron-api' // Expose a typed API to the renderer process via window.electron -contextBridge.exposeInMainWorld('electron', createElectronApi(ipcRenderer, webFrame)) +contextBridge.exposeInMainWorld('electron', createElectronApi(ipcRenderer, webFrame, webUtils)) diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 2995ddea..121f185f 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -13,6 +13,7 @@ import { useWorkflowRunStore } from '@areas/workflows/workflowRunStore' import { useWaitButton } from '@areas/workflows/useWaitButton' import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflows/mockExtensions' import { validateWorkflowPreflight } from '@areas/workflows/preflight' +import { mimeFromPath } from '@areas/workflows/nodes/imageUtils' import type { WorkflowExtension } from '@areas/workflows/mockExtensions' import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d' import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker' @@ -29,6 +30,8 @@ const TYPE_COLOR: Record = { text: '#fbbf24', } +const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) + // ─── Helpers ────────────────────────────────────────────────────────────────── function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNode[] { @@ -54,13 +57,6 @@ function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNo return result } -function mimeFromPath(p: string): string { - const ext = p.split('.').pop()?.toLowerCase() ?? '' - if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg' - if (ext === 'webp') return 'image/webp' - return 'image/png' -} - // ─── Param field ────────────────────────────────────────────────────────────── const inputCls = 'w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' @@ -212,17 +208,40 @@ function ImageParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: Flow const node = nodes.find((n) => n.id === nodeId) const data = node?.data as { params: Record } | undefined const preview = data?.params.preview as string | undefined + const showToast = useAppStore((state) => state.showToast) + const loadRequest = useRef(0) + + const applyImagePath = useCallback(async (path: string | null) => { + const request = ++loadRequest.current + if (!path) return + try { + const base64 = await window.electron.fs.readFileBase64(path) + if (request !== loadRequest.current) return + const src = `data:${mimeFromPath(path)};base64,${base64}` + onPatch(nodeId, { params: { ...(data?.params ?? {}), filePath: path, preview: src } }) + } catch { + if (request === loadRequest.current) showToast('Unable to load the selected image') + } + }, [nodeId, data?.params, onPatch, showToast]) const browse = useCallback(async () => { - const p = await window.electron.fs.selectImage() - if (!p) return - const base64 = await window.electron.fs.readFileBase64(p) - const src = `data:${mimeFromPath(p)};base64,${base64}` - onPatch(nodeId, { params: { ...(data?.params ?? {}), filePath: p, preview: src } }) - }, [nodeId, data?.params, onPatch]) + await applyImagePath(await window.electron.fs.selectImage()) + }, [applyImagePath]) return ( -
+
{ + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + }} + onDrop={(event) => { + event.preventDefault() + const file = event.dataTransfer.files[0] + if (!file || !SUPPORTED_IMAGE_TYPES.has(file.type)) return + void applyImagePath(window.electron.fs.getPathForFile(file)) + }} + >
diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 5119a1d4..ffe434ed 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -184,6 +184,7 @@ declare global { offLog: () => void } fs: { + getPathForFile: (file: File) => string selectImage: () => Promise selectMeshFile: () => Promise saveModel: (defaultName: string) => Promise