diff --git a/apps/desktop/src/image-drag-files.ts b/apps/desktop/src/image-drag-files.ts index 0b01ea497c..be690aa59b 100644 --- a/apps/desktop/src/image-drag-files.ts +++ b/apps/desktop/src/image-drag-files.ts @@ -3,12 +3,14 @@ import { randomUUID } from 'node:crypto' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' -const IMAGE_EXTENSIONS: Readonly> = { +// Keep supported MIME types in sync with IMAGE_MEDIA_TYPES in +// packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx (extensions: adjacent index.ts). +const DRAG_FILE_EXTENSION_BY_MIME: Readonly> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico', 'image/svg+xml': 'svg', } // Bounds aggregate original bytes in preparation and retained temporary files. -const MAX_IMAGE_BYTES = 64 * 1024 * 1024 +const MAX_TOTAL_DRAG_FILE_BYTES = 64 * 1024 * 1024 interface ImageDragFile { readonly directory: string @@ -36,16 +38,17 @@ export class ImageDragFiles { * @returns application-local opaque handle; failures retain no usable handle or partial file. */ prepare(bytes: unknown, name: unknown, mediaType: unknown): Promise { - const extension = typeof mediaType === 'string' && Object.hasOwn(IMAGE_EXTENSIONS, mediaType) - ? IMAGE_EXTENSIONS[mediaType] : undefined - if (this.disposed || !(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES - this.stagedBytes - || typeof name !== 'string' || typeof mediaType !== 'string' || extension === undefined) { + const outputExtension = typeof mediaType === 'string' && Object.hasOwn(DRAG_FILE_EXTENSION_BY_MIME, mediaType) + ? DRAG_FILE_EXTENSION_BY_MIME[mediaType] : undefined + if (this.disposed || !(bytes instanceof Uint8Array) || bytes.byteLength === 0 + || bytes.byteLength > MAX_TOTAL_DRAG_FILE_BYTES - this.stagedBytes + || typeof name !== 'string' || typeof mediaType !== 'string' || outputExtension === undefined) { return Promise.reject(new Error('Invalid image transfer')) } let stem = name.replace(/\.[^.]*$/u, '').replace(/[<>:"/\\|?*\x00-\x1f]/gu, '_').replace(/^[. ]+|[. ]+$/gu, '').slice(0, 120) || 'image' if (/^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(stem)) stem = `_${stem}` this.stagedBytes += bytes.byteLength - const operation = this.write(bytes, `${stem}.${extension}`).catch((error: unknown) => { + const operation = this.write(bytes, `${stem}.${outputExtension}`).catch((error: unknown) => { this.stagedBytes -= bytes.byteLength throw error }) diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index 3d08df15ff..67dc743bf5 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -1,5 +1,5 @@ /** Document drag-and-drop listeners owned by one mounted attachment view. */ -import { IMAGE_DRAG_TYPE } from '@deepseek-ai/dsh-client-ui-primitives' +import { IMAGE_DRAG_TOKEN_TYPE } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** @@ -38,7 +38,7 @@ export function installDocumentDropEvents( ): () => void { const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => { const dataTransfer = event.dataTransfer - if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(IMAGE_DRAG_TYPE))) return null + if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE))) return null return dataTransfer } const reset = (): void => { @@ -50,7 +50,7 @@ export function installDocumentDropEvents( if (dataTransfer === null) return event.preventDefault() dragDepth.current += 1 - if (!dataTransfer.types.includes(IMAGE_DRAG_TYPE)) setDragActive(true) + if (!dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE)) setDragActive(true) } const onDragOver = (event: globalThis.DragEvent): void => { const dataTransfer = fileTransfer(event) @@ -72,7 +72,7 @@ export function installDocumentDropEvents( event.preventDefault() reset() if (canAcceptDrop) { - const image = dataTransfer.types.includes(IMAGE_DRAG_TYPE) ? readImageDrag(dataTransfer) : undefined + const image = dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE) ? readImageDrag(dataTransfer) : undefined const files = image === undefined ? [...dataTransfer.files] : [image] if (files.length > 0) onAddFiles(files, droppedDirectories(dataTransfer, files)) } diff --git a/packages/client/ui-primitives/src/ImageDragProvider.tsx b/packages/client/ui-primitives/src/ImageDragProvider.tsx index 2027011017..0d335f78dc 100644 --- a/packages/client/ui-primitives/src/ImageDragProvider.tsx +++ b/packages/client/ui-primitives/src/ImageDragProvider.tsx @@ -3,8 +3,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { Toast } from './Toast.tsx' import type { DesktopImageDrag } from './image-drag.ts' -/** Browser drag token; file bytes remain in the owning mounted application. */ -export const IMAGE_DRAG_TYPE = 'application/x-dsh-image' +/** DataTransfer type for an application-local drag token; file bytes remain in the owning mounted application. */ +export const IMAGE_DRAG_TOKEN_TYPE = 'application/x-dsh-image' interface ImageTransfer { desktop: DesktopImageDrag | undefined @@ -43,7 +43,7 @@ export function ImageDragProvider({ children }: { readonly children: ReactNode } }, [end]) const read = useCallback((transfer: DataTransfer): File | undefined => { const current = active.current - return current !== undefined && current.token === transfer.getData(IMAGE_DRAG_TYPE) ? current.file : undefined + return current !== undefined && current.token === transfer.getData(IMAGE_DRAG_TOKEN_TYPE) ? current.file : undefined }, []) const value = useMemo(() => ({ desktop, report, begin, end, read }), [desktop, report, begin, end, read]) return diff --git a/packages/client/ui-primitives/src/image-drag.ts b/packages/client/ui-primitives/src/image-drag.ts index ad56f51b25..94714ae2ce 100644 --- a/packages/client/ui-primitives/src/image-drag.ts +++ b/packages/client/ui-primitives/src/image-drag.ts @@ -1,7 +1,7 @@ /** Original image files supplied to browser and native drag sessions. */ import { useCallback, useEffect, useRef, type DragEvent, type MouseEvent, type PointerEvent } from 'react' -import { IMAGE_DRAG_TYPE, useImageDragTransfer } from './ImageDragProvider.tsx' +import { IMAGE_DRAG_TOKEN_TYPE, useImageDragTransfer } from './ImageDragProvider.tsx' /** Application-owned native image transfer; prepared handles never expose host paths. */ export interface DesktopImageDrag { @@ -106,7 +106,7 @@ export function useImageDrag(src: string, name: string, failureText: string, ori void desktop.start(prepared.handle).then((started) => { if (!started) failed() }, failed) return } - event.dataTransfer.setData(IMAGE_DRAG_TYPE, transfer.begin(prepared.file)) + event.dataTransfer.setData(IMAGE_DRAG_TOKEN_TYPE, transfer.begin(prepared.file)) event.dataTransfer.setData('DownloadURL', `${prepared.file.type}:${prepared.file.name}:${src}`) }, [src, desktop, prepare, reportFailure, failureText, transfer]) const onPointerDown = useCallback((event: PointerEvent): void => { diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index bdd9d37e14..37e04c877f 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -67,7 +67,7 @@ export { pointerModality } from './input-modality.ts' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' export { writeClipboard } from './clipboard.ts' -export { IMAGE_DRAG_TYPE, ImageDragProvider, ImageDrop } from './ImageDragProvider.tsx' +export { IMAGE_DRAG_TOKEN_TYPE, ImageDragProvider, ImageDrop } from './ImageDragProvider.tsx' export { DraggableImage } from './DraggableImage.tsx' export type { DraggableImageProps } from './DraggableImage.tsx' export type { DesktopImageDrag } from './image-drag.ts' diff --git a/packages/client/ui-primitives/tests/image-drag.client.spec.tsx b/packages/client/ui-primitives/tests/image-drag.client.spec.tsx index 712683b863..d2561d77b5 100644 --- a/packages/client/ui-primitives/tests/image-drag.client.spec.tsx +++ b/packages/client/ui-primitives/tests/image-drag.client.spec.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react' import { File as NodeFile } from 'node:buffer' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { IMAGE_DRAG_TYPE, ImageDragProvider, useImageDrop } from '../src/ImageDragProvider.tsx' +import { IMAGE_DRAG_TOKEN_TYPE, ImageDragProvider, useImageDrop } from '../src/ImageDragProvider.tsx' import { DraggableImage } from '../src/DraggableImage.tsx' import { useImageDrag, type DesktopImageDrag } from '../src/image-drag.ts' @@ -36,7 +36,7 @@ it('hands original bytes to the browser without alt text or an internal URI payl await act(async () => { fireEvent.dragStart(screen.getByRole('img'), { dataTransfer }) }) expect(dataTransfer.items.add).not.toHaveBeenCalled() expect(dataTransfer.items.clear).toHaveBeenCalledOnce() - expect(dataTransfer.setData).toHaveBeenCalledWith(IMAGE_DRAG_TYPE, expect.any(String)) + expect(dataTransfer.setData).toHaveBeenCalledWith(IMAGE_DRAG_TOKEN_TYPE, expect.any(String)) expect(dataTransfer.effectAllowed).toBe('copy') expect(dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', 'image/png:original.png:blob:original') fireEvent.click(screen.getByRole('img'), { detail: 1 }) @@ -169,7 +169,7 @@ it('fetches a readable source once and rejects a foreign drag token', async () = expect(fetchImage).toHaveBeenCalledOnce() const dataTransfer = transfer() fireEvent.dragStart(screen.getByRole('img'), { dataTransfer }) - dataTransfer.setData(IMAGE_DRAG_TYPE, 'foreign') + dataTransfer.setData(IMAGE_DRAG_TOKEN_TYPE, 'foreign') fireEvent.drop(screen.getByTestId('drop'), { dataTransfer }) expect(receive).toHaveBeenCalledWith(undefined) })