fix: scope markdown image previews to desktop

This commit is contained in:
ChangeHow
2026-08-13 16:19:06 +08:00
parent 61626db245
commit efba4f33a7
5 changed files with 12 additions and 291 deletions
@@ -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}
/>
</div>
);
@@ -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 });
});
});
@@ -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<HTMLDivElement | null>(null);
const activePointersRef = React.useRef(new Map<number, ImagePreviewPoint>());
const previousPointsRef = React.useRef<ImagePreviewPoint[]>([]);
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<{
<Dialog open={popup.open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'max-w-none overflow-hidden',
mobileMarkdownViewer
? 'h-[calc(100dvh-2rem)] gap-3 p-3'
: 'gap-3 p-4',
'max-w-none gap-3 overflow-hidden p-4',
'[&>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<{
</DialogHeader>
<div
ref={imageViewportRef}
className={cn(
'relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20',
mobileMarkdownViewer && 'w-full flex-1 touch-none',
)}
style={mobileMarkdownViewer
? { touchAction: 'none' }
: { width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
onPointerDown={handleImagePointerDown}
onPointerMove={handleImagePointerMove}
onPointerUp={handleImagePointerEnd}
onPointerCancel={handleImagePointerEnd}
onLostPointerCapture={handleImagePointerEnd}
className="relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20"
style={{ width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
>
{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 ? (
<div
className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-lg border border-border/60 bg-background/90 p-1"
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-interactive-hover focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => adjustImageScale(-0.5)}
aria-label={t('markdownRenderer.mermaid.actions.zoomOutTitle')}
data-openchamber-image-preview-zoom="out"
>
<Icon name="subtract" className="h-4 w-4" />
</button>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-interactive-hover focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => setImageTransform(IDENTITY_IMAGE_PREVIEW_TRANSFORM)}
aria-label={t('markdownRenderer.mermaid.actions.resetViewTitle')}
data-openchamber-image-preview-zoom="reset"
>
<Icon name="restart" className="h-4 w-4" />
</button>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-interactive-hover focus:outline-none focus:ring-2 focus:ring-primary/60"
onClick={() => adjustImageScale(0.5)}
aria-label={t('markdownRenderer.mermaid.actions.zoomInTitle')}
data-openchamber-image-preview-zoom="in"
>
<Icon name="add" className="h-4 w-4" />
</button>
</div>
) : null}
</div>
{!mobileMarkdownViewer ? <div aria-hidden="true" className="h-4 shrink-0" /> : null}
<div aria-hidden="true" className="h-4 shrink-0" />
</DialogContent>
</Dialog>
);
@@ -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;
};
@@ -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.