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 + + ); +}