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'); return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]); }, [visibleParts]);
const finalizedAssistantMarkdownContents = React.useMemo(() => ( const finalizedAssistantMarkdownContents = React.useMemo(() => (
isMessageCompleted isMessageCompleted && !isMobile
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0) ? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
: [] : []
), [assistantTextParts, isMessageCompleted]); ), [assistantTextParts, isMessageCompleted, isMobile]);
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]); const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]); const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
@@ -1868,7 +1868,7 @@ const AssistantMessageBody = React.memo(({
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange} onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
enableMarkdownImages={isMessageCompleted} enableMarkdownImages={isMessageCompleted && !isMobile}
/> />
</div> </div>
); );
@@ -1,13 +1,6 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing';
clampImagePreviewTransform,
getContainedImagePreviewSize,
getImagePreviewBounds,
getImagePreviewDialogLayout,
getImagePreviewGestureTransform,
getLocalImagePreviewPoints,
} from './imagePreviewSizing';
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
describe('getMermaidDataUrlSourcePromise', () => { 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 { runtimeFetch } from '@/lib/runtime-fetch';
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
import { import {
clampImagePreviewTransform,
getContainedImagePreviewSize,
getImagePreviewGestureTransform,
getImagePreviewBounds, getImagePreviewBounds,
getImagePreviewDialogLayout, getImagePreviewDialogLayout,
getLocalImagePreviewPoints,
IDENTITY_IMAGE_PREVIEW_TRANSFORM,
type ImagePreviewPoint,
type ImagePreviewViewport, type ImagePreviewViewport,
} from './imagePreviewSizing'; } from './imagePreviewSizing';
@@ -370,76 +364,6 @@ const ImagePreviewDialog: React.FC<{
const imageTitle = currentImage?.filename || popup.title || 'Image preview'; const imageTitle = currentImage?.filename || popup.title || 'Image preview';
const hasMultipleImages = gallery.length > 1; const hasMultipleImages = gallery.length > 1;
const markdownImage = popup.metadata?.tool === 'markdown-image-preview'; 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(() => { const showPrevious = React.useCallback(() => {
if (gallery.length <= 1) return; if (gallery.length <= 1) return;
@@ -519,17 +443,10 @@ const ImagePreviewDialog: React.FC<{
<Dialog open={popup.open} onOpenChange={onOpenChange}> <Dialog open={popup.open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className={cn( className={cn(
'max-w-none overflow-hidden', 'max-w-none gap-3 overflow-hidden p-4',
mobileMarkdownViewer
? 'h-[calc(100dvh-2rem)] gap-3 p-3'
: 'gap-3 p-4',
'[&>button]:right-3 [&>button]:top-3', '[&>button]:right-3 [&>button]:top-3',
)} )}
style={mobileMarkdownViewer ? { style={{
width: 'calc(100vw - 2rem)',
maxWidth: 'none',
maxHeight: 'none',
} : {
width: `${dialogLayout.dialogWidth}px`, width: `${dialogLayout.dialogWidth}px`,
maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)', maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)',
maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)', maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)',
@@ -546,19 +463,8 @@ const ImagePreviewDialog: React.FC<{
</DialogHeader> </DialogHeader>
<div <div
ref={imageViewportRef} className="relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20"
className={cn( style={{ width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
'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}
> >
{hasMultipleImages ? ( {hasMultipleImages ? (
<> <>
@@ -584,51 +490,12 @@ const ImagePreviewDialog: React.FC<{
src={currentImage.url} src={currentImage.url}
alt={imageTitle} alt={imageTitle}
className="block h-full w-full object-contain" 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" loading="lazy"
onLoad={handleImageLoad} onLoad={handleImageLoad}
data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined} 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> </div>
{!mobileMarkdownViewer ? <div aria-hidden="true" className="h-4 shrink-0" /> : null} <div aria-hidden="true" className="h-4 shrink-0" />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -1,10 +1,5 @@
export type ImagePreviewViewport = { width: number; height: number }; export type ImagePreviewViewport = { width: number; height: number };
type ImagePreviewSize = { 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 = ( export const getImagePreviewBounds = (
viewport: ImagePreviewViewport, viewport: ImagePreviewViewport,
@@ -39,88 +34,3 @@ export const getImagePreviewDialogLayout = (
imageHeight: Math.max(1, Math.round(image.height * scale)), 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, completed assistant message hydrates at most 12 unique image candidates,
including persisted text parts that omit their optional part-level end time. including persisted text parts that omit their optional part-level end time.
The image modal reserves readable title width even for narrow portrait media. The image modal reserves readable title width even for narrow portrait media.
Markdown image previews use the 60vw contained dialog on desktop and a This gallery and modal-preview enhancement is desktop-only. Mobile keeps the
full-height mobile dialog with 1x-4x pinch zoom, zoom controls, and panning standard Markdown rendering path until a dedicated, non-modal full-screen
when zoomed. image viewer can own safe areas, orientation, and touch gestures coherently.
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`. - `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`. - 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. - 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.