Files
deepseek-harness/packages/client/ui-attachment/src/MessageImage.tsx
T
zhc a5f1d4a16a refactor(client): bind context menus at their Slot consumers
Merge master and preserve pinned-row visibility. Remove redundant Chat and tool-tree menu forwarding, unify trigger names, and bind file targets at the consuming Slot. Address CI static and coverage gaps without changing upstream scroll assertions.
2026-10-28 19:15:45 +08:00

191 lines
8.3 KiB
TypeScript

import type { ContextMenuBindings } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage, ImageLightbox, type FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './MessageImage.module.css'
export type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Loads a Gateway-authorized durable image URL and may expose a cached URL synchronously. */
export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise<string>) & {
peek?: (attachment: ImageAttachmentRef) => string | undefined
}
/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */
export type MessageImageSpec =
| {
readonly attachment: ImageAttachmentRef
/** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */
readonly label?: string
}
| {
readonly preview: {
readonly url: string
readonly name?: string
readonly width?: number
readonly height?: number
}
}
/** 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(
dimensions: { readonly width: number; readonly height: number },
): { width: number; height: number; objectPosition: string } {
const natural = dimensions.width / dimensions.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, dimensions.width / box.width, dimensions.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',
}
}
/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */
function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined {
if ('attachment' in image) return image.attachment
return image.preview.width !== undefined && image.preview.height !== undefined
? { width: image.preview.width, height: image.preview.height }
: undefined
}
/**
* Compact history renderer with retryable loading and click-to-open original
* preview. A lone image renders at its `singleFit` size; an image among
* several renders as a fixed 64px square tile. The preview arm displays its
* local URL directly — no loader round-trip, no failure/retry surface.
* List thumbnails use status icons with localized tooltips and accessible names.
*
* @param props.image - the durable reference to load, or the local preview to display.
* @param props.load - Gateway-authorized URL loader for the durable arm.
* @param props.variant - lone image, cropped gallery tile, or uncropped list thumbnail.
* @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, fileDrag, getTriggerProps }: {
image: MessageImageSpec
load: ImageLoader
variant: 'single' | 'tile' | 'thumbnail'
fileDrag: FileDragActions
labels: MessageImageLabels
getTriggerProps: ContextMenuBindings['getTriggerProps']
}) {
const preview = 'preview' in image ? image.preview : undefined
const attachment = 'attachment' in image ? image.attachment : undefined
const [loaded, setLoaded] = useState<string | null>(() =>
attachment === undefined ? null : (load.peek?.(attachment) ?? null))
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
// Retry re-arms the one load effect below, so every attempt — first load or
// retry — runs under the same liveness guard and the same reset.
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const dimensions = useMemo(() => dimensionsOf(image), [image])
const fit = useMemo(
() => {
if (variant !== 'single') return undefined
// A preview whose intake probe has not resolved sizes as a square crop;
// the durable replacement restores the exact fit.
return dimensions === undefined
? { width: 240, height: 240, objectPosition: 'center' }
: singleFit(dimensions)
},
[dimensions, variant],
)
useEffect(() => {
if (attachment === undefined) return
let live = true
setError(false)
setLoaded(load.peek?.(attachment) ?? null)
void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load, attempt])
const src = preview?.url ?? loaded
const label = ('attachment' in image ? image.label : undefined)
?? preview?.name ?? attachment?.name ?? labels.image
const contextMenuProps = getTriggerProps(src === null ? undefined : { kind: 'image', value: {
url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }),
} })
const loadingThumbnail = variant === 'thumbnail' && src === null
if (error) return (
<button
type="button"
className={css.error}
data-variant={variant}
title={variant === 'thumbnail' ? labels.loadFailed : undefined}
aria-label={variant === 'thumbnail' ? labels.loadFailed : undefined}
onClick={request}
>
{variant === 'thumbnail'
? <span aria-hidden="true"><IconRefreshOutlineRegular /></span>
: labels.loadFailed}
</button>
)
return (
<>
<button
type="button"
className={css.frame}
data-variant={variant}
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
title={loadingThumbnail ? labels.loading : labels.open}
aria-label={loadingThumbnail ? labels.loading : labels.openNamed(label)}
aria-busy={loadingThumbnail || undefined}
{...contextMenuProps}
onClick={() => { if (src !== null) setOpen(true) }}
>
{src === null
? (
<span className={css.loading} aria-hidden={loadingThumbnail || undefined}>
{loadingThumbnail ? <IconLoadingOutlineRegular className={css.spinner} /> : labels.loading}
</span>
)
: <DraggableImage fileDrag={fileDrag} src={src} alt={label} name={preview?.name ?? attachment?.name ?? 'image'}
style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close}
contextMenuProps={contextMenuProps} />}
</>
)
}
/** 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, fileDrag, getTriggerProps }: {
images: readonly MessageImageSpec[]
load: ImageLoader
align: 'start' | 'end'
compact?: boolean
thumbnail?: boolean
fileDrag: FileDragActions
labels: MessageImageLabels
getTriggerProps: ContextMenuBindings['getTriggerProps']
}) {
if (images.length === 0) return null
const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single'
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage
key={`${'attachment' in image ? image.attachment.attachmentId : image.preview.url}:${index}`}
image={image}
load={load}
variant={variant}
fileDrag={fileDrag}
labels={labels}
getTriggerProps={getTriggerProps}
/>
))}
</div>
)
}