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:
1 parent
3f7be66f2d
commit
525b9af5e7
3 files changed
+24
-107
No files matched your search
@@ -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,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; }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user