feat(web): whole-page image drop, projected intake limits, and thumbnail tiling
Second alignment step for #2248: document-level drag intake behind the new DropOverlay atom, lightbox close icon and shared dialog mask, DeepSeek Chat thumbnail rules (single 240px long edge with ratio clamp, 64px tiles, merged consecutive assistant images), image limits raised to 20/10MiB/100MiB and published to clients as the imageLimits projection, whole-batch intake pre-check with product-copy banners, and attachment-error reasons mapped to localized copy.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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: 9fab9c23b958606030b1e87fcbfa45130c980947
|
||||
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5
|
||||
README.md: 65db3f03b3ef174d12de75786e197da4712c4de1
|
||||
README.zh.md: 2b714e880912d6dc097ce73aca4286a674bdade5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), the original-image lightbox (`ImageLightbox`), and the full-page drop overlay (`DropOverlay`). 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
|
||||
|
||||
@@ -10,7 +10,11 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
|
||||
|
||||
## 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.
|
||||
`MessageImage` renders one durable history image, 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). Sizing follows DeepSeek Chat: a message's lone image (`variant="single"`) renders at 240px on its longer edge with the displayed aspect ratio clamped to [0.25, 4] — the overflow is cropped by `object-fit: cover`, anchored to the top of very tall images and the left of very wide ones — and never upscales past its natural size; an image among several (`variant="tile"`) is a fixed 64px square. `ImageGallery` wraps a message's images in one aligned wrapping flex group (`end` for user messages, `start` for assistant messages), picks the variant from the image count, and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview over the shared dialog mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`, painted on its own layer so the blur never touches the previewed image) that closes on Escape, a mask press, or its close control, and restores focus to its opener on unmount.
|
||||
|
||||
## Drop overlay
|
||||
|
||||
`DropOverlay` is the full-viewport invitation shown while a file drag is over the page: illustration, title, and a limits line while drops are accepted (`disabled` swaps the blocked illustration and hides the limits line). The layer is pointer-inert — the owner's document-level drag listeners keep the enter/leave count and decide accept/reject; the overlay only shows state. It portals to the body like the lightbox.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
|
||||
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)、原图灯箱(`ImageLightbox`)与整页拖放遮罩(`DropOverlay`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
|
||||
|
||||
## 附件栏
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
|
||||
## 消息图片与灯箱
|
||||
|
||||
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
|
||||
`MessageImage` 渲染一张持久化历史图片,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。尺寸规则对齐 DeepSeek Chat:一条消息仅有的一张图(`variant="single"`)长边 240px、展示宽高比钳制在 [0.25, 4] 之间——超出部分由 `object-fit: cover` 裁切,特别高的图锚定顶部、特别宽的图锚定左侧——且从不放大超过原始尺寸;多图中的一张(`variant="tile"`)为固定 64px 方块。`ImageGallery` 将一条消息的图片包为一个对齐的可换行弹性分组(用户消息 `end`,助手消息 `start`),按图片数量选择 variant,空列表不渲染。`ImageLightbox` 是文档级模态预览,铺在共享的对话框遮罩上(`--dsw-alias-bg-mask-1` 加 `--dsw-mask-blur`,画在独立图层上,模糊不会波及预览图本身),按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
|
||||
|
||||
## 拖放遮罩
|
||||
|
||||
`DropOverlay` 是文件拖拽悬停页面时的全视口邀请层:插画、标题,接受拖放时再加一行上限说明(`disabled` 换为禁用插画并隐藏上限行)。该层不接收指针事件——持有方的 document 级拖拽监听器负责 enter/leave 计数和接受与否的判定;遮罩只呈现状态。与灯箱一样经 body portal 渲染。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
45
packages/client/ui-attachment/src/DropOverlay.module.css
Normal file
45
packages/client/ui-attachment/src/DropOverlay.module.css
Normal file
@@ -0,0 +1,45 @@
|
||||
/* Full-viewport drop invitation (DeepSeek Chat DragMask). pointer-events:
|
||||
none — the layer is decoration; drag events must keep hitting the page so
|
||||
the owner's enter/leave count stays balanced. */
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .mask {
|
||||
background-color: rgba(39, 39, 48, 0.7);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: -3%;
|
||||
padding: 0 40px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 115px;
|
||||
height: 84px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 16px;
|
||||
font: var(--dsw-font-l-20);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: 16px;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
78
packages/client/ui-attachment/src/DropOverlay.tsx
Normal file
78
packages/client/ui-attachment/src/DropOverlay.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import css from './DropOverlay.module.css'
|
||||
|
||||
/** Drop-overlay strings the owner resolves from its own locale namespace. */
|
||||
export interface DropOverlayLabels {
|
||||
/** Headline inviting the drop, or naming why it is unavailable. */
|
||||
title: string
|
||||
/** Limits line under the title; shown only while drops are accepted. */
|
||||
desc?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-viewport invitation shown while a file drag is over the page
|
||||
* (DeepSeek Chat's DragMask). Decoration only: `pointer-events: none` keeps
|
||||
* drag targeting on the page below, so the owner's document-level listeners
|
||||
* keep an accurate enter/leave count and own accept/reject. Rendered through
|
||||
* a body portal for the same transformed-ancestor reason as the lightbox.
|
||||
*
|
||||
* @param props.disabled - drops are currently refused; renders the blocked
|
||||
* illustration and drops the desc line.
|
||||
* @param props.labels - resolved title and limits strings.
|
||||
* @returns the overlay layer.
|
||||
*/
|
||||
export function DropOverlay({ disabled, labels }: {
|
||||
disabled: boolean
|
||||
labels: DropOverlayLabels
|
||||
}) {
|
||||
return createPortal(
|
||||
<div className={clsx(css.mask, disabled && css.disabled)} role="status">
|
||||
<div className={css.wrap}>
|
||||
<div className={css.illustration} aria-hidden="true">
|
||||
{disabled ? <UploadDisabledIllustration /> : <UploadIllustration />}
|
||||
</div>
|
||||
<div className={css.title}>{labels.title}</div>
|
||||
{!disabled && labels.desc !== undefined && <div className={css.desc}>{labels.desc}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
/** Tilted photo-and-note cards (DeepSeek Chat upload illustration). */
|
||||
const UploadIllustration = () => (
|
||||
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#dshDropOverlayClip)">
|
||||
<rect y="17.0742" width="44.1832" height="43.6431" rx="12" transform="rotate(-22.7338 0 17.0742)" fill="#9CE5ED" />
|
||||
<rect x="73.4043" y="8.54297" width="43.7267" height="50.5284" rx="8" transform="rotate(17.403 73.4043 8.54297)" fill="#679EFE" />
|
||||
<path d="M30.4917 28.1369L40.8865 33.4564L37.2232 34.9524L29.5302 31.0159L26.7919 39.2122L23.1285 40.7082L26.8287 29.6338L16.8967 24.5516L20.5601 23.0556L27.7902 26.7549L30.3639 19.052L34.0273 17.556L30.4917 28.1369Z" fill="white" />
|
||||
<path d="M77.5088 26.3047L101.057 33.7966" stroke="white" strokeWidth="3" />
|
||||
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
|
||||
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
|
||||
<rect x="31.583" y="38.6641" width="44.9157" height="44.3666" rx="12" transform="rotate(-0.134233 31.583 38.6641)" fill="#3964FE" />
|
||||
<path d="M38.9521 73.0337C39.6129 71.7086 41.7113 66.0937 43.5113 61.1663C44.1607 59.3885 46.7484 59.3923 47.4591 61.1465C48.9728 64.8828 50.7969 68.6922 51.9988 69.1925C54.2946 70.1482 57.9854 59.3573 68.0064 70.1801" stroke="white" strokeWidth="3" />
|
||||
<circle cx="60.6157" cy="52.247" r="4.38794" transform="rotate(22.5996 60.6157 52.247)" fill="white" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="dshDropOverlayClip">
|
||||
<rect width="115" height="84" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Greyed cards with a blocked badge (DeepSeek Chat disabled illustration). */
|
||||
const UploadDisabledIllustration = () => (
|
||||
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.6829 4.63701L11.0677 12.4368C4.95519 14.998 2.07624 22.0294 4.6374 28.1419L12.2285 46.259C14.7896 52.3715 21.8211 55.2505 27.9336 52.6893L46.5488 44.8895C52.6613 42.3283 55.5403 35.2969 52.9791 29.1844L45.388 11.0673C42.8269 4.9548 35.7954 2.07585 29.6829 4.63701Z" fill="#979DA6" />
|
||||
<path d="M30.4915 28.1375L40.8863 33.4569L37.223 34.9529L29.53 31.0165L26.7917 39.2128L23.1283 40.7088L26.8285 29.6344L16.8965 24.5522L20.5599 23.0562L27.79 26.7555L30.3637 19.0526L34.0271 17.5566L30.4915 28.1375Z" fill="white" />
|
||||
<path d="M107.496 19.2285L81.0381 10.9357C76.8221 9.61423 72.333 11.9607 71.0116 16.1768L60.6844 49.1246C59.363 53.3406 61.7095 57.8297 65.9255 59.1511L92.383 67.4439C96.599 68.7654 101.088 66.4189 102.41 62.2029L112.737 29.255C114.058 25.039 111.712 20.55 107.496 19.2285Z" fill="#979DA6" />
|
||||
<path d="M77.5088 26.3047L101.057 33.7967" stroke="white" strokeWidth="3" />
|
||||
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
|
||||
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
|
||||
<path d="M66.5798 30.1418L41.481 30.2006C33.5281 30.2193 27.0962 36.6815 27.1148 44.6343L27.172 69.0742C27.1907 77.0271 33.6529 83.459 41.6057 83.4404L66.7045 83.3816C74.6574 83.363 81.0894 76.9008 81.0707 68.9479L81.0135 44.5081C80.9949 36.5552 74.5327 30.1232 66.5798 30.1418Z" fill="#F59E0B" />
|
||||
<path d="M54 70.7969C61.732 70.7969 68 64.5289 68 56.7969C68 49.0649 61.732 42.7969 54 42.7969C46.268 42.7969 40 49.0649 40 56.7969C40 64.5289 46.268 70.7969 54 70.7969Z" stroke="white" strokeWidth="3.5" />
|
||||
<path d="M44 46.7969L64 66.7969" stroke="white" strokeWidth="3.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
@@ -5,10 +5,21 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
|
||||
}
|
||||
|
||||
/* Same mask recipe as the Modal primitive and the settings dialog. A separate
|
||||
layer, not a background on .backdrop: backdrop-filter there would blur the
|
||||
previewed image and the close control along with the page. */
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: var(--dsw-mask-blur);
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: min(100%, 1600px);
|
||||
max-height: calc(100vh - 80px);
|
||||
object-fit: contain;
|
||||
@@ -29,6 +40,5 @@
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Lightbox strings the owner resolves from its own locale namespace. */
|
||||
@@ -51,10 +52,12 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.dialog}
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||||
>
|
||||
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
|
||||
<IconCloseOutline16 size={16} />
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: min(240px, 100%);
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.gallery[data-align='end'] {
|
||||
@@ -29,11 +29,18 @@
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.frame[data-variant='tile'] {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
min-width: 64px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.loading,
|
||||
|
||||
@@ -23,18 +23,38 @@ export interface MessageImageLabels {
|
||||
lightbox: ImageLightboxLabels
|
||||
}
|
||||
|
||||
/** Display box for a lone image (DeepSeek Chat rule): long edge 240px with
|
||||
* the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by
|
||||
* `object-fit: cover` — and never upscaled past the image's natural size. The
|
||||
* crop anchor keeps the top of very tall images and the left of very wide
|
||||
* ones, where the informative content usually starts. */
|
||||
function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } {
|
||||
const natural = attachment.width / attachment.height
|
||||
const ratio = Math.min(4, Math.max(0.25, natural))
|
||||
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 }
|
||||
const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height)
|
||||
return {
|
||||
width: Math.max(1, Math.round(box.width * scale)),
|
||||
height: Math.max(1, Math.round(box.height * scale)),
|
||||
objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact history renderer with retryable loading and click-to-open original
|
||||
* preview.
|
||||
* preview. A lone image renders at its `singleFit` size; an image among
|
||||
* several renders as a fixed 64px square tile.
|
||||
*
|
||||
* @param props.attachment - the durable image reference to load and bound.
|
||||
* @param props.load - session-authorized URL loader.
|
||||
* @param props.variant - `single` for a message's lone image, `tile` otherwise.
|
||||
* @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 }: {
|
||||
export function MessageImage({ attachment, load, variant, labels }: {
|
||||
attachment: ImageAttachmentRef
|
||||
load: ImageLoader
|
||||
variant: 'single' | 'tile'
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
@@ -45,10 +65,10 @@ export function MessageImage({ attachment, load, labels }: {
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
|
||||
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 fit = useMemo(
|
||||
() => (variant === 'single' ? singleFit(attachment) : undefined),
|
||||
[attachment, variant],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
@@ -65,19 +85,23 @@ export function MessageImage({ attachment, load, labels }: {
|
||||
<button
|
||||
type="button"
|
||||
className={css.frame}
|
||||
style={size}
|
||||
data-variant={variant}
|
||||
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
|
||||
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} />}
|
||||
{src === null
|
||||
? <span className={css.loading}>{labels.loading}</span>
|
||||
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
/** Wrapping image group shared by user and assistant history: a lone image
|
||||
* renders large, several render as 64px square tiles (DeepSeek Chat rule). */
|
||||
export function ImageGallery({ images, load, align, labels }: {
|
||||
images: readonly { attachment: ImageAttachmentRef }[]
|
||||
load: ImageLoader
|
||||
@@ -85,10 +109,11 @@ export function ImageGallery({ images, load, align, labels }: {
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
const variant = images.length === 1 ? 'single' : 'tile'
|
||||
return (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} variant={variant} labels={labels} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
* the chat-history image gallery, the original-image lightbox, and the
|
||||
* full-page drop overlay. 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 { DropOverlay } from './DropOverlay.tsx'
|
||||
export type { DropOverlayLabels } from './DropOverlay.tsx'
|
||||
export { ImageLightbox } from './ImageLightbox.tsx'
|
||||
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { DropOverlay } from '../src/DropOverlay.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('DropOverlay', () => {
|
||||
it('portals the invitation with its title and limits desc to the body', () => {
|
||||
const view = render(
|
||||
<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加', desc: '最多 20 张,每张 10MB' }} />,
|
||||
)
|
||||
const overlay = view.getByRole('status')
|
||||
expect(overlay.parentElement).toBe(document.body)
|
||||
expect(overlay.textContent).toContain('图片拖动到此处即可添加')
|
||||
expect(overlay.textContent).toContain('最多 20 张,每张 10MB')
|
||||
})
|
||||
|
||||
it('omits the desc line when none is resolved', () => {
|
||||
const view = render(<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加' }} />)
|
||||
expect(view.getByRole('status').textContent).toBe('图片拖动到此处即可添加')
|
||||
})
|
||||
|
||||
it('drops the desc and switches the illustration while disabled', () => {
|
||||
const enabled = render(
|
||||
<DropOverlay disabled={false} labels={{ title: '拖入', desc: '限制' }} />,
|
||||
)
|
||||
const enabledSvg = enabled.getByRole('status').querySelector('svg')!.innerHTML
|
||||
enabled.unmount()
|
||||
const disabled = render(
|
||||
<DropOverlay disabled labels={{ title: '当前无法添加图片', desc: '限制' }} />,
|
||||
)
|
||||
const overlay = disabled.getByRole('status')
|
||||
expect(overlay.textContent).toBe('当前无法添加图片')
|
||||
expect(overlay.querySelector('svg')!.innerHTML).not.toBe(enabledSvg)
|
||||
})
|
||||
})
|
||||
@@ -39,12 +39,13 @@ describe('ImageLightbox', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('closes on a backdrop press but not on a press over the image', () => {
|
||||
it('closes on a mask 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: '原图预览' }))
|
||||
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
|
||||
fireEvent.mouseDown(mask)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ const attachment = {
|
||||
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 view = render(<MessageImage attachment={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')
|
||||
@@ -44,7 +44,7 @@ describe('MessageImage', () => {
|
||||
|
||||
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 view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(view.getByText('图片加载中…')).toBeTruthy()
|
||||
fireEvent.click(frame)
|
||||
@@ -54,7 +54,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 attachment={unnamed} load={load} labels={labels} />)
|
||||
const view = render(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
|
||||
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
||||
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
||||
})
|
||||
@@ -64,7 +64,7 @@ describe('MessageImage', () => {
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockRejectedValueOnce(new Error('still offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
||||
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
@@ -73,16 +73,52 @@ describe('MessageImage', () => {
|
||||
expect(load).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
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 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')
|
||||
expect(tallFrame.getAttribute('style')).toContain('height: 240px')
|
||||
await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
|
||||
tall.unmount()
|
||||
const wide = render(
|
||||
<MessageImage 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')
|
||||
expect(wideFrame.getAttribute('style')).toContain('height: 60px')
|
||||
await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
|
||||
wide.unmount()
|
||||
const small = render(
|
||||
<MessageImage 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')
|
||||
expect(smallFrame.getAttribute('style')).toContain('height: 100px')
|
||||
})
|
||||
|
||||
it('renders a tile at the fixed square without inline sizing', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const view = render(<MessageImage attachment={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()
|
||||
})
|
||||
|
||||
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} />)
|
||||
const view = render(<MessageImage attachment={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 attachment={attachment} load={failing} labels={labels} />)
|
||||
const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
|
||||
second.unmount()
|
||||
reject?.(new Error('late failure'))
|
||||
await Promise.resolve()
|
||||
@@ -100,4 +136,15 @@ describe('ImageGallery', () => {
|
||||
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
|
||||
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
|
||||
})
|
||||
|
||||
it('renders a lone image large and several images as square tiles', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
|
||||
lone.unmount()
|
||||
const several = render(
|
||||
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
)
|
||||
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user