refactor(client): ride the platform ImageLightbox for attachment previews

The ported AttachmentLightbox predates the discovery that the shell seeds
ALL of ui-primitives into the browser module table: ImageLightbox is
exported on both supported lines (0.1.7-rc.2, 0.2.0-rc.2) with the exact
props the call site already builds. Deleting the port also drops the
lc-att-lightbox sheet; the spec keeps only the plugin-owned open/close
wiring (dialog behavior is covered upstream), and upstream adds Escape
stopPropagation plus a Tab trap.
This commit is contained in:
bowenliang123 committed 2026-10-08 00:24:31 +08:00
1 parent 3f7be66f2d
commit 525b9af5e7
3 files changed
+24 -107

No files matched your search

+4 -45
View File
@@ -4,9 +4,8 @@
* handed down as `load`; absent loader or failed load degrades to the metadata row alone — the card never throws.
*/
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
import { createPortal } from 'react-dom'
import { IconClose } from '../primitives'
import { useCallback, useEffect, useState, type ReactElement } from 'react'
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
import { fmtBytes } from '../format'
import type { ImageLoader, ImageRefLike } from '../services'
import { estimateImageTokens } from '../../shared/imageTokens'
@@ -48,50 +47,10 @@ export function imageRefOf(block: unknown): ImageRefLike | null {
}
}
/**
* Document-level original-image preview — the chat history's ImageLightbox recipe (dsh ui-attachment, which the browser module table does
* not seed) ported onto the plugin's lc-* classes: body portal (a transformed/filtered ancestor cannot trap the fixed backdrop), blurred
* mask, contain-fit image, circular close, Escape/mask close, focus restored to the opener.
*/
function AttachmentLightbox(props: {
src: string
alt: string
labels: { dialog: string; close: string }
onClose: () => void
}): ReactElement {
const { src, alt, labels, onClose } = props
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: KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return createPortal(
<div className="lc-att-lightbox" role="dialog" aria-modal="true" aria-label={labels.dialog}>
<div className="lc-att-lightbox-mask" aria-hidden="true" onMouseDown={onClose} />
<img className="lc-att-lightbox-img" src={src} alt={alt} />
<button ref={closeRef} type="button" className="lc-att-lightbox-close" aria-label={labels.close} onClick={onClose}>
<IconClose size={16} />
</button>
</div>,
document.body,
)
}
/**
* One attachment card, the WHOLE card the click target: 64px cover tile + metadata column — Raw (the pre-normalization raster dsh records
* when normalization reduced the image), Sent (the normalized raster the model receives, with byte size), estimated provider-billed tokens.
* Click opens the chat-style lightbox; load failures retry on click; unknown facts leave no row.
* Click opens the platform ImageLightbox; load failures retry on click; unknown facts leave no row.
*/
export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
const { t, fmt } = kit
@@ -166,7 +125,7 @@ export function makeImageCard(kit: ViewKit): ImageKit['Card'] {
</span>
</button>
{preview && src !== null && (
<AttachmentLightbox
<ImageLightbox
src={src}
alt={name}
labels={{ dialog: t('attach.preview'), close: t('attach.close') }}
+1 -5
View File
@@ -1,4 +1,4 @@
/* Attachment grids and the image lightbox (browser.tsx inline grid + images.tsx lightbox). */
/* Attachment grids (browser.tsx inline grid + images.tsx cards). */
.lc-att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 8px 10px; }
.lc-att-item { display: flex; gap: 8px; align-items: center; min-width: 0; width: 100%; box-sizing: border-box; padding: 6px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: transparent; font: inherit; text-align: left; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-att-thumb { width: 64px; height: 64px; flex: none; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
@@ -8,7 +8,3 @@
.lc-att-name { font-size: 12px; color: var(--dsw-alias-label-primary); line-height: 1.3; overflow-wrap: anywhere; word-break: break-word; }
.lc-att-row { font-size: 11px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-att-row-label { display: inline-block; min-width: 3.2em; font-weight: 400; }
.lc-att-lightbox { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 40px; }
.lc-att-lightbox-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.45)); backdrop-filter: var(--dsw-mask-blur, blur(2px)); }
.lc-att-lightbox-img { position: relative; max-width: min(100%, 1600px); max-height: calc(100vh - 80px); object-fit: contain; border-radius: 12px; background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-1)); box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); }
.lc-att-lightbox-close { position: fixed; top: 20px; right: 20px; z-index: 1; display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-border-l1)); border-radius: 999px; background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-1)); color: var(--dsw-alias-label-primary); cursor: pointer; }
+19 -57
View File
@@ -1,7 +1,9 @@
// images.tsx — imageRefOf narrowing (pure) plus ImageCard and the
// AttachmentLightbox rendered with real React (portal to document.body).
// images.tsx — imageRefOf narrowing (pure) plus ImageCard and its preview
// wiring, rendered with real React. The preview itself is the platform
// ImageLightbox; its dialog behavior is covered upstream, so only the
// plugin-owned open/close wiring is pinned here.
import { act, createElement as h } from 'react'
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test, vi } from 'vitest'
import { imageRefOf, makeImageCard } from '../../../src/client/components/images'
@@ -12,11 +14,7 @@ import { click, flush, keydown, makeKit, mount, query, queryAll } from '../helpe
const kit = makeKit()
const ImageCard = makeImageCard(kit)
async function mousedown(el: Element): Promise<void> {
await act(async () => {
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
})
}
const dialog = (): Element | null => document.body.querySelector('[role=dialog]')
describe('imageRefOf', () => {
test('rejects non-objects and non-image blocks', () => {
@@ -120,7 +118,7 @@ describe('ImageCard metadata', () => {
assert.ok(rows[2].getAttribute('title')!.includes('384'))
assert.equal(rows[0].getAttribute('title'), null)
await click(card)
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0)
assert.equal(dialog(), null)
await m.unmount()
})
@@ -198,7 +196,7 @@ describe('ImageCard loading', () => {
})
})
describe('AttachmentLightbox', () => {
describe('ImageLightbox wiring', () => {
async function mountOpenable() {
const load = () => Promise.resolve('blob:x')
const m = await mount(h(ImageCard, { attachment: FULL, load }))
@@ -206,71 +204,35 @@ describe('AttachmentLightbox', () => {
return m
}
test('click opens the body portal; Escape closes it and restores focus', async () => {
test('click opens the body-portaled dialog; Escape closes it', async () => {
const m = await mountOpenable()
const card = query(m.container, '.lc-att-item')
card.focus()
assert.equal(document.activeElement, card)
await click(card)
const box = query(document.body, '.lc-att-lightbox')
assert.equal(box.getAttribute('role'), 'dialog')
await click(query(m.container, '.lc-att-item'))
const box = dialog()!
assert.equal(box.getAttribute('aria-label'), 'Image preview')
assert.equal(query<HTMLImageElement>(box, '.lc-att-lightbox-img').getAttribute('src'), 'blob:x')
const close = query(box, '.lc-att-lightbox-close')
assert.equal(close.getAttribute('aria-label'), 'Close')
assert.ok(query(box, 'svg') instanceof SVGElement) // real IconCloseOutlineRegular
assert.equal(document.activeElement, close) // focus moved into the dialog
await keydown('Enter') // non-Escape keys keep it open
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 1)
assert.equal(query<HTMLImageElement>(box, 'img').getAttribute('src'), 'blob:x')
assert.equal(query(box, 'button').getAttribute('aria-label'), 'Close')
await keydown('Escape')
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0)
assert.equal(document.activeElement, card) // focus restored to the opener
assert.equal(dialog(), null)
await m.unmount()
})
test('mask mousedown closes the lightbox', async () => {
test('the close control closes the lightbox', async () => {
const m = await mountOpenable()
await click(query(m.container, '.lc-att-item'))
await mousedown(query(document.body, '.lc-att-lightbox-mask'))
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0)
await click(query(dialog()!, 'button'))
assert.equal(dialog(), null)
await m.unmount()
})
test('the close button closes the lightbox', async () => {
const m = await mountOpenable()
await click(query(m.container, '.lc-att-item'))
await click(query(document.body, '.lc-att-lightbox-close'))
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0)
await m.unmount()
})
test('without a focused opener there is nothing to restore to', async () => {
// jsdom always reports <body> as activeElement; force the null arm of the
// `instanceof HTMLElement` guard.
const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement')
Object.defineProperty(document, 'activeElement', { get: () => null, configurable: true })
try {
const m = await mountOpenable()
await click(query(m.container, '.lc-att-item'))
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 1)
await keydown('Escape')
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0)
await m.unmount()
} finally {
delete (document as { activeElement?: unknown }).activeElement
if (descriptor) Object.defineProperty(Document.prototype, 'activeElement', descriptor)
}
})
test('switching the attachment while open closes the preview', async () => {
const load = () => Promise.resolve('blob:x')
const m = await mount(h(ImageCard, { attachment: FULL, load }))
await flush()
await click(query(m.container, '.lc-att-item'))
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 1)
assert.ok(dialog() !== null)
// New attachment with a pending load: src resets, preview drops out.
await m.update(h(ImageCard, { attachment: { ...FULL, attachmentId: 'a2' }, load: () => new Promise<string>(() => {}) }))
assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0)
assert.equal(dialog(), null)
await m.unmount()
})
})