feat: improve mobile markdown image previews
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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;
|
||||
@@ -443,10 +519,17 @@ const ImagePreviewDialog: React.FC<{
|
||||
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'max-w-none gap-3 overflow-hidden p-4',
|
||||
'max-w-none overflow-hidden',
|
||||
mobileMarkdownViewer
|
||||
? 'h-[calc(100dvh-2rem)] gap-3 p-3'
|
||||
: 'gap-3 p-4',
|
||||
'[&>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<{
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
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` }}
|
||||
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}
|
||||
>
|
||||
{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 ? (
|
||||
<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 aria-hidden="true" className="h-4 shrink-0" />
|
||||
{!mobileMarkdownViewer ? <div aria-hidden="true" className="h-4 shrink-0" /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user