diff --git a/src/client/components/images.tsx b/src/client/components/images.tsx index e3ed218..277d618 100644 --- a/src/client/components/images.tsx +++ b/src/client/components/images.tsx @@ -10,8 +10,9 @@ */ import type * as ReactNS from 'react' +import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { fmtBytes } from '../format' -import { React } from '../react' +import { React, ReactDOM } from '../react' import type { ImageLoader, ImageRefLike } from '../services' import { estimateImageTokens } from '../../shared/imageTokens' import type { ViewKit } from '../viewkit' @@ -57,12 +58,56 @@ export function imageRefOf(block: unknown): ImageRefLike | null { } /** - * One attachment card: a 64px cover tile (click opens the full image in a - * new tab; load failures retry on click) beside a metadata column — display - * name, then one labeled row per known fact: Raw (the pre-normalization - * raster dsh 0.1.1 records when normalization reduced the image), Sent (the - * normalized raster the model receives, with its stored byte size), and the - * estimated provider-billed tokens. Unknown facts leave no row behind. + * 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 so a transformed or + * filtered ancestor cannot trap the fixed backdrop, blurred mask layer, + * contain-fit image, circular close control, Escape/mask close, and focus + * restored to the opener on unmount. + */ +function AttachmentLightbox(props: { + src: string + alt: string + labels: { dialog: string; close: string } + onClose: () => void +}): ReactNS.ReactElement { + const { src, alt, labels, onClose } = props + const closeRef = React.useRef(null) + const restoreRef = React.useRef(null) + + React.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 ReactDOM.createPortal( +
+ , + document.body, + ) +} + +/** + * One attachment card: the WHOLE card is the click target — a 64px cover + * tile beside a metadata column (display name, then one labeled row per + * known fact: Raw, the pre-normalization raster dsh 0.1.1 records when + * normalization reduced the image; Sent, the normalized raster the model + * receives, with its stored byte size; and the estimated provider-billed + * tokens). Clicking opens the chat-style lightbox preview (load failures + * retry on click instead). Unknown facts leave no row behind. */ export function makeImageCard(kit: ViewKit): ImageKit['Card'] { const { t, fmt } = kit @@ -73,6 +118,8 @@ export function makeImageCard(kit: ViewKit): ImageKit['Card'] { // Retry re-arms the one load effect, so every attempt runs under the // same liveness guard and the same reset. const [attempt, setAttempt] = React.useState(0) + const [preview, setPreview] = React.useState(false) + const closePreview = React.useCallback(() => { setPreview(false) }, []) React.useEffect(() => { if (load === undefined) return @@ -107,35 +154,45 @@ export function makeImageCard(kit: ViewKit): ImageKit['Card'] { : null if (tokens !== null) rows.push({ label: t('attach.token'), value: `≈${fmt(tokens)}`, tip: t('attach.tokensTip') }) - const open = (): void => { - if (src === null) return - try { window.open(src, '_blank', 'noopener') } catch { /* popup blocked: ignore */ } + const activate = (): void => { + if (error) { setAttempt(a => a + 1); return } + if (src !== null) setPreview(true) } return ( -
+ <> -
- {name} - {rows.map(r => ( - - {r.label}{r.value} - - ))} -
-
+ {preview && src !== null && ( + + )} + ) } } diff --git a/src/client/i18n.ts b/src/client/i18n.ts index befd0e5..3521dba 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -119,6 +119,8 @@ export const DICT_ZH: Record = { 'attach.other': '其他内容', 'attach.image': '图片', 'attach.open': '查看原图', + 'attach.preview': '图片预览', + 'attach.close': '关闭', 'attach.loading': '…', 'attach.loadFailed': '加载失败 · 点击重试', 'attach.raw': '原图', @@ -241,6 +243,8 @@ export const DICT_EN: Record = { 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', + 'attach.preview': 'Image preview', + 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', diff --git a/src/client/react.ts b/src/client/react.ts index 2007f84..14aef9e 100644 --- a/src/client/react.ts +++ b/src/client/react.ts @@ -10,3 +10,10 @@ import type * as ReactNS from 'react' // oxlint-disable-next-line typescript/no-require-imports export const React: typeof ReactNS = require('react') as typeof ReactNS export const h = React.createElement + +// react-dom rides the same platform seed; only the portal API is used (the +// image lightbox escapes transformed ancestors by portaling to body). +// oxlint-disable-next-line typescript/no-require-imports +export const ReactDOM = require('react-dom') as { + createPortal: (children: ReactNS.ReactNode, container: Element) => ReactNS.ReactPortal +} diff --git a/src/client/styles.css b/src/client/styles.css index a6f824c..8922bbc 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -451,15 +451,27 @@ tile are new. The grid is two EQUAL-WIDTH columns (minmax(0,…) so long names shrink instead of stretching the track); each image is a 64px cover tile (the chat history's tile size) beside a name + - dims + size + estimated token cost column. ---- + dims + size + estimated token cost column; the whole card is the click + target. ---- */ .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; } -.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); cursor: pointer; font: inherit; } +.lc-att-item { display: flex; gap: 8px; align-items: center; min-width: 0; width: 100%; box-sizing: border-box; padding: 4px; border: 0; border-radius: 8px; background: transparent; font: inherit; text-align: left; cursor: pointer; } +.lc-att-item:hover { background: var(--dsw-alias-bg-layer-2); } +.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); } .lc-att-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } .lc-att-ph, .lc-att-err { font-size: 10px; color: var(--dsw-alias-label-secondary); text-align: center; padding: 2px; } .lc-att-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .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; } +/* + ---- Image preview lightbox: the chat history's original-image preview + recipe (fixed backdrop grid + separate blurred mask layer, centered + contain-fit image, circular close control) on the plugin's lc-* namespace, + reusing the same dsw theme variables (with fallbacks). ---- + */ +.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; } [data-conversation-scroll]:has(.lc-root) > [data-composer-seat]:not(:has([data-approval-key], [data-question-key], [data-plan-review-key])) { display: none; } diff --git a/tests/client.spec.ts b/tests/client.spec.ts index e704d33..eaf012e 100644 --- a/tests/client.spec.ts +++ b/tests/client.spec.ts @@ -40,9 +40,11 @@ globalThis.window = { globalThis.document = fakeDoc const require = (spec) => { - assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives', + assert.ok(spec === 'react' || spec === 'react-dom' || spec === '@deepseek-ai/dsh-client-ui-primitives', `bundle must only require platform modules (got "${spec}")`) - return spec === 'react' ? fakeReact : FAKE_PRIMITIVES + if (spec === 'react') return fakeReact + if (spec === 'react-dom') return { createPortal: () => null } + return FAKE_PRIMITIVES } // ---- materialize the bundle the way the loader does ---- diff --git a/tests/helpers/viewBed.ts b/tests/helpers/viewBed.ts index adcca4c..17e4323 100644 --- a/tests/helpers/viewBed.ts +++ b/tests/helpers/viewBed.ts @@ -50,11 +50,12 @@ export function makeFakeDoc() { export const FAKE_PRIMITIVES = { IconPlusOutline16: (p) => ({ kind: 'icon', name: 'IconPlusOutline16', props: p }), IconBranchOutline16: (p) => ({ kind: 'icon', name: 'IconBranchOutline16', props: p }), + IconCloseOutline16: (p) => ({ kind: 'icon', name: 'IconCloseOutline16', props: p }), MarkdownText: (p) => ({ kind: 'element', args: ['div', { className: 'lc-md-stub' }, p.text] }), } export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build', - 'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens' } + 'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens' } /** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */ export const snapshot = { @@ -153,6 +154,8 @@ export async function bootViewBed() { let hookCursor = 0 const statefulReact = { createElement: (...args) => ({ kind: 'element', args }), + // Fragment marker: evaluate() flattens it to its evaluated children. + Fragment: 'lc-fragment', useCallback: (fn) => fn, useState(init) { const i = hookCursor++ @@ -195,9 +198,12 @@ export async function bootViewBed() { }, } const requireStateful = (spec) => { - assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives', + assert.ok(spec === 'react' || spec === 'react-dom' || spec === '@deepseek-ai/dsh-client-ui-primitives', `bundle must only require platform modules (got "${spec}")`) - return spec === 'react' ? statefulReact : FAKE_PRIMITIVES + if (spec === 'react') return statefulReact + // Portals flatten to their tree: the lightbox stays walkable in tests. + if (spec === 'react-dom') return { createPortal: (node) => node } + return FAKE_PRIMITIVES } const pluginExports = handoff.factory(requireStateful) @@ -244,6 +250,22 @@ export async function bootViewBed() { if (node === null || typeof node !== 'object') return node if (node.kind === 'element') { const [type, props, ...children] = node.args + // A fragment flattens to its evaluated children (an array), which every + // tree walker here already understands. + if (type === statefulReact.Fragment) { + const kids = [] + let f = 0 + const walkFragment = (c) => { + if (Array.isArray(c)) { for (const x of c) walkFragment(x); return } + if (c !== null && typeof c === 'object' && c.kind === 'element' && typeof c.args[0] === 'function') { + kids.push(evaluate(c, path, f++)) + } else { + kids.push(evaluate(c, path, f)) + } + } + for (const c of children) walkFragment(c) + return kids + } // The fake createElement keeps children in args; real React puts them // on props.children (single child as-is, several as an array) — merge // them back for BOTH component kinds, exactly what React would pass. diff --git a/tests/hooks-order.client.spec.ts b/tests/hooks-order.client.spec.ts index ce411c3..fb8aa1a 100644 --- a/tests/hooks-order.client.spec.ts +++ b/tests/hooks-order.client.spec.ts @@ -19,6 +19,7 @@ import { test } from 'vitest' import { readFile } from 'node:fs/promises' import { JSDOM } from 'jsdom' import React from 'react' +import * as ReactDOM from 'react-dom' import { createRoot } from 'react-dom/client' const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8') @@ -31,10 +32,12 @@ function boot() { globalThis.window.__ModuleLoader__ = { load(h) { handoff = h } } const require = (spec) => { if (spec === 'react') return React + if (spec === 'react-dom') return ReactDOM if (spec === '@deepseek-ai/dsh-client-ui-primitives') { return { IconPlusOutline16: (p) => React.createElement('svg', p, null), IconBranchOutline16: (p) => React.createElement('svg', p, null), + IconCloseOutline16: (p) => React.createElement('svg', p, null), } } throw new Error(`unexpected module: ${spec}`) diff --git a/tests/images.spec.ts b/tests/images.spec.ts index 63f4a42..f7f1bf2 100644 --- a/tests/images.spec.ts +++ b/tests/images.spec.ts @@ -64,12 +64,20 @@ test('image attachment cards: card layout, metadata degradation, resolveImage th assert.match(textOf(byClass(trImg, 'lc-ts-card')[2]), /file:\/\/x/, 'unrecognized blocks keep the raw JSON card') assert.match(textOf(byClass(trImg, 'lc-br-content')[0]), /WHAT-IS-THIS/, 'prose stays in the text card') assert.equal(byClass(trImg, 'lc-att-thumb')[0].args.slice(2).filter(c => c !== null && typeof c === 'object' && c.args[0] === 'img').length, 0, 'no thumbnail img without the loader') + // The whole card is the click target (a single button wrapping tile + + // metadata); clicking before anything loads opens nothing. + const cardEl = byClass(trImg, 'lc-att-item')[0] + assert.equal(cardEl.args[0], 'button', 'the whole card is one button') + cardEl.args[1].onClick() + trImg = renderView() + assert.equal(byClass(trImg, 'lc-att-lightbox').length, 0, 'no preview without a loaded image') // Arm the conversation service: the loader resolves a display URL per ref. bed.conversationHolder = { resolveImage: async (sid, att) => 'blob:' + att.attachmentId } trImg = renderView() - // Drive every ImageCard's load effect (slot 3 = its useEffect), then flush. + // Drive every ImageCard's load effect (slot 4 = its useEffect, after the + // src/error/attempt/preview states), then flush. for (const [key, slots] of hookStates) { - if (key.includes('ImageCard') && slots[3] !== undefined) slots[3].effect() + if (key.includes('ImageCard') && slots[4] !== undefined) slots[4].effect() } await new Promise(r => setTimeout(r, 0)) await new Promise(r => setTimeout(r, 0)) @@ -78,6 +86,16 @@ test('image attachment cards: card layout, metadata degradation, resolveImage th .flatMap(t => t.args.slice(2)) .filter(c => c !== null && typeof c === 'object' && c.args[0] === 'img') assert.deepEqual(thumbImgs.map(i => i.args[1].src), ['blob:a1', 'blob:a2'], 'thumbnails resolve through resolveImage') + // Clicking the loaded card opens the chat-style lightbox preview (no new + // tab); the mask press closes it again. + byClass(trImg, 'lc-att-item')[0].args[1].onClick() + trImg = renderView() + assert.equal(byClass(trImg, 'lc-att-lightbox').length, 1, 'click opens the lightbox preview') + assert.equal(byClass(trImg, 'lc-att-lightbox-img')[0].args[1].src, 'blob:a1', 'lightbox shows the clicked image') + assert.equal(byClass(trImg, 'lc-att-lightbox-close').length, 1, 'lightbox has a close control') + byClass(trImg, 'lc-att-lightbox-mask')[0].args[1].onMouseDown() + trImg = renderView() + assert.equal(byClass(trImg, 'lc-att-lightbox').length, 0, 'mask press closes the preview') - console.log('✔ image attachment card test passed (card layout, metadata degradation, resolveImage thumbnails)') + console.log('✔ image attachment card test passed (card layout, metadata degradation, resolveImage thumbnails, lightbox preview)') }) diff --git a/tests/real-react.client.spec.ts b/tests/real-react.client.spec.ts index b132e6f..9f773fe 100644 --- a/tests/real-react.client.spec.ts +++ b/tests/real-react.client.spec.ts @@ -9,6 +9,7 @@ import { test } from 'vitest' import { readFile } from 'node:fs/promises' import { JSDOM } from 'jsdom' import React from 'react' +import * as ReactDOM from 'react-dom' import { createRoot } from 'react-dom/client' test('real-React jsdom mount: rapid step<->turn toggles never hit React #185', async () => { @@ -56,11 +57,13 @@ test('real-React jsdom mount: rapid step<->turn toggles never hit React #185', a } const require = (spec) => { if (spec === 'react') return React + if (spec === 'react-dom') return ReactDOM if (spec === '@deepseek-ai/dsh-client-ui-primitives') { // Glyph-only usage in this bundle; render inert svgs (never asserted). return { IconPlusOutline16: (p) => React.createElement('svg', p, null), IconBranchOutline16: (p) => React.createElement('svg', p, null), + IconCloseOutline16: (p) => React.createElement('svg', p, null), } } throw new Error(`unexpected module: ${spec}`)