mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(image): restore pan and sharing with plugin-owned transfers
This commit is contained in:
1 parent
9e6692931c
commit
31bd06d503
105 files changed
+2054
-142
No files matched your search
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-image-drag-plugin-ownership.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 .agents/notes/implemented/architecture/2026-10-19-image-drag-plugin-ownership.md
|
||||
/agent-note-image-drag-ownership-outside-the-application-layout:
|
||||
en: 094f036caf9062fd
|
||||
zh: 71c0837748922a1f
|
||||
/agent-note-image-drag-ownership-outside-the-application-layout/problem:
|
||||
en: 557edc1db9bee8d8
|
||||
zh: 20f47c78c8b0078b
|
||||
/agent-note-image-drag-ownership-outside-the-application-layout/decision:
|
||||
en: d40e8d31fe2fca0c
|
||||
zh: ae8bd790cd80a640
|
||||
/agent-note-image-drag-ownership-outside-the-application-layout/alternatives-considered:
|
||||
en: 32d570f71930fcad
|
||||
zh: 85886d87a58d11ca
|
||||
/agent-note-image-drag-ownership-outside-the-application-layout/consequences:
|
||||
en: 5259dd44c9118e19
|
||||
zh: 022f64b1baa3030f
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: Image drag ownership outside the application layout
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-image-drag-plugin-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Image dragging spans conversation images, sidebar previews, and composer drop targets. Original files must survive source unmount during a drag, and failures must remain visible after the originating panel closes. An application-root image provider puts feature state in layout and makes independent image entries depend on an undeclared React ancestor.
|
||||
|
||||
## Decision
|
||||
|
||||
The [image-drag plugin](../../../../packages/client/ui-image-drag/README.md) owns browser transfer tokens, retained original files, desktop bridge selection, and transient failure feedback. Consumers receive narrow callbacks through their slot or Factory injection. The plugin registers its feedback through `shell.overlay`; layout declares the generic slot and has no image dependency. The shared image primitive owns preparation and gesture handlers with explicit action props.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Root React provider.** It preserves transfer lifetime but adds image-specific assembly to the layout package and hides dependencies from slot registration. Moving the same wrapper into the renderer would move the feature coupling into another generic owner.
|
||||
|
||||
**State on each image.** It avoids shared assembly but cannot deliver the retained file after the source unmounts or keep a late failure visible after the panel closes.
|
||||
|
||||
## Consequences
|
||||
|
||||
One plugin lifetime governs the transfer and window listener. Independent render roots can share a transfer without sharing a React ancestor. Custom compositions must load the service and declare the feedback slot; custom image Factory registrations must supply the required actions. Plugin disposal removes the feedback and clears the retained transfer. Loader-backed browser scenarios cover the shipped composition, while focused tests exercise cross-root delivery and late failure feedback.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: 应用布局之外的图片拖放归属
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-image-drag-plugin-ownership.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
图片拖放跨越会话图片、侧栏预览和输入框接收区。拖动期间原始文件必须在源组件卸载后继续保留,发起操作的面板关闭后失败提示也必须可见。应用根部的图片 Provider 把功能状态放进布局,并使独立图片入口依赖未声明的 React 祖先组件。
|
||||
|
||||
## Decision
|
||||
|
||||
[图片拖放插件](../../../../packages/client/ui-image-drag/README.zh.md) 持有浏览器传递令牌、原始文件、桌面桥接选择和临时失败提示。消费方通过 slot 或 Factory 注入接收范围明确的回调。插件通过 `shell.overlay` 注册提示,布局只声明通用插槽,不依赖图片功能。共享图片原语通过显式操作属性负责准备和手势处理。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**根部 React Provider。** 它能保留传递生命周期,但会把图片专属组合放进布局包,并对 slot 注册隐藏依赖。把同一个包裹组件移到渲染器,只会把功能耦合转移到另一个通用模块。
|
||||
|
||||
**每张图片独立持有状态。** 这能避免共享组合,却不能在源组件卸载后交付保留文件,也不能在面板关闭后继续显示延迟到达的失败提示。
|
||||
|
||||
## Consequences
|
||||
|
||||
一个插件生命周期管理传递状态与窗口监听器。独立渲染根可以共享传递,而不共享 React 祖先组件。自定义组合必须加载服务并声明反馈插槽,自定义图片 Factory 注册必须提供所需回调。插件卸载会移除提示并清除保留的传递状态。通过 Loader 启动的浏览器场景覆盖实际产品组合,定向测试验证跨根交付和延迟失败提示。
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 372297005b57a08b
|
||||
zh: 074a635ac17e4362
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: 22153a298a6662e1
|
||||
zh: 3600803c8c9d0274
|
||||
en: 7e2329afb2bfa6e4
|
||||
zh: bee6b40de5551daf
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: a1a47b09f4a72ba4
|
||||
zh: 7b1cf27f0b72cf31
|
||||
|
||||
@@ -142,6 +142,8 @@ 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.
|
||||
|
||||
@@ -142,6 +142,8 @@ 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 校验。
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ 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',
|
||||
@@ -73,6 +76,11 @@ 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
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ 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'
|
||||
@@ -345,6 +346,17 @@ 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')
|
||||
@@ -537,6 +549,10 @@ 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
|
||||
@@ -621,6 +637,7 @@ 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()
|
||||
@@ -1261,7 +1278,8 @@ 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) })])
|
||||
platformView.dispose().catch((error: unknown) => { console.error(error) }),
|
||||
disposeImageDrag().catch((error: unknown) => { console.error(error) })])
|
||||
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
|
||||
}
|
||||
app.on('before-quit', (event) => {
|
||||
|
||||
@@ -14,6 +14,13 @@ 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>,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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))
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
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([])
|
||||
})
|
||||
@@ -16,6 +16,9 @@ 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.
|
||||
@@ -259,6 +262,8 @@ 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
|
||||
},
|
||||
@@ -1478,6 +1483,33 @@ 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]!
|
||||
@@ -1978,6 +2010,7 @@ 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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @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'
|
||||
@@ -254,3 +255,25 @@ 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
|
||||
@@ -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: false })
|
||||
}))).toEqual({ width: 1, height: 1, draggable: true })
|
||||
const centering = await tinyImage.evaluate((node) => {
|
||||
const image = node.getBoundingClientRect()
|
||||
const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
|
||||
@@ -1105,6 +1105,14 @@ 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()
|
||||
@@ -1143,6 +1151,17 @@ 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()
|
||||
@@ -1151,9 +1170,11 @@ 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>()
|
||||
|
||||
@@ -319,6 +319,15 @@ 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()
|
||||
|
||||
@@ -4722,6 +4722,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-image-drag` | — | [`packages/client/ui-image-drag/src/index.ts`](../packages/client/ui-image-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-jobs` | — | [`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-layout` | — | [`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts) |
|
||||
|
||||
@@ -4724,6 +4724,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-image-drag` | — | [`packages/client/ui-image-drag/src/index.ts`](../packages/client/ui-image-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-jobs` | — | [`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-layout` | — | [`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts) |
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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: 43730c13427b45a1
|
||||
zh: 85edabec816e1a84
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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. Load `@deepseek-ai/dsh-client-ui-image-drag` in custom compositions and declare `shell.overlay`. Consumers declare the `imageDrag` service dependency, inject `ctx.imageDrag.actions` into image entries, and supply it as `MarkdownDelegateProvider.fileImages.imageDrag` or `DraggableImage.imageDrag`.
|
||||
4. Custom replacements for the `conversation.images.content` Factory add `inject: () => ({ imageDrag: ctx.imageDrag.actions })`. Its declaration now requires these callbacks; render callers still pass only image data, the loader, and labels.
|
||||
5. Rebuild and verify original-image drops and localized drag failures. No React Provider or application-shell wrapper is required.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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. 自定义组合加载 `@deepseek-ai/dsh-client-ui-image-drag`,并声明 `shell.overlay`。消费插件声明 `imageDrag` 服务依赖,将 `ctx.imageDrag.actions` 注入到图片入口,并作为 `MarkdownDelegateProvider.fileImages.imageDrag` 或 `DraggableImage.imageDrag` 提供。
|
||||
4. 替换 `conversation.images.content` Factory 时,增加 `inject: () => ({ imageDrag: ctx.imageDrag.actions })`。该 Factory 的声明现在要求这组回调;渲染调用方仍只传图片数据、加载函数和文案。
|
||||
5. 重新构建插件,确认原图拖回输入框及拖放失败提示正常。无需增加 React Provider,应用外壳保持原有结构。
|
||||
@@ -339,6 +339,9 @@
|
||||
- id: ui-attachment
|
||||
name: '@deepseek-ai/dsh-client-ui-attachment'
|
||||
|
||||
- id: ui-image-drag
|
||||
name: '@deepseek-ai/dsh-client-ui-image-drag'
|
||||
|
||||
# Tool call tree, generic fallback, and keyed business Tool views.
|
||||
- id: ui-tool
|
||||
name: '@deepseek-ai/dsh-client-ui-tool'
|
||||
|
||||
@@ -190,7 +190,8 @@
|
||||
"@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*",
|
||||
"@deepseek-ai/dsh-harness-dev-context": "workspace:*",
|
||||
"@deepseek-ai/dsh-deliverable-file-references": "workspace:*"
|
||||
"@deepseek-ai/dsh-deliverable-file-references": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-image-drag": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package:
|
||||
en: 15cdd9d52dcc71b8
|
||||
zh: 53933fbbf5d39100
|
||||
en: 830f35efb0efa348
|
||||
zh: 11334a9476dd3af2
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments:
|
||||
en: a77c8ec48e219aec
|
||||
zh: 756d4fe881403006
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox:
|
||||
en: 8b3d7729152d8060
|
||||
zh: 815f932ccc9653f5
|
||||
en: 38717dbf90a2520d
|
||||
zh: b81915d2b06e18f8
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package/drop-overlay:
|
||||
en: 836a62c8f73366ac
|
||||
zh: 35da7836eaf68a67
|
||||
en: bf1a8ebd1a550396
|
||||
zh: a4d736010ab6602e
|
||||
/deepseek-ai-dsh-client-ui-attachment/understand-the-implementation:
|
||||
en: 07204b7d538b7434
|
||||
zh: 705cc6b0c9387cce
|
||||
|
||||
@@ -25,6 +25,8 @@ This package renders everything the conversation UI shows about attachments: one
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Image drag actions and the composer’s original-file reader arrive through slot injection from [ui-image-drag](../ui-image-drag/README.md). The image gallery Factory also receives these actions through its declared inject face; custom Factory replacements must supply that face.
|
||||
|
||||
Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) and [`ui-tool`](../ui-tool/README.md) when tool results need an image gallery. It waits for their slot declarations and registers its components into them. Users then see the mixed draft-attachment rail, DeepSeek Web file cards with upload controls, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox.
|
||||
|
||||
### Draft attachments
|
||||
@@ -37,9 +39,13 @@ 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 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.
|
||||
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.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
图片拖放回调与输入框的原图读取回调通过 slot 注入从 [ui-image-drag](../ui-image-drag/README.zh.md) 获取。图片图库 Factory 也通过声明的注入接口接收回调;自定义 Factory 替换实现必须提供该接口。
|
||||
|
||||
与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件,工具结果需要图片图库时也要挂载 [`ui-tool`](../ui-tool/README.zh.md)。插件等待这些 slot 的声明,并把组件注册进去。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带限制说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片图库,以及支持 Escape、遮罩和关闭按钮的灯箱。
|
||||
|
||||
### 草稿附件
|
||||
@@ -37,9 +39,13 @@ Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行
|
||||
|
||||
Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。
|
||||
|
||||
已加载的会话图片可拖入输入框作为图片附件,也可将原文件拖到其他应用。拖动不会打开灯箱。共享图片传递组件将 Web 拖动的原图字节保留到手势结束;Desktop 使用系统文件拖放。准备或拖放失败时保留图片,并显示本地化的重试提示。
|
||||
|
||||
应用内 Web 图片拖放不显示全屏遮罩。没有实际文件的外来或过期图片令牌会被忽略。浏览器传递限制见 [ui-primitives](../ui-primitives/README.zh.md#known-limitations-and-deferred-work)。
|
||||
|
||||
### 拖放遮罩
|
||||
|
||||
文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
|
||||
外部文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory",
|
||||
"@deepseek-ai/dsh-client-ui-tool"
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-image-drag"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -57,7 +58,8 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:*",
|
||||
"clsx": "^2.0.0"
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/dsh-client-ui-image-drag": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage } 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 type { ImageDragActions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
export type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -68,10 +69,11 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon
|
||||
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
|
||||
* @returns the bounded thumbnail button, or the retry control on failure.
|
||||
*/
|
||||
export function MessageImage({ image, load, variant, labels }: {
|
||||
export function MessageImage({ image, load, variant, labels, imageDrag }: {
|
||||
image: MessageImageSpec
|
||||
load: ImageLoader
|
||||
variant: 'single' | 'tile' | 'thumbnail'
|
||||
imageDrag: ImageDragActions
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
const preview = 'preview' in image ? image.preview : undefined
|
||||
@@ -143,7 +145,8 @@ export function MessageImage({ image, load, variant, labels }: {
|
||||
{loadingThumbnail ? <IconLoadingOutlineRegular className={css.spinner} /> : labels.loading}
|
||||
</span>
|
||||
)
|
||||
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||
: <DraggableImage imageDrag={imageDrag} src={src} alt={label} name={preview?.name ?? attachment?.name ?? 'image'} failureText={labels.dragFailed}
|
||||
style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
</>
|
||||
@@ -152,12 +155,13 @@ export function MessageImage({ image, load, variant, labels }: {
|
||||
|
||||
/** Wrapping image group shared by user and assistant history: a lone image
|
||||
* renders large unless its owner requests compact tiles or contained list thumbnails. */
|
||||
export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: {
|
||||
export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, imageDrag }: {
|
||||
images: readonly MessageImageSpec[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
compact?: boolean
|
||||
thumbnail?: boolean
|
||||
imageDrag: ImageDragActions
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
@@ -170,6 +174,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail =
|
||||
image={image}
|
||||
load={load}
|
||||
variant={variant}
|
||||
imageDrag={imageDrag}
|
||||
labels={labels}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -19,8 +19,8 @@ interface ComposerRailItem extends AttachmentRailItem {
|
||||
|
||||
/** Draft image previews, pending-file cards, drop target, and original-image preview. */
|
||||
export function ComposerAttachments({
|
||||
attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t,
|
||||
}: ComposerAttachmentsProps) {
|
||||
attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t, readImageDrag,
|
||||
}: ComposerAttachmentsProps & { readonly readImageDrag: (transfer: DataTransfer) => File | undefined }) {
|
||||
const [preview, setPreview] = useState<ComposerImageAttachment | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const dragDepth = useRef(0)
|
||||
@@ -30,8 +30,8 @@ export function ComposerAttachments({
|
||||
}, [attachments, preview])
|
||||
|
||||
useEffect(() => {
|
||||
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive)
|
||||
}, [canAcceptDrop, onAddFiles])
|
||||
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive, readImageDrag)
|
||||
}, [canAcceptDrop, onAddFiles, readImageDrag])
|
||||
|
||||
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
|
||||
id: attachment.id,
|
||||
|
||||
@@ -12,11 +12,11 @@ type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories
|
||||
* @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat.
|
||||
* @returns the official gallery; no Session binding is needed by this component.
|
||||
*/
|
||||
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t }:
|
||||
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, imageDrag }:
|
||||
FactoryComponentPropsOf<'conversation.images.content'>) {
|
||||
const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t])
|
||||
return <ImageGallery images={images} load={loadImage} align={align}
|
||||
compact={compact} thumbnail={thumbnail} labels={resolved} />
|
||||
compact={compact} thumbnail={thumbnail} labels={resolved} imageDrag={imageDrag} />
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** 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'
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,7 @@ 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(
|
||||
@@ -32,10 +34,11 @@ 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')) return null
|
||||
if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE))) return null
|
||||
return dataTransfer
|
||||
}
|
||||
const reset = (): void => {
|
||||
@@ -43,10 +46,11 @@ export function installDocumentDropEvents(
|
||||
setDragActive(false)
|
||||
}
|
||||
const onDragEnter = (event: globalThis.DragEvent): void => {
|
||||
if (fileTransfer(event) === null) return
|
||||
const dataTransfer = fileTransfer(event)
|
||||
if (dataTransfer === null) return
|
||||
event.preventDefault()
|
||||
dragDepth.current += 1
|
||||
setDragActive(true)
|
||||
if (!dataTransfer.types.includes(IMAGE_DRAG_TOKEN_TYPE)) setDragActive(true)
|
||||
}
|
||||
const onDragOver = (event: globalThis.DragEvent): void => {
|
||||
const dataTransfer = fileTransfer(event)
|
||||
@@ -68,8 +72,9 @@ export function installDocumentDropEvents(
|
||||
event.preventDefault()
|
||||
reset()
|
||||
if (canAcceptDrop) {
|
||||
const files = [...dataTransfer.files]
|
||||
onAddFiles(files, droppedDirectories(dataTransfer, files))
|
||||
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))
|
||||
}
|
||||
}
|
||||
document.addEventListener('dragenter', onDragEnter)
|
||||
|
||||
@@ -5,11 +5,12 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-image-drag/client'
|
||||
import { ComposerAttachments } from './ComposerAttachments.tsx'
|
||||
import { MessageImages, MessageImagesContent } from './MessageImages.tsx'
|
||||
|
||||
/** Slot registry required by the attachment presentation plugin. */
|
||||
export const inject = ['slots']
|
||||
export const inject = ['slots', 'imageDrag']
|
||||
|
||||
/**
|
||||
* Register composer attachments and the shared gallery with its domain adapters.
|
||||
@@ -18,9 +19,11 @@ export const inject = ['slots']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.slots.registerFactory({
|
||||
name: 'conversation.images.content', scope: 'root', locale: 'conversation',
|
||||
inject: () => ({ imageDrag: ctx.imageDrag.actions }),
|
||||
}, MessageImagesContent)
|
||||
ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({
|
||||
name: 'conversation.input.attachments',
|
||||
inject: () => ({ readImageDrag: ctx.imageDrag.read }),
|
||||
locale: 'conversation',
|
||||
}, ComposerAttachments))
|
||||
ctx.slots.inject('conversation.chat.message.images', () => ctx.slots.register({
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 }),
|
||||
|
||||
@@ -74,7 +74,9 @@ function fileDraft(id: string, name = `${id}.pdf`): ComposerAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): ComposerAttachmentsProps {
|
||||
type AttachmentProps = ComposerAttachmentsProps & { readImageDrag: (transfer: DataTransfer) => File | undefined }
|
||||
|
||||
function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): AttachmentProps {
|
||||
return {
|
||||
attachments: [],
|
||||
canAcceptDrop: true,
|
||||
@@ -84,7 +86,8 @@ function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): Composer
|
||||
onRetryFile: () => {},
|
||||
t,
|
||||
...overrides,
|
||||
} as unknown as ComposerAttachmentsProps
|
||||
readImageDrag: () => undefined,
|
||||
} as AttachmentProps
|
||||
}
|
||||
|
||||
describe('ComposerAttachments', () => {
|
||||
@@ -306,3 +309,29 @@ 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 imageDrag={imageDrag} alt="drag source" src="blob:original" name={file.name} failureText="拖放失败,请重试" original={file} />
|
||||
}
|
||||
const onAddFiles = vi.fn()
|
||||
const view = render(<><Source /><ComposerAttachments {...props({ onAddFiles })} readImageDrag={transfer => transfer.getData('application/x-dsh-image') === 'token' ? file : undefined} /></>)
|
||||
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()
|
||||
})
|
||||
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Independent domain image positions with one shared official gallery Factory. */
|
||||
import * as imageDragPlugin from '@deepseek-ai/dsh-client-ui-image-drag/client'
|
||||
import { act, cleanup, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -27,6 +28,7 @@ async function bench() {
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
new UiConversation(runtime.ctx, runtime.sessions)
|
||||
await runtime.mount(imageDragPlugin)
|
||||
await runtime.mount(attachmentPlugin)
|
||||
await runtime.sessions.add({ id: 'image-domains' })
|
||||
const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains'))
|
||||
|
||||
@@ -18,6 +18,7 @@ afterEach(cleanup)
|
||||
|
||||
const labels: MessageImageLabels = {
|
||||
image: '图片',
|
||||
dragFailed: '拖放失败,请重试',
|
||||
open: '查看原图',
|
||||
openNamed: label => `${label},点击查看原图`,
|
||||
loading: '图片加载中…',
|
||||
@@ -39,7 +40,7 @@ describe('MessageImage', () => {
|
||||
const load = Object.assign(vi.fn(() => new Promise<string>(() => {})), {
|
||||
peek: vi.fn(() => 'blob:seeded'),
|
||||
})
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
expect(view.queryByText('图片加载中…')).toBeNull()
|
||||
expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded')
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
@@ -47,7 +48,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
@@ -62,7 +63,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it('ignores a click while the thumbnail is still loading', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(view.getByText('图片加载中…')).toBeTruthy()
|
||||
fireEvent.click(frame)
|
||||
@@ -72,7 +73,7 @@ describe('MessageImage', () => {
|
||||
it('falls back to the image label for an unnamed attachment', async () => {
|
||||
const { name: _named, ...unnamed } = attachment
|
||||
const load = vi.fn().mockResolvedValue('blob:unnamed')
|
||||
const view = render(<MessageImage image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
|
||||
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
||||
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
||||
})
|
||||
@@ -82,7 +83,7 @@ describe('MessageImage', () => {
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockRejectedValueOnce(new Error('still offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
@@ -94,7 +95,7 @@ describe('MessageImage', () => {
|
||||
it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:ratio')
|
||||
const tall = render(
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage imageDrag={imageDrag} image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
|
||||
@@ -103,7 +104,7 @@ describe('MessageImage', () => {
|
||||
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
|
||||
tall.unmount()
|
||||
const wide = render(
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage imageDrag={imageDrag} image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
|
||||
@@ -112,7 +113,7 @@ describe('MessageImage', () => {
|
||||
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
|
||||
wide.unmount()
|
||||
const small = render(
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage imageDrag={imageDrag} image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
|
||||
@@ -121,7 +122,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it('renders a tile at the fixed square without inline sizing', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('data-variant')).toBe('tile')
|
||||
expect(frame.getAttribute('style')).toBeNull()
|
||||
@@ -129,7 +130,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => {
|
||||
const load = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant={variant} labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant={variant} labels={labels} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
expect(retry.getAttribute('data-variant')).toBe(variant)
|
||||
})
|
||||
@@ -140,7 +141,7 @@ describe('MessageImage', () => {
|
||||
] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => {
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry')
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
|
||||
const loading = view.getByRole('button', { name: statusLabels.loading })
|
||||
expect(loading.getAttribute('title')).toBe(statusLabels.loading)
|
||||
expect(loading.getAttribute('aria-busy')).toBe('true')
|
||||
@@ -164,7 +165,7 @@ describe('MessageImage', () => {
|
||||
it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => {
|
||||
const { name: _name, ...unnamed } = attachment
|
||||
const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail')
|
||||
const view = render(<ImageGallery images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
|
||||
const view = render(<ImageGallery imageDrag={imageDrag} images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
|
||||
fireEvent.click(await view.findByRole('button', { name: labels.loadFailed }))
|
||||
const image = await view.findByAltText('Image 2')
|
||||
expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail')
|
||||
@@ -182,13 +183,13 @@ describe('MessageImage', () => {
|
||||
it('ignores a load settling after unmount', async () => {
|
||||
let resolve: ((url: string) => void) | undefined
|
||||
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
view.unmount()
|
||||
resolve?.('blob:late')
|
||||
await Promise.resolve()
|
||||
let reject: ((error: Error) => void) | undefined
|
||||
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
||||
const second = render(<MessageImage image={{ attachment }} load={failing} variant="single" labels={labels} />)
|
||||
const second = render(<MessageImage imageDrag={imageDrag} image={{ attachment }} load={failing} variant="single" labels={labels} />)
|
||||
second.unmount()
|
||||
reject?.(new Error('late failure'))
|
||||
await Promise.resolve()
|
||||
@@ -199,7 +200,7 @@ describe('MessageImage preview arm', () => {
|
||||
it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => {
|
||||
const load = vi.fn()
|
||||
const view = render(
|
||||
<MessageImage
|
||||
<MessageImage imageDrag={imageDrag}
|
||||
image={{ preview: { url: 'blob:echo', name: 'echo.png', width: 640, height: 320 } }}
|
||||
load={load}
|
||||
variant="single"
|
||||
@@ -217,7 +218,7 @@ describe('MessageImage preview arm', () => {
|
||||
it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => {
|
||||
const load = vi.fn()
|
||||
const view = render(
|
||||
<MessageImage image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage imageDrag={imageDrag} image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const img = view.getByAltText('图片') as HTMLImageElement
|
||||
const frame = img.closest('button') as HTMLButtonElement
|
||||
@@ -227,7 +228,7 @@ describe('MessageImage preview arm', () => {
|
||||
|
||||
it('opens the lightbox from a preview thumbnail', () => {
|
||||
const view = render(
|
||||
<MessageImage image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} variant="tile" labels={labels} />,
|
||||
<MessageImage imageDrag={imageDrag} image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} variant="tile" labels={labels} />,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' }))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
@@ -237,10 +238,10 @@ describe('MessageImage preview arm', () => {
|
||||
describe('ImageGallery', () => {
|
||||
it('renders nothing without images and an aligned wrapping group with them', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:gallery')
|
||||
const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
|
||||
const empty = render(<ImageGallery imageDrag={imageDrag} images={[]} load={load} align="start" labels={labels} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const view = render(
|
||||
<ImageGallery
|
||||
<ImageGallery imageDrag={imageDrag}
|
||||
images={[{ attachment }, { preview: { url: 'blob:echo', name: 'echo.png' } }, { attachment }]}
|
||||
load={load}
|
||||
align="end"
|
||||
@@ -254,16 +255,16 @@ describe('ImageGallery', () => {
|
||||
|
||||
it('renders a lone image large and compact or grouped images as square tiles', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||
const lone = render(<ImageGallery imageDrag={imageDrag} images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
|
||||
lone.unmount()
|
||||
const compact = render(
|
||||
<ImageGallery images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
|
||||
<ImageGallery imageDrag={imageDrag} images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
|
||||
)
|
||||
expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1)
|
||||
compact.unmount()
|
||||
const several = render(
|
||||
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
<ImageGallery imageDrag={imageDrag} images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
)
|
||||
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
|
||||
})
|
||||
@@ -276,6 +277,7 @@ describe('ImageGallery', () => {
|
||||
locale.setLocale('en')
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
runtime.ctx.provide('imageDrag', { actions: imageDrag, read: () => undefined })
|
||||
await runtime.mount(attachmentPlugin)
|
||||
const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }]
|
||||
const loadImage = vi.fn(async () => 'blob:unused')
|
||||
@@ -316,6 +318,7 @@ describe('ImageGallery', () => {
|
||||
new UiConversation(runtime.ctx, runtime.sessions)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } })
|
||||
runtime.ctx.provide('imageDrag', { actions: imageDrag, read: () => undefined })
|
||||
await runtime.mount(attachmentPlugin)
|
||||
await runtime.sessions.add({ id: 'message-images-test' })
|
||||
const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test'))
|
||||
@@ -342,3 +345,5 @@ describe('ImageGallery', () => {
|
||||
expect(loadImage).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
@@ -33,6 +33,7 @@ async function bench() {
|
||||
'tool.call.images': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('imageDrag', { actions: imageDrag, read: () => undefined })
|
||||
const registerFactory = vi.spyOn(ctx.slots, 'registerFactory')
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
@@ -46,10 +47,12 @@ describe('attachment plugin', () => {
|
||||
|
||||
it('registers all entries and removes them with the plugin fiber', async () => {
|
||||
const { ctx, fiber, registerFactory } = await bench()
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(registerFactory).toHaveBeenCalledWith({
|
||||
expect(inject).toEqual(['slots', 'imageDrag'])
|
||||
expect(registerFactory.mock.calls[0]?.[0]).toMatchObject({
|
||||
name: 'conversation.images.content', scope: 'root', locale: 'conversation',
|
||||
}, MessageImagesContent)
|
||||
})
|
||||
expect(typeof registerFactory.mock.calls[0]?.[0].inject).toBe('function')
|
||||
expect(registerFactory.mock.calls[0]?.[1]).toBe(MessageImagesContent)
|
||||
expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(1)
|
||||
expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{
|
||||
locale: 'conversation', component: ComposerAttachments,
|
||||
@@ -63,3 +66,5 @@ describe('attachment plugin', () => {
|
||||
expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../locale/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-image-drag"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 41cd21266011d571
|
||||
zh: 3172d1a2acbb27b7
|
||||
/deepseek-ai-dsh-client-ui-chat/reference-previews:
|
||||
en: aa6b1abec265bf1b
|
||||
zh: 7260ce78afba2584
|
||||
en: a0b72c0707aa1332
|
||||
zh: 93fd88b1f48ce1d0
|
||||
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
|
||||
en: cbf1f0a2205046a5
|
||||
zh: 2e59d0815351349f
|
||||
|
||||
@@ -33,6 +33,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
|
||||
<a id="reference-previews"></a>
|
||||
## Reference previews
|
||||
|
||||
Image drag actions arrive through the image entry’s inject face from [ui-image-drag](../ui-image-drag/README.md).
|
||||
|
||||
Sent generic-file attachments retain the draft's [file-card radius](../../../docs/ui-radius.md#radius-scale).
|
||||
|
||||
`ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
|
||||
|
||||
@@ -33,6 +33,8 @@ kind: "package-reference"
|
||||
<a id="reference-previews"></a>
|
||||
## 引用预览
|
||||
|
||||
图片拖放回调通过图片入口的注入接口从 [ui-image-drag](../ui-image-drag/README.zh.md) 获取。
|
||||
|
||||
已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。
|
||||
|
||||
`ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-ui-image-drag"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -95,7 +96,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-image-drag": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Register the Chat Conversation target, renderers, stats, and details surface. */
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-image-drag/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -75,7 +76,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = {
|
||||
/** Services required by the Chat target and its presentation registrations. */
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
|
||||
'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight',
|
||||
'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'imageDrag',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -250,6 +251,7 @@ export function apply(ctx: Context): void {
|
||||
const chat = chatSource(binding)
|
||||
return {
|
||||
...nodeSources(binding),
|
||||
imageDrag: ctx.imageDrag.actions,
|
||||
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
|
||||
// Files open in the right Sidebar, not in a desktop application: the
|
||||
// content stays in the product, beside the conversation that produced
|
||||
|
||||
@@ -60,7 +60,7 @@ const MOTION_TAIL_MS = 800
|
||||
export function ChatView({
|
||||
useSession, useChat, useConversation, useSessions, renderSlot,
|
||||
sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
|
||||
usePresentation, useProjection, t,
|
||||
usePresentation, useProjection, t, imageDrag,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useChat(s => s.order)
|
||||
const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries)
|
||||
@@ -84,12 +84,13 @@ export function ChatView({
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const fileImages = useMemo(() => ({
|
||||
imageDrag,
|
||||
resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)),
|
||||
labels: {
|
||||
open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
|
||||
dragFailed: t('image.dragFailed'), open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
|
||||
dialog: t('image.dialog'), close: t('image.close'),
|
||||
},
|
||||
}), [cwd, t])
|
||||
}), [cwd, t, imageDrag])
|
||||
const running = useSession(s => s.running)
|
||||
// Fold transitions run while a Turn is live, while an input is pending, and briefly after settling.
|
||||
const [motionTail, setMotionTail] = useState(false)
|
||||
|
||||
@@ -297,6 +297,8 @@ export interface PerformanceUsageInjected {
|
||||
|
||||
/** Business callbacks injected into the Chat view. */
|
||||
export interface ChatViewInjected {
|
||||
/** Original-image transfer and failure actions owned by the image-drag plugin. */
|
||||
readonly imageDrag: import('@deepseek-ai/dsh-client-ui-primitives').ImageDragActions
|
||||
hooks: {
|
||||
/** Live presentation policy derived from the accepted work-details mode. */
|
||||
presentation: ObservableSnapshot<ChatPresentationPolicy>
|
||||
|
||||
@@ -67,6 +67,7 @@ export const zh = {
|
||||
'view.chat': '对话',
|
||||
'image.open': '查看大图',
|
||||
'image.loading': '加载图片…',
|
||||
'image.dragFailed': '拖放失败,请重试',
|
||||
'image.failed': '图片无法预览',
|
||||
'image.dialog': '图片预览',
|
||||
'image.close': '关闭图片预览',
|
||||
@@ -269,6 +270,7 @@ 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',
|
||||
|
||||
@@ -62,6 +62,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true,
|
||||
} as never)
|
||||
const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
|
||||
runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
|
||||
runtime.ctx.provide('imageDrag', { actions: imageDrag, read: () => undefined })
|
||||
runtime.ctx.provide('layout', layout as never)
|
||||
const sidebarRight = {
|
||||
openResource: vi.fn<(address: string) => void>(),
|
||||
@@ -491,3 +492,5 @@ describe('Chat inject API', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
@@ -44,6 +44,7 @@ const SID = 'session-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
const chatSettings = stubConfigForm<ChatSettings>()
|
||||
runtime.ctx.provide('configForms', {
|
||||
developerTools: { enabled: createSnapshotStore(true) },
|
||||
|
||||
@@ -467,6 +467,7 @@ function makeHarness(
|
||||
const useConnectionState = bindSnapshotSelector(connection)
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
|
||||
const props: HarnessStandardProps = {
|
||||
imageDrag,
|
||||
renderFactorySlot,
|
||||
usePanelInfo: selector => selector({ activePanelId: null }),
|
||||
sessionId: SID,
|
||||
@@ -4600,3 +4601,5 @@ describe('ChatView', () => {
|
||||
expect(rows).toEqual(['fixture:steering:3'])
|
||||
})
|
||||
})
|
||||
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
@@ -46,6 +46,7 @@ const quotaEntry = (seq: number, code: string): SessionLiveEventEntry => ({
|
||||
*/
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
disposers.push(() => runtime.dispose())
|
||||
const chatSettings = stubConfigForm<ChatSettings>()
|
||||
runtime.ctx.provide('configForms', {
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../product-analytics/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-image-drag"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -131,6 +131,8 @@ 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
|
||||
}
|
||||
@@ -187,6 +189,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'conversation.images.content': {
|
||||
scope: 'root'
|
||||
props: MessageImagesContentInput
|
||||
inject: { readonly imageDrag: import('@deepseek-ai/dsh-client-ui-primitives').ImageDragActions }
|
||||
locale: 'conversation'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const zh = {
|
||||
'image.label': '图片',
|
||||
'image.loadFailed': '图片加载失败,点击重试',
|
||||
'image.loading': '图片加载中…',
|
||||
'image.dragFailed': '拖放失败,请重试',
|
||||
'image.preview': '原图预览',
|
||||
'image.closePreview': '关闭原图预览',
|
||||
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
|
||||
@@ -427,6 +428,7 @@ 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',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for README.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 packages/client/ui-image-drag/README.md
|
||||
/:
|
||||
en: 3062fba206b44f36
|
||||
zh: f9a25f471f4c7ce1
|
||||
/deepseek-ai-dsh-client-ui-image-drag:
|
||||
en: f1831a1b97f5dc82
|
||||
zh: b44fa2fd17812171
|
||||
/deepseek-ai-dsh-client-ui-image-drag/summary:
|
||||
en: b9fd57af1a4d9ed5
|
||||
zh: 97e2bfc158465d12
|
||||
/deepseek-ai-dsh-client-ui-image-drag/table-of-contents:
|
||||
en: 122f0d41696980b1
|
||||
zh: 5423073f383f055d
|
||||
/deepseek-ai-dsh-client-ui-image-drag/use-this-package:
|
||||
en: 25e44d801adbbc1d
|
||||
zh: c9dddbc9d22e6ac8
|
||||
/deepseek-ai-dsh-client-ui-image-drag/understand-the-implementation:
|
||||
en: 8ab102c22b293621
|
||||
zh: 764be52c3ab39910
|
||||
/deepseek-ai-dsh-client-ui-image-drag/model-experience:
|
||||
en: bcbde555cf1ac05e
|
||||
zh: c555dba949ddd0e9
|
||||
/deepseek-ai-dsh-client-ui-image-drag/model-experience/kv-cache-effect:
|
||||
en: 410edb0ad2da08cf
|
||||
zh: 581cbff891b5f34d
|
||||
/deepseek-ai-dsh-client-ui-image-drag/known-limitations-and-deferred-work:
|
||||
en: 48cc312a91cc0a0d
|
||||
zh: da1bf9065be87c4d
|
||||
/deepseek-ai-dsh-client-ui-image-drag/known-limitations-and-deferred-work/dev-note:
|
||||
en: b836f01bd0bceb4a
|
||||
zh: 25e979141fbe5501
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
description: "Application-scoped original-image transfers and transient drag failure feedback."
|
||||
kind: package-reference
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-ui-image-drag
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Users can drag original images from conversations or the sidebar into the composer, or into other applications through Desktop. Drag failures remain visible after the originating image or panel closes. Transfers between browser windows remain subject to browser capabilities.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Web application profile includes this plugin. Custom compositions load it alongside the slot renderer and a shell declaring `shell.overlay`. Consumer plugins declare the `imageDrag` service injection and pass `ctx.imageDrag.actions` through their entry’s inject face to `DraggableImage` or `MarkdownDelegate.fileImages.imageDrag`. Drop targets receive `ctx.imageDrag.read` as a callback. Components do not read a service or React context.
|
||||
|
||||
The service retains one original `File` for the current browser drag. A random token in `application/x-dsh-image` resolves only in the application instance that created it. Source unmount does not invalidate an in-flight transfer. Drag end or plugin disposal clears the retained file. Unknown tokens return no file.
|
||||
|
||||
Localized failures are reported through the injected actions. The plugin’s overlay outlives the image or panel that initiated the gesture; each report restarts the Toast lifetime. Plugin disposal removes the overlay and its window listener. The desktop bridge is resolved once during activation and used only for supported protocol version 1.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals</summary>
|
||||
|
||||
The Cordis plugin owns transfer bytes and a snapshot source for transient feedback. The renderer binds only the feedback source to its overlay; image consumers receive stable callbacks and subscribe to no global drag state. [DraggableImage](../ui-primitives/README.md) owns per-image preparation and gesture cancellation; [Desktop](../../../apps/desktop/README.md) owns temporary native files.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as this plugin changes browser interaction without adding model-visible messages or tools.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this plugin neither constructs nor sends model requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Browser tokens do not transfer between applications, windows, or tabs. External browser file delivery depends on Chromium `DownloadURL` and the receiving application; Desktop supplies native file delivery. Unreadable cross-origin images retain browser-native dragging.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
description: "应用作用域内的原图传递与临时拖放失败反馈。"
|
||||
kind: package-reference
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-ui-image-drag
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
用户可以把会话或侧栏中的原图拖回输入框,也可以通过桌面端拖到其他应用。图片或面板关闭后,拖放失败提示仍然可见。浏览器跨窗口传递受浏览器能力限制。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用此包](#use-this-package)
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
Web 应用 profile 包含该插件。自定义组合需要同时加载插槽渲染器,以及声明 `shell.overlay` 的外壳。消费插件声明 `imageDrag` 服务依赖,通过入口的注入接口将 `ctx.imageDrag.actions` 传给 `DraggableImage` 或 `MarkdownDelegate.fileImages.imageDrag`。接收方通过回调获取 `ctx.imageDrag.read`。组件不读取服务或 React context。
|
||||
|
||||
服务为当前浏览器拖放保留一个原始 `File`。`application/x-dsh-image` 中的随机令牌仅能由创建它的应用实例解析。源组件卸载不会使进行中的传递失效。拖放结束或插件卸载会清除保留的文件。未知令牌不返回文件。
|
||||
|
||||
本地化失败提示通过注入回调报告。插件的覆盖层比发起手势的图片或面板存活更久,每次报告都会重新开始 Toast 的展示周期。插件卸载会移除覆盖层和窗口监听器。桌面桥接在插件激活时解析一次,仅使用受支持的协议版本 1。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现内部细节</summary>
|
||||
|
||||
Cordis 插件持有传递字节和临时反馈的快照源。渲染器只为覆盖层绑定反馈源;图片消费方接收稳定回调,不订阅全局拖放状态。[DraggableImage](../ui-primitives/README.zh.md) 负责单张图片的准备与手势取消,[Desktop](../../../apps/desktop/README.zh.md) 负责原生临时文件。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无;该插件只改变浏览器交互,不增加模型可见消息或工具。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;该插件既不构建也不发送模型请求。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 浏览器令牌不能跨应用、窗口或标签页传递。浏览器向外部交付文件依赖 Chromium 的 `DownloadURL` 和接收应用;Desktop 提供原生文件交付。不可读取的跨域图片保留浏览器原生拖动。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作上下文</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-image-drag",
|
||||
"description": "Application-owned original-image drag transfers and failure feedback",
|
||||
"version": "0.2.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-image-drag"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Image-drag failure presentation in the existing application overlay. */
|
||||
import { Toast } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** Localized transient failure, with identity for restarting its presentation lifetime. */
|
||||
export interface ImageDragFailure {
|
||||
readonly text: string
|
||||
readonly sequence: number
|
||||
}
|
||||
|
||||
/** Registration-owned feedback source and dismissal callback. */
|
||||
export interface ImageDragFeedbackInjected {
|
||||
readonly hooks: { readonly failure: ObservableSnapshot<ImageDragFailure | null> }
|
||||
readonly dismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep transient failures visible after their originating image unmounts.
|
||||
* @param props - framework-bound failure source and dismissal callback.
|
||||
* @returns the current failure toast, or nothing.
|
||||
*/
|
||||
export function ImageDragFeedback({ useFailure, dismiss }: InjectFace<ImageDragFeedbackInjected>) {
|
||||
const failure = useFailure(value => value)
|
||||
return failure === null ? null : <Toast key={failure.sequence} text={failure.text} onDone={dismiss} />
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Cordis-owned image transfers shared by independently rendered image and composer entries. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { IMAGE_DRAG_TOKEN_TYPE, type DesktopImageDrag, type ImageDragActions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { ImageDragFeedback, type ImageDragFailure, type ImageDragFeedbackInjected } from './feedback.tsx'
|
||||
|
||||
/** Application-local original bytes and the narrow operations injected into image entries. */
|
||||
export interface ImageDragService {
|
||||
readonly actions: ImageDragActions
|
||||
/** Resolve this application's retained image; @param transfer - browser drop payload. @returns original file for a matching token. */
|
||||
readonly read: (transfer: DataTransfer) => File | undefined
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Image transfer ownership scoped to this plugin's lifetime. */
|
||||
imageDrag: ImageDragService
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry used to contribute feedback to the existing shell overlay. */
|
||||
export const inject = ['slots']
|
||||
|
||||
/**
|
||||
* Own one application's drag bytes, desktop bridge, and transient failure feedback.
|
||||
* @param ctx - client plugin context carrying the slot registry.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const bridge = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; imageDrag?: DesktopImageDrag } }).dshDesktop
|
||||
const desktop = bridge?.protocolVersion === 1 ? bridge.imageDrag : undefined
|
||||
const failure = createSnapshotStore<ImageDragFailure | null>(null)
|
||||
let active: { token: string; file: File } | undefined
|
||||
let sequence = 0
|
||||
const end = (): void => { active = undefined }
|
||||
const dismiss = (): void => { failure.set(null) }
|
||||
const actions: ImageDragActions = {
|
||||
desktop,
|
||||
report: (text) => { failure.set({ text, sequence: ++sequence }) },
|
||||
begin: (file) => {
|
||||
const token = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
active = { token, file }
|
||||
return token
|
||||
},
|
||||
end,
|
||||
}
|
||||
ctx.provide('imageDrag', {
|
||||
actions,
|
||||
read: transfer => active !== undefined && active.token === transfer.getData(IMAGE_DRAG_TOKEN_TYPE) ? active.file : undefined,
|
||||
})
|
||||
ctx.effect(() => {
|
||||
window.addEventListener('dragend', end)
|
||||
return () => { window.removeEventListener('dragend', end); end(); dismiss() }
|
||||
})
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'image-drag.feedback',
|
||||
inject: (): ImageDragFeedbackInjected => ({ hooks: { failure }, dismiss }),
|
||||
}, ImageDragFeedback))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Host Loader entry; image dragging executes in the browser and desktop shell. */
|
||||
|
||||
/** Register the browser package without adding Host behavior. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Plugin-owned transfers and feedback across independent render roots. */
|
||||
import { File as NodeFile } from 'node:buffer'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { DraggableImage, IMAGE_DRAG_TOKEN_TYPE, type DesktopImageDrag } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import * as plugin from '../src/client/index.ts'
|
||||
import { apply as applyHost } from '../src/index.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers() })
|
||||
|
||||
function transfer(token = '') {
|
||||
const data = new Map([[IMAGE_DRAG_TOKEN_TYPE, token]])
|
||||
return {
|
||||
items: { clear: vi.fn() }, effectAllowed: 'none',
|
||||
getData: (type: string) => data.get(type) ?? '',
|
||||
setData: (type: string, value: string) => { data.set(type, value) },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
onTestFinished(() => runtime.dispose())
|
||||
const owner = await runtime.mount(plugin)
|
||||
await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } },
|
||||
({ renderSlot }) => renderSlot('shell.overlay', {}))
|
||||
const view = runtime.renderRoot()
|
||||
return { runtime, owner, view, service: runtime.ctx.imageDrag }
|
||||
}
|
||||
|
||||
it('keeps the Host entry empty', () => { applyHost() })
|
||||
|
||||
it('shares original bytes across roots, rejects foreign tokens, and clears bytes when the drag ends', async () => {
|
||||
const { service, owner, runtime } = await bench()
|
||||
const file = new NodeFile([Uint8Array.of(137, 80, 78, 71)], 'original.png', { type: 'image/png' }) as File
|
||||
const source = render(<DraggableImage imageDrag={service.actions} src="blob:original" name={file.name}
|
||||
original={file} failureText="拖放失败,请重试" alt="original" />)
|
||||
const receive = vi.fn()
|
||||
const target = render(<div data-testid="target" onDrop={(event) => { receive(service.read(event.dataTransfer)) }} />)
|
||||
const data = transfer()
|
||||
fireEvent.drop(target.getByTestId('target'), { dataTransfer: data })
|
||||
expect(receive).toHaveBeenLastCalledWith(undefined)
|
||||
await act(async () => { fireEvent.pointerEnter(source.getByRole('img')) })
|
||||
fireEvent.dragStart(source.getByRole('img'), { dataTransfer: data })
|
||||
source.unmount()
|
||||
fireEvent.drop(target.getByTestId('target'), { dataTransfer: data })
|
||||
expect(receive).toHaveBeenLastCalledWith(file)
|
||||
data.setData(IMAGE_DRAG_TOKEN_TYPE, 'foreign')
|
||||
fireEvent.drop(target.getByTestId('target'), { dataTransfer: data })
|
||||
expect(receive).toHaveBeenLastCalledWith(undefined)
|
||||
const token = service.actions.begin(file)
|
||||
data.setData(IMAGE_DRAG_TOKEN_TYPE, token)
|
||||
fireEvent.dragEnd(window)
|
||||
fireEvent.drop(target.getByTestId('target'), { dataTransfer: data })
|
||||
expect(receive).toHaveBeenLastCalledWith(undefined)
|
||||
data.setData(IMAGE_DRAG_TOKEN_TYPE, service.actions.begin(file))
|
||||
await owner.dispose()
|
||||
expect(runtime.ctx.get('imageDrag')).toBeUndefined()
|
||||
expect(runtime.slots.entries('shell.overlay')).toHaveLength(0)
|
||||
fireEvent.drop(target.getByTestId('target'), { dataTransfer: data })
|
||||
expect(receive).toHaveBeenLastCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('reports late native failure after source unmount, then dismisses its overlay toast', async () => {
|
||||
const pending = Promise.withResolvers<boolean>()
|
||||
const desktop: DesktopImageDrag = { prepare: vi.fn(async () => 'handle'), start: vi.fn(() => pending.promise), release: vi.fn() }
|
||||
vi.stubGlobal('dshDesktop', { protocolVersion: 1, imageDrag: desktop })
|
||||
const { service } = await bench()
|
||||
expect(service.actions.desktop).toBe(desktop)
|
||||
vi.stubGlobal('dshDesktop', { protocolVersion: 2 })
|
||||
expect(service.actions.desktop).toBe(desktop)
|
||||
const file = new NodeFile(['image'], 'image.png', { type: 'image/png' }) as File
|
||||
const source = render(<DraggableImage imageDrag={service.actions} src="blob:original" name={file.name}
|
||||
original={file} failureText="拖放失败,请重试" alt="original" />)
|
||||
await act(async () => { fireEvent.pointerEnter(source.getByRole('img')) })
|
||||
fireEvent.dragStart(source.getByRole('img'), { dataTransfer: transfer() })
|
||||
source.unmount()
|
||||
expect(desktop.release).toHaveBeenCalledWith('handle')
|
||||
vi.useFakeTimers()
|
||||
await act(async () => { pending.resolve(false); await pending.promise })
|
||||
expect(screen.getByRole('alert').textContent).toBe('拖放失败,请重试')
|
||||
await act(async () => { await vi.runAllTimersAsync() })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an unsupported desktop protocol and removes feedback when the plugin leaves', async () => {
|
||||
vi.stubGlobal('dshDesktop', { protocolVersion: 2, imageDrag: {} })
|
||||
const { service, owner } = await bench()
|
||||
expect(service.actions.desktop).toBeUndefined()
|
||||
act(() => { service.actions.report('拖放失败,请重试') })
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
await owner.dispose()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
@@ -18,14 +18,14 @@
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: cb874a0ca53a6747
|
||||
zh: 31179193dd1d6e12
|
||||
en: 65b69f3927a74f2a
|
||||
zh: f6837e7021e58d16
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: 09c61f61c560f8a2
|
||||
zh: 895ac73add1199b8
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: 3a31ad8de6dd0af8
|
||||
zh: 6b22881a929c33df
|
||||
en: a2912514ca674818
|
||||
zh: f6b1cc4323220c28
|
||||
/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: 22fc14aa3a224552
|
||||
zh: aa6309e84ad8c2a3
|
||||
en: 843daa09327f1aad
|
||||
zh: 434560a9da1e6ddf
|
||||
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -60,6 +60,7 @@ 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` | Original-image gestures with explicit transfer and feedback callbacks. |
|
||||
| `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,6 +113,8 @@ 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.
|
||||
|
||||
`DraggableImage` requires explicit `imageDrag: ImageDragActions` callbacks, also supplied through `MarkdownDelegateProvider.fileImages.imageDrag` for managed Markdown images. The [image-drag plugin](../ui-image-drag/README.md) owns cross-entry transfers and feedback; the primitive owns only its image preparation and gesture lifecycle. 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.
|
||||
@@ -200,6 +203,7 @@ 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,6 +60,7 @@ kind: "package-library"
|
||||
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
|
||||
| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
|
||||
| `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 |
|
||||
| `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 |
|
||||
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
|
||||
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
|
||||
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 |
|
||||
@@ -112,6 +113,8 @@ kind: "package-library"
|
||||
|
||||
`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。
|
||||
|
||||
`DraggableImage` 要求显式的 `imageDrag: ImageDragActions` 回调;受管理的 Markdown 图片通过 `MarkdownDelegateProvider.fileImages.imageDrag` 接收同一组回调。[图片拖放插件](../ui-image-drag/README.zh.md) 持有跨入口传递状态和反馈,原语只负责自身图片的准备与手势生命周期。原图在悬停或主指针按下时准备,不在挂载时准备;过期准备会取消。受管理的 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 接口。
|
||||
@@ -200,6 +203,7 @@ kind: "package-library"
|
||||
|
||||
这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
|
||||
|
||||
- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。
|
||||
- **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。
|
||||
- **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。
|
||||
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Original-image drag gestures owned by a presentation primitive. */
|
||||
import { forwardRef, type ImgHTMLAttributes } from 'react'
|
||||
import { useImageDrag } from './image-drag.ts'
|
||||
import type { ImageDragActions } from './image-drag-actions.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 imageDrag: ImageDragActions
|
||||
readonly src: string
|
||||
readonly name: string
|
||||
readonly failureText: string
|
||||
readonly original?: File | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an image with original-file dragging; using explicit application-owned callbacks.
|
||||
* @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(
|
||||
{ imageDrag, src, name, failureText, original, ...props }, ref,
|
||||
) {
|
||||
const drag = useImageDrag(imageDrag, src, name, failureText, original)
|
||||
return <img {...props} {...drag} src={src} ref={ref} draggable />
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Application-owned callbacks used by original-image drag primitives. */
|
||||
import type { DesktopImageDrag } from './image-drag.ts'
|
||||
|
||||
/** DataTransfer type containing an application-local token, not image bytes. */
|
||||
export const IMAGE_DRAG_TOKEN_TYPE = 'application/x-dsh-image'
|
||||
|
||||
/** Narrow operations supplied by an image-transfer owner that outlives the source component. */
|
||||
export interface ImageDragActions {
|
||||
/** Native file operations when running in a supported desktop shell. */
|
||||
readonly desktop: DesktopImageDrag | undefined
|
||||
/** Display localized failure feedback independently of the source image. */
|
||||
readonly report: (text: string) => void
|
||||
/** Retain original bytes for this drag; @returns the application-local transfer token. */
|
||||
readonly begin: (file: File) => string
|
||||
/** End the current browser transfer and release its retained bytes. */
|
||||
readonly end: () => void
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/** 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, type ImageDragActions } from './image-drag-actions.ts'
|
||||
|
||||
/** 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.
|
||||
* The supplied actions own transfer bytes and feedback across source unmounts.
|
||||
* @param transfer - application-owned native, transfer, and feedback callbacks.
|
||||
* @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(
|
||||
transfer: ImageDragActions, 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 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,6 +67,11 @@ 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 } from './image-drag-actions.ts'
|
||||
export type { ImageDragActions } from './image-drag-actions.ts'
|
||||
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'
|
||||
|
||||
@@ -13,8 +13,9 @@ export type MarkdownExternalLinkHandler = (href: string) => void
|
||||
export interface MarkdownDelegate {
|
||||
/** Image previews for decoded local paths in this owner's workspace. */
|
||||
readonly fileImages?: {
|
||||
imageDrag: import('../image-drag-actions.ts').ImageDragActions
|
||||
resolve: (path: string) => string | undefined
|
||||
labels: ImageLightboxLabels & { open: string; loading: string; failed: string }
|
||||
labels: ImageLightboxLabels & { open: string; loading: string; failed: string; dragFailed: string }
|
||||
} | undefined
|
||||
/** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */
|
||||
readonly openExternalLink?: MarkdownExternalLinkHandler | undefined
|
||||
|
||||
@@ -29,6 +29,7 @@ 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'
|
||||
@@ -658,9 +659,11 @@ 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 img = <img className={css.image} src={src} alt={alt}
|
||||
onError={() => { setFailed(true) }} loading="lazy" decoding="async" referrerPolicy="no-referrer" />
|
||||
if (preview === undefined) return img
|
||||
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 imageDrag={preview.imageDrag} {...imageProps} name={destination.split('/').at(-1)?.split(/[?#]/u)[0] || 'image'}
|
||||
failureText={preview.labels.dragFailed} />
|
||||
return <>
|
||||
<button type="button" className={css.imageButton} title={preview.labels.open}
|
||||
aria-label={alt ? `${preview.labels.open}: ${alt}` : preview.labels.open}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// @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, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { IMAGE_DRAG_TOKEN_TYPE, type ImageDragActions } from '../src/image-drag-actions.ts'
|
||||
import { DraggableImage } from '../src/DraggableImage.tsx'
|
||||
import { useImageDrag, type DesktopImageDrag } from '../src/image-drag.ts'
|
||||
|
||||
let imageDrag: ImageDragActions
|
||||
beforeEach(() => { imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() } })
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
function Image({ src, file, onOpen }: { src: string; file?: File; onOpen?: () => void }) {
|
||||
return <button onClick={onOpen}><DraggableImage imageDrag={imageDrag} 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() }
|
||||
imageDrag = { ...imageDrag, desktop: 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(<><Image src="blob:original" file={file} onOpen={open} /></>)
|
||||
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(<><Image src="blob:original" file={file} /></>)
|
||||
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(<><Image src="blob:first" file={original()} /></>)
|
||||
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
|
||||
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
|
||||
expect(imageDrag.report).toHaveBeenCalledWith('Image drag failed. Try again.')
|
||||
view.rerender(<><span>Image closed</span></>)
|
||||
expect(imageDrag.report).toHaveBeenCalledWith('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(<><Image src="blob:original" file={original()} /></>)
|
||||
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
|
||||
fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })
|
||||
view.rerender(<><span>Image closed</span></>)
|
||||
await act(async () => { pending.resolve(false); await pending.promise })
|
||||
expect(imageDrag.report).toHaveBeenCalledWith('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(<><Image src="https://example.test/image.png" /></>)
|
||||
fireEvent.pointerEnter(screen.getByRole('img'))
|
||||
const request = fetchImage.mock.calls[0]?.[1]
|
||||
view.rerender(<><span>Closed</span></>)
|
||||
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()
|
||||
})
|
||||
|
||||
it('does not prepare on mount, ignores secondary buttons, and keeps keyboard activation after dragging', async () => {
|
||||
const bridge = native()
|
||||
const open = vi.fn()
|
||||
render(<><Image src="blob:original" file={original()} onOpen={open} /></>)
|
||||
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(<><Image src="blob:original" file={original()} /></>)
|
||||
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(imageDrag.report).toHaveBeenCalledWith('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(<><Image src="https://example.test/image.png" /></>)
|
||||
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 retains the original file for its owner', async () => {
|
||||
const fetchImage = vi.fn<typeof fetch>().mockResolvedValue(new Response('bytes', { headers: { 'Content-Type': 'image/png' } }))
|
||||
vi.stubGlobal('fetch', fetchImage)
|
||||
render(<><Image src="/image.png" /></>)
|
||||
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 })
|
||||
expect(imageDrag.begin).toHaveBeenCalledWith(expect.objectContaining({ name: 'original.png', type: 'image/png' }))
|
||||
fireEvent.dragEnd(screen.getByRole('img'))
|
||||
expect(imageDrag.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
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(<><Image src={src} /></>)
|
||||
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(imageDrag.report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps disabled and already-cancelled gestures inert', () => {
|
||||
const bridge = native()
|
||||
const view = render(<><Image src="" /></>)
|
||||
fireEvent.pointerEnter(screen.getByRole('img'))
|
||||
const dataTransfer = transfer()
|
||||
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(true)
|
||||
view.rerender(<><div onDragStartCapture={(event) => { event.preventDefault() }}><Image src="blob:original" file={original()} /></div></>)
|
||||
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(false)
|
||||
expect(bridge.prepare).not.toHaveBeenCalled()
|
||||
expect(bridge.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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(<><Image src="/image.png" /></>)
|
||||
expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() })).toBe(false)
|
||||
expect(imageDrag.report).toHaveBeenCalled()
|
||||
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(imageDrag, 'blob:original', 'original.png', 'Image drag failed')
|
||||
useEffect(() => { handlers = drag }, [drag])
|
||||
return null
|
||||
}
|
||||
const view = render(<><Source /></>)
|
||||
view.rerender(<><span>Closed</span></>)
|
||||
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()
|
||||
})
|
||||
@@ -7,8 +7,9 @@ import { MarkdownText } from './markdown-test-components.tsx'
|
||||
import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
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 }
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
const labels = { dragFailed: 'Image drag failed', open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' }
|
||||
const fileImages = { imageDrag, resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels }
|
||||
function mount(text: string, streaming = false) {
|
||||
const openFile = vi.fn()
|
||||
const view = render(<MarkdownDelegateProvider openFile={openFile} fileImages={fileImages}>
|
||||
@@ -21,7 +22,7 @@ it('loads Desktop inline and hover previews through the file route after streami
|
||||
vi.useFakeTimers()
|
||||
const path = '/work/测试 图片#100%.png'
|
||||
const src = fileMediaUrl('dsh-app://app/', path)!
|
||||
const images = { resolve: (value: string) => fileMediaUrl('dsh-app://app/', value), labels }
|
||||
const images = { imageDrag, resolve: (value: string) => fileMediaUrl('dsh-app://app/', value), labels }
|
||||
const text = `.replace('#', '%23')}>)\n\n[打开图片](<${encodeURI(path).replace('#', '%23')}>)`
|
||||
const openFile = vi.fn()
|
||||
const content = (streaming: boolean) => <MarkdownDelegateProvider fileImages={images} openFile={openFile}>
|
||||
@@ -147,3 +148,8 @@ it.each([parseGfm, parseGfmWithMath])('uses the shared image categories for bare
|
||||
}
|
||||
expect(JSON.stringify(parse(''))).not.toContain('"type":"image"')
|
||||
})
|
||||
|
||||
it('uses the fallback filename for an external image URL without a path', () => {
|
||||
mount('')
|
||||
expect(screen.getByRole('img').getAttribute('src')).toBe('https://example.com/')
|
||||
})
|
||||
@@ -80,6 +80,7 @@ function sectionOperations(account: AccountSnapshot, pages: PlatformPages): Acco
|
||||
async function bench() {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
disposers.push(() => runtime.dispose())
|
||||
const forms = new Map<string, ReturnType<typeof stubConfigForm>['scope']>()
|
||||
const formFor = (namespace: string) => {
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
en: 0dd8c3a22e28aacf
|
||||
zh: c27c11bf4b5cceba
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers:
|
||||
en: 54a50f4a3a6e4d45
|
||||
zh: af905ed570812432
|
||||
en: d65f2d8e3d1037ba
|
||||
zh: 32ede37d2d61c2fb
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses:
|
||||
en: 57942270e23d3a8e
|
||||
zh: 6617124e652b3f67
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
|
||||
en: b056bbe0f74fe328
|
||||
zh: fd9ac88f08825afc
|
||||
en: e197303166b5a86f
|
||||
zh: 8d151e49321d67aa
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
|
||||
en: b99b0e0b8efc2853
|
||||
zh: 4327352d1334fc6b
|
||||
|
||||
@@ -28,6 +28,8 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo
|
||||
<a id="what-it-registers"></a>
|
||||
## What it registers
|
||||
|
||||
Image drag actions arrive through the image entry’s inject face from [ui-image-drag](../ui-image-drag/README.md).
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
|
||||
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
|
||||
@@ -59,7 +61,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. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ kind: "package-reference"
|
||||
<a id="what-it-registers"></a>
|
||||
## 注册了什么
|
||||
|
||||
图片拖放回调通过图片入口的注入接口从 [ui-image-drag](../ui-image-drag/README.zh.md) 获取。
|
||||
|
||||
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
|
||||
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
|
||||
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
|
||||
@@ -59,7 +61,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 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `<img>` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 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。
|
||||
|
||||
共享文案来自 `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 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-image-drag"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -84,7 +85,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yaml": "^2.9.0"
|
||||
"yaml": "^2.9.0",
|
||||
"@deepseek-ai/dsh-client-ui-image-drag": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
|
||||
@@ -52,3 +52,6 @@
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.image[data-pan="available"] { cursor: grab; }
|
||||
.image[data-pan="active"] { cursor: grabbing; }
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Complete image bytes rendered in a shared zoom viewport. */
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
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 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'
|
||||
@@ -24,8 +26,13 @@ const IMAGE_MEDIA_TYPES = {
|
||||
|
||||
type ImageMediaType = typeof IMAGE_MEDIA_TYPES[keyof typeof IMAGE_MEDIA_TYPES]
|
||||
|
||||
/** Image-only operations injected alongside the shared zoom lifecycle. */
|
||||
export interface ImageBodyInjected extends ZoomInjected {
|
||||
readonly imageDrag: import('@deepseek-ai/dsh-client-ui-primitives').ImageDragActions
|
||||
}
|
||||
|
||||
/** Standard document props plus the image renderer's dictionary. */
|
||||
export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> & PropsStore<ZoomStore> & ZoomInjected
|
||||
export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> & PropsStore<ZoomStore> & ImageBodyInjected
|
||||
|
||||
type ImageSource =
|
||||
| {
|
||||
@@ -33,6 +40,7 @@ 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 }
|
||||
|
||||
@@ -71,15 +79,16 @@ export function ImageBody(props: ImageBodyProps): ReactNode {
|
||||
if (data === undefined || mediaType === undefined) return
|
||||
let url: string | undefined
|
||||
try {
|
||||
url = URL.createObjectURL(new Blob([data], { type: mediaType }))
|
||||
setSource({ kind: 'ready', data, mediaType, url })
|
||||
const file = new File([data], pathPartsOf(path).name, { type: mediaType })
|
||||
url = URL.createObjectURL(file)
|
||||
setSource({ kind: 'ready', data, mediaType, url, file })
|
||||
} catch {
|
||||
setSource({ kind: 'failed', data, mediaType })
|
||||
}
|
||||
return () => {
|
||||
if (url !== undefined) URL.revokeObjectURL(url)
|
||||
}
|
||||
}, [data, mediaType])
|
||||
}, [data, mediaType, path])
|
||||
|
||||
if (data === undefined || mediaType === undefined) {
|
||||
return <p className={css.status} role="alert">{t('unsupported')}</p>
|
||||
@@ -93,15 +102,17 @@ 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} preference={preference}
|
||||
return <LoadedImage imageDrag={props.imageDrag} key={source.url} url={source.url} name={name} file={source.file} 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, preference, onPreference, labels, signal, scrollportRef, t }: {
|
||||
function LoadedImage({ url, name, file, preference, onPreference, labels, signal, scrollportRef, t, imageDrag }: {
|
||||
readonly imageDrag: ImageBodyInjected['imageDrag']
|
||||
readonly url: string
|
||||
readonly name: string
|
||||
readonly file: File
|
||||
readonly preference: ZoomPreference
|
||||
readonly onPreference: (preference: ZoomPreference) => void
|
||||
readonly labels: ZoomLabels
|
||||
@@ -109,6 +120,11 @@ function LoadedImage({ url, name, preference, onPreference, labels, signal, scro
|
||||
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}
|
||||
@@ -118,12 +134,13 @@ function LoadedImage({ url, name, preference, onPreference, labels, signal, scro
|
||||
{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'}>
|
||||
<img
|
||||
<DraggableImage imageDrag={imageDrag}
|
||||
ref={attachImage}
|
||||
className={css.image}
|
||||
src={url}
|
||||
alt={t('preview', { name })}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
name={name} original={file} failureText={t('dragFailed')}
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={(event) => { setWidth(event.currentTarget.naturalWidth); setState('ready') }}
|
||||
onError={() => { setState('failed') }}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/** Builtin image metadata and keyed document-body registration. */
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-image-drag/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '../index.ts'
|
||||
import type { DocumentPreviewDefinition } from '../document/registry.ts'
|
||||
import { retainDocumentTabs } from '../document/tab-lifetime.ts'
|
||||
import { createZoomStore, type ZoomInjected, type ZoomStore } from '../zoom/store.ts'
|
||||
import { ImageBody } from './ImageBody.tsx'
|
||||
import { createZoomStore, type ZoomStore } from '../zoom/store.ts'
|
||||
import { ImageBody, type ImageBodyInjected } from './ImageBody.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
/** Image implementation identity, shared by metadata and the keyed slot. */
|
||||
@@ -47,7 +48,8 @@ export function apply(ctx: Context): void {
|
||||
const retainTab = retainDocumentTabs(ctx)
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({
|
||||
name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage', store,
|
||||
inject: (_sessionId: SessionId, actions: BoundActions<ZoomStore>): ZoomInjected => ({
|
||||
inject: (_sessionId: SessionId, actions: BoundActions<ZoomStore>): ImageBodyInjected => ({
|
||||
imageDrag: ctx.imageDrag.actions,
|
||||
retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) },
|
||||
}),
|
||||
}, ImageBody)), 'document-image: body')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { zoomEn, zoomZh } from '../zoom/locales.ts'
|
||||
export const zh = {
|
||||
...zoomZh,
|
||||
title: '图片',
|
||||
dragFailed: '拖放失败,请重试',
|
||||
preview: '图片预览:{name}',
|
||||
loading: '文档渲染中...',
|
||||
failed: '无法显示这张图片',
|
||||
@@ -17,6 +18,7 @@ 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.',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* Required browser services: the tab registry, the slot registry, copy, and the
|
||||
* workspace Remote for bytes and paged text reads.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'configForms', 'resources']
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'configForms', 'resources', 'imageDrag']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the type, its dictionaries, its body, and its chip title.
|
||||
|
||||
@@ -47,6 +47,7 @@ interface Recorded {
|
||||
|
||||
async function boot() {
|
||||
const ctx = new Context()
|
||||
ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
ctx.provide('resources', createResources())
|
||||
ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never)
|
||||
const tabs = new SidebarRightTabRegistry(ctx)
|
||||
|
||||
@@ -38,6 +38,7 @@ afterEach(async () => {
|
||||
|
||||
async function boot() {
|
||||
const rt = await SlotTestRuntime.create()
|
||||
rt.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
runtime = rt
|
||||
rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never)
|
||||
rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo } as never)
|
||||
|
||||
@@ -43,6 +43,7 @@ function props(path = 'asset.png', data: Uint8Array<ArrayBuffer> = new Uint8Arra
|
||||
return selector(useSyncExternalStore(subscribe, snapshot))
|
||||
}
|
||||
return {
|
||||
imageDrag,
|
||||
resourceAddress: `dsh-resource://file/session/image/${path}`,
|
||||
content: { kind: 'bytes', data },
|
||||
wrap: false,
|
||||
@@ -83,13 +84,13 @@ describe('ImageBody', () => {
|
||||
expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
|
||||
})
|
||||
|
||||
it('keeps the image non-draggable, then shows it after decoding succeeds', async () => {
|
||||
it('allows a contained image to drag, 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('false')
|
||||
expect(image.getAttribute('draggable')).toBe('true')
|
||||
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
|
||||
expect(image.parentElement?.hasAttribute('hidden')).toBe(true)
|
||||
fireEvent.load(image)
|
||||
@@ -151,3 +152,5 @@ describe('ImageBody', () => {
|
||||
expect(instance.getSnapshot().byTab['image-tab' as TabId]).toEqual({ kind: 'fit-width' })
|
||||
})
|
||||
})
|
||||
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
@@ -0,0 +1,137 @@
|
||||
// @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()
|
||||
})
|
||||
@@ -47,6 +47,7 @@ describe('image registration', () => {
|
||||
entries.push(options)
|
||||
return () => { bodies.delete(options.key); entries.splice(entries.indexOf(options), 1) }
|
||||
})
|
||||
ctx.provide('imageDrag', { actions: imageDrag, read: () => undefined })
|
||||
ctx.provide('documentPreviews', registry)
|
||||
ctx.provide('slots', { inject: (_key: string, callback: () => () => void) => callback(), register } as never)
|
||||
ctx.provide('locale', {
|
||||
@@ -83,3 +84,5 @@ describe('image registration', () => {
|
||||
expect(dictionaries.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
const imageDrag = { desktop: undefined, begin: vi.fn(() => 'token'), end: vi.fn(), report: vi.fn() }
|
||||
@@ -67,6 +67,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-image-drag"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -83,6 +83,7 @@ const LAYOUT_CHILDREN = {
|
||||
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
runtime.remote.provideNamespaces({
|
||||
session: {
|
||||
openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })),
|
||||
|
||||
@@ -102,6 +102,7 @@ const ROOT_CHILDREN = {
|
||||
*/
|
||||
async function bench(snapshot: ChatSnapshot) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
runtimes.push(runtime)
|
||||
const ctx = runtime.ctx
|
||||
const chat = createSnapshotStore(snapshot)
|
||||
|
||||
@@ -76,6 +76,7 @@ const LAYOUT_CHILDREN = {
|
||||
*/
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } }))
|
||||
runtime.remote.provideNamespaces({ session: { openWorkspacePath } })
|
||||
runtime.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never)
|
||||
@@ -295,6 +296,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
describe('registrant declaration injection', () => {
|
||||
it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('imageDrag', { actions: { desktop: undefined, begin: () => 'token', end: () => {}, report: () => {} }, read: () => undefined })
|
||||
runtime.remote.provideNamespaces({
|
||||
session: {
|
||||
openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })),
|
||||
|
||||
@@ -182,7 +182,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:447',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:449',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.commandview',
|
||||
@@ -233,7 +233,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:429',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:431',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.flow',
|
||||
@@ -279,7 +279,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.chat.flow\', () => ctx.slots.register(\n { name: \'conversation.chat.flow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:396',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:398',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.message.images',
|
||||
@@ -325,7 +325,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.chat.message.images\', () => ctx.slots.register(\n { name: \'conversation.chat.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:421',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:423',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.node',
|
||||
@@ -398,7 +398,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.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:408',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:410',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.reasoning.body',
|
||||
@@ -441,7 +441,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.chat.reasoning.body\', () => ctx.slots.register(\n { name: \'conversation.chat.reasoning.body\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:435',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:437',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.turnTail',
|
||||
@@ -506,7 +506,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:441',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:443',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.user.images',
|
||||
@@ -552,7 +552,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.chat.user.images\', () => ctx.slots.register(\n { name: \'conversation.chat.user.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:423',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:425',
|
||||
},
|
||||
{
|
||||
key: 'conversation.composer',
|
||||
@@ -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:265',
|
||||
},
|
||||
{
|
||||
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:287',
|
||||
},
|
||||
{
|
||||
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:279',
|
||||
},
|
||||
{
|
||||
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:212',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:215',
|
||||
},
|
||||
{
|
||||
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:241',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:244',
|
||||
},
|
||||
{
|
||||
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:271',
|
||||
},
|
||||
{
|
||||
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:269',
|
||||
},
|
||||
{
|
||||
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:273',
|
||||
},
|
||||
{
|
||||
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:267',
|
||||
},
|
||||
{
|
||||
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:285',
|
||||
},
|
||||
{
|
||||
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:289',
|
||||
},
|
||||
{
|
||||
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:275',
|
||||
},
|
||||
{
|
||||
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:281',
|
||||
},
|
||||
{
|
||||
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:303',
|
||||
},
|
||||
{
|
||||
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:277',
|
||||
},
|
||||
{
|
||||
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:297',
|
||||
},
|
||||
{
|
||||
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:295',
|
||||
},
|
||||
{
|
||||
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:283',
|
||||
},
|
||||
{
|
||||
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:206',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:209',
|
||||
},
|
||||
{
|
||||
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:214',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:217',
|
||||
},
|
||||
{
|
||||
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:229',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:232',
|
||||
},
|
||||
{
|
||||
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:252',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:255',
|
||||
},
|
||||
{
|
||||
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:223',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:226',
|
||||
},
|
||||
{
|
||||
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:235',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:238',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session.header.views.actions',
|
||||
@@ -1884,7 +1884,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.views.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.views.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:258',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:261',
|
||||
},
|
||||
{
|
||||
key: 'conversation.trajectory.images',
|
||||
@@ -1985,7 +1985,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:196',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199',
|
||||
},
|
||||
{
|
||||
key: 'conversation.view',
|
||||
@@ -2049,7 +2049,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:263',
|
||||
},
|
||||
{
|
||||
key: 'deliverables.file.actions',
|
||||
@@ -2252,7 +2252,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:204',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:207',
|
||||
},
|
||||
{
|
||||
key: 'plugins.add.actions',
|
||||
@@ -3445,6 +3445,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-chat QuotaNoticeHost id \'chat.quota-notice\'',
|
||||
'client-ui-image-drag ImageDragFeedback id \'image-drag.feedback\'',
|
||||
'client-ui-plugin-manager PluginRefreshToast id \'plugin-manager.refresh-toast\'',
|
||||
'client-ui-schedule ScheduleDeleteToast id \'schedule.delete-toast\'',
|
||||
'client-ui-settings-account DesktopOnboardingEntry id \'desktop-onboarding\'',
|
||||
@@ -3501,7 +3502,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.quota-notice\', () => ctx.slots.register(\n { name: \'shell.quota-notice\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:455',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:457',
|
||||
},
|
||||
{
|
||||
key: 'sidebar',
|
||||
|
||||
+1
@@ -80,6 +80,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | no | Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser |
|
||||
| `@deepseek-ai/dsh-client-ui-entry-customization` | no | Customize plugin entries in the left sidebar, conversation views, and right sidebar, and quickly create new plugins. |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | no | Session goal surface: GoalBar docked above the composer, read from the goal session projection |
|
||||
| `@deepseek-ai/dsh-client-ui-image-drag` | no | Application-owned original-image drag transfers and failure feedback |
|
||||
| `@deepseek-ai/dsh-client-ui-input-trigger` | no | Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources |
|
||||
| `@deepseek-ai/dsh-client-ui-jobs` | no | Session-header background-job list with on-demand streaming record panels |
|
||||
| `@deepseek-ai/dsh-client-ui-layout` | no | Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels) |
|
||||
|
||||
Generated
+39
@@ -2899,6 +2899,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-goal':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-goal
|
||||
'@deepseek-ai/dsh-client-ui-image-drag':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-image-drag
|
||||
'@deepseek-ai/dsh-client-ui-input-trigger':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-input-trigger
|
||||
@@ -3638,6 +3641,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-image-drag':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-image-drag
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-primitives
|
||||
@@ -3735,6 +3741,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-image-drag':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-image-drag
|
||||
'@deepseek-ai/dsh-client-ui-input-trigger':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-input-trigger
|
||||
@@ -4338,6 +4347,33 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-image-drag:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-layout':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-layout
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-renderer':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-renderer
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-slots
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-input-trigger:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -5864,6 +5900,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-dockkit':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-dockkit
|
||||
'@deepseek-ai/dsh-client-ui-image-drag':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-image-drag
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-primitives
|
||||
|
||||
@@ -194,6 +194,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
shortcuts: 'client-side interface-typed keyboard service — packages/client/shortcuts/README.md owns the API',
|
||||
userQuestionPanels: 'client-side slot-contract accessor (UserQuestionPanels) — packages/client/ui-tool/README.md owns the API',
|
||||
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
|
||||
imageDrag: 'client-side original-image transfer service — packages/client/ui-image-drag/README.md owns the API',
|
||||
feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API',
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user