feat(web): align attachment display with DeepSeek Chat via ui-attachment atoms
Single-click original preview in the composer rail and chat history; remove control inside the thumbnail, revealed on hover/focus (always on touch); hidden-scrollbar rail overflow paged by edge arrows with wheel panning and end-reveal on add; image-intake rejections and prompt failures announce as a transient top-center toast instead of inline strips. The attachment atoms move to a new zero-cordis package @deepseek-ai/dsh-client-ui-attachment (rail, message gallery, lightbox), seeded as a platform module; the toast is a ui-primitives atom. Strings arrive as label props bridged from the conversation dictionary.
This commit is contained in:
6
packages/client/ui-attachment/README.i18n.yaml
Normal file
6
packages/client/ui-attachment/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
|
||||
README.md: a0a410532a631f243c3dfef7debfc6ce0bb0699e
|
||||
README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6
|
||||
26
packages/client/ui-attachment/README.md
Normal file
26
packages/client/ui-attachment/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# @deepseek-ai/dsh-client-ui-attachment
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
|
||||
|
||||
## Attachment rail
|
||||
|
||||
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling, and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and window resizes. A vertical wheel pans the rail horizontally with per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
|
||||
|
||||
## Message images and the lightbox
|
||||
|
||||
`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments.
|
||||
- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only.
|
||||
26
packages/client/ui-attachment/README.zh.md
Normal file
26
packages/client/ui-attachment/README.zh.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# @deepseek-ai/dsh-client-ui-attachment
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
|
||||
|
||||
## 附件栏
|
||||
|
||||
`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动,箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
|
||||
|
||||
## 消息图片与灯箱
|
||||
|
||||
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
|
||||
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。
|
||||
49
packages/client/ui-attachment/package.json
Normal file
49
packages/client/ui-attachment/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-attachment",
|
||||
"description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-attachment"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
}
|
||||
112
packages/client/ui-attachment/src/AttachmentRail.module.css
Normal file
112
packages/client/ui-attachment/src/AttachmentRail.module.css
Normal file
@@ -0,0 +1,112 @@
|
||||
/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a
|
||||
16px radius, remove control fully inside the card, arrows overlaid at the
|
||||
edges instead of a scrollbar. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* Edge arrows page the overflow; the scrollbar stays hidden (both engines). */
|
||||
scrollbar-width: none;
|
||||
/* The rail scrolls on the composer's elevated input surface: bind the l2
|
||||
pair (ui-theme styles/scrollbar.css rebinding contract) so anything that
|
||||
does draw a thumb here matches the surface. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
flex: 0 0 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-button-contrast-fill);
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.item:hover .remove,
|
||||
.remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Touch surfaces have no hover to reveal the control. */
|
||||
@media (pointer: coarse) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
right: 4px;
|
||||
}
|
||||
152
packages/client/ui-attachment/src/AttachmentRail.tsx
Normal file
152
packages/client/ui-attachment/src/AttachmentRail.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged
|
||||
* by edge arrows, hover-revealed per-item remove, single-click open. */
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { WheelEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './AttachmentRail.module.css'
|
||||
|
||||
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
|
||||
export interface AttachmentRailItem {
|
||||
/** Stable identity for the React key. */
|
||||
id: string
|
||||
/** Object or data URL rendered as the thumbnail. */
|
||||
previewUrl: string
|
||||
/** Image alt text (display name with the owner's fallback applied). */
|
||||
alt: string
|
||||
/** Accessible label of the item's remove control. */
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
/** Rail-level strings the owner resolves from its own locale namespace. */
|
||||
export interface AttachmentRailLabels {
|
||||
/** Accessible name of the rail group. */
|
||||
group: string
|
||||
/** Thumbnail tooltip inviting the original-image preview. */
|
||||
open: string
|
||||
/** Accessible label of the left paging arrow. */
|
||||
scrollLeft: string
|
||||
/** Accessible label of the right paging arrow. */
|
||||
scrollRight: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal thumbnail rail over the caller's draft attachments.
|
||||
*
|
||||
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
||||
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
||||
* window resizes. A vertical wheel pans horizontally, a newly added item is
|
||||
* revealed at the rail's end, and each thumbnail opens on a single click while
|
||||
* its remove control sits inside the card and reveals on hover or focus.
|
||||
* The owner decides mounting; it renders the rail only while items exist.
|
||||
*
|
||||
* @param props.items - resolved thumbnails in draft order.
|
||||
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
|
||||
* @param props.onOpen - single-click open of one item's original image.
|
||||
* @param props.onRemove - remove one item from the draft.
|
||||
* @returns the rail group with its paging arrows.
|
||||
*/
|
||||
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
|
||||
items: readonly T[]
|
||||
labels: AttachmentRailLabels
|
||||
onOpen: (item: T) => void
|
||||
onRemove: (item: T) => void
|
||||
}) {
|
||||
const railRef = useRef<HTMLDivElement | null>(null)
|
||||
const countRef = useRef(0)
|
||||
const [edges, setEdges] = useState({ left: false, right: false })
|
||||
const updateEdges = useCallback(() => {
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */
|
||||
if (el === null) return
|
||||
// 1px slack: engines report fractional scroll positions at the edges.
|
||||
const left = el.scrollLeft > 1
|
||||
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
||||
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
||||
}, [])
|
||||
useLayoutEffect(() => {
|
||||
const grew = items.length > countRef.current
|
||||
countRef.current = items.length
|
||||
const el = railRef.current
|
||||
// A newly added attachment lands at the rail's end: reveal it.
|
||||
if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth
|
||||
updateEdges()
|
||||
}, [items.length, updateEdges])
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', updateEdges)
|
||||
return () => { window.removeEventListener('resize', updateEdges) }
|
||||
}, [updateEdges])
|
||||
const page = (direction: -1 | 1): void => {
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */
|
||||
if (el === null) return
|
||||
// One viewport minus a card keeps the last visible thumbnail as context;
|
||||
// the floor keeps narrow rails paging a useful distance.
|
||||
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' })
|
||||
}
|
||||
// A vertical wheel pans the rail horizontally (trackpads pan natively via
|
||||
// deltaX); per-tick travel is clamped so a fast notch wheel stays followable.
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
|
||||
if (event.deltaX !== 0 || event.deltaY === 0) return
|
||||
event.currentTarget.scrollBy({
|
||||
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60),
|
||||
behavior: 'auto',
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{edges.left && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.arrow, css.arrowLeft)}
|
||||
aria-label={labels.scrollLeft}
|
||||
onClick={() => { page(-1) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={railRef}
|
||||
className={css.rail}
|
||||
role="group"
|
||||
aria-label={labels.group}
|
||||
onScroll={updateEdges}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className={css.item}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.thumbnail}
|
||||
title={labels.open}
|
||||
onClick={() => { onOpen(item) }}
|
||||
>
|
||||
<img src={item.previewUrl} alt={item.alt} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.remove}
|
||||
aria-label={item.removeLabel}
|
||||
onClick={() => { onRemove(item) }}
|
||||
>
|
||||
<IconCloseFill14 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{edges.right && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.arrow, css.arrowRight)}
|
||||
aria-label={labels.scrollRight}
|
||||
onClick={() => { page(1) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
packages/client/ui-attachment/src/ImageLightbox.module.css
Normal file
34
packages/client/ui-attachment/src/ImageLightbox.module.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: min(100%, 1600px);
|
||||
max-height: calc(100vh - 80px);
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
57
packages/client/ui-attachment/src/ImageLightbox.tsx
Normal file
57
packages/client/ui-attachment/src/ImageLightbox.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Lightbox strings the owner resolves from its own locale namespace. */
|
||||
export interface ImageLightboxLabels {
|
||||
/** Accessible name of the preview dialog. */
|
||||
dialog: string
|
||||
/** Accessible label of the close control. */
|
||||
close: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-level original-image preview opened by clicking a thumbnail.
|
||||
* Closes on Escape, backdrop press, or the close control, and restores focus
|
||||
* to the opener on unmount.
|
||||
*
|
||||
* @param props.src - the original image URL.
|
||||
* @param props.alt - the image's alt text.
|
||||
* @param props.labels - dialog and close-control strings.
|
||||
* @param props.onClose - dismiss callback owned by the opener.
|
||||
* @returns the modal preview dialog.
|
||||
*/
|
||||
export function ImageLightbox({ src, alt, labels, onClose }: {
|
||||
src: string
|
||||
alt: string
|
||||
labels: ImageLightboxLabels
|
||||
onClose: () => void
|
||||
}) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const restoreRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
closeRef.current?.focus()
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
restoreRef.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.dialog}
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||||
>
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
packages/client/ui-attachment/src/MessageImage.module.css
Normal file
53
packages/client/ui-attachment/src/MessageImage.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.gallery[data-align='end'] {
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.gallery[data-align='start'] {
|
||||
justify-content: flex-start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
max-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
96
packages/client/ui-attachment/src/MessageImage.tsx
Normal file
96
packages/client/ui-attachment/src/MessageImage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
/** Loads a session-authorized durable image URL. */
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** Message-image strings the owner resolves from its own locale namespace. */
|
||||
export interface MessageImageLabels {
|
||||
/** Fallback display name for an unnamed image. */
|
||||
image: string
|
||||
/** Thumbnail tooltip inviting the original-image preview. */
|
||||
open: string
|
||||
/** Accessible thumbnail label; receives the image's display name. */
|
||||
openNamed: (label: string) => string
|
||||
/** Loading placeholder shown until bytes resolve. */
|
||||
loading: string
|
||||
/** Retry-control label shown when the load fails. */
|
||||
loadFailed: string
|
||||
/** Lightbox strings forwarded to the opened preview. */
|
||||
lightbox: ImageLightboxLabels
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact history renderer with retryable loading and click-to-open original
|
||||
* preview.
|
||||
*
|
||||
* @param props.attachment - the durable image reference to load and bound.
|
||||
* @param props.load - session-authorized URL loader.
|
||||
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
|
||||
* @returns the bounded thumbnail button, or the retry control on failure.
|
||||
*/
|
||||
export function MessageImage({ attachment, load, labels }: {
|
||||
attachment: ImageAttachmentRef
|
||||
load: ImageLoader
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const size = useMemo(() => {
|
||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
||||
}, [attachment.height, attachment.width])
|
||||
|
||||
const request = useCallback(() => {
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then(setSrc).catch(() => { setError(true) })
|
||||
}, [attachment, load])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setError(false)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
|
||||
const label = attachment.name ?? labels.image
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.frame}
|
||||
style={size}
|
||||
title={labels.open}
|
||||
aria-label={labels.openNamed(label)}
|
||||
onClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
export function ImageGallery({ images, load, align, labels }: {
|
||||
images: readonly { attachment: ImageAttachmentRef }[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
packages/client/ui-attachment/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-attachment/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
14
packages/client/ui-attachment/src/index.ts
Normal file
14
packages/client/ui-attachment/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
|
||||
* the chat-history image gallery, and the original-image lightbox. Owners
|
||||
* resolve every string through their own locale namespace and pass it down;
|
||||
* nothing here reads application state.
|
||||
* @module @deepseek-ai/dsh-client-ui-attachment
|
||||
*/
|
||||
|
||||
export { AttachmentRail } from './AttachmentRail.tsx'
|
||||
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
|
||||
export { ImageLightbox } from './ImageLightbox.tsx'
|
||||
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
||||
export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx'
|
||||
31
packages/client/ui-attachment/src/invariant.ts
Normal file
31
packages/client/ui-attachment/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`.
|
||||
* @module @deepseek-ai/dsh-client-ui-attachment/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-attachment'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-attachment-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: pure props-in React atoms with no Cordis API —
|
||||
* no events, no services, no mutable cross-plugin state; rendering contracts
|
||||
* are asserted directly by this package's component specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
128
packages/client/ui-attachment/tests/attachment-rail.spec.tsx
Normal file
128
packages/client/ui-attachment/tests/attachment-rail.spec.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
|
||||
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
|
||||
// vertical-wheel pan, and the new-item end reveal.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { AttachmentRail } from '../src/AttachmentRail.tsx'
|
||||
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labels: AttachmentRailLabels = {
|
||||
group: '待发送图片',
|
||||
open: '查看原图',
|
||||
scrollLeft: '向左滚动图片',
|
||||
scrollRight: '向右滚动图片',
|
||||
}
|
||||
|
||||
function item(id: string): AttachmentRailItem {
|
||||
return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` }
|
||||
}
|
||||
|
||||
/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */
|
||||
function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
|
||||
Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true })
|
||||
Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true })
|
||||
let scrollLeft = 0
|
||||
Object.defineProperty(rail, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get: () => scrollLeft,
|
||||
set: (value: number) => { scrollLeft = value },
|
||||
})
|
||||
const scrollBy = vi.fn((options: { left: number }) => {
|
||||
scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left))
|
||||
})
|
||||
rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy
|
||||
return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } }
|
||||
}
|
||||
|
||||
describe('AttachmentRail', () => {
|
||||
it('renders thumbnails in order and routes open and remove clicks', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const items = [item('a'), item('b')]
|
||||
const view = render(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png'])
|
||||
fireEvent.click(view.getAllByTitle('查看原图')[0]!)
|
||||
expect(onOpen).toHaveBeenCalledWith(items[0])
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' }))
|
||||
expect(onRemove).toHaveBeenCalledWith(items[1])
|
||||
})
|
||||
|
||||
it('shows edge arrows from scroll geometry and pages a viewport at a time', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
// No arrows until geometry is observed (mount saw jsdom's zero metrics).
|
||||
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
|
||||
fireEvent.scroll(rail)
|
||||
// Same-edges scroll takes the memoized-state path.
|
||||
fireEvent.scroll(rail)
|
||||
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
|
||||
const right = view.getByLabelText('向右滚动图片')
|
||||
// clientWidth 200 - 64 < the 200 floor: pages by the floor.
|
||||
fireEvent.click(right)
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
|
||||
fireEvent.scroll(rail)
|
||||
// Scrolled to the far edge: only the left arrow remains.
|
||||
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
|
||||
fireEvent.click(view.getByLabelText('向左滚动图片'))
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' })
|
||||
fireEvent.scroll(rail)
|
||||
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows both arrows mid-scroll and recomputes on window resize', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
setScrollLeft(100)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pans horizontally on a vertical wheel with clamped travel', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
fireEvent.wheel(rail, { deltaY: 30 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: 500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: -500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
|
||||
// A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
|
||||
fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })
|
||||
fireEvent.wheel(rail, { deltaY: 0 })
|
||||
expect(scrollBy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('reveals the rail end when an item is added, not when one is removed', () => {
|
||||
const first = [item('a'), item('b')]
|
||||
const view = render(
|
||||
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
view.rerender(
|
||||
<AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
expect(rail.scrollLeft).toBe(200)
|
||||
view.rerender(
|
||||
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
// Removal keeps the position; only growth jumps to the end.
|
||||
expect(rail.scrollLeft).toBe(200)
|
||||
})
|
||||
})
|
||||
50
packages/client/ui-attachment/tests/image-lightbox.spec.tsx
Normal file
50
packages/client/ui-attachment/tests/image-lightbox.spec.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { ImageLightbox } from '../src/ImageLightbox.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labels = { dialog: '原图预览', close: '关闭原图预览' }
|
||||
|
||||
describe('ImageLightbox', () => {
|
||||
it('focuses its close control, closes by button and Escape, and restores focus', () => {
|
||||
const opener = document.createElement('button')
|
||||
document.body.appendChild(opener)
|
||||
opener.focus()
|
||||
const onClose = vi.fn()
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
||||
const close = view.getByRole('button', { name: '关闭原图预览' })
|
||||
expect(document.activeElement).toBe(close)
|
||||
fireEvent.keyDown(window, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
fireEvent.click(close)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
view.unmount()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
opener.remove()
|
||||
})
|
||||
|
||||
it('tolerates a focus owner it cannot restore (no active element at mount)', () => {
|
||||
// jsdom always reports body as the fallback active element; stub the
|
||||
// element-less state a detached focus can leave.
|
||||
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null })
|
||||
try {
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={vi.fn()} />)
|
||||
view.unmount()
|
||||
} finally {
|
||||
delete (document as { activeElement?: unknown }).activeElement
|
||||
}
|
||||
})
|
||||
|
||||
it('closes on a backdrop press but not on a press over the image', () => {
|
||||
const onClose = vi.fn()
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
||||
fireEvent.mouseDown(view.getByRole('img'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
12
packages/client/ui-attachment/tests/invariant.spec.ts
Normal file
12
packages/client/ui-attachment/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
103
packages/client/ui-attachment/tests/message-image.spec.tsx
Normal file
103
packages/client/ui-attachment/tests/message-image.spec.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
|
||||
import type { MessageImageLabels } from '../src/MessageImage.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labels: MessageImageLabels = {
|
||||
image: '图片',
|
||||
open: '查看原图',
|
||||
openNamed: label => `${label},点击查看原图`,
|
||||
loading: '图片加载中…',
|
||||
loadFailed: '图片加载失败,点击重试',
|
||||
lightbox: { dialog: '原图预览', close: '关闭原图预览' },
|
||||
}
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
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 attachment={attachment} load={load} labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
expect(frame.getAttribute('title')).toBe('查看原图')
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
fireEvent.click(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a click while the thumbnail is still loading', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(view.getByText('图片加载中…')).toBeTruthy()
|
||||
fireEvent.click(frame)
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
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 attachment={unnamed} load={load} labels={labels} />)
|
||||
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
||||
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockRejectedValueOnce(new Error('still offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retryAgain)
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
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 attachment={attachment} load={load} 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 attachment={attachment} load={failing} labels={labels} />)
|
||||
second.unmount()
|
||||
reject?.(new Error('late failure'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
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} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const view = render(
|
||||
<ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
)
|
||||
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
|
||||
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
|
||||
})
|
||||
})
|
||||
21
packages/client/ui-attachment/tsconfig.json
Normal file
21
packages/client/ui-attachment/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
}
|
||||
]
|
||||
}
|
||||
31
packages/client/ui-attachment/tsdown.config.ts
Normal file
31
packages/client/ui-attachment/tsdown.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { clientOnly } from '../tsdown.client.ts'
|
||||
|
||||
/**
|
||||
* ui-attachment is browser-only, but its lib bundle IS imported under plain
|
||||
* Node because the web shell is a lib (dsh-client-web's lib chain reaches
|
||||
* this package). CSS imports are therefore stubbed to empty modules instead
|
||||
* of externalized — the hashed class maps only matter in bundler contexts
|
||||
* (loader module table / vite source paths), which compile src directly and
|
||||
* never read lib.
|
||||
*/
|
||||
export default clientOnly([{
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
plugins: [{
|
||||
name: 'dsh-css-stub',
|
||||
resolveId(source: string) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
return `\0dsh-css-stub:${source}.mjs`
|
||||
},
|
||||
load(id: string) {
|
||||
if (!id.startsWith('\0dsh-css-stub:')) return null
|
||||
return 'export default {};'
|
||||
},
|
||||
}],
|
||||
}])
|
||||
Reference in New Issue
Block a user