From 77206bd4f5b68e3362e4009a8dcfd793709af648 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 00:03:18 +0800 Subject: [PATCH] fix(web-ui): preview conversation images on click Conversation images were only visible at inline size. An image in a markdown message, an image inside an MCP tool result and a composer attachment thumbnail had no full-size view; the only surface with one was the sent user-message gallery, which keeps its own overlay. Lift the markdown renderer's overlay into a shared ImageLightbox and let the surface that resolved the image bytes own it, so the markdown renderer, the MCP tool card and the composer attachment each mount one instance and pass their own resolved source: a data: URL for a local file read through the host transport, or the http(s) URL the message already carries. A preview click keeps the owner's behaviour: an image wrapped in a link or a file link still follows the link, and the tool-card toggle is not hit. An open preview closes when the active surface changes, so bytes that belong to the previous surface cannot stay on screen, and only data:image and http(s) sources become click targets, so the loading placeholder and the failed-read fallback never advertise an empty view. Co-authored-by: bitfun-ai <318544290+bitfun-ai@users.noreply.github.com> --- .../components/ChatInputAttachment.scss | 16 ++++ .../components/ChatInputAttachments.test.tsx | 13 ++- .../components/ChatInputImagePreview.test.tsx | 22 ++++- .../components/ChatInputImagePreview.tsx | 26 ++++- .../flow_chat/tool-cards/MCPToolDisplay.scss | 14 +++ .../tool-cards/MCPToolDisplay.test.tsx | 51 +++++++++- .../flow_chat/tool-cards/MCPToolDisplay.tsx | 17 +++- .../registry/defaultAppearanceRegistry.ts | 2 + .../src/infrastructure/markdown/Markdown.scss | 10 ++ .../markdown/MarkdownRenderer.test.tsx | 95 +++++++++++++++++++ .../markdown/MarkdownRenderer.tsx | 45 ++++++++- .../markdown/SessionMarkdownImage.tsx | 14 ++- src/web-ui/src/locales/en-US/components.json | 3 + src/web-ui/src/locales/zh-CN/components.json | 3 + src/web-ui/src/locales/zh-TW/components.json | 3 + .../src/shared/ui/ImageLightbox.appearance.ts | 10 ++ src/web-ui/src/shared/ui/ImageLightbox.scss | 86 +++++++++++++++++ src/web-ui/src/shared/ui/ImageLightbox.tsx | 54 +++++++++++ 18 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 src/web-ui/src/shared/ui/ImageLightbox.appearance.ts create mode 100644 src/web-ui/src/shared/ui/ImageLightbox.scss create mode 100644 src/web-ui/src/shared/ui/ImageLightbox.tsx diff --git a/src/web-ui/src/flow_chat/components/ChatInputAttachment.scss b/src/web-ui/src/flow_chat/components/ChatInputAttachment.scss index a4dfa33ff5..030cc1380d 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputAttachment.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputAttachment.scss @@ -20,6 +20,22 @@ &:focus-within .openbitfun-chat-input__attachment-remove { opacity: 1; } } + &__image-chip-preview { + display: block; + inline-size: 100%; + block-size: 100%; + padding: 0; + border: 0; + border-radius: inherit; + background: none; + cursor: zoom-in; + + &:focus-visible { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: var(--openbitfun-focus-offset); + } + } + &__image-chip-thumb { width: 100%; height: 100%; diff --git a/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx index ae781475fd..fb6fb9383e 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx @@ -17,11 +17,14 @@ vi.mock('@/shared/notification-system', () => ({ notificationService: { warning: vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { getState: () => ({ sessions: new Map([['main', { sessionId: 'main', dialogTurns: [] }]]) }), } })); -vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ - formatNumber: (number: number) => String(number), - t: (key: string, values?: Record) => key === 'selection.numbered' ? `Annotation ${values?.number}` - : key === 'selection.removeNumbered' ? `Remove ${values?.annotation}` : key, -}) })); +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + formatNumber: (number: number) => String(number), + t: (key: string, values?: Record) => key === 'selection.numbered' ? `Annotation ${values?.number}` + : key === 'selection.removeNumbered' ? `Remove ${values?.annotation}` : key, + }), + i18nService: { t: (key: string) => key }, +})); const excerpt: ConversationExcerptContext = { id: 'annotation-1', type: 'conversation-excerpt', timestamp: 1, annotationNumber: 1, diff --git a/src/web-ui/src/flow_chat/components/ChatInputImagePreview.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputImagePreview.test.tsx index 4ba6bad1b7..d068c7e02c 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputImagePreview.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputImagePreview.test.tsx @@ -8,7 +8,10 @@ vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({ getActiveSurfaceS const { readFileContent } = vi.hoisted(() => ({ readFileContent: vi.fn() })); vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({ workspaceAPI: { readFileContent } })); -vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (_key: string, values: { message: string }) => `Load failed: ${values.message}` }) })); +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (_key: string, values: { message: string }) => `Load failed: ${values.message}` }), + i18nService: { t: (key: string) => key }, +})); vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }) })); const image = { id: 'image', imageName: 'Photo.png', imagePath: '/Lark images/Photo.png', mimeType: 'image/png' } as ImageContext; @@ -59,4 +62,21 @@ describe('ChatInputImagePreview', () => { await act(async () => { finishOld('OLD'); }); expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,NEW'); }); + + it('previews the resolved thumbnail and closes the overlay on demand', async () => { + await render(); + const trigger = container.querySelector('.openbitfun-chat-input__image-chip-preview')!; + expect(trigger.getAttribute('aria-label')).toBe('components:imageLightbox.label'); + expect(document.querySelector('.image-lightbox')).toBeNull(); + + act(() => { trigger.click(); }); + + const overlay = document.querySelector('.image-lightbox'); + expect(overlay).not.toBeNull(); + expect(overlay!.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,AA=='); + + act(() => { document.querySelector('.image-lightbox-close')!.click(); }); + + expect(document.querySelector('.image-lightbox')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputImagePreview.tsx b/src/web-ui/src/flow_chat/components/ChatInputImagePreview.tsx index 64b78c9ca0..c6712fa91c 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputImagePreview.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputImagePreview.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'; import { Icon } from '@openbitfun/ui'; import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; -import { useI18n } from '@/infrastructure/i18n'; +import { i18nService, useI18n } from '@/infrastructure/i18n'; +import { ImageLightbox, type ImageLightboxState } from '@/shared/ui/ImageLightbox'; import type { ImageContext } from '@/types/context'; import { getMimeTypeFromFilename } from '../utils/imageUtils'; @@ -15,6 +16,8 @@ export function ChatInputImagePreview({ image, surfaceEpoch }: { const path = image.imagePath; const [loaded, setLoaded] = useState<{ path: string; epoch: number; source: string } | null>(null); const [error, setError] = useState(null); + // The attachment owns the overlay for the bytes it resolved. + const [preview, setPreview] = useState(null); const source = embedded || (loaded?.path === path && loaded.epoch === surfaceEpoch ? loaded.source : undefined); useEffect(() => { @@ -36,10 +39,25 @@ export function ChatInputImagePreview({ image, surfaceEpoch }: { return () => { cancelled = true; }; }, [embedded, path, image.mimeType, surfaceEpoch]); + useEffect(() => { + // A surface switch invalidates the bytes behind an open preview. + setPreview(null); + }, [surfaceEpoch]); + return source && !error ? ( - {image.imageName} setError(image.imageName)} /> + <> + + setPreview(null)} /> + ) : (
({ }, })); +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { t: (key: string) => key }, +})); + vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ + trace: vi.fn(), + debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), @@ -112,7 +118,7 @@ describe('MCPToolDisplay', () => { }); Object.defineProperty(dom.window, 'matchMedia', { configurable: true, - value: () => ({ matches: true }), + value: () => ({ matches: true, addEventListener: () => {}, removeEventListener: () => {} }), }); vi.stubGlobal('window', dom.window); vi.stubGlobal('document', dom.window.document); @@ -362,4 +368,47 @@ describe('MCPToolDisplay', () => { expect(container.querySelector('.mcp-input-disclosure button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false'); expect(container.querySelector('.mcp-input-code')).toBeNull(); }); + + it('previews a result image on click without collapsing the card', () => { + const item = toolItem({ + toolResult: { + success: true, + result: { + content: [{ type: 'image', data: 'aW1n', mime_type: 'image/png' }], + }, + }, + }); + + act(() => { + root.render(); + }); + + act(() => { + container.querySelector('[data-testid="mcp-tool-card-toggle"]')?.dispatchEvent( + new dom.window.MouseEvent('click', { bubbles: true }) + ); + }); + + const trigger = container.querySelector('.image-content-preview'); + expect(trigger).not.toBeNull(); + expect(container.querySelector('.image-content')?.getAttribute('data-openbitfun-part')).toBe('image'); + + act(() => { + trigger?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const overlay = dom.window.document.querySelector('.image-lightbox'); + expect(overlay).not.toBeNull(); + expect(overlay?.getAttribute('data-openbitfun-native-webview-occlusion')).toBe('true'); + expect(overlay?.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,aW1n'); + expect(container.querySelector('[data-openbitfun-component="mcp-tool-display"]')?.getAttribute('data-openbitfun-state')).toContain('expanded'); + + act(() => { + dom.window.document.querySelector('.image-lightbox-close')?.dispatchEvent( + new dom.window.MouseEvent('click', { bubbles: true }) + ); + }); + + expect(dom.window.document.querySelector('.image-lightbox')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx index b33835c6b6..c99664b146 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx @@ -20,6 +20,7 @@ import type { ToolInfo } from '@/shared/types/agent-api'; import { ToolCardCopyAction } from './ToolCardCopyAction'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import { useFlowChatContext } from '../components/modern/FlowChatContext'; +import { ImageLightbox, type ImageLightboxState } from '@/shared/ui/ImageLightbox'; import './MCPToolDisplay.scss'; const log = createLogger('MCPToolDisplay'); @@ -204,6 +205,8 @@ export const MCPToolDisplay: React.FC = ({ } = toolItem; const [isExpanded, setIsExpanded] = useState(false); const [isInputExpanded, setIsInputExpanded] = useState(false); + // Tool-result images are inline content of this card, so the card owns their overlay. + const [imagePreview, setImagePreview] = useState(null); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState, dispatchToolCardToggle } = useToolCardHeightContract({ toolId, @@ -821,7 +824,18 @@ export const MCPToolDisplay: React.FC = ({ )} {item.type === 'image' && item.data && (
- +
)} {item.type === 'resource' && item.resource && ( @@ -866,6 +880,7 @@ export const MCPToolDisplay: React.FC = ({ requiresConfirmation={needsConfirmation} toggleTestId="mcp-tool-card-toggle" /> + setImagePreview(null)} />
); }; diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 277db27363..3b6dc576e9 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -53,6 +53,7 @@ import { announcementAppearanceDescriptor } from '@/shared/announcement-system/a import { contextMenuAppearanceDescriptor } from '@/shared/context-menu-system/appearance'; import { contextListAppearanceDescriptor } from '@/shared/context-system/appearance'; import { notificationAppearanceDescriptor } from '@/shared/notification-system/appearance'; +import { imageLightboxAppearanceDescriptor } from '@/shared/ui/ImageLightbox.appearance'; import { canvasToolAppearanceDescriptor } from '@/tools/openbitfun-canvas/appearance'; import { generativeWidgetAppearanceDescriptor } from '@/tools/generative-widget/appearance'; import { editorToolAppearanceDescriptor } from '@/tools/editor/appearance'; @@ -312,6 +313,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(contextMenuAppearanceDescriptor) .registerComponent(contextListAppearanceDescriptor) .registerComponent(notificationAppearanceDescriptor) + .registerComponent(imageLightboxAppearanceDescriptor) .registerComponent(canvasToolAppearanceDescriptor) .registerComponent(generativeWidgetAppearanceDescriptor) .registerComponent(editorToolAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/markdown/Markdown.scss b/src/web-ui/src/infrastructure/markdown/Markdown.scss index a963f5eab2..31aa80c9f4 100644 --- a/src/web-ui/src/infrastructure/markdown/Markdown.scss +++ b/src/web-ui/src/infrastructure/markdown/Markdown.scss @@ -470,6 +470,16 @@ opacity: 0.75; } +/* Only sources with real bytes reach this state, so the affordance is honest. */ +.markdown-renderer .markdown-image--previewable { + cursor: zoom-in; +} + +/* A linked image belongs to its link, not to the preview affordance. */ +.markdown-renderer :is(a, button):has(> img) > img.markdown-image--previewable { + cursor: pointer; +} + .markdown-renderer .markdown-image-fallback { display: inline-flex; align-items: center; diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx index 95e5e53701..fd21cca639 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx @@ -901,4 +901,99 @@ describe('Markdown file links', () => { 'remote-connection-1', ); }); + + it('previews the resolved bytes of a markdown image and closes on the scrim or the close button', async () => { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const image = container.querySelector('img[alt="ReLU 图像"]'); + expect(image?.classList.contains('markdown-image--previewable')).toBe(true); + + act(() => image?.click()); + + const overlay = document.querySelector('.image-lightbox'); + expect(overlay).not.toBeNull(); + expect(overlay?.getAttribute('data-openbitfun-native-webview-occlusion')).toBe('true'); + // The preview shows the bytes the inline image resolved, not the raw path. + const preview = overlay?.querySelector('img'); + expect(preview?.getAttribute('src')).toBe('data:image/png;base64,cmVsdS1wbmc='); + expect(preview?.getAttribute('data-openbitfun-part')).toBe('image'); + const surface = overlay?.querySelector('.image-lightbox-surface'); + expect(surface?.getAttribute('aria-label')).toBe('ReLU 图像'); + + // Clicking the previewed image itself must not dismiss the overlay. + act(() => preview?.click()); + expect(document.querySelector('.image-lightbox')).not.toBeNull(); + + act(() => surface?.click()); + expect(document.querySelector('.image-lightbox')).toBeNull(); + + act(() => image?.click()); + act(() => document.querySelector('.image-lightbox-close')?.click()); + expect(document.querySelector('.image-lightbox')).toBeNull(); + }); + + it('leaves images owned by a markdown link to that link', async () => { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const image = container.querySelector('img[alt="Badge"]'); + expect(image?.classList.contains('markdown-image--previewable')).toBe(true); + + act(() => image?.click()); + + expect(document.querySelector('.image-lightbox')).toBeNull(); + // The file link still owns the click. + expect(onFileViewRequest).toHaveBeenCalled(); + }); + + it('does not offer a preview before an inline image resolves', async () => { + mocks.readFileContent.mockImplementationOnce(() => new Promise(() => {})); + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const image = container.querySelector('img[alt="Pending"]'); + expect(image?.classList.contains('markdown-image--previewable')).toBe(false); + act(() => image?.click()); + expect(document.querySelector('.image-lightbox')).toBeNull(); + }); + + it('closes an open image preview when the surface switches hosts', async () => { + await act(async () => { + root.render(); + }); + + act(() => container.querySelector('img')?.click()); + expect(document.querySelector('.image-lightbox')).not.toBeNull(); + + await act(async () => activateSurface('peer:output-second')); + + // The previewed bytes belonged to the previous host. + expect(document.querySelector('.image-lightbox')).toBeNull(); + }); }); diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx index 7d05bb7960..0f61bee463 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx @@ -37,6 +37,7 @@ import { getActiveSurfaceScope, onSurfaceActivated, type SurfaceScope } from '@/ import './Markdown.scss'; import { useStreamingTextReveal } from './useStreamingTextReveal'; import { SessionMarkdownImage, type SessionImageReader } from './SessionMarkdownImage'; +import { ImageLightbox, type ImageLightboxState } from '@/shared/ui/ImageLightbox'; import { rehypeSourceRange, type MarkdownSourceRange } from './rehypeSourceRange'; const log = createLogger('Markdown'); @@ -527,12 +528,19 @@ async function getLocalImageDataUrl( return request; } +/** Only sources the browser can display may open the full-size preview. */ +function isPreviewableImageSource(source: string): boolean { + return /^(?:data:image\/|https?:)/i.test(source); +} + interface MarkdownImageProps extends React.ImgHTMLAttributes { basePath?: string; /** Owning workspace ID; authoritative for the read when present. */ workspaceId?: string; /** Legacy owner selector for renderers without a workspace ID. */ remoteConnectionId?: string; + /** Opens the full-size preview for the source this renderer resolved. */ + onPreview?: (source: string, alt?: string) => void; } const ScopedMarkdownImage: React.FC = ({ @@ -543,6 +551,8 @@ const ScopedMarkdownImage: React.FC { + // An image owned by a link or a file link keeps that owner's behavior. + if (!previewable || event.currentTarget.closest('a, button')) { + onClick?.(event); + return; + } + event.preventDefault(); + event.stopPropagation(); + onPreview?.(resolvedSrc, typeof alt === 'string' && alt ? alt : undefined); + }} onLoad={(event) => { if (resolvedSrc !== LOCAL_IMAGE_PLACEHOLDER) { setLoadState('loaded'); @@ -933,6 +960,19 @@ export const MarkdownRenderer = React.memo(({ const onHttpLinkClickRef = useLiveValueRef(onHttpLinkClick); const traceContextRef = useLiveValueRef(traceContext); const sourceRangeRef = useLiveValueRef(sourceRange); + + // The overlay belongs to the renderer that resolved the image bytes: a + // Markdown image is inline content, not an independently mounted viewer. + const [imagePreview, setImagePreview] = useState(null); + const openImagePreview = useCallback((source: string, alt?: string) => { + setImagePreview({ source, alt }); + }, []); + const onImagePreviewRef = useLiveValueRef(openImagePreview); + + useEffect(() => { + // A surface switch invalidates the bytes behind an open preview. + setImagePreview(null); + }, [surfaceScope.epoch]); const syntaxTheme = useMemo(() => buildMarkdownPrismStyle(isLight), [isLight]); const syntaxThemeRef = useLiveValueRef(syntaxTheme); @@ -1626,7 +1666,7 @@ export const MarkdownRenderer = React.memo(({ img({ node: _node, ...props }: any) { if (onImageReadRef.current && isLocalAssetPath(props.src || '')) { return ; + read={onImageReadRef.current} download={onFileDownloadRef.current} onPreview={onImagePreviewRef.current} />; } // Dispatch observers have no local filesystem ownership. Do not mount // MarkdownImage here: even its initial state can reuse controller bytes @@ -1650,6 +1690,7 @@ export const MarkdownRenderer = React.memo(({ basePath={basePathRef.current || currentWorkspacePathRef.current} workspaceId={workspaceIdRef.current} remoteConnectionId={remoteConnectionIdRef.current} + onPreview={onImagePreviewRef.current} /> ); }, @@ -1683,6 +1724,7 @@ export const MarkdownRenderer = React.memo(({ }), [ onFileDownloadRef, onImageReadRef, + onImagePreviewRef, handleFileViewRequest, handleRevealInExplorer, handleLocalFileContextMenu, @@ -1747,6 +1789,7 @@ export const MarkdownRenderer = React.memo(({ ) : basicMarkdownRenderer} + setImagePreview(null)} /> ); diff --git a/src/web-ui/src/infrastructure/markdown/SessionMarkdownImage.tsx b/src/web-ui/src/infrastructure/markdown/SessionMarkdownImage.tsx index d12149b958..596594ea38 100644 --- a/src/web-ui/src/infrastructure/markdown/SessionMarkdownImage.tsx +++ b/src/web-ui/src/infrastructure/markdown/SessionMarkdownImage.tsx @@ -6,9 +6,10 @@ import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer export type SessionImageReader = (path: string, refresh?: boolean) => Promise; /** Session providers own these bytes; neither loading nor fallback may read local files. */ -export function SessionMarkdownImage({ path, alt, title, read, download }: { +export function SessionMarkdownImage({ path, alt, title, read, download, onPreview }: { path: string; alt?: string; title?: string; read: SessionImageReader; download?: (path: string) => Promise; + onPreview?: (source: string, alt?: string) => void; }) { const scope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); const [attempt, setAttempt] = useState(0); @@ -30,7 +31,16 @@ export function SessionMarkdownImage({ path, alt, title, read, download }: { return () => { cancelled = true; }; }, [path, read, scope, attempt]); - if (current?.src) return {alt { + // An image owned by a link or a file link keeps that owner's behavior. + if (!onPreview || event.currentTarget.closest('a, button')) return; + event.preventDefault(); + event.stopPropagation(); + onPreview(source, alt || undefined); + }} onError={() => setResult({ read, path, epoch: scope.epoch, error: i18nService.t('components:markdown.imageUnavailable') })} />; return {alt || path.split('/').pop()} · {i18nService.t(current?.error ? 'components:markdown.imageUnavailable' : 'common:status.loading')} diff --git a/src/web-ui/src/locales/en-US/components.json b/src/web-ui/src/locales/en-US/components.json index f4798b6a85..a05ce3c4b0 100644 --- a/src/web-ui/src/locales/en-US/components.json +++ b/src/web-ui/src/locales/en-US/components.json @@ -51,6 +51,9 @@ "copyLink": "Copy link", "copyFilePath": "Copy file path" }, + "imageLightbox": { + "label": "Image preview" + }, "mermaidBlock": { "renderFailed": "Diagram render failed", "codeIncomplete": "Code incomplete, waiting for more content...", diff --git a/src/web-ui/src/locales/zh-CN/components.json b/src/web-ui/src/locales/zh-CN/components.json index 32a18b1cfc..c126771030 100644 --- a/src/web-ui/src/locales/zh-CN/components.json +++ b/src/web-ui/src/locales/zh-CN/components.json @@ -51,6 +51,9 @@ "copyLink": "复制链接", "copyFilePath": "复制文件路径" }, + "imageLightbox": { + "label": "图片预览" + }, "mermaidBlock": { "renderFailed": "图表渲染失败", "codeIncomplete": "代码不完整,等待更多内容...", diff --git a/src/web-ui/src/locales/zh-TW/components.json b/src/web-ui/src/locales/zh-TW/components.json index cb39e2f261..8dff3a06ef 100644 --- a/src/web-ui/src/locales/zh-TW/components.json +++ b/src/web-ui/src/locales/zh-TW/components.json @@ -51,6 +51,9 @@ "copyLink": "複製連結", "copyFilePath": "複製檔案路徑" }, + "imageLightbox": { + "label": "圖片預覽" + }, "mermaidBlock": { "renderFailed": "圖表渲染失敗", "codeIncomplete": "代碼不完整,等待更多內容...", diff --git a/src/web-ui/src/shared/ui/ImageLightbox.appearance.ts b/src/web-ui/src/shared/ui/ImageLightbox.appearance.ts new file mode 100644 index 0000000000..0e0bbf1fb6 --- /dev/null +++ b/src/web-ui/src/shared/ui/ImageLightbox.appearance.ts @@ -0,0 +1,10 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +/** + * Shared full-size image preview. The scrim and close control belong to the + * design-system `dialog` surface, so only the previewed image is app-owned. + */ +export const imageLightboxAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'image-lightbox', + parts: [{ id: 'image', visualRole: 'content' }], +}; diff --git a/src/web-ui/src/shared/ui/ImageLightbox.scss b/src/web-ui/src/shared/ui/ImageLightbox.scss new file mode 100644 index 0000000000..a6999c045b --- /dev/null +++ b/src/web-ui/src/shared/ui/ImageLightbox.scss @@ -0,0 +1,86 @@ +/* Full-size image preview. It renders into the app overlay host, so these + rules are deliberately not scoped to the surface that opened it. */ +.image-lightbox { + position: fixed; + inset: 0; + z-index: var(--openbitfun-layer-fullscreen); + background: color-mix(in srgb, var(--openbitfun-color-content-on-light) 80%, transparent); + display: flex; + align-items: center; + justify-content: center; + animation: image-lightbox-fade-in 180ms cubic-bezier(0.23, 1, 0.32, 1); + + img { + max-width: 90vw; + max-height: 85vh; + border-radius: 8px; + box-shadow: 0 8px 32px var(--openbitfun-color-overlay-scrim); + object-fit: contain; + } +} + +.image-lightbox-close { + position: absolute; + top: 16px; + right: 16px; + background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 15%, transparent); + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + color: var(--openbitfun-color-content-on-dark); + cursor: pointer; + transition: transform 120ms cubic-bezier(0.23, 1, 0.32, 1); + + &:hover { + background: color-mix(in srgb, var(--openbitfun-color-content-on-dark) 30%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--openbitfun-color-content-on-dark); + outline-offset: 2px; + } +} + +@keyframes image-lightbox-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .image-lightbox { + animation: none; + } + + .image-lightbox-close { + transition-duration: 0ms; + } +} + +.image-lightbox[data-openbitfun-component='dialog'] { + padding: 0; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.image-lightbox-surface[data-openbitfun-component='dialog'] { + width: 100%; + height: 100%; + max-inline-size: none; + max-block-size: none; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + animation: none; +} + +.image-lightbox-close[data-openbitfun-component='icon-button'] { + &::before { content: none; } + > [data-openbitfun-part='icon'] { display: contents; } +} diff --git a/src/web-ui/src/shared/ui/ImageLightbox.tsx b/src/web-ui/src/shared/ui/ImageLightbox.tsx new file mode 100644 index 0000000000..2994957d82 --- /dev/null +++ b/src/web-ui/src/shared/ui/ImageLightbox.tsx @@ -0,0 +1,54 @@ +import { Dialog, DialogClose, Icon } from '@openbitfun/ui'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { i18nService } from '@/infrastructure/i18n'; +import './ImageLightbox.scss'; + +export interface ImageLightboxState { + /** Image bytes or URL already resolved by the owning surface. */ + source: string; + alt?: string; +} + +/** + * Full-size preview for an image rendered by any product surface. + * + * The caller owns the state: an inline image, a tool-result image and a + * composer attachment all resolve their own bytes, so the surface that + * resolved them owns the overlay as well. Only the previewed image is declared + * as an Appearance part here; the scrim and the close control are + * design-system `dialog` chrome that this component restyles. + */ +export function ImageLightbox({ image, onClose }: { + image: ImageLightboxState | null; + onClose: () => void; +}) { + if (!image) return null; + const label = image.alt || i18nService.t('components:imageLightbox.label'); + return ( + { if (event.target === event.currentTarget) onClose(); }} + > + } /> + {image.alt + + ); +}