diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts index a0d7f916..17d62a75 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing'; +import { + clampImagePreviewTransform, + getContainedImagePreviewSize, + getImagePreviewBounds, + getImagePreviewDialogLayout, + getImagePreviewGestureTransform, + getLocalImagePreviewPoints, +} from './imagePreviewSizing'; import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; describe('getMermaidDataUrlSourcePromise', () => { @@ -70,3 +77,52 @@ describe('Markdown image preview bounds', () => { }); }); }); + +describe('Mobile image preview gestures', () => { + test('zooms around the midpoint of a two-finger gesture', () => { + expect(getImagePreviewGestureTransform( + { scale: 1, x: 0, y: 0 }, + [{ x: 100, y: 200 }, { x: 200, y: 200 }], + [{ x: 50, y: 200 }, { x: 250, y: 200 }], + { width: 300, height: 500 }, + { width: 300, height: 500 }, + )).toEqual({ scale: 2, x: 0, y: 50 }); + }); + + test('supports panning after zoom and clamps the image to the viewport', () => { + expect(getImagePreviewGestureTransform( + { scale: 2, x: 0, y: 0 }, + [{ x: 100, y: 100 }], + [{ x: 400, y: -400 }], + { width: 300, height: 500 }, + { width: 300, height: 500 }, + )).toEqual({ scale: 2, x: 150, y: -250 }); + }); + + test('limits pinch zoom to four times', () => { + expect(clampImagePreviewTransform( + { scale: 8, x: 1000, y: -1000 }, + { width: 300, height: 500 }, + { width: 300, height: 500 }, + )).toEqual({ scale: 4, x: 450, y: -750 }); + }); + + test('converts page coordinates into the image viewport coordinate system', () => { + expect(getLocalImagePreviewPoints( + [{ x: 129, y: 257 }, { x: 229, y: 257 }], + { x: 29, y: 57 }, + )).toEqual([{ x: 100, y: 200 }, { x: 200, y: 200 }]); + }); + + test('clamps letterboxed wide images against their visible content', () => { + const content = getContainedImagePreviewSize( + { width: 2908, height: 1686 }, + { width: 332, height: 758 }, + ); + expect(clampImagePreviewTransform( + { scale: 2.5, x: 1000, y: 1000 }, + { width: 332, height: 758 }, + content, + )).toEqual({ scale: 2.5, x: 249, y: 0 }); + }); +}); diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index ce01e48b..d6a1ad30 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -30,8 +30,14 @@ import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; import { + clampImagePreviewTransform, + getContainedImagePreviewSize, + getImagePreviewGestureTransform, getImagePreviewBounds, getImagePreviewDialogLayout, + getLocalImagePreviewPoints, + IDENTITY_IMAGE_PREVIEW_TRANSFORM, + type ImagePreviewPoint, type ImagePreviewViewport, } from './imagePreviewSizing'; @@ -364,6 +370,76 @@ const ImagePreviewDialog: React.FC<{ const imageTitle = currentImage?.filename || popup.title || 'Image preview'; const hasMultipleImages = gallery.length > 1; const markdownImage = popup.metadata?.tool === 'markdown-image-preview'; + const mobileMarkdownViewer = isMobile && markdownImage; + const imageViewportRef = React.useRef(null); + const activePointersRef = React.useRef(new Map()); + const previousPointsRef = React.useRef([]); + const [imageTransform, setImageTransform] = React.useState(IDENTITY_IMAGE_PREVIEW_TRANSFORM); + + React.useEffect(() => { + activePointersRef.current.clear(); + previousPointsRef.current = []; + setImageTransform(IDENTITY_IMAGE_PREVIEW_TRANSFORM); + }, [currentImage?.url, popup.open]); + + const handleImagePointerDown = React.useCallback((event: React.PointerEvent) => { + if (!mobileMarkdownViewer || event.pointerType !== 'touch') return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + previousPointsRef.current = Array.from(activePointersRef.current.values()); + }, [mobileMarkdownViewer]); + + const handleImagePointerMove = React.useCallback((event: React.PointerEvent) => { + if (!mobileMarkdownViewer || !activePointersRef.current.has(event.pointerId)) return; + event.preventDefault(); + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + const currentPoints = Array.from(activePointersRef.current.values()); + const previousPoints = previousPointsRef.current; + const imageViewport = imageViewportRef.current; + if (imageViewport) { + const bounds = imageViewport.getBoundingClientRect(); + const viewportSize = { width: bounds.width, height: bounds.height }; + const image = imageViewport.querySelector('img'); + const contentSize = getContainedImagePreviewSize({ + width: image?.naturalWidth || bounds.width, + height: image?.naturalHeight || bounds.height, + }, viewportSize); + setImageTransform((current) => getImagePreviewGestureTransform( + current, + getLocalImagePreviewPoints(previousPoints, { x: bounds.left, y: bounds.top }), + getLocalImagePreviewPoints(currentPoints, { x: bounds.left, y: bounds.top }), + viewportSize, + contentSize, + )); + } + previousPointsRef.current = currentPoints; + }, [mobileMarkdownViewer]); + + const handleImagePointerEnd = React.useCallback((event: React.PointerEvent) => { + if (!mobileMarkdownViewer) return; + activePointersRef.current.delete(event.pointerId); + previousPointsRef.current = Array.from(activePointersRef.current.values()); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, [mobileMarkdownViewer]); + + const adjustImageScale = React.useCallback((delta: number) => { + const imageViewport = imageViewportRef.current; + if (!imageViewport) return; + const bounds = imageViewport.getBoundingClientRect(); + const viewportSize = { width: bounds.width, height: bounds.height }; + const image = imageViewport.querySelector('img'); + const contentSize = getContainedImagePreviewSize({ + width: image?.naturalWidth || bounds.width, + height: image?.naturalHeight || bounds.height, + }, viewportSize); + setImageTransform((current) => clampImagePreviewTransform({ + ...current, + scale: current.scale + delta, + }, viewportSize, contentSize)); + }, []); const showPrevious = React.useCallback(() => { if (gallery.length <= 1) return; @@ -443,10 +519,17 @@ const ImagePreviewDialog: React.FC<{ button]:right-3 [&>button]:top-3', )} - style={{ + style={mobileMarkdownViewer ? { + width: 'calc(100vw - 2rem)', + maxWidth: 'none', + maxHeight: 'none', + } : { width: `${dialogLayout.dialogWidth}px`, maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)', maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)', @@ -463,8 +546,19 @@ const ImagePreviewDialog: React.FC<{
{hasMultipleImages ? ( <> @@ -490,12 +584,51 @@ const ImagePreviewDialog: React.FC<{ src={currentImage.url} alt={imageTitle} className="block h-full w-full object-contain" + style={mobileMarkdownViewer ? { + transform: `translate3d(${imageTransform.x}px, ${imageTransform.y}px, 0) scale(${imageTransform.scale})`, + transformOrigin: 'center', + } : undefined} loading="lazy" onLoad={handleImageLoad} data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined} + data-openchamber-image-preview-scale={mobileMarkdownViewer ? imageTransform.scale : undefined} /> + {mobileMarkdownViewer ? ( +
event.stopPropagation()} + > + + + +
+ ) : null}
-
); diff --git a/packages/ui/src/components/chat/message/imagePreviewSizing.ts b/packages/ui/src/components/chat/message/imagePreviewSizing.ts index 9ef842ee..846ce09b 100644 --- a/packages/ui/src/components/chat/message/imagePreviewSizing.ts +++ b/packages/ui/src/components/chat/message/imagePreviewSizing.ts @@ -1,5 +1,10 @@ export type ImagePreviewViewport = { width: number; height: number }; type ImagePreviewSize = { width: number; height: number }; +export type ImagePreviewPoint = { x: number; y: number }; +type ImagePreviewTransform = { scale: number; x: number; y: number }; + +export const IDENTITY_IMAGE_PREVIEW_TRANSFORM: ImagePreviewTransform = { scale: 1, x: 0, y: 0 }; +const MAX_IMAGE_PREVIEW_SCALE = 4; export const getImagePreviewBounds = ( viewport: ImagePreviewViewport, @@ -34,3 +39,88 @@ export const getImagePreviewDialogLayout = ( imageHeight: Math.max(1, Math.round(image.height * scale)), }; }; + +const midpoint = (first: ImagePreviewPoint, second: ImagePreviewPoint): ImagePreviewPoint => ({ + x: (first.x + second.x) / 2, + y: (first.y + second.y) / 2, +}); + +const distance = (first: ImagePreviewPoint, second: ImagePreviewPoint): number => ( + Math.hypot(second.x - first.x, second.y - first.y) +); + +export const getContainedImagePreviewSize = ( + image: ImagePreviewSize, + viewport: ImagePreviewViewport, +): ImagePreviewSize => { + const scale = Math.min( + viewport.width / Math.max(1, image.width), + viewport.height / Math.max(1, image.height), + ); + return { + width: image.width * scale, + height: image.height * scale, + }; +}; + +export const getLocalImagePreviewPoints = ( + points: ImagePreviewPoint[], + origin: ImagePreviewPoint, +): ImagePreviewPoint[] => points.map((point) => ({ + x: point.x - origin.x, + y: point.y - origin.y, +})); + +export const clampImagePreviewTransform = ( + transform: ImagePreviewTransform, + viewport: ImagePreviewViewport, + content: ImagePreviewSize, +): ImagePreviewTransform => { + const scale = Math.min(MAX_IMAGE_PREVIEW_SCALE, Math.max(1, transform.scale)); + const maxX = Math.max(0, (content.width * scale - viewport.width) / 2); + const maxY = Math.max(0, (content.height * scale - viewport.height) / 2); + + return { + scale, + x: Math.min(maxX, Math.max(-maxX, scale === 1 ? 0 : transform.x)), + y: Math.min(maxY, Math.max(-maxY, scale === 1 ? 0 : transform.y)), + }; +}; + +export const getImagePreviewGestureTransform = ( + transform: ImagePreviewTransform, + previousPoints: ImagePreviewPoint[], + currentPoints: ImagePreviewPoint[], + viewport: ImagePreviewViewport, + content: ImagePreviewSize, +): ImagePreviewTransform => { + if (previousPoints.length >= 2 && currentPoints.length >= 2) { + const previousDistance = distance(previousPoints[0], previousPoints[1]); + if (previousDistance <= 0) return transform; + + const previousMidpoint = midpoint(previousPoints[0], previousPoints[1]); + const currentMidpoint = midpoint(currentPoints[0], currentPoints[1]); + const scale = Math.min( + MAX_IMAGE_PREVIEW_SCALE, + Math.max(1, transform.scale * distance(currentPoints[0], currentPoints[1]) / previousDistance), + ); + const ratio = scale / transform.scale; + const center = { x: viewport.width / 2, y: viewport.height / 2 }; + + return clampImagePreviewTransform({ + scale, + x: currentMidpoint.x - center.x - (previousMidpoint.x - center.x - transform.x) * ratio, + y: currentMidpoint.y - center.y - (previousMidpoint.y - center.y - transform.y) * ratio, + }, viewport, content); + } + + if (previousPoints.length === 1 && currentPoints.length === 1 && transform.scale > 1) { + return clampImagePreviewTransform({ + ...transform, + x: transform.x + currentPoints[0].x - previousPoints[0].x, + y: transform.y + currentPoints[0].y - previousPoints[0].y, + }, viewport, content); + } + + return transform; +}; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index e8dc194e..99c68046 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -68,6 +68,9 @@ Use this doc when you ask an agent to change tool/header/description behavior. completed assistant message hydrates at most 12 unique image candidates, including persisted text parts that omit their optional part-level end time. The image modal reserves readable title width even for narrow portrait media. + Markdown image previews use the 60vw contained dialog on desktop and a + full-height mobile dialog with 1x-4x pinch zoom, zoom controls, and panning + when zoomed. - `read` and `skill` are **static navigation tools** and render via `StaticToolRow`. - Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`. - The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.