From efba4f33a7b50ee349095c9e0f48556697932d34 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 13 Aug 2026 15:36:35 +0800 Subject: [PATCH] fix: scope markdown image previews to desktop --- .../components/chat/message/MessageBody.tsx | 6 +- .../chat/message/ToolOutputDialog.test.ts | 58 +------ .../chat/message/ToolOutputDialog.tsx | 143 +----------------- .../chat/message/imagePreviewSizing.ts | 90 ----------- .../chat/message/parts/DOCUMENTATION.md | 6 +- 5 files changed, 12 insertions(+), 291 deletions(-) diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index a697c165..8963e5fb 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1212,10 +1212,10 @@ const AssistantMessageBody = React.memo(({ return visibleParts.filter((part) => part.type === 'text'); }, [visibleParts]); const finalizedAssistantMarkdownContents = React.useMemo(() => ( - isMessageCompleted + isMessageCompleted && !isMobile ? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0) : [] - ), [assistantTextParts, isMessageCompleted]); + ), [assistantTextParts, isMessageCompleted, isMobile]); const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]); const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]); @@ -1868,7 +1868,7 @@ const AssistantMessageBody = React.memo(({ chatRenderMode={chatRenderMode} onContentChange={onContentChange} onShowPopup={onShowPopup} - enableMarkdownImages={isMessageCompleted} + enableMarkdownImages={isMessageCompleted && !isMobile} /> ); diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts index 17d62a75..a0d7f916 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts @@ -1,13 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { - clampImagePreviewTransform, - getContainedImagePreviewSize, - getImagePreviewBounds, - getImagePreviewDialogLayout, - getImagePreviewGestureTransform, - getLocalImagePreviewPoints, -} from './imagePreviewSizing'; +import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing'; import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; describe('getMermaidDataUrlSourcePromise', () => { @@ -77,52 +70,3 @@ 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 d6a1ad30..ce01e48b 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -30,14 +30,8 @@ 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'; @@ -370,76 +364,6 @@ 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; @@ -519,17 +443,10 @@ const ImagePreviewDialog: React.FC<{ button]:right-3 [&>button]:top-3', )} - style={mobileMarkdownViewer ? { - width: 'calc(100vw - 2rem)', - maxWidth: 'none', - maxHeight: 'none', - } : { + style={{ width: `${dialogLayout.dialogWidth}px`, maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)', maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)', @@ -546,19 +463,8 @@ const ImagePreviewDialog: React.FC<{
{hasMultipleImages ? ( <> @@ -584,51 +490,12 @@ 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}
- {!mobileMarkdownViewer ?
); diff --git a/packages/ui/src/components/chat/message/imagePreviewSizing.ts b/packages/ui/src/components/chat/message/imagePreviewSizing.ts index 846ce09b..9ef842ee 100644 --- a/packages/ui/src/components/chat/message/imagePreviewSizing.ts +++ b/packages/ui/src/components/chat/message/imagePreviewSizing.ts @@ -1,10 +1,5 @@ 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, @@ -39,88 +34,3 @@ 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 99c68046..f709d590 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -68,9 +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. + This gallery and modal-preview enhancement is desktop-only. Mobile keeps the + standard Markdown rendering path until a dedicated, non-modal full-screen + image viewer can own safe areas, orientation, and touch gestures coherently. - `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.