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.
191 lines
8.3 KiB
TypeScript
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>
|
|
)
|
|
}
|