diff --git a/src/client/components/images.tsx b/src/client/components/images.tsx index 1423249..e341a89 100644 --- a/src/client/components/images.tsx +++ b/src/client/components/images.tsx @@ -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(null) - const restoreRef = useRef(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( -
- , - 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'] { {preview && src !== null && ( - { - 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(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(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 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(() => {}) })) - assert.equal(queryAll(document.body, '.lc-att-lightbox').length, 0) + assert.equal(dialog(), null) await m.unmount() }) })