Revert "feat(gui): 支持图片拖动平移与原图拖放分享"

This commit is contained in:
imccyu authored and GitHub committed 2026-10-19 18:04:17 +08:00
1 parent f6a6a3407f
commit a3debca9ab
57 files changed
+124 -1511

No files matched your search

+2 -2
View File
@@ -33,8 +33,8 @@
en: 372297005b57a08b
zh: 074a635ac17e4362
/deepseek-harness-desktop/installation-ownership:
en: 7e2329afb2bfa6e4
zh: bee6b40de5551daf
en: 22153a298a6662e1
zh: 3600803c8c9d0274
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: a1a47b09f4a72ba4
zh: 7b1cf27f0b72cf31
-2
View File
@@ -142,8 +142,6 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages
The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
The image-drag bridge accepts original PNG, JPEG, GIF, WebP, BMP, ICO, or SVG bytes from the owned application main frame. Pending and retained files share a 64 MiB limit; when it is full, new preparation fails until unused files are released or the app restarts. It prepares private temporary files and asynchronous system file icons before `startDrag`, exposes only opaque handles, and never accepts renderer filesystem paths or decodes full images in the main process. Unused files are released with their source image; files handed to the OS remain readable until exit because receivers may read them after the drag returns. Shutdown waits for pending writes and releases, retries failed deletions, and does not wait for the system icon service. Update preparation completes cleanup before launching the installer; failed installation restores the bridge. Cleanup errors do not interrupt Host shutdown. Native drag errors use the application’s localized image feedback.
Only the main application window enables `<webview>`. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering.
The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks.
-2
View File
@@ -142,8 +142,6 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
图片拖放桥接只接受主应用所属主框架传入的 PNG、JPEG、GIF、WebP、BMP、ICO 或 SVG 原图字节。准备中及保留的文件共享 64 MiB 上限;达到上限后,新准备会失败,直到未使用文件被释放或应用重启。桥接在 `startDrag` 前准备私有临时文件和异步系统文件图标,只暴露不透明句柄,不接受渲染进程指定的文件系统路径,也不在主进程解码完整图片。未使用文件随来源图片释放;已交给操作系统的文件保留到退出,因为接收方可能在拖动返回后继续读取。退出等待进行中的写入和释放,并重试失败的删除,但不等待系统图标服务。更新准备在启动安装器前完成清理;安装失败后恢复桥接。清理错误不会中断 Host 关闭。原生拖放错误使用应用的本地化图片反馈。
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
`dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。
-120
View File
@@ -1,120 +0,0 @@
/** Process-owned original image files staged for native copy drags. */
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
// 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<Record<string, string>> = {
'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_TOTAL_DRAG_FILE_BYTES = 64 * 1024 * 1024
interface ImageDragFile {
readonly directory: string
readonly path: string
readonly bytes: number
used: boolean
}
/** Original files belong to the application; handed-off files remain readable until app shutdown. */
export class ImageDragFiles {
private readonly files = new Map<string, ImageDragFile>()
private readonly pending = new Set<Promise<string>>()
private readonly releasing = new Map<string, Promise<void>>()
private stagedBytes = 0
private disposed = false
/** @param temporaryDirectory - OS temporary directory in which private export directories are allocated. */
constructor(private readonly temporaryDirectory: string) {}
/**
* Validate renderer bytes and stage one original file without accepting renderer filesystem paths.
* @param bytes - complete image bytes; all pending and retained files share a 64 MiB budget.
* @param name - suggested filename; directory characters and platform-reserved characters are removed.
* @param mediaType - supported image MIME type, which owns the output suffix.
* @returns application-local opaque handle; failures retain no usable handle or partial file.
*/
prepare(bytes: unknown, name: unknown, mediaType: unknown): Promise<string> {
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}.${outputExtension}`).catch((error: unknown) => {
this.stagedBytes -= bytes.byteLength
throw error
})
this.pending.add(operation)
void operation.then(() => { this.pending.delete(operation) }, () => { this.pending.delete(operation) })
return operation
}
private async write(bytes: Uint8Array, name: string): Promise<string> {
const directory = await mkdtemp(join(this.temporaryDirectory, 'dsh-image-drag-'))
try {
const path = join(directory, name)
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 })
if (this.disposed) throw new Error('Image transfer closed')
const handle = randomUUID()
this.files.set(handle, { directory, path, bytes: bytes.byteLength, used: false })
return handle
} catch (error) {
await rm(directory, { recursive: true, force: true })
throw error
}
}
/**
* Resolve and retain a prepared file for an OS-owned drag.
* @param handle - handle created by this application's preparation operation.
* @param retain - whether the OS accepted the file; false while preparing or attempting a drag.
* @returns original file path, or undefined after release/disposal or for a foreign handle.
*/
take(handle: unknown, retain = true): string | undefined {
if (typeof handle !== 'string') return undefined
const file = this.files.get(handle)
if (file === undefined || this.disposed || this.releasing.has(handle)) return undefined
if (retain) file.used = true
return file.path
}
/**
* Release a preparation that no longer has a mounted image consumer.
* @param handle - application-local handle; OS-used files stay until disposal.
*/
async release(handle: unknown): Promise<void> {
if (typeof handle !== 'string') return
const file = this.files.get(handle)
if (file === undefined || file.used) return
await this.remove(handle, file)
}
private remove(handle: string, file: ImageDragFile): Promise<void> {
const existing = this.releasing.get(handle)
if (existing !== undefined) return existing
const operation = rm(file.directory, { recursive: true, force: true }).then(() => {
this.files.delete(handle)
this.stagedBytes -= file.bytes
}).finally(() => { this.releasing.delete(handle) })
this.releasing.set(handle, operation)
return operation
}
/** Stop preparation, await writes and releases, and report removal failure only after all files settle. */
async dispose(): Promise<void> {
this.disposed = true
await Promise.allSettled(this.pending)
await Promise.allSettled(this.releasing.values())
const results = await Promise.allSettled([...this.files].map(([handle, file]) => this.remove(handle, file)))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
}
-80
View File
@@ -1,80 +0,0 @@
/** Native copy drags authorized only for the owning application window. */
import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type NativeImage } from 'electron'
import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
import { ImageDragFiles } from './image-drag-files.ts'
/**
* Register native image preparation and drag operations for the application window.
* @param getWindow - current application window; subframes and other windows are rejected.
* @returns cleanup that waits for preparation and removes all exported temporary files.
*/
export function installDesktopImageDrag(getWindow: () => BrowserWindow | undefined): () => Promise<void> {
let disposed = false
const closed = Promise.withResolvers<void>()
const files = new ImageDragFiles(app.getPath('temp'))
const pending = new Set<Promise<string>>()
const icons = new Map<string, NativeImage>()
const owner = (event: IpcMainInvokeEvent): BrowserWindow => {
const window = getWindow()
if (disposed || window === undefined || window.isDestroyed() || event.sender !== window.webContents
|| event.senderFrame !== window.webContents.mainFrame) throw new Error('Unowned image drag request')
assertDesktopSender(event, ['app'])
return window
}
const prepare = async (event: IpcMainInvokeEvent, bytes: unknown, name: unknown, mediaType: unknown): Promise<string> => {
owner(event)
const handle = await files.prepare(bytes, name, mediaType)
try {
const file = files.take(handle, false)
if (file === undefined) throw new Error('Image transfer closed')
const icon = await Promise.race([app.getFileIcon(file, { size: 'normal' }), closed.promise])
owner(event)
if (icon === undefined) throw new Error('Image transfer closed')
icons.set(handle, icon)
return handle
} catch (error) {
await files.release(handle)
throw error
}
}
ipcMain.handle(DESKTOP_IPC.imageDragPrepare, (event, bytes: unknown, name: unknown, mediaType: unknown) => {
const operation = prepare(event, bytes, name, mediaType)
pending.add(operation)
void operation.then(() => { pending.delete(operation) }, () => { pending.delete(operation) })
return operation
})
ipcMain.handle(DESKTOP_IPC.imageDragStart, (event, handle: unknown) => {
const window = owner(event)
const icon = typeof handle === 'string' ? icons.get(handle) : undefined
if (icon === undefined) return false
const file = files.take(handle, false)
if (file === undefined) return false
try {
window.webContents.startDrag({ file, icon })
files.take(handle)
return true
} catch (_error) {
if (typeof handle === 'string') icons.delete(handle)
void files.release(handle).catch((error: unknown) => { console.error(error) })
// The renderer reports one localized failure; no filesystem paths cross IPC.
return false
}
})
ipcMain.handle(DESKTOP_IPC.imageDragRelease, async (event, handle: unknown) => {
owner(event)
if (typeof handle === 'string') icons.delete(handle)
await files.release(handle)
})
return async () => {
if (!disposed) {
disposed = true
closed.resolve()
ipcMain.removeHandler(DESKTOP_IPC.imageDragPrepare)
ipcMain.removeHandler(DESKTOP_IPC.imageDragStart)
ipcMain.removeHandler(DESKTOP_IPC.imageDragRelease)
}
await Promise.allSettled(pending)
icons.clear()
await files.dispose()
}
}
-8
View File
@@ -21,9 +21,6 @@ export const DESKTOP_IPC = {
browserRelease: 'dsh-desktop:browser-release',
browserOpenRequested: 'dsh-desktop:browser-open-requested',
directoryPick: 'dsh-desktop:directory-pick',
imageDragPrepare: 'dsh-desktop:image-drag-prepare',
imageDragStart: 'dsh-desktop:image-drag-start',
imageDragRelease: 'dsh-desktop:image-drag-release',
deviceInfo: 'dsh-desktop:device-info',
localeBootstrap: 'dsh-desktop:locale-bootstrap',
localeChanged: 'dsh-desktop:locale-changed',
@@ -76,11 +73,6 @@ export interface DesktopUpdatePresentation {
export interface DshDesktopProductApi {
readonly protocolVersion: 1
readonly browser: DesktopBrowserBridge
readonly imageDrag: {
prepare(bytes: Uint8Array, name: string, mediaType: string): Promise<string>
start(handle: string): Promise<boolean>
release(handle: string): void
}
readonly keyboard: DesktopKeyboardApi
readonly shortcuts: DesktopShortcutsApi
/**
+1 -19
View File
@@ -26,7 +26,6 @@ import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
import { installDesktopImageDrag } from './image-drag.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
import { installMicrophonePermissions } from './microphone-permissions.ts'
import { DesktopBackendController } from './backend-controller.ts'
@@ -346,17 +345,6 @@ async function main(): Promise<void> {
let workspaceRecovery: Promise<void> | undefined
let mainWindow: BrowserWindow | undefined
let welcomeWindow: BrowserWindow | undefined
const imageDragCleanups = new Set([installDesktopImageDrag(() => mainWindow)])
let imageDragStopped = false
const disposeImageDrag = async (): Promise<void> => {
imageDragStopped = true
const results = await Promise.allSettled([...imageDragCleanups].map(async (release) => {
await release()
imageDragCleanups.delete(release)
}))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
let enteredWorkspace = false
// NSIS passes --updated when it launches the application after installation.
let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated')
@@ -549,10 +537,6 @@ async function main(): Promise<void> {
window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
}
if (state.phase === 'error' && state.failedOperation !== 'check') {
if (state.failedOperation === 'install' && imageDragStopped && !quitting) {
imageDragCleanups.add(installDesktopImageDrag(() => mainWindow))
imageDragStopped = false
}
const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
shellInstallerOwnsQuit = false
updateStoppedHost = false
@@ -637,7 +621,6 @@ async function main(): Promise<void> {
mandatoryUI?.preparingRestart(stillActive)
// The embedded Platform document holds credentials issued by the Host that is about to stop.
await platformView.closeAndWait()
await disposeImageDrag()
requireCleanStop = true
updateStopFailure = undefined
await backend.stop()
@@ -1278,8 +1261,7 @@ async function main(): Promise<void> {
mandatoryUI?.dispose()
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(),
// A Platform cleanup failure is logged without cutting the remaining Host shutdown short.
platformView.dispose().catch((error: unknown) => { console.error(error) }),
disposeImageDrag().catch((error: unknown) => { console.error(error) })])
platformView.dispose().catch((error: unknown) => { console.error(error) })])
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
}
app.on('before-quit', (event) => {
-7
View File
@@ -14,13 +14,6 @@ function createProductApi(): DshDesktopProductApi {
return {
protocolVersion: 1,
browser: createDesktopBrowserBridge(),
imageDrag: {
prepare: (bytes, name, mediaType) => ipcRenderer.invoke(DESKTOP_IPC.imageDragPrepare, bytes, name, mediaType) as Promise<string>,
start: handle => ipcRenderer.invoke(DESKTOP_IPC.imageDragStart, handle) as Promise<boolean>,
release: (handle) => { void ipcRenderer.invoke(DESKTOP_IPC.imageDragRelease, handle).catch((_error: unknown) => {
// Main-process shutdown also releases every prepared file.
}) },
},
deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise<string>,
keyboard: {
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
@@ -1,63 +0,0 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { expect, it, onTestFinished } from 'vitest'
import { ImageDragFiles } from '../src/image-drag-files.ts'
async function files() {
const root = await mkdtemp(join(tmpdir(), 'dsh-drag-test-'))
const store = new ImageDragFiles(root)
onTestFinished(async () => { await store.dispose(); await rm(root, { recursive: true, force: true }) })
return { root, store }
}
it('exports original bytes and keeps a used file readable until disposal', async () => {
const { root, store } = await files()
const bytes = new Uint8Array([137, 80, 78, 71])
const handle = await store.prepare(bytes, '../../original.png', 'image/png')
const path = store.take(handle)!
expect(basename(path)).toBe('_.._original.png')
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
await store.release(handle)
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
await store.dispose()
expect(await readdir(root)).toEqual([])
expect(store.take(handle)).toBeUndefined()
})
it('rejects non-images, foreign handles and writes after disposal', async () => {
const { store } = await files()
await expect(store.prepare(new Uint8Array([1]), 'x.html', 'text/html')).rejects.toThrow('Invalid image transfer')
await expect(store.prepare(new Uint8Array(), 'x.png', 'image/png')).rejects.toThrow('Invalid image transfer')
await expect(store.prepare(new Uint8Array([1]), 'x.png', 'constructor')).rejects.toThrow('Invalid image transfer')
expect(store.take('foreign')).toBeUndefined()
await store.dispose()
await expect(store.prepare(new Uint8Array([1]), 'x.png', 'image/png')).rejects.toThrow('Invalid image transfer')
})
it('releases unused preparations and waits for a concurrent write before cleanup', async () => {
const { root, store } = await files()
const handle = await store.prepare(new Uint8Array([1]), 'x.png', 'image/png')
await store.release(handle)
expect(await readdir(root)).toEqual([])
const pending = store.prepare(new Uint8Array([2]), 'y.png', 'image/png')
const rejected = expect(pending).rejects.toThrow('Image transfer closed')
await store.dispose()
await rejected
expect(await readdir(root)).toEqual([])
})
it.each(['CON', 'prn', 'AUX', 'NUL', 'COM1', 'LPT9', 'COM¹', 'CON.extra'])('sanitizes the Windows device filename %s', async (name) => {
const { store } = await files()
const handle = await store.prepare(new Uint8Array([1]), `${name}.png`, 'image/png')
expect(basename(store.take(handle, false)!)).toBe(`_${name}.png`)
})
it('bounds all retained bytes, including pending writes, and frees unused capacity', async () => {
const { store } = await files()
const pending = store.prepare(new Uint8Array(64 * 1024 * 1024), 'large.png', 'image/png')
await expect(store.prepare(new Uint8Array([1]), 'extra.png', 'image/png')).rejects.toThrow('Invalid image transfer')
const handle = await pending
await store.release(handle)
await expect(store.prepare(new Uint8Array([1]), 'extra.png', 'image/png')).resolves.toEqual(expect.any(String))
})
@@ -1,67 +0,0 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
vi.mock('node:fs/promises', async importOriginal => ({
...await importOriginal<typeof import('node:fs/promises')>(), rm: vi.fn(),
}))
const fs = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
const { ImageDragFiles } = await import('../src/image-drag-files.ts')
afterEach(() => { vi.mocked(rm).mockReset() })
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-image-release-'))
const store = new ImageDragFiles(root)
vi.mocked(rm).mockImplementation(fs.rm)
onTestFinished(async () => {
vi.mocked(rm).mockImplementation(fs.rm)
await store.dispose()
await fs.rm(root, { recursive: true, force: true })
})
const handle = await store.prepare(new Uint8Array([1]), 'image.png', 'image/png')
return { root, store, handle }
}
it('waits for an in-flight release before disposal completes', async () => {
const { root, store, handle } = await fixture()
const barrier = Promise.withResolvers<undefined>()
vi.mocked(rm).mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
const releasing = store.release(handle)
const secondRelease = store.release(handle)
expect(store.take(handle, false)).toBeUndefined()
let stopped = false
const disposing = store.dispose().then(() => { stopped = true })
await Promise.resolve()
expect(stopped).toBe(false)
expect(await readdir(root)).toHaveLength(1)
barrier.resolve(undefined)
await Promise.all([releasing, secondRelease, disposing])
expect(await readdir(root)).toEqual([])
})
it('retains a failed release for the shutdown cleanup to retry', async () => {
const { root, store, handle } = await fixture()
const path = store.take(handle, false)!
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
await expect(store.release(handle)).rejects.toThrow('file busy')
expect(new Uint8Array(await readFile(path))).toEqual(new Uint8Array([1]))
await store.dispose()
expect(await readdir(root)).toEqual([])
})
it('waits for all removals before reporting a shutdown cleanup failure', async () => {
const { store } = await fixture()
await store.prepare(new Uint8Array([2]), 'second.png', 'image/png')
const barrier = Promise.withResolvers<undefined>()
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
.mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
let settled = false
const disposing = store.dispose().finally(() => { settled = true })
const rejected = expect(disposing).rejects.toThrow('file busy')
await vi.waitFor(() => { expect(rm).toHaveBeenCalledTimes(2) })
expect(settled).toBe(false)
barrier.resolve(undefined)
await rejected
await store.dispose()
})
-88
View File
@@ -1,88 +0,0 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import type { BrowserWindow, IpcMainInvokeEvent, NativeImage, WebContents, WebFrameMain } from 'electron'
import { DESKTOP_IPC } from '../src/ipc.ts'
const electron = vi.hoisted(() => ({
handle: vi.fn(), removeHandler: vi.fn(), getPath: vi.fn(), getFileIcon: vi.fn(), createFromPath: vi.fn(),
}))
vi.mock('electron', () => ({
app: electron,
ipcMain: electron,
nativeImage: { createFromPath: electron.createFromPath },
}))
const { installDesktopImageDrag } = await import('../src/image-drag.ts')
afterEach(() => { vi.resetAllMocks() })
type Handler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-native-image-drag-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
electron.getPath.mockReturnValue(root)
const icon: NativeImage = Object.assign({} as NativeImage, { isEmpty: () => false, resize: () => icon })
electron.getFileIcon.mockResolvedValue(icon)
const frame = Object.assign({} as WebFrameMain, { url: 'dsh-app://app/' })
const window = Object.assign({} as BrowserWindow, { isDestroyed: () => false,
webContents: Object.assign({} as WebContents, { mainFrame: frame, startDrag: vi.fn() }) })
let current: BrowserWindow | undefined = window
const dispose = installDesktopImageDrag(() => current)
onTestFinished(dispose)
const handlers = new Map<string, Handler>(electron.handle.mock.calls.map(([channel, handler]) => [channel, handler]))
const event = Object.assign({} as IpcMainInvokeEvent, { sender: window.webContents, senderFrame: frame })
const call = (channel: string, ...args: unknown[]) => (handlers.get(channel) as Handler)(event, ...args)
return { root, window, frame, event, icon, call, dispose, detach: () => { current = undefined } }
}
it('stages original bytes before the gesture and synchronously starts an OS copy drag', async () => {
const f = await fixture()
const bytes = new Uint8Array([137, 80, 78, 71])
const handle = await f.call(DESKTOP_IPC.imageDragPrepare, bytes, 'original.png', 'image/png')
expect(f.window.webContents.startDrag).not.toHaveBeenCalled()
expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(true)
const [request] = f.window.webContents.startDrag.mock.calls[0] as [{ file: string; icon: NativeImage }]
expect(request.icon).toBe(f.icon)
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
await f.call(DESKTOP_IPC.imageDragRelease, handle)
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(false)
await f.dispose()
expect(await readdir(f.root)).toEqual([])
})
it('rejects foreign senders, subframes, revoked handles and native failures', async () => {
const f = await fixture()
expect(f.call(DESKTOP_IPC.imageDragStart, 'foreign')).toBe(false)
const handle = await f.call(DESKTOP_IPC.imageDragPrepare, new Uint8Array([1]), 'x.png', 'image/png')
f.window.webContents.startDrag.mockImplementationOnce(() => { throw new Error('OS rejected drag') })
expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(false)
await vi.waitFor(async () => { expect(await readdir(f.root)).toEqual([]) })
f.frame.url = 'https://other.test/'
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('unowned renderer')
f.frame.url = 'dsh-app://app/'
Object.assign(f.event, { senderFrame: { url: 'dsh-app://app/' } })
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request')
Object.assign(f.event, { senderFrame: f.frame, sender: {} })
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request')
f.detach()
expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request')
})
it('cancels icon preparation on shutdown without waiting for the icon service', async () => {
const f = await fixture()
const pending = Promise.withResolvers<NativeImage>()
electron.getFileIcon.mockReturnValue(pending.promise)
const prepare = f.call(DESKTOP_IPC.imageDragPrepare, new Uint8Array([1]), 'x.svg', 'image/svg+xml')
const rejected = expect(prepare).rejects.toThrow('Unowned image drag request')
await vi.waitFor(() => { expect(electron.getFileIcon).toHaveBeenCalledOnce() })
let stopped = false
const stopping = f.dispose().then(() => { stopped = true })
await rejected
await stopping
expect(stopped).toBe(true)
pending.resolve(f.icon)
await pending.promise
expect(f.window.webContents.startDrag).not.toHaveBeenCalled()
expect(await readdir(f.root)).toEqual([])
})
-33
View File
@@ -16,9 +16,6 @@ import { writeCrashReport } from '../src/crash-report.ts'
type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
const imageDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise<void>>(), install: vi.fn() }))
vi.mock('../src/image-drag.ts', () => ({ installDesktopImageDrag: () => { imageDrag.install(); return imageDrag.dispose } }))
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
// Report persistence has its own unit tests; here it resolves within microtasks so the fatal
// dialog never outlives the test that triggered it.
@@ -262,8 +259,6 @@ const harness = await vi.hoisted(async () => {
navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred()
policyBlocked = deferred()
embeddedPolicy = undefined
imageDrag.install.mockReset()
imageDrag.dispose.mockReset().mockResolvedValue(undefined)
platformDisposeDeferred = undefined
platformCloseDeferred = undefined
},
@@ -1483,33 +1478,6 @@ describe('desktop main startup', () => {
expect(harness.app.quit).toHaveBeenCalledTimes(2)
})
it('reports image cleanup failure without ending Host shutdown early', async () => {
const host = await readyForUpdate()
const failure = new Error('image cleanup failed')
imageDrag.dispose.mockRejectedValueOnce(failure)
harness.app.quit()
await host.stopping.promise
await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith(failure) })
expect(harness.app.quit).toHaveBeenCalledOnce()
host.exited.resolve()
await harness.quitCompleted.promise
expect(harness.app.quit).toHaveBeenCalledTimes(2)
})
it('waits for image cleanup before stopping the Host for installation', async () => {
const host = await readyForUpdate()
const cleanup = Promise.withResolvers<undefined>()
imageDrag.dispose.mockReturnValueOnce(cleanup.promise)
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
const preparing = harness.prepareUpdate()
await vi.waitFor(() => { expect(imageDrag.dispose).toHaveBeenCalledOnce() })
expect(host.stop).not.toHaveBeenCalled()
cleanup.resolve(undefined)
await host.stopping.promise
host.exited.resolve()
await expect(preparing).resolves.toBe(true)
})
it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => {
const host = await readyForUpdate()
const window = harness.windows[0]!
@@ -2010,7 +1978,6 @@ describe('desktop main startup', () => {
const restarted = harness.nextHostStart()
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' })
await restarted
expect(imageDrag.install).toHaveBeenCalledTimes(2)
const replacement = harness.hosts[1]!
replacement.url = 'http://127.0.0.1:3099/?token=replacement'
if (mandatory) replacement.updateTasks.mockResolvedValue(true)
-23
View File
@@ -1,5 +1,4 @@
// @vitest-environment jsdom
import type { DesktopImageDrag } from '../../../packages/client/ui-primitives/src/image-drag.ts'
import { JSDOM } from 'jsdom'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
@@ -255,25 +254,3 @@ it('forwards browser guest input only for the focused live webview lease', async
expect(listener).toHaveBeenCalledOnce()
off()
})
it('prepares original image bytes and starts native drags without renderer filesystem paths', async () => {
vi.stubGlobal('location', new URL('dsh-app://app/'))
await import('../src/preload-app.ts')
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
const bytes = new Uint8Array([137, 80, 78, 71])
electron.ipcRenderer.invoke.mockResolvedValueOnce('image-handle').mockResolvedValueOnce(true).mockResolvedValueOnce(undefined)
await expect(api.imageDrag.prepare(bytes, 'original.png', 'image/png')).resolves.toBe('image-handle')
await expect(api.imageDrag.start('image-handle')).resolves.toBe(true)
api.imageDrag.release('image-handle')
expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
[DESKTOP_IPC.imageDragPrepare, bytes, 'original.png', 'image/png'],
[DESKTOP_IPC.imageDragStart, 'image-handle'],
[DESKTOP_IPC.imageDragRelease, 'image-handle'],
])
})
// The Host and Client compiler faces share no runtime entry; this assignment checks their image protocol.
const imageProtocol = (value: import('../src/ipc.ts').DshDesktopProductApi['imageDrag']): DesktopImageDrag => value
const imageProtocolBack = (value: DesktopImageDrag): import('../src/ipc.ts').DshDesktopProductApi['imageDrag'] => value
void imageProtocol
void imageProtocolBack
+1 -22
View File
@@ -1082,7 +1082,7 @@ else process.exit(1);
width: (node as HTMLImageElement).naturalWidth,
height: (node as HTMLImageElement).naturalHeight,
draggable: (node as HTMLImageElement).draggable,
}))).toEqual({ width: 1, height: 1, draggable: true })
}))).toEqual({ width: 1, height: 1, draggable: false })
const centering = await tinyImage.evaluate((node) => {
const image = node.getBoundingClientRect()
const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
@@ -1105,14 +1105,6 @@ else process.exit(1);
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
await expect.poll(() => imageZoom.innerText()).toBe('100%')
await tinyImage.dragTo(input)
const draggedPreview = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img')
await draggedPreview.waitFor({ state: 'visible', timeout: 5000 })
const draggedBytes = await draggedPreview.evaluate(async (node: HTMLImageElement) =>
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer())))
expect(Buffer.from(draggedBytes)).toEqual(TINY_PNG)
expect(await input.innerText()).toBe('')
await page.getByRole('button', { name: 'Remove image tiny.png', exact: true }).click()
await openFile('large.svg')
await expect.poll(() => viewer.innerText()).toBe('Image')
await viewer.click()
@@ -1151,17 +1143,6 @@ else process.exit(1);
await page.getByRole('menuitem', { name: '100%', exact: true }).click()
await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(1200, 0)
expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
await imageScrollport.evaluate((node) => { node.scrollLeft = 0; node.scrollTop = 0 })
const panBox = await imageScrollport.boundingBox()
if (panBox === null) throw new Error('image viewport missing')
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
await page.mouse.move(panBox.x + 180, panBox.y + 180)
await page.mouse.down()
await page.mouse.move(panBox.x + 100, panBox.y + 100, { steps: 8 })
await expect.poll(() => imageScrollport.evaluate(node => [node.scrollLeft, node.scrollTop])).toEqual([80, 80])
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grabbing')
await page.mouse.up()
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
svgZoom = await revealDocumentZoom(page, preview)
await svgZoom.click()
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
@@ -1170,11 +1151,9 @@ else process.exit(1);
expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
sections.push([
'## Image zoom', '',
'- Contained sidebar PNG drags into the composer as an original-byte image attachment',
'- Small PNG fit width remains at intrinsic size; 200% doubles it',
'- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false',
'- Image and Blob identities remain stable while zoom changes',
'- Overflowing image drag pans both axes by 80px; releasing restores the grab cursor',
].join('\n'))
const releaseRead = Promise.withResolvers<undefined>()
-9
View File
@@ -319,15 +319,6 @@ describe('web e2e: Markdown image rendering', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
const input = page.locator('[data-composer-input]').first()
await workspaceImage.dragTo(input)
const dragged = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img')
await dragged.waitFor({ state: 'visible' })
expect(Buffer.from(await dragged.evaluate(async (node: HTMLImageElement) =>
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer()))))).toEqual(PNG)
expect(await input.innerText()).toBe('')
expect(await page.getByRole('dialog', { name: 'Image preview', exact: true }).count()).toBe(0)
await page.getByRole('button', { name: 'Remove image valid.png', exact: true }).click()
await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) })
await page.mouse.move(0, 0)
await link.hover()
@@ -1,16 +0,0 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/image-drag-labels/guide.md
/:
en: 0f508094085d53cd
zh: c4ed2bc22a35ca88
/image-drag-labels:
en: 0ff7158e0569f72f
zh: c7e157db1d84abc9
/image-drag-labels/change:
en: 82caa20a5d75c5fc
zh: 5f7b01a74c055336
/image-drag-labels/migration:
en: c355c2ce5478973a
zh: 9e29643003f1d367
@@ -1,19 +0,0 @@
---
kind: upgrade-guide
description: "Custom image UI consumers must supply a localized drag failure label."
---
# Image drag labels
English | [中文](guide.zh.md)
## Change
`MessageImageLabels` from `@deepseek-ai/dsh-client-ui-conversation/client` and `MarkdownDelegate.fileImages.labels` from `@deepseek-ai/dsh-client-ui-primitives` now require `dragFailed: string`. Custom plugins that construct either labels object must add this field to compile. The message reports an unsuccessful original-image drag and tells the user to retry.
## Migration
1. Add `dragFailed` to custom `MessageImageLabels` and `MarkdownDelegateProvider.fileImages.labels` objects.
2. Resolve the text through the plugin's locale dictionary, for example “Drag failed. Try again.” or “拖放失败,请重试”.
3. If a custom application shell renders managed image components or `MarkdownDelegateProvider.fileImages`, wrap that tree in `ImageDragProvider` from `@deepseek-ai/dsh-client-ui-primitives`. The standard application shell already supplies it; missing providers fail during rendering.
4. Rebuild the plugin and confirm that an unsuccessful image drag displays the localized message. Existing desktop, chat, and conversation consumers already supply the field.
@@ -1,19 +0,0 @@
---
kind: upgrade-guide
description: "自定义图片 UI 消费方必须提供本地化拖放失败文案。"
---
# 图片拖放文案
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-client-ui-conversation/client` 的 `MessageImageLabels` 和 `@deepseek-ai/dsh-client-ui-primitives` 的 `MarkdownDelegate.fileImages.labels` 现在要求 `dragFailed: string`。自定义插件若构造这两类 labels 对象,需要补充该字段才能编译。该文案报告原图拖放失败,并提示用户重试。
## 迁移
1. 为自定义 `MessageImageLabels` 和 `MarkdownDelegateProvider.fileImages.labels` 对象补充 `dragFailed`。
2. 通过插件的 locale 字典解析文案,例如“拖放失败,请重试”或“Drag failed. Try again.”。
3. 如果自定义应用外壳渲染受管理的图片组件或 `MarkdownDelegateProvider.fileImages`,用 `@deepseek-ai/dsh-client-ui-primitives` 的 `ImageDragProvider` 包裹组件树。标准应用外壳已提供它;缺少 Provider 会在渲染时报错。
4. 重新构建插件,确认图片拖放失败时显示本地化提示。现有桌面端、Chat 和 Conversation 消费方已提供该字段。
@@ -21,11 +21,11 @@
en: a77c8ec48e219aec
zh: 756d4fe881403006
/deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox:
en: 38717dbf90a2520d
zh: b81915d2b06e18f8
en: 8b3d7729152d8060
zh: 815f932ccc9653f5
/deepseek-ai-dsh-client-ui-attachment/use-this-package/drop-overlay:
en: bf1a8ebd1a550396
zh: a4d736010ab6602e
en: 836a62c8f73366ac
zh: 35da7836eaf68a67
/deepseek-ai-dsh-client-ui-attachment/understand-the-implementation:
en: 07204b7d538b7434
zh: 705cc6b0c9387cce
+1 -5
View File
@@ -37,13 +37,9 @@ In Chat, one user message presents files and images in a right-aligned wrapping
Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup.
Loaded conversation images can be dragged into the composer as image attachments or to another application as original files. Dragging does not open the lightbox. The shared image-transfer owner retains Web drag bytes until the gesture ends; Desktop uses the system file drag. A failed preparation or drag keeps the image visible and reports a localized retry message.
Internal Web image drags do not show the full-page overlay. A foreign or expired image token without actual files is ignored. Browser transfer limitations are documented by [ui-primitives](../ui-primitives/README.md#known-limitations-and-deferred-work).
### Drop overlay
While an external file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them.
While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them.
-----
+1 -5
View File
@@ -37,13 +37,9 @@ Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行
Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。
已加载的会话图片可拖入输入框作为图片附件,也可将原文件拖到其他应用。拖动不会打开灯箱。共享图片传递组件将 Web 拖动的原图字节保留到手势结束;Desktop 使用系统文件拖放。准备或拖放失败时保留图片,并显示本地化的重试提示。
应用内 Web 图片拖放不显示全屏遮罩。没有实际文件的外来或过期图片令牌会被忽略。浏览器传递限制见 [ui-primitives](../ui-primitives/README.zh.md#known-limitations-and-deferred-work)。
### 拖放遮罩
外部文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
-----
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './MessageImage.module.css'
@@ -143,8 +143,7 @@ export function MessageImage({ image, load, variant, labels }: {
{loadingThumbnail ? <IconLoadingOutlineRegular className={css.spinner} /> : labels.loading}
</span>
)
: <DraggableImage src={src} alt={label} name={preview?.name ?? attachment?.name ?? 'image'} failureText={labels.dragFailed}
style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
</>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { IconCloseFillRegular, ImageDrop } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail } from '../AttachmentRail.tsx'
import type { AttachmentRailItem } from '../AttachmentRail.tsx'
import { DropOverlay } from '../DropOverlay.tsx'
@@ -29,8 +29,8 @@ export function ComposerAttachments({
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
}, [attachments, preview])
const bindDrop = useCallback((readImageDrag: (transfer: DataTransfer) => File | undefined) => {
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive, readImageDrag)
useEffect(() => {
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive)
}, [canAcceptDrop, onAddFiles])
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
@@ -40,7 +40,6 @@ export function ComposerAttachments({
return (
<>
<ImageDrop bind={bindDrop} />
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
@@ -1,5 +1,4 @@
/** Document drag-and-drop listeners owned by one mounted attachment view. */
import { IMAGE_DRAG_TOKEN_TYPE } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
/**
@@ -26,7 +25,6 @@ function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]):
* @param onAddFiles - attachment intake callback.
* @param dragDepth - the view's retained nested-drag counter.
* @param setDragActive - publish whether a file drag is active.
* @param readImageDrag - resolve an original image retained by this application during a browser drag.
* @returns cleanup for exactly these listeners.
*/
export function installDocumentDropEvents(
@@ -34,11 +32,10 @@ export function installDocumentDropEvents(
onAddFiles: ComposerAttachmentsProps['onAddFiles'],
dragDepth: { current: number },
setDragActive: (active: boolean) => void,
readImageDrag: (transfer: DataTransfer) => File | undefined,
): () => void {
const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
const dataTransfer = event.dataTransfer
if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE))) return null
if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
return dataTransfer
}
const reset = (): void => {
@@ -46,11 +43,10 @@ export function installDocumentDropEvents(
setDragActive(false)
}
const onDragEnter = (event: globalThis.DragEvent): void => {
const dataTransfer = fileTransfer(event)
if (dataTransfer === null) return
if (fileTransfer(event) === null) return
event.preventDefault()
dragDepth.current += 1
if (!dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE)) setDragActive(true)
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
const dataTransfer = fileTransfer(event)
@@ -72,9 +68,8 @@ export function installDocumentDropEvents(
event.preventDefault()
reset()
if (canAcceptDrop) {
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))
const files = [...dataTransfer.files]
onAddFiles(files, droppedDirectories(dataTransfer, files))
}
}
document.addEventListener('dragenter', onDragEnter)
@@ -21,7 +21,6 @@ export function lightboxLabels(t: TranslateNS<'conversation'>): ImageLightboxLab
*/
export function messageImageLabels(t: TranslateNS<'conversation'>): MessageImageLabels {
return {
dragFailed: t('image.dragFailed'),
image: t('image.label'),
open: t('image.openOriginal'),
openNamed: label => t('image.openOriginalLabel', { label }),
@@ -1,9 +1,7 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render as renderBare } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import { cleanup, fireEvent, render } from '@testing-library/react'
import type {
ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -308,27 +306,3 @@ describe('ComposerAttachments file drafts', () => {
expect(view.getByTitle('.env').textContent).toContain('ENV 3B')
})
})
it('accepts only the current app image token without showing an external-file overlay', async () => {
const { DraggableImage } = await import('@deepseek-ai/dsh-client-ui-primitives')
const file = new File([Uint8Array.of(1, 2)], 'original.png', { type: 'image/png' })
function Source() {
return <DraggableImage alt="drag source" src="blob:original" name={file.name} failureText="拖放失败,请重试" original={file} />
}
const onAddFiles = vi.fn()
const view = render(<ImageDragProvider><Source /><ComposerAttachments {...props({ onAddFiles })} /></ImageDragProvider>)
const data = new Map<string, string>()
const transfer = {
types: ['application/x-dsh-image'], items: Object.assign([], { clear: vi.fn() }), files: [],
getData: (key: string) => data.get(key) ?? '', setData: (key: string, value: string) => data.set(key, value),
}
fireEvent.pointerEnter(view.getByAltText('drag source'))
fireEvent.dragStart(view.getByAltText('drag source'), { dataTransfer: transfer })
fireEvent.dragEnter(document.body, { dataTransfer: transfer })
expect(view.queryByText('文件或图片拖动到此处即可添加')).toBeNull()
fireEvent.drop(document.body, { dataTransfer: transfer })
expect(onAddFiles).toHaveBeenCalledExactlyOnceWith([file], new Set())
data.set('application/x-dsh-image', 'foreign')
fireEvent.drop(document.body, { dataTransfer: transfer })
expect(onAddFiles).toHaveBeenCalledOnce()
})
@@ -1,7 +1,5 @@
// @vitest-environment jsdom
/** Independent domain image positions with one shared official gallery Factory. */
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
import { render } from '@testing-library/react'
import { act, cleanup, within } from '@testing-library/react'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
@@ -22,7 +20,6 @@ const SLOTS = [
async function bench() {
const runtime = await SlotTestRuntime.create()
vi.spyOn(runtime, 'renderRoot').mockImplementation(() => render(<ImageDragProvider>{runtime.slots.renderSlot('root', {})}</ImageDragProvider>))
onTestFinished(() => runtime.dispose())
const locale = new LocaleRuntime(runtime.ctx)
locale.register('conversation', { en, zh })
@@ -3,9 +3,7 @@
import { useState } from 'react'
import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render as renderBare, waitFor } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
@@ -20,7 +18,6 @@ afterEach(cleanup)
const labels: MessageImageLabels = {
image: '图片',
dragFailed: '拖放失败,请重试',
open: '查看原图',
openNamed: label => `${label},点击查看原图`,
loading: '图片加载中…',
@@ -273,7 +270,6 @@ describe('ImageGallery', () => {
it('uses complete Factory labels unchanged and restores defaults without remounting the image', async () => {
const runtime = await SlotTestRuntime.create()
vi.spyOn(runtime, 'renderRoot').mockImplementation(() => render(runtime.slots.renderSlot('root', {})))
onTestFinished(() => runtime.dispose())
const locale = new LocaleRuntime(runtime.ctx)
locale.register('conversation', { en, zh })
@@ -312,7 +308,6 @@ describe('ImageGallery', () => {
it('renders the conversation slot entry with translated labels', async () => {
const runtime = await SlotTestRuntime.create()
vi.spyOn(runtime, 'renderRoot').mockImplementation(() => render(runtime.slots.renderSlot('root', {})))
onTestFinished(() => runtime.dispose())
const locale = new LocaleRuntime(runtime.ctx)
locale.register('conversation', { en, zh })
@@ -86,7 +86,7 @@ export function ChatView({
const fileImages = useMemo(() => ({
resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)),
labels: {
dragFailed: t('image.dragFailed'), open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
dialog: t('image.dialog'), close: t('image.close'),
},
}), [cwd, t])
@@ -67,7 +67,6 @@ export const zh = {
'view.chat': '对话',
'image.open': '查看大图',
'image.loading': '加载图片…',
'image.dragFailed': '拖放失败,请重试',
'image.failed': '图片无法预览',
'image.dialog': '图片预览',
'image.close': '关闭图片预览',
@@ -270,7 +269,6 @@ export const en = {
'view.chat': 'Chat',
'image.open': 'View full image',
'image.loading': 'Loading image…',
'image.dragFailed': 'Drag failed. Try again.',
'image.failed': 'Image preview unavailable',
'image.dialog': 'Image preview',
'image.close': 'Close image preview',
@@ -131,8 +131,6 @@ export interface MessageImageLabels {
loading: string
/** Retry-control label shown when the load fails. */
loadFailed: string
/** Failed original-image drag, with retry guidance. */
dragFailed: string
/** Lightbox strings forwarded to the opened preview. */
lightbox: ImageLightboxLabels
}
@@ -46,7 +46,6 @@ export const zh = {
'image.label': '图片',
'image.loadFailed': '图片加载失败,点击重试',
'image.loading': '图片加载中…',
'image.dragFailed': '拖放失败,请重试',
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
@@ -427,7 +426,6 @@ export const en = {
'image.label': 'Image',
'image.loadFailed': 'Image failed to load; click to retry',
'image.loading': 'Loading image…',
'image.dragFailed': 'Drag failed. Try again.',
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
+1 -2
View File
@@ -56,8 +56,7 @@
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*"
"@deepseek-ai/dsh-client-shortcuts": "workspace:*"
},
"files": [
"locale/*.json",
@@ -20,7 +20,6 @@ import type {
PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
} from '@deepseek-ai/dsh-client-ui-slots'
import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT } from './columns.ts'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
import { DocumentTitle } from './DocumentTitle.tsx'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
@@ -257,54 +256,52 @@ export function AppFrame({
const leadingMounted = darwin && sidebarCollapsed
return (
<ImageDragProvider>
<div
ref={frameRef}
className={css.frame}
style={{
...(document.documentElement.hasAttribute('data-windows-titlebar')
? { '--dsh-windows-sidebar-width': `${cols.sidebar}px` } : {}),
gridTemplateColumns:
`${cols.sidebar}px minmax(${cols.rightbar === 0 ? 0 : CENTER_MIN}px, 1fr) minmax(0px, ${rightbarMax}px)`,
}}
data-sidebar-collapsed={sidebarCollapsed || undefined}
data-rightbar-collapsed={cols.rightbar === 0 || undefined}
data-rightbar-fullscreen={layoutInfo.rightbarFullscreen || undefined}
data-rightbar-instant={layoutInfo.rightbarInstant || undefined}
data-dragging={dragging || undefined}
data-animating={animating > 0 || undefined}
>
<DocumentTitle
productTitle={productTitle}
useSessions={useSessions}
usePanelInfo={usePanelInfo}
/>
<div className={css.sidebarCol}>
{sidebar}
</div>
<>
<CenterColumn>{main}</CenterColumn>
<RightbarColumn>
{renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
</RightbarColumn>
</>
<div className={css.bottomRow} data-shell-bottom>
{renderSlot('shell.bottom', {})}
</div>
<div className={css.overlayLayer} data-shell-overlay>
{overlays}
</div>
{leadingMounted && (
<div className={css.leadingSeat} data-shell-leading>
{leading}
</div>
)}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (
<DragHandle side="rightbar" left={viewport - normal.rightbar} onStart={onRightbarStart} onDrag={onRightbarDrag} onEnd={onDragEnd} />
)}
<div
ref={frameRef}
className={css.frame}
style={{
...(document.documentElement.hasAttribute('data-windows-titlebar')
? { '--dsh-windows-sidebar-width': `${cols.sidebar}px` } : {}),
gridTemplateColumns:
`${cols.sidebar}px minmax(${cols.rightbar === 0 ? 0 : CENTER_MIN}px, 1fr) minmax(0px, ${rightbarMax}px)`,
}}
data-sidebar-collapsed={sidebarCollapsed || undefined}
data-rightbar-collapsed={cols.rightbar === 0 || undefined}
data-rightbar-fullscreen={layoutInfo.rightbarFullscreen || undefined}
data-rightbar-instant={layoutInfo.rightbarInstant || undefined}
data-dragging={dragging || undefined}
data-animating={animating > 0 || undefined}
>
<DocumentTitle
productTitle={productTitle}
useSessions={useSessions}
usePanelInfo={usePanelInfo}
/>
<div className={css.sidebarCol}>
{sidebar}
</div>
</ImageDragProvider>
<>
<CenterColumn>{main}</CenterColumn>
<RightbarColumn>
{renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
</RightbarColumn>
</>
<div className={css.bottomRow} data-shell-bottom>
{renderSlot('shell.bottom', {})}
</div>
<div className={css.overlayLayer} data-shell-overlay>
{overlays}
</div>
{leadingMounted && (
<div className={css.leadingSeat} data-shell-leading>
{leading}
</div>
)}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (
<DragHandle side="rightbar" left={viewport - normal.rightbar} onStart={onRightbarStart} onDrag={onRightbarDrag} onEnd={onDragEnd} />
)}
</div>
)
}
@@ -18,14 +18,14 @@
en: 7bae72c97d0449f5
zh: 4589ecc2b216edc5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: bfcb301478943364
zh: 5e88e5f3592da502
en: 6f5457b84fc9db09
zh: 85993f1c44bfee2c
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 28ac8f0b1edbcf81
zh: 5c4c324bf84a282f
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: bf371fb93897a3db
zh: a63bed4089e2aac9
en: 12fe9d9b1410edef
zh: f2dfdc01170d7cce
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: 4aef65dea11de083
zh: abc22cd7c65cdf38
@@ -54,8 +54,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work:
en: 843daa09327f1aad
zh: 434560a9da1e6ddf
en: 22fc14aa3a224552
zh: aa6309e84ad8c2a3
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
-4
View File
@@ -60,7 +60,6 @@ Check this table before writing a control in a feature package. A plugin cannot
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
| `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. |
| `DraggableImage`, `ImageDrop`, `ImageDragProvider` | Original-image gestures, drop binding, and application-owned transfer feedback. |
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. |
@@ -112,8 +111,6 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal.
`ImageDragProvider` owns transient file transfers and failure feedback for one application. `DraggableImage` and `ImageDrop` require this ancestor and fail during rendering if it is absent; feature components pass image props or a listener-installation callback without reading React context. The provider resolves the versioned Desktop bridge once. Original bytes are prepared on hover or primary pointer down, never on mount, and obsolete preparations are cancelled. Managed Web transfers carry an application-local token plus Chromium’s `DownloadURL`; only the owning application resolves the token to the original `File`. These managed transfers do not place internal URLs or alt text in `text/plain`.
### Localizing copy
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
@@ -202,7 +199,6 @@ None; this package neither assembles nor sends a provider request.
These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
- **Browser image transfers** — external targets depend on Chromium `DownloadURL` and the receiving application; native file delivery is provided by Desktop. Web tokens do not transfer between windows or tabs. Unreadable cross-origin images retain browser-native dragging, as do linked Markdown images and Markdown without `fileImages`; those native paths may include URLs.
- **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
- **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
@@ -60,7 +60,6 @@ kind: "package-library"
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
| `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 |
| `DraggableImage`、`ImageDrop`、`ImageDragProvider` | 原图拖放、接收监听绑定与应用持有的传递反馈。 |
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 |
@@ -112,8 +111,6 @@ kind: "package-library"
`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。
`ImageDragProvider` 持有单个应用的临时文件传递状态和失败提示。`DraggableImage` 与 `ImageDrop` 要求该祖先组件,缺失时在渲染阶段报错;业务组件只传图片属性或监听器安装回调,不读取 React context。Provider 只解析一次带版本的 Desktop 桥接。原图在悬停或主指针按下时准备,不在挂载时准备;过期准备会取消。受管理的 Web 拖放携带应用内令牌及 Chromium 的 `DownloadURL`,只有持有令牌的应用能将其解析为原始 `File`。这些受管理的拖放不会将内部 URL 或 alt 文本放入 `text/plain`。
### 本地化文案
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
@@ -202,7 +199,6 @@ kind: "package-library"
这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。
- **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。
- **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
@@ -1,24 +0,0 @@
/** Original-image drag gestures owned by a presentation primitive. */
import { forwardRef, type ImgHTMLAttributes } from 'react'
import { useImageDrag } from './image-drag.ts'
/** Image presentation plus the original bytes and localized drag failure. */
export interface DraggableImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'draggable' | 'onDragStart' | 'onDragEnd' | 'onPointerEnter' | 'onPointerDown' | 'onClickCapture'> {
readonly src: string
readonly name: string
readonly failureText: string
readonly original?: File | undefined
}
/**
* Render an image with original-file dragging; requires an ancestor ImageDragProvider.
* @param props - image attributes, original filename and localized failure text.
* @param ref - image element for measurement and pointer-mode bindings.
* @returns a draggable image that suppresses only the mouse click following its drag.
*/
export const DraggableImage = forwardRef<HTMLImageElement, DraggableImageProps>(function DraggableImage(
{ src, name, failureText, original, ...props }, ref,
) {
const drag = useImageDrag(src, name, failureText, original)
return <img {...props} {...drag} src={src} ref={ref} draggable />
})
@@ -1,77 +0,0 @@
/** Application-lifetime feedback for image drag operations. */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Toast } from './Toast.tsx'
import type { DesktopImageDrag } from './image-drag.ts'
/** 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
report: (text: string) => void
begin: (file: File) => string
end: () => void
read: (transfer: DataTransfer) => File | undefined
}
const ImageDragFeedback = createContext<ImageTransfer | undefined>(undefined)
/**
* Keep drag failures visible when their source image or conversation unmounts.
* @param props - application children.
* @returns children and the current localized failure toast.
*/
export function ImageDragProvider({ children }: { readonly children: ReactNode }): ReactNode {
const [desktop] = useState(() => {
const bridge = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; imageDrag?: DesktopImageDrag } }).dshDesktop
return bridge?.protocolVersion === 1 ? bridge.imageDrag : undefined
})
const active = useRef<{ token: string; file: File }>()
const sequence = useRef(0)
const [failure, setFailure] = useState<{ text: string; sequence: number }>()
const report = useCallback((text: string) => { setFailure({ text, sequence: ++sequence.current }) }, [])
const dismiss = useCallback(() => { setFailure(undefined) }, [])
const begin = useCallback((file: File): string => {
const token = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
active.current = { token, file }
return token
}, [])
const end = useCallback(() => { active.current = undefined }, [])
useEffect(() => {
window.addEventListener('dragend', end)
return () => { window.removeEventListener('dragend', end) }
}, [end])
const read = useCallback((transfer: DataTransfer): File | undefined => {
const current = active.current
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 <ImageDragFeedback.Provider value={value}>
{children}
{failure !== undefined && <Toast key={failure.sequence} text={failure.text} onDone={dismiss} />}
</ImageDragFeedback.Provider>
}
/** Requires ImageDragProvider; @returns the mounted application's image transfer owner. */
export function useImageDragTransfer(): ImageTransfer {
const transfer = useContext(ImageDragFeedback)
if (transfer === undefined) throw new Error('Image dragging requires ImageDragProvider')
return transfer
}
/** Requires ImageDragProvider; @returns a reader for this application's active original-image drag. */
export function useImageDrop(): (transfer: DataTransfer) => File | undefined {
return useImageDragTransfer().read
}
/**
* Bind a drop target to its application's original-image reader without exposing React context.
* Requires an ancestor ImageDragProvider.
* @param props - listener installer; its cleanup runs on replacement or unmount.
* @returns no visible content.
*/
export function ImageDrop({ bind }: { readonly bind: (read: (transfer: DataTransfer) => File | undefined) => () => void }): ReactNode {
const read = useImageDrop()
useEffect(() => bind(read), [bind, read])
return null
}
@@ -1,124 +0,0 @@
/** 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_TOKEN_TYPE, useImageDragTransfer } from './ImageDragProvider.tsx'
/** Application-owned native image transfer; prepared handles never expose host paths. */
export interface DesktopImageDrag {
/** @returns an opaque handle for the original file, retained until release or application exit. */
prepare: (bytes: Uint8Array, name: string, mediaType: string) => Promise<string>
/** Start a system copy drag from an already prepared handle. */
start: (handle: string) => Promise<boolean>
/** Release an unused handle; files already handed to the OS survive until application exit. */
release: (handle: string) => void
}
interface PreparedImage {
readonly file: File
readonly handle?: string
readonly release?: () => void
}
interface ImageDragHandlers {
onPointerEnter: () => void
onPointerDown: (event: PointerEvent<HTMLImageElement>) => void
onDragStart: (event: DragEvent<HTMLImageElement>) => void
onDragEnd: () => void
onClickCapture: (event: MouseEvent) => void
}
/**
* Prepare original bytes on hover and supply them synchronously when dragging begins.
* Requires an ancestor ImageDragProvider; it owns transfer bytes and feedback across source unmounts.
* @param src - authorized image URL; changing it retires pending preparation.
* @param name - suggested original filename, without a directory.
* @param failureText - localized failure message, including a retry action.
* @param original - already loaded original file; omitted sources are read from their image URL.
* @returns drag handlers; cancellation never starts a late drag.
*/
export function useImageDrag(src: string, name: string, failureText: string, original?: File): ImageDragHandlers {
const state = useRef<{
source: string
prepared?: PreparedImage | undefined
pending?: Promise<void> | undefined
abort: AbortController
}>()
const dragged = useRef(false)
const transfer = useImageDragTransfer()
const reportFailure = transfer.report
const desktop = transfer.desktop
const prepare = useCallback((): void => {
const current = state.current
if (src === '' || current === undefined || current.source !== src || current.pending !== undefined || current.prepared !== undefined) return
current.pending = (async () => {
let file = original
if (file === undefined) {
const response = await fetch(src, { signal: current.abort.signal, referrerPolicy: 'no-referrer' })
if (!response.ok) throw new Error('Image read failed')
const blob = await response.blob()
if (!blob.type.startsWith('image/')) throw new Error('Image media type unavailable')
file = new File([blob], name, { type: blob.type })
}
if (current.abort.signal.aborted) return
if (desktop === undefined) {
current.prepared = { file }
return
}
const handle = await desktop.prepare(new Uint8Array(await file.arrayBuffer()), file.name, file.type)
if (state.current !== current) {
desktop.release(handle)
return
}
current.prepared = { file, handle, release: () => { desktop.release(handle) } }
})().catch((_error: unknown) => {
// Preparation is speculative; a failed user gesture reports through the owning image.
}).finally(() => { current.pending = undefined })
}, [src, name, original, desktop])
useEffect(() => {
const current: NonNullable<typeof state.current> = { source: src, abort: new AbortController() }
state.current = current
return () => {
current.abort.abort()
current.prepared?.release?.()
state.current = undefined
}
}, [src, name, original, desktop, prepare])
const onDragStart = useCallback((event: DragEvent<HTMLImageElement>): void => {
if (event.defaultPrevented || src === '') return
dragged.current = true
const prepared = state.current?.source === src ? state.current.prepared : undefined
if (prepared === undefined) {
const url = new URL(src, location.href)
if (original === undefined && (url.protocol === 'https:' || url.protocol === 'http:') && url.origin !== location.origin) return
event.preventDefault()
prepare()
reportFailure(failureText)
return
}
event.dataTransfer.items.clear()
event.dataTransfer.effectAllowed = 'copy'
if (desktop !== undefined && prepared.handle !== undefined) {
event.preventDefault()
const failed = (): void => {
if (state.current?.prepared === prepared) state.current.prepared = undefined
reportFailure(failureText)
}
void desktop.start(prepared.handle).then((started) => { if (!started) failed() }, failed)
return
}
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<HTMLImageElement>): void => {
dragged.current = false
if (event.button === 0) prepare()
}, [prepare])
const onClickCapture = useCallback((event: MouseEvent): void => {
if (event.detail === 0) { dragged.current = false; return }
if (!dragged.current) return
event.preventDefault()
event.stopPropagation()
dragged.current = false
}, [])
return { onPointerEnter: prepare, onPointerDown, onDragStart, onDragEnd: transfer.end, onClickCapture }
}
@@ -67,10 +67,6 @@ 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_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'
export { SettingsForm } from './settings-form/SettingsForm.tsx'
export type { SettingsFormLabels, SettingsFormProps } from './settings-form/SettingsForm.tsx'
export { SettingsSecretField, SettingsValueField } from './settings-form/fields.tsx'
@@ -14,7 +14,7 @@ export interface MarkdownDelegate {
/** Image previews for decoded local paths in this owner's workspace. */
readonly fileImages?: {
resolve: (path: string) => string | undefined
labels: ImageLightboxLabels & { open: string; loading: string; failed: string; dragFailed: string }
labels: ImageLightboxLabels & { open: string; loading: string; failed: string }
} | undefined
/** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */
readonly openExternalLink?: MarkdownExternalLinkHandler | undefined
@@ -29,7 +29,6 @@ import { renderTexToReact } from './katex.tsx'
import { LinkIconMedium, classifyLinkPath } from '../LinkIcon.tsx'
import { useMarkdownDelegate } from './MarkdownDelegate.tsx'
import { HoverCard } from '../HoverCard.tsx'
import { DraggableImage } from '../DraggableImage.tsx'
import { ImageLightbox } from '../ImageLightbox.tsx'
import { ImagePreview } from '../ImagePreview.tsx'
import type { PositionedBlock } from './incremental.ts'
@@ -652,11 +651,9 @@ function LoadedMarkdownImage({ src, alt, destination, preview }: {
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
if (failed) return <span className={css.imageAlt}>{preview === undefined ? '' : `${preview.labels.failed} · `}{alt || destination}</span>
const imageProps = { className: css.image, src, alt, onError: () => { setFailed(true) },
loading: 'lazy' as const, decoding: 'async' as const, referrerPolicy: 'no-referrer' as const }
if (preview === undefined) return <img {...imageProps} />
const img = <DraggableImage {...imageProps} name={destination.split('/').at(-1)?.split(/[?#]/u)[0] || 'image'}
failureText={preview.labels.dragFailed} />
const img = <img className={css.image} src={src} alt={alt}
onError={() => { setFailed(true) }} loading="lazy" decoding="async" referrerPolicy="no-referrer" />
if (preview === undefined) return img
return <>
<button type="button" className={css.imageButton} title={preview.labels.open}
aria-label={alt ? `${preview.labels.open}: ${alt}` : preview.labels.open}
@@ -1,246 +0,0 @@
// @vitest-environment jsdom
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_TOKEN_TYPE, ImageDragProvider, useImageDrop } from '../src/ImageDragProvider.tsx'
import { DraggableImage } from '../src/DraggableImage.tsx'
import { useImageDrag, type DesktopImageDrag } from '../src/image-drag.ts'
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
function Image({ src, file, onOpen }: { src: string; file?: File; onOpen?: () => void }) {
return <button onClick={onOpen}><DraggableImage alt="original" src={src} name="original.png" failureText="Image drag failed. Try again." original={file} /></button>
}
function transfer() {
const add = vi.fn()
const values = new Map<string, string>()
return { items: { add, clear: vi.fn() }, getData: (type: string) => values.get(type) ?? '',
setData: vi.fn((type: string, value: string) => { values.set(type, value) }), effectAllowed: 'none' }
}
function native() {
const bridge: DesktopImageDrag = { prepare: vi.fn(async () => 'handle'), start: vi.fn(async () => true), release: vi.fn() }
vi.stubGlobal('dshDesktop', { protocolVersion: 1, imageDrag: bridge })
return bridge
}
function original() {
return new NodeFile([new Uint8Array([137, 80, 78, 71])], 'original.png', { type: 'image/png' }) as File
}
it('hands original bytes to the browser without alt text or an internal URI payload', async () => {
const file = original()
const open = vi.fn()
render(<ImageDragProvider><Image src="blob:original" file={file} onOpen={open} /></ImageDragProvider>)
const dataTransfer = transfer()
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
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_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 })
expect(open).not.toHaveBeenCalled()
fireEvent.pointerDown(screen.getByRole('img'), { button: 0 })
fireEvent.click(screen.getByRole('img'), { detail: 1 })
expect(open).toHaveBeenCalledOnce()
})
it('prepares before the gesture, starts native file dragging and releases its handle on unmount', async () => {
const bridge = native()
const file = original()
const view = render(<ImageDragProvider><Image src="blob:original" file={file} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
expect(bridge.prepare).toHaveBeenCalledExactlyOnceWith(new Uint8Array([137, 80, 78, 71]), 'original.png', 'image/png')
const dataTransfer = transfer()
await act(async () => { expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(false) })
expect(bridge.start).toHaveBeenCalledExactlyOnceWith('handle')
expect(dataTransfer.items.add).not.toHaveBeenCalled()
view.unmount()
expect(bridge.release).toHaveBeenCalledExactlyOnceWith('handle')
})
it('cancels an unready gesture and releases late preparation without starting a delayed drag', async () => {
const bridge = native()
const pending = Promise.withResolvers<string>()
vi.mocked(bridge.prepare).mockReturnValue(pending.promise)
const view = render(<ImageDragProvider><Image src="blob:first" file={original()} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
view.rerender(<ImageDragProvider><span>Image closed</span></ImageDragProvider>)
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
await act(async () => { pending.resolve('late'); await pending.promise })
expect(bridge.release).toHaveBeenCalledExactlyOnceWith('late')
expect(bridge.start).not.toHaveBeenCalled()
})
it('reports native failure even after its source image closes', async () => {
const bridge = native()
const pending = Promise.withResolvers<boolean>()
vi.mocked(bridge.start).mockReturnValue(pending.promise)
const view = render(<ImageDragProvider><Image src="blob:original" file={original()} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
view.rerender(<ImageDragProvider><span>Image closed</span></ImageDragProvider>)
await act(async () => { pending.resolve(false); await pending.promise })
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
})
it('never exports a failed read or starts a native drag after fetch cancellation', async () => {
const bridge = native()
const pending = Promise.withResolvers<Response>()
const fetchImage = vi.fn<typeof fetch>().mockReturnValue(pending.promise)
vi.stubGlobal('fetch', fetchImage)
const view = render(<ImageDragProvider><Image src="https://example.test/image.png" /></ImageDragProvider>)
fireEvent.pointerEnter(screen.getByRole('img'))
const request = fetchImage.mock.calls[0]?.[1]
view.rerender(<ImageDragProvider><span>Closed</span></ImageDragProvider>)
expect(request?.signal?.aborted).toBe(true)
await act(async () => { pending.resolve(new Response('unavailable', { status: 404 })); await pending.promise })
expect(bridge.prepare).not.toHaveBeenCalled()
expect(bridge.start).not.toHaveBeenCalled()
})
function DropTarget({ receive }: { receive: (file: File | undefined) => void }) {
const read = useImageDrop()
return <div data-testid="drop" onDrop={(event) => { event.preventDefault(); receive(read(event.dataTransfer)) }} />
}
it('resolves only the active application drag token and clears it after dragging ends', async () => {
const file = original()
const receive = vi.fn()
render(<ImageDragProvider><Image src="blob:original" file={file} /><DropTarget receive={receive} /></ImageDragProvider>)
const dataTransfer = transfer()
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer })
expect(receive).toHaveBeenLastCalledWith(file)
fireEvent.dragEnd(screen.getByRole('img'), { dataTransfer })
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer })
expect(receive).toHaveBeenLastCalledWith(undefined)
})
it('does not prepare on mount, ignores secondary buttons, and keeps keyboard activation after dragging', async () => {
const bridge = native()
const open = vi.fn()
render(<ImageDragProvider><Image src="blob:original" file={original()} onOpen={open} /></ImageDragProvider>)
expect(bridge.prepare).not.toHaveBeenCalled()
fireEvent.pointerDown(screen.getByRole('img'), { button: 2 })
expect(bridge.prepare).not.toHaveBeenCalled()
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
fireEvent.dragEnd(screen.getByRole('img'), { dataTransfer: transfer() })
fireEvent.click(screen.getByRole('button'), { detail: 0 })
expect(open).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('img'), { detail: 0 })
expect(open).toHaveBeenCalledTimes(2)
})
it('reports rejected native starts and accepts later preparation attempts', async () => {
const bridge = native()
vi.mocked(bridge.prepare).mockRejectedValueOnce(new Error('read failed'))
vi.mocked(bridge.start).mockRejectedValueOnce(new Error('drag failed'))
render(<ImageDragProvider><Image src="blob:original" file={original()} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
await act(async () => { fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() }) })
expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.')
})
it.each(['image/png', 'text/plain'])('cancels late fetched %s responses after unmount', async (type) => {
const bridge = native()
const pending = Promise.withResolvers<Response>()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockReturnValue(pending.promise))
const view = render(<ImageDragProvider><Image src="https://example.test/image.png" /></ImageDragProvider>)
fireEvent.pointerEnter(screen.getByRole('img'))
view.unmount()
await act(async () => { pending.resolve(new Response('bytes', { headers: { 'Content-Type': type } })); await pending.promise })
expect(bridge.prepare).not.toHaveBeenCalled()
})
it('fetches a readable source once and rejects a foreign drag token', async () => {
const fetchImage = vi.fn<typeof fetch>().mockResolvedValue(new Response('bytes', { headers: { 'Content-Type': 'image/png' } }))
vi.stubGlobal('fetch', fetchImage)
const receive = vi.fn()
render(<ImageDragProvider><Image src="/image.png" /><DropTarget receive={receive} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')); fireEvent.pointerEnter(screen.getByRole('img')) })
fireEvent.pointerEnter(screen.getByRole('img'))
expect(fetchImage).toHaveBeenCalledOnce()
const dataTransfer = transfer()
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })
dataTransfer.setData(IMAGE_DRAG_TOKEN_TYPE, 'foreign')
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer })
expect(receive).toHaveBeenCalledWith(undefined)
})
it.each(['https://other.test/image.png', 'http://other.test/image.png'])('leaves unreadable external %s drags native', async (src) => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('CORS')))
render(<ImageDragProvider><Image src={src} /></ImageDragProvider>)
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
const dataTransfer = transfer()
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(true)
expect(dataTransfer.items.clear).not.toHaveBeenCalled()
expect(screen.queryByRole('alert')).toBeNull()
})
it('keeps disabled and already-cancelled gestures inert', () => {
const bridge = native()
const view = render(<ImageDragProvider><Image src="" /></ImageDragProvider>)
fireEvent.pointerEnter(screen.getByRole('img'))
const dataTransfer = transfer()
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(true)
view.rerender(<ImageDragProvider><div onDragStartCapture={(event) => { event.preventDefault() }}><Image src="blob:original" file={original()} /></div></ImageDragProvider>)
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(false)
expect(bridge.prepare).not.toHaveBeenCalled()
expect(bridge.start).not.toHaveBeenCalled()
})
it('ignores unsupported desktop protocols and requires a provider for drop resolution', () => {
vi.stubGlobal('dshDesktop', { protocolVersion: 2 })
const receive = vi.fn()
const view = render(<ImageDragProvider><DropTarget receive={receive} /></ImageDragProvider>)
fireEvent.drop(screen.getByTestId('drop'), { dataTransfer: transfer() })
expect(receive).toHaveBeenCalledWith(undefined)
view.unmount()
function WithoutProvider() { useImageDrop(); return null }
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<WithoutProvider />)).toThrow('Image dragging requires ImageDragProvider')
error.mockRestore()
})
it('dismisses failure feedback after its presentation lifetime', async () => {
vi.useFakeTimers()
try {
render(<ImageDragProvider><Image src="blob:original" file={original()} /></ImageDragProvider>)
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
expect(screen.getByRole('alert')).toBeTruthy()
await act(async () => { await vi.runAllTimersAsync() })
expect(screen.queryByRole('alert')).toBeNull()
} finally { vi.useRealTimers() }
})
it('reports an unready same-origin HTTP image instead of exporting its URL', async () => {
const pending = Promise.withResolvers<Response>()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockReturnValue(pending.promise))
render(<ImageDragProvider><Image src="/image.png" /></ImageDragProvider>)
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })).toBe(false)
expect(screen.getByRole('alert')).toBeTruthy()
await act(async () => { pending.resolve(new Response('failure', { status: 404 })); await pending.promise })
})
it('cancels a handler retained after its image unmounts', () => {
let handlers!: ReturnType<typeof useImageDrag>
function Source() {
const drag = useImageDrag('blob:original', 'original.png', 'Image drag failed')
useEffect(() => { handlers = drag }, [drag])
return null
}
const view = render(<ImageDragProvider><Source /></ImageDragProvider>)
view.rerender(<ImageDragProvider><span>Closed</span></ImageDragProvider>)
const event = { defaultPrevented: false, preventDefault: vi.fn(), dataTransfer: transfer() }
const captured: Parameters<typeof handlers.onDragStart>[0] = Object.assign({} as Parameters<typeof handlers.onDragStart>[0], event)
act(() => { handlers.onDragStart(captured) })
expect(event.preventDefault).toHaveBeenCalledOnce()
})
@@ -1,7 +1,5 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render as renderBare, screen } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { fileMediaUrl } from '@deepseek-ai/dsh-util-workspace-path'
import { MarkdownDelegateProvider } from '../src/markdown/MarkdownDelegate.tsx'
@@ -9,7 +7,7 @@ import { MarkdownText } from './markdown-test-components.tsx'
import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts'
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() })
const labels = { dragFailed: 'Image drag failed', open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' }
const labels = { open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' }
const fileImages = { resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels }
function mount(text: string, streaming = false) {
const openFile = vi.fn()
@@ -149,8 +147,3 @@ it.each([parseGfm, parseGfmWithMath])('uses the shared image categories for bare
}
expect(JSON.stringify(parse('![report](/work/test dir/report.pdf)'))).not.toContain('"type":"image"')
})
it('uses the fallback filename for an external image URL without a path', () => {
mount('![Remote](https://example.com/)')
expect(screen.getByRole('img').getAttribute('src')).toBe('https://example.com/')
})
@@ -21,8 +21,8 @@
en: 57942270e23d3a8e
zh: 6617124e652b3f67
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
en: e197303166b5a86f
zh: 8d151e49321d67aa
en: b056bbe0f74fe328
zh: fd9ac88f08825afc
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
en: b99b0e0b8efc2853
zh: 4327352d1334fc6b
@@ -59,7 +59,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
When Coding Tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. When either axis overflows the viewport, left-drag pans the image with grab/grabbing cursors. A fully contained image instead drags its original file into the composer or an external application; the gesture mode remains fixed until release. Ctrl+wheel and trackpad pinch retain the shared zoom behavior. Zoom changes retain the same `<img>` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `<img>` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a cool light-grey background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport.
@@ -59,7 +59,7 @@ Web 和桌面端均通过代码工作工具选择 HTML 预览策略。渲染器
开启代码工作工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。图片在任一方向超出视口时,按住鼠标左键拖动可平移,指针显示为抓手或抓取状态。图片完全位于视口内时,拖动会将原文件交给输入框或外部应用;一次手势的模式保持到松开为止。Ctrl+滚轮和触控板捏合沿用共享缩放行为。缩放不会替换 `<img>` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `<img>` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用冷调浅灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。
@@ -52,6 +52,3 @@
line-height: 1.5;
white-space: normal;
}
.image[data-pan="available"] { cursor: grab; }
.image[data-pan="active"] { cursor: grabbing; }
@@ -1,7 +1,5 @@
/** Complete image bytes rendered in a shared zoom viewport. */
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives'
import { bindImagePan } from './pan.ts'
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { DocumentPreviewProps } from '../document/contract.ts'
@@ -35,7 +33,6 @@ type ImageSource =
readonly data: Uint8Array<ArrayBuffer>
readonly mediaType: ImageMediaType
readonly url: string
readonly file: File
}
| { readonly kind: 'failed'; readonly data: Uint8Array<ArrayBuffer>; readonly mediaType: ImageMediaType }
@@ -74,16 +71,15 @@ export function ImageBody(props: ImageBodyProps): ReactNode {
if (data === undefined || mediaType === undefined) return
let url: string | undefined
try {
const file = new File([data], pathPartsOf(path).name, { type: mediaType })
url = URL.createObjectURL(file)
setSource({ kind: 'ready', data, mediaType, url, file })
url = URL.createObjectURL(new Blob([data], { type: mediaType }))
setSource({ kind: 'ready', data, mediaType, url })
} catch {
setSource({ kind: 'failed', data, mediaType })
}
return () => {
if (url !== undefined) URL.revokeObjectURL(url)
}
}, [data, mediaType, path])
}, [data, mediaType])
if (data === undefined || mediaType === undefined) {
return <p className={css.status} role="alert">{t('unsupported')}</p>
@@ -97,16 +93,15 @@ export function ImageBody(props: ImageBodyProps): ReactNode {
controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'),
fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }),
}
return <LoadedImage key={source.url} url={source.url} name={name} file={source.file} preference={preference}
return <LoadedImage key={source.url} url={source.url} name={name} preference={preference}
onPreference={setPreference} labels={labels} signal={tab.signal}
scrollportRef={props.scrollportRef} t={t} />
}
/** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */
function LoadedImage({ url, name, file, preference, onPreference, labels, signal, scrollportRef, t }: {
function LoadedImage({ url, name, preference, onPreference, labels, signal, scrollportRef, t }: {
readonly url: string
readonly name: string
readonly file: File
readonly preference: ZoomPreference
readonly onPreference: (preference: ZoomPreference) => void
readonly labels: ZoomLabels
@@ -114,11 +109,6 @@ function LoadedImage({ url, name, file, preference, onPreference, labels, signal
readonly scrollportRef: ImageBodyProps['scrollportRef']
readonly t: ImageBodyProps['t']
}): ReactNode {
const releasePan = useRef<() => void>()
const attachImage = useCallback((image: HTMLImageElement | null): void => {
releasePan.current?.()
releasePan.current = image === null ? undefined : bindImagePan(image)
}, [])
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
const [width, setWidth] = useState<number>()
return <ZoomViewport preference={preference} intrinsicWidth={width} horizontalInset={24}
@@ -128,13 +118,12 @@ function LoadedImage({ url, name, file, preference, onPreference, labels, signal
{state === 'failed' && <p className={css.status} role="alert">{t('failed')}</p>}
<div className={`${css.surface} ${zoomSurfaceClass}`} data-document-zoom-surface
style={{ '--document-zoom-width': `${width ?? 0}px` } as CSSProperties} hidden={state !== 'ready'}>
<DraggableImage
ref={attachImage}
<img
className={css.image}
src={url}
alt={t('preview', { name })}
decoding="async"
name={name} original={file} failureText={t('dragFailed')}
draggable={false}
referrerPolicy="no-referrer"
onLoad={(event) => { setWidth(event.currentTarget.naturalWidth); setState('ready') }}
onError={() => { setState('failed') }}
@@ -4,7 +4,6 @@ import { zoomEn, zoomZh } from '../zoom/locales.ts'
export const zh = {
...zoomZh,
title: '图片',
dragFailed: '拖放失败,请重试',
preview: '图片预览:{name}',
loading: '文档渲染中...',
failed: '无法显示这张图片',
@@ -18,7 +17,6 @@ export type ImagePreviewKey = keyof typeof zh
export const en = {
...zoomEn,
title: 'Image',
dragFailed: 'Drag failed. Try again.',
preview: 'Image preview: {name}',
loading: 'Rendering document...',
failed: 'This image could not be displayed.',
@@ -1,79 +0,0 @@
/** Mouse panning for overflowing image previews; contained images keep native file dragging. */
/**
* Bind image panning to its existing scrollport without changing zoom or replacing the image.
* @param image - mounted preview image inside a document zoom scrollport.
* @returns cleanup releasing observation, pointer capture, and window listeners.
*/
export function bindImagePan(image: HTMLImageElement): () => void {
const scrollport = image.closest<HTMLElement>('[data-document-zoom-scrollport]')
if (scrollport === null) throw new Error('Image panning requires a document zoom scrollport')
let pointer: { id: number; x: number; y: number; pan: boolean } | undefined
const overflowing = (): boolean => scrollport.scrollWidth > scrollport.clientWidth
|| scrollport.scrollHeight > scrollport.clientHeight
const measure = (): void => {
if (pointer !== undefined) return
const pan = overflowing()
image.dataset.pan = pan ? 'available' : 'none'
image.draggable = !pan
}
const finish = (): void => {
const previous = pointer
pointer = undefined
if (previous !== undefined && image.hasPointerCapture(previous.id)) image.releasePointerCapture(previous.id)
measure()
}
const down = (event: PointerEvent): void => {
if (event.button !== 0 || event.pointerType !== 'mouse') return
measure()
const pan = overflowing()
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, pan }
if (!pan) return
event.preventDefault()
image.dataset.pan = 'active'
image.setPointerCapture(event.pointerId)
}
const move = (event: PointerEvent): void => {
if (pointer === undefined || !pointer.pan || event.pointerId !== pointer.id) return
if (event.buttons !== 1) { finish(); return }
event.preventDefault()
scrollport.scrollLeft = Math.max(0, Math.min(scrollport.scrollWidth - scrollport.clientWidth,
scrollport.scrollLeft + pointer.x - event.clientX))
scrollport.scrollTop = Math.max(0, Math.min(scrollport.scrollHeight - scrollport.clientHeight,
scrollport.scrollTop + pointer.y - event.clientY))
pointer.x = event.clientX
pointer.y = event.clientY
}
const up = (event: PointerEvent): void => { if (event.pointerId === pointer?.id) finish() }
const drag = (event: DragEvent): void => { if (pointer?.pan ?? overflowing()) event.preventDefault() }
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
observer?.observe(image)
observer?.observe(scrollport)
image.addEventListener('load', measure)
image.addEventListener('pointerenter', measure)
image.addEventListener('pointerdown', down)
image.addEventListener('pointermove', move)
image.addEventListener('pointerup', up)
image.addEventListener('pointercancel', up)
image.addEventListener('lostpointercapture', finish)
image.addEventListener('dragstart', drag)
image.addEventListener('dragend', finish)
window.addEventListener('pointerup', up)
window.addEventListener('blur', finish)
measure()
return () => {
observer?.disconnect()
image.removeEventListener('load', measure)
image.removeEventListener('pointerenter', measure)
image.removeEventListener('pointerdown', down)
image.removeEventListener('pointermove', move)
image.removeEventListener('pointerup', up)
image.removeEventListener('pointercancel', up)
image.removeEventListener('lostpointercapture', finish)
image.removeEventListener('dragstart', drag)
image.removeEventListener('dragend', finish)
window.removeEventListener('pointerup', up)
window.removeEventListener('blur', finish)
finish()
}
}
@@ -2,9 +2,7 @@
/** Image Blob ownership, media types, pane-contained rendering, and failure states. */
import { useSyncExternalStore } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render as renderBare, screen } from '@testing-library/react'
import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives'
const render = (ui: import('react').ReactNode) => renderBare(ui, { wrapper: ImageDragProvider })
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import { ImageBody, imageMediaType, type ImageBodyProps } from '../src/client/image/ImageBody.tsx'
@@ -85,13 +83,13 @@ describe('ImageBody', () => {
expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
})
it('allows a contained image to drag, then shows it after decoding succeeds', async () => {
it('keeps the image non-draggable, then shows it after decoding succeeds', async () => {
render(<ImageBody {...props('photo.svg')} />)
const image = await screen.findByRole('img', { hidden: true })
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 50 })
expect(image.getAttribute('alt')).toBe('Image preview: photo.svg')
expect(image.getAttribute('decoding')).toBe('async')
expect(image.getAttribute('draggable')).toBe('true')
expect(image.getAttribute('draggable')).toBe('false')
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(image.parentElement?.hasAttribute('hidden')).toBe(true)
fireEvent.load(image)
@@ -1,137 +0,0 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { bindImagePan } from '../src/client/image/pan.ts'
afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals() })
function viewport(width: number, height: number) {
const scrollport = document.createElement('div')
scrollport.dataset.documentZoomScrollport = ''
Object.defineProperties(scrollport, {
clientWidth: { value: 400 }, clientHeight: { value: 300 },
scrollWidth: { configurable: true, value: width }, scrollHeight: { configurable: true, value: height },
})
const image = document.createElement('img')
scrollport.append(image)
document.body.append(scrollport)
let captured: number | undefined
image.setPointerCapture = (id) => { captured = id }
image.hasPointerCapture = id => captured === id
image.releasePointerCapture = () => { captured = undefined }
return { scrollport, image, captured: () => captured }
}
function pointer(image: HTMLImageElement, type: string, values: Partial<PointerEventInit> = {}) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, buttons: 1, ...values })
Object.defineProperties(event, { pointerId: { value: values.pointerId ?? 1 }, pointerType: { value: values.pointerType ?? 'mouse' } })
image.dispatchEvent(event)
return event
}
it('pans a fit-width long image on its overflowing axis and leaves no pointer capture after release', () => {
vi.stubGlobal('ResizeObserver', undefined)
const { image, scrollport, captured } = viewport(400, 900)
const dispose = bindImagePan(image)
try {
expect(image.draggable).toBe(false)
expect(image.dataset.pan).toBe('available')
expect(pointer(image, 'pointerdown', { clientX: 200, clientY: 200 }).defaultPrevented).toBe(true)
pointer(image, 'pointermove', { clientX: 100, clientY: 100 })
expect(scrollport.scrollLeft).toBe(0)
expect(scrollport.scrollTop).toBe(100)
expect(captured()).toBe(1)
pointer(image, 'pointerup')
expect(captured()).toBeUndefined()
pointer(image, 'pointermove', { clientY: 0 })
expect(scrollport.scrollTop).toBe(100)
} finally { dispose() }
})
it('clamps both axes, reverses immediately at an edge, and releases on blur', () => {
const { image, scrollport, captured } = viewport(800, 600)
const dispose = bindImagePan(image)
try {
pointer(image, 'pointerdown', { clientX: 100, clientY: 100 })
pointer(image, 'pointermove', { clientX: -500, clientY: -500 })
expect([scrollport.scrollLeft, scrollport.scrollTop]).toEqual([400, 300])
pointer(image, 'pointermove', { clientX: -490, clientY: -490 })
expect([scrollport.scrollLeft, scrollport.scrollTop]).toEqual([390, 290])
window.dispatchEvent(new Event('blur'))
expect(captured()).toBeUndefined()
expect(image.dataset.pan).toBe('available')
} finally { dispose() }
})
it('lets contained images drag as files, and switches mode when their viewport changes', () => {
const { image, scrollport } = viewport(400, 300)
let resize = () => {}
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { resize = callback }
observe() {}
disconnect = disconnect
})
const dispose = bindImagePan(image)
expect(image.draggable).toBe(true)
expect(pointer(image, 'pointerdown').defaultPrevented).toBe(false)
Object.defineProperty(scrollport, 'scrollHeight', { value: 600 })
resize()
expect(image.draggable).toBe(true)
pointer(image, 'pointerup')
expect(image.draggable).toBe(false)
pointer(image, 'pointerdown', { pointerType: 'touch' })
expect(image.dataset.pan).toBe('available')
dispose()
expect(disconnect).toHaveBeenCalledOnce()
})
it('rejects a missing scrollport with an explicit error', () => {
expect(() => bindImagePan(document.createElement('img'))).toThrow('Image panning requires a document zoom scrollport')
})
it('ignores unrelated pointers and releases capture when the mouse button is no longer held', () => {
const { image, scrollport, captured } = viewport(800, 600)
const dispose = bindImagePan(image)
try {
pointer(image, 'pointerdown', { button: 2 })
expect(captured()).toBeUndefined()
pointer(image, 'pointerdown', { clientX: 100, clientY: 100 })
pointer(image, 'pointerup', { pointerId: 2 })
pointer(image, 'pointermove', { pointerId: 2, clientX: 0 })
expect(captured()).toBe(1)
expect(scrollport.scrollLeft).toBe(0)
pointer(image, 'pointermove', { buttons: 0 })
expect(captured()).toBeUndefined()
expect(image.dataset.pan).toBe('available')
} finally { dispose() }
})
it('blocks native dragging only for a pan and retains the selected mode until release', () => {
const { image, scrollport } = viewport(400, 300)
const dispose = bindImagePan(image)
const drag = () => {
const event = new Event('dragstart', { cancelable: true })
image.dispatchEvent(event)
return event.defaultPrevented
}
try {
expect(drag()).toBe(false)
pointer(image, 'pointerdown')
pointer(image, 'pointermove', { clientX: 30 })
Object.defineProperty(scrollport, 'scrollWidth', { value: 800 })
expect(drag()).toBe(false)
image.dispatchEvent(new Event('dragend'))
expect(drag()).toBe(true)
pointer(image, 'pointerdown')
expect(drag()).toBe(true)
pointer(image, 'pointercancel')
expect(image.dataset.pan).toBe('available')
} finally { dispose() }
})
it('does not end an idle pan for a foreign pointer release', () => {
const { image } = viewport(800, 600)
const dispose = bindImagePan(image)
pointer(image, 'pointerup', { pointerId: 2 })
expect(image.dataset.pan).toBe('available')
dispose()
})
@@ -607,7 +607,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:262',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260',
},
{
key: 'conversation.composer.bar',
@@ -649,7 +649,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282',
},
{
key: 'conversation.composer.dock',
@@ -709,7 +709,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274',
},
{
key: 'conversation.header',
@@ -747,7 +747,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:212',
},
{
key: 'conversation.header.leading',
@@ -778,7 +778,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:243',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:241',
},
{
key: 'conversation.hero.agentPreset',
@@ -818,7 +818,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266',
},
{
key: 'conversation.hero.brand.mark',
@@ -849,7 +849,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:264',
},
{
key: 'conversation.hero.options',
@@ -906,7 +906,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268',
},
{
key: 'conversation.hero.workspace',
@@ -941,7 +941,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:264',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:262',
},
{
key: 'conversation.hero.workspace.directoryFlow',
@@ -1019,7 +1019,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280',
},
{
key: 'conversation.input.attachments',
@@ -1063,7 +1063,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:286',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284',
},
{
key: 'conversation.input.dock',
@@ -1130,7 +1130,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270',
},
{
key: 'conversation.input.left',
@@ -1187,7 +1187,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276',
},
{
key: 'conversation.input.model',
@@ -1229,7 +1229,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:300',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:298',
},
{
key: 'conversation.input.model.label',
@@ -1335,7 +1335,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272',
},
{
key: 'conversation.input.permission',
@@ -1377,7 +1377,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292',
},
{
key: 'conversation.input.plan',
@@ -1419,7 +1419,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:290',
},
{
key: 'conversation.input.right',
@@ -1476,7 +1476,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278',
},
{
key: 'conversation.plan-review.actions',
@@ -1577,7 +1577,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:208',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
},
{
key: 'conversation.session.header',
@@ -1617,7 +1617,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:216',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
},
{
key: 'conversation.session.header.actions',
@@ -1681,7 +1681,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:229',
},
{
key: 'conversation.session.header.corner',
@@ -1723,7 +1723,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:254',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:252',
},
{
key: 'conversation.session.header.lineage',
@@ -1767,7 +1767,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:225',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:223',
},
{
key: 'conversation.session.header.utilities',
@@ -1830,7 +1830,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:235',
},
{
key: 'conversation.trajectory.images',
@@ -1931,7 +1931,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.user.text.node\', () => ctx.slots.register(\n { name: \'conversation.user.text.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196',
},
{
key: 'conversation.view',
@@ -1995,7 +1995,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:258',
},
{
key: 'deliverables.file.actions',
@@ -2198,7 +2198,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204',
},
{
key: 'plugins.add.actions',
-3
View File
@@ -4304,9 +4304,6 @@ importers:
'@deepseek-ai/dsh-client-store':
specifier: workspace:*
version: link:../store
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:*
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:*
version: link:../ui-renderer
@@ -65,11 +65,9 @@
## Image zoom
- Contained sidebar PNG drags into the composer as an original-byte image attachment
- Small PNG fit width remains at intrinsic size; 200% doubles it
- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false
- Image and Blob identities remain stable while zoom changes
- Overflowing image drag pans both axes by 80px; releasing restores the grab cursor
## Code paging