feat(app): add zoomable image previews (#4032)

Image files now zoom in place, while timeline images open the existing lightbox. A shared viewport extracted from Mermaid keeps fit, focal zoom, pan bounds, and consumer toolbar actions consistent across content types.
This commit is contained in:
Mohamed Boudra
2026-08-29 22:08:17 +02:00
committed by GitHub
parent 650f3ca7f6
commit eef9be8863
15 changed files with 1155 additions and 438 deletions
@@ -45,6 +45,37 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
});
test("opens a timeline image in a zoomable lightbox", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Zoomable timeline image",
fileName: "zoomable-timeline-image.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Zoomable image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.getByRole("button", { name: "Open image attachment" }).click();
const lightboxImage = page.getByTestId("attachment-lightbox-image");
const canvas = page.getByTestId("attachment-lightbox-canvas");
await expect(lightboxImage).toBeVisible();
await canvas.hover();
const transformedContent = canvas.locator(":scope > div").first();
const initialBox = await transformedContent.boundingBox();
expect(initialBox).not.toBeNull();
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
.toBeGreaterThan(initialBox!.width * 1.2);
await page.getByTestId("attachment-lightbox-backdrop").click({ position: { x: 4, y: 4 } });
await expect(lightboxImage).toHaveCount(0);
});
test("reloading a timeline anchors near-tail assistant image growth", async ({
imageWorkspace: workspace,
page,
@@ -538,6 +538,16 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
await expect(image).toBeVisible();
const imageCanvas = page.getByTestId("image-file-preview-canvas");
await expect(imageCanvas).toBeVisible();
await imageCanvas.hover();
const transformedContent = imageCanvas.locator(":scope > div").first();
const fittedImageBox = await transformedContent.boundingBox();
expect(fittedImageBox).not.toBeNull();
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
.toBeGreaterThan(fittedImageBox!.width * 1.2);
const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL);
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
@@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import { AttachmentLightbox } from "./attachment-lightbox";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
const hoistedTheme = {
@@ -42,6 +42,11 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
),
};
});
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
const assistantImageSource: ImageLightboxSource = {
type: "uri",
uri: "blob:assistant-image",
};
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
@@ -78,14 +83,11 @@ vi.mock("lucide-react-native", () => {
};
});
vi.mock("expo-image", () => ({
Image: (props: Record<string, unknown>) => {
const source = props.source as { uri?: string } | string | undefined;
const uri = typeof source === "string" ? source : source?.uri;
vi.mock("@/components/zoomable-viewport/image", () => ({
ZoomableImage: (props: Record<string, unknown>) => {
return React.createElement("div", {
"data-testid": props.testID,
"data-source": uri,
"data-style": JSON.stringify(props.style ?? null),
"data-testid": `${String(props.testID)}-image`,
"data-source": props.uri,
role: "img",
});
},
@@ -150,42 +152,32 @@ function queryByTestId(testID: string): HTMLElement | null {
}
describe("AttachmentLightbox", () => {
it("renders nothing when metadata is null", () => {
render(<AttachmentLightbox metadata={null} onClose={vi.fn()} />);
it("renders nothing when the source is null", () => {
render(<AttachmentLightbox source={null} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull();
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
});
it("renders the image when metadata is provided", () => {
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
const image = queryByTestId("attachment-lightbox-image");
expect(image).not.toBeNull();
expect(image?.getAttribute("data-source")).toBe("blob:preview");
});
it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => {
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
it("renders a direct timeline image without resolving attachment storage", () => {
render(<AttachmentLightbox source={assistantImageSource} onClose={vi.fn()} />);
const image = queryByTestId("attachment-lightbox-image");
const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as {
position?: string;
top?: number;
left?: number;
right?: number;
bottom?: number;
} | null;
expect(style?.position).toBe("absolute");
expect(style?.top).toBe(0);
expect(style?.left).toBe(0);
expect(style?.right).toBe(0);
expect(style?.bottom).toBe(0);
expect(image?.getAttribute("data-source")).toBe("blob:assistant-image");
expect(useAttachmentPreviewUrlMock).toHaveBeenLastCalledWith(null);
});
it("calls onClose when the backdrop is pressed", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
const backdrop = queryByTestId("attachment-lightbox-backdrop");
expect(backdrop).not.toBeNull();
@@ -196,7 +188,7 @@ describe("AttachmentLightbox", () => {
it("calls onClose when the close button is pressed", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
const closeButton = document.querySelector(
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
@@ -209,7 +201,7 @@ describe("AttachmentLightbox", () => {
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
@@ -217,7 +209,7 @@ describe("AttachmentLightbox", () => {
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
act(() => {
window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
@@ -2,32 +2,40 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Image as ExpoImage } from "expo-image";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isWeb } from "@/constants/platform";
import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
export type ImageLightboxSource =
| { type: "attachment"; metadata: AttachmentMetadata }
| { type: "uri"; uri: string; contentSize?: ViewportSize };
interface AttachmentLightboxProps {
metadata: AttachmentMetadata | null;
source: ImageLightboxSource | null;
onClose: () => void;
}
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const url = useAttachmentPreviewUrl(metadata);
const metadata = source?.type === "attachment" ? source.metadata : null;
const attachmentUrl = useAttachmentPreviewUrl(metadata);
const url = source?.type === "uri" ? source.uri : attachmentUrl;
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
const [errored, setErrored] = useState(false);
useEffect(() => {
setErrored(false);
}, [metadata?.id]);
}, [metadata?.id, url]);
useEffect(() => {
if (!isWeb || !metadata) return;
if (!isWeb || !source) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
@@ -37,7 +45,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
return () => {
window.removeEventListener("keydown", handleKeydown);
};
}, [metadata, onClose]);
}, [onClose, source]);
const closeButtonRowStyle = useMemo(
() => [
@@ -54,10 +62,8 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
);
const handleImageError = useCallback(() => setErrored(true), []);
const noopPress = useCallback(() => {}, []);
const imageSource = useMemo(() => ({ uri: url ?? "" }), [url]);
if (!metadata) {
if (!source) {
return null;
}
@@ -74,20 +80,20 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
onPress={onClose}
style={styles.backdrop}
/>
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
<View pointerEvents="box-none" style={styles.contentLayer}>
<View pointerEvents="box-none" style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : (
<Pressable onPress={noopPress} style={styles.imagePressable}>
<ExpoImage
testID="attachment-lightbox-image"
source={imageSource}
contentFit="contain"
<View style={styles.imageViewport}>
<ZoomableImage
accessibilityLabel={t("composer.attachments.openImage")}
contentSize={contentSize}
onError={handleImageError}
style={imageFillStyle}
testID="attachment-lightbox"
uri={url}
/>
</Pressable>
</View>
)}
</View>
<WindowChromeSafeArea placement="inline" style={closeButtonRowStyle}>
@@ -109,14 +115,6 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
);
}
const imageFillStyle = {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
} as const;
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
@@ -135,23 +133,20 @@ const styles = StyleSheet.create((theme) => ({
left: 0,
right: 0,
bottom: 0,
pointerEvents: "box-none",
},
closeButtonRow: {
position: "absolute",
left: 0,
right: 0,
alignItems: "flex-end",
pointerEvents: "box-none",
},
imageArea: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
pointerEvents: "box-none",
},
imagePressable: {
imageViewport: {
flex: 1,
width: "100%",
alignSelf: "center",
@@ -1,17 +1,10 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
import { Code, Workflow } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Code, Scan, Workflow, ZoomIn, ZoomOut } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { useIsCompactFormFactor } from "@/constants/layout";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "../types";
import type { MermaidRenderRequest } from "./render-model";
@@ -21,13 +14,9 @@ import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
import { useMermaidRenderModel } from "./use-render-model";
import { getDiagramBoxStyle } from "./presentation";
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
interface MermaidIframeRuntimeProps {
request: MermaidRenderRequest | null;
height: number;
height: React.CSSProperties["height"];
onRendered: (message: {
revision: number;
source: string;
@@ -61,9 +50,7 @@ function MermaidIframeRuntime({
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
const target = iframeRef.current?.contentWindow;
if (!current || !target) {
return;
}
if (!current || !target) return;
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
@@ -80,13 +67,9 @@ function MermaidIframeRuntime({
useEffect(() => {
function receiveMessage(event: MessageEvent): void {
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
if (event.source !== iframeRef.current?.contentWindow) return;
const message = parseMermaidRuntimeMessage(event.data);
if (!message) {
return;
}
if (!message) return;
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
@@ -116,8 +99,9 @@ function MermaidIframeRuntime({
);
}
const MIN_SCALE = 0.25;
const MAX_SCALE = 8;
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
function MermaidFenceHostImpl({
code,
@@ -133,6 +117,9 @@ function MermaidFenceHostImpl({
colorScheme,
});
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
const [showSource, setShowSource] = useState(false);
const showSourcePress = useCallback(() => setShowSource(true), []);
const showDiagramPress = useCallback(() => setShowSource(false), []);
const handleRendered = useCallback(
(message: {
revision: number;
@@ -153,152 +140,18 @@ function MermaidFenceHostImpl({
);
const visible = state.visible;
const canShowDiagram = visible !== null && hasRuntimeContent;
const diagramVisible = canShowDiagram && !showSource;
const runtimeHeight = Math.max(visible?.height ?? 240, 1);
const viewportRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const controlsRegionRef = useRef<HTMLDivElement | null>(null);
const transformRef = useRef({ x: 0, y: 0, scale: 1 });
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
const applyTransformToElement = useCallback(
(element: HTMLDivElement, transform: { x: number; y: number; scale: number }) => {
const { x, y, scale } = transform;
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
},
[],
const actions = useMemo(
() => [
{
icon: Code,
label: t("message.diagram.viewSource"),
onPress: showSourcePress,
},
],
[showSourcePress, t],
);
const applyTransform = useCallback(() => {
if (contentRef.current) {
applyTransformToElement(contentRef.current, transformRef.current);
}
}, [applyTransformToElement]);
const setContentRef = useCallback(
(node: HTMLDivElement | null) => {
contentRef.current = node;
if (node) {
applyTransformToElement(node, transformRef.current);
}
},
[applyTransformToElement],
);
const setControlsRegionRef = useCallback((node: unknown) => {
controlsRegionRef.current = node instanceof HTMLDivElement ? node : null;
}, []);
const hasFocusedControls = useCallback(() => {
const region = controlsRegionRef.current;
const active = document.activeElement;
return Boolean(region && active instanceof Node && region.contains(active));
}, []);
const [isFocusWithin, setIsFocusWithin] = useState(false);
const handleFocusWithin = useCallback(() => setIsFocusWithin(true), []);
const handleBlurWithin = useCallback(() => {
window.requestAnimationFrame(() => setIsFocusWithin(hasFocusedControls()));
}, [hasFocusedControls]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !visible) {
return;
}
const activeViewport = viewport;
function zoomWithWheel(event: WheelEvent): void {
if (!event.ctrlKey && !event.metaKey) {
return;
}
event.preventDefault();
const transform = transformRef.current;
const factor = Math.min(1.25, Math.max(0.8, Math.exp(-event.deltaY * 0.01)));
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
const rect = activeViewport.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
transform.x = x - ((x - transform.x) / transform.scale) * scale;
transform.y = y - ((y - transform.y) / transform.scale) * scale;
transform.scale = scale;
applyTransform();
}
viewport.addEventListener("wheel", zoomWithWheel, { passive: false });
return () => viewport.removeEventListener("wheel", zoomWithWheel);
}, [applyTransform, visible]);
const onPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || event.pointerType !== "mouse") {
return;
}
const transform = transformRef.current;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: transform.x,
originY: transform.y,
};
event.currentTarget.setPointerCapture(event.pointerId);
}, []);
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || event.pointerId !== drag.pointerId) {
return;
}
transformRef.current.x = drag.originX + event.clientX - drag.startX;
transformRef.current.y = drag.originY + event.clientY - drag.startY;
applyTransform();
},
[applyTransform],
);
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null;
}
}, []);
const resetTransform = useCallback(() => {
transformRef.current = { x: 0, y: 0, scale: 1 };
applyTransform();
}, [applyTransform]);
const zoomBy = useCallback(
(factor: number) => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const rect = viewport.getBoundingClientRect();
const x = rect.width / 2;
const y = rect.height / 2;
const transform = transformRef.current;
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
transform.x = x - ((x - transform.x) / transform.scale) * scale;
transform.y = y - ((y - transform.y) / transform.scale) * scale;
transform.scale = scale;
applyTransform();
},
[applyTransform],
);
const zoomInPress = useCallback(() => zoomBy(1.25), [zoomBy]);
const zoomOutPress = useCallback(() => zoomBy(0.8), [zoomBy]);
const [showSource, setShowSource] = useState(false);
const showSourcePress = useCallback(() => setShowSource(true), []);
const showDiagramPress = useCallback(() => setShowSource(false), []);
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const isCompact = useIsCompactFormFactor();
const controlsVisible = isHovered || isCompact || isFocusWithin;
useEffect(() => {
if (!showSource && contentRef.current) {
applyTransformToElement(contentRef.current, transformRef.current);
}
}, [applyTransformToElement, showSource]);
const sourceView = useMemo(() => {
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
const margins: ViewStyle = {
@@ -306,182 +159,90 @@ function MermaidFenceHostImpl({
marginBottom: marginBottom ?? marginVertical,
};
const text: TextStyle = sourceTextStyle;
return {
container: [margins, sourceContainerStyle],
text,
};
return { container: [margins, sourceContainerStyle], text };
}, [textStyle]);
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
const diagramVisible = canShowDiagram && !showSource;
const sourceVisible = !canShowDiagram || showSource;
let rootStyle: StyleProp<ViewStyle> = sourceContainerStyle;
if (diagramVisible) {
rootStyle = [diagramBoxStyle, containerStyle];
} else if (showSource) {
rootStyle = sourceView.container;
}
const diagramStyle = useMemo(
() => [getDiagramBoxStyle(textStyle), containerStyle, { height: runtimeHeight }],
[runtimeHeight, textStyle],
);
const diagramSize = useMemo(
() => (visible ? { width: visible.width, height: visible.height } : MEASURING_SIZE),
[visible],
);
const sourceVisible = !diagramVisible;
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
const sourceTextStyle = showSource ? sourceView.text : textStyle;
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
const runtime = (
<MermaidIframeRuntime
request={request}
height={runtimeHeight}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
);
return (
<View
ref={setControlsRegionRef}
style={rootStyle}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocusWithin}
onBlur={handleBlurWithin}
>
<>
{sourceVisible ? (
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={sourceTextStyle}
/>
<View style={sourceContainer}>
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={sourceTextStyle}
/>
{showSource && canShowDiagram ? (
<Pressable
accessibilityLabel={t("message.diagram.viewDiagram")}
accessibilityRole="button"
hitSlop={4}
onPress={showDiagramPress}
style={controlStyles.sourceButton}
>
{({ hovered }) => (
<Workflow
size={14}
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
/>
)}
</Pressable>
) : null}
</View>
) : null}
<div
ref={viewportRef}
role={diagramVisible ? "img" : undefined}
aria-label={diagramVisible ? t("message.diagram.diagram") : undefined}
aria-hidden={!diagramVisible}
style={diagramVisible ? viewportDomStyle : measuringRuntimeStyle}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={resetTransform}
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<div ref={setContentRef} style={contentDomStyle}>
{runtime}
</div>
</div>
{diagramVisible ? (
<View style={controlStyles.cluster}>
<DiagramControlButton
icon={ZoomIn}
label={t("message.diagram.zoomIn")}
onPress={zoomInPress}
visible={controlsVisible}
/>
<DiagramControlButton
icon={ZoomOut}
label={t("message.diagram.zoomOut")}
onPress={zoomOutPress}
visible={controlsVisible}
/>
<DiagramControlButton
icon={Scan}
label={t("message.diagram.resetZoom")}
onPress={resetTransform}
visible={controlsVisible}
/>
<DiagramControlButton
icon={Code}
label={t("message.diagram.viewSource")}
onPress={showSourcePress}
visible={controlsVisible}
/>
</View>
) : null}
{showSource && canShowDiagram ? (
<View style={[controlStyles.cluster, controlStyles.clusterSourceOffset]}>
<DiagramControlButton
icon={Workflow}
label={t("message.diagram.viewDiagram")}
onPress={showDiagramPress}
visible={controlsVisible}
plain
/>
</View>
) : null}
</View>
);
}
interface DiagramControlButtonProps {
icon: ComponentType<{ size?: number; color?: string }>;
label: string;
onPress: () => void;
visible: boolean;
plain?: boolean;
}
const DiagramControlButton = React.memo(function DiagramControlButton({
icon: Icon,
label,
onPress,
visible,
plain = false,
}: DiagramControlButtonProps) {
const pillStyle = visible ? controlStyles.button : controlStyles.buttonHidden;
const plainStyle = visible ? controlStyles.plainButton : controlStyles.plainButtonHidden;
return (
<Pressable
onPress={onPress}
style={plain ? plainStyle : pillStyle}
accessibilityRole="button"
accessibilityLabel={label}
hitSlop={4}
>
{({ hovered }) => (
<Icon
size={14}
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
<MermaidIframeRuntime
request={request}
height="100%"
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
)}
</Pressable>
</ZoomableViewport>
</>
);
});
}
const MEASURING_SIZE = { width: 240, height: 240 };
const sourceContainerStyle: ViewStyle = { position: "relative" };
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
const measuringStyle: ViewStyle = {
position: "absolute",
left: 0,
right: 0,
top: 0,
height: 240,
opacity: 0,
pointerEvents: "none",
};
const controlStyles = StyleSheet.create((theme) => ({
cluster: {
sourceButton: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[2],
flexDirection: "row",
gap: theme.spacing[1],
},
clusterSourceOffset: { right: 40 },
button: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: 1,
pointerEvents: "auto",
},
buttonHidden: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: 0,
pointerEvents: "none",
},
plainButton: { padding: theme.spacing[1], opacity: 1, pointerEvents: "auto" },
plainButtonHidden: { padding: theme.spacing[1], opacity: 0, pointerEvents: "none" },
icon: { color: theme.colors.foregroundMuted },
iconHovered: { color: theme.colors.foreground },
}));
const sourceContainerStyle: ViewStyle = { position: "relative" };
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
const viewportDomStyle: React.CSSProperties = { cursor: "grab", userSelect: "none" };
const contentDomStyle: React.CSSProperties = { transformOrigin: "0 0" };
const measuringRuntimeStyle: React.CSSProperties = {
position: "absolute",
inset: 0,
opacity: 0,
pointerEvents: "none",
overflow: "hidden",
};
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
+45 -16
View File
@@ -103,7 +103,7 @@ import {
AttachmentLabel,
AttachmentThumbnail,
} from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
@@ -441,6 +441,10 @@ export const UserMessage = memo(function UserMessage({
const [isHovered, setIsHovered] = useState(false);
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
const lightboxSource = useMemo<ImageLightboxSource | null>(
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
[lightboxMetadata],
);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
@@ -566,7 +570,7 @@ export const UserMessage = memo(function UserMessage({
</View>
) : null}
</View>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
</View>
);
});
@@ -816,6 +820,10 @@ function AssistantMarkdownImage({
workspaceRoot?: string;
serverId?: string;
}) {
const { t } = useTranslation();
const [viewerOpen, setViewerOpen] = useState(false);
const openViewer = useCallback(() => setViewerOpen(true), []);
const closeViewer = useCallback(() => setViewerOpen(false), []);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? 16 : 0,
@@ -848,6 +856,14 @@ function AssistantMarkdownImage({
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
[imageSizeStyle],
);
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
if (!viewerOpen || !imageUri) return null;
return {
type: "uri",
uri: imageUri,
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
};
}, [aspectRatio, imageUri, viewerOpen]);
const stateFrameStyle = useMemo<StyleProp<ViewStyle>>(
() => [
@@ -877,21 +893,34 @@ function AssistantMarkdownImage({
return (
<View style={frameStyle}>
<View style={surfaceStyle} accessibilityRole="image" accessibilityLabel={alt}>
<Image
ref={binding.onRef}
source={imageSource}
<Pressable
accessibilityLabel={t("composer.attachments.openImage")}
accessibilityRole="button"
disabled={image.status !== "loaded"}
onPress={openViewer}
style={surfaceStyle}
>
<View
style={assistantMessageStylesheet.image}
resizeMode="contain"
onLoad={binding.onLoad}
onError={binding.onError}
/>
{image.status === "loading" ? (
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
accessibilityRole="image"
accessibilityLabel={alt}
>
<Image
ref={binding.onRef}
source={imageSource}
style={assistantMessageStylesheet.image}
resizeMode="contain"
onLoad={binding.onLoad}
onError={binding.onError}
/>
{image.status === "loading" ? (
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
</Pressable>
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
</View>
);
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
FIT_TRANSFORM,
clampTransform,
fitContentSize,
panContent,
zoomContentAtPoint,
} from "./geometry";
const viewport = { width: 800, height: 600 };
const fittedContent = { width: 800, height: 400 };
describe("zoomable viewport geometry", () => {
it("fits content inside the viewport without changing its aspect ratio", () => {
expect(fitContentSize({ width: 1600, height: 800 }, viewport)).toEqual(fittedContent);
expect(fitContentSize({ width: 400, height: 800 }, viewport)).toEqual({
width: 300,
height: 600,
});
});
it("returns to the centered fit transform at the minimum scale", () => {
expect(
clampTransform({ scale: 0.5, x: 200, y: -200 }, fittedContent, viewport, {
minScale: 1,
maxScale: 8,
}),
).toEqual(FIT_TRANSFORM);
});
it("keeps the focal content point under the cursor while zooming", () => {
expect(
zoomContentAtPoint({
transform: FIT_TRANSFORM,
scale: 2,
focalPoint: { x: 600, y: 300 },
fittedContent,
viewport,
limits: { minScale: 1, maxScale: 8 },
}),
).toEqual({ scale: 2, x: -200, y: 0 });
});
it("clamps panning to the scaled content edges", () => {
expect(
panContent({
transform: { scale: 2, x: 0, y: 0 },
delta: { x: 900, y: -900 },
fittedContent,
viewport,
limits: { minScale: 1, maxScale: 8 },
}),
).toEqual({ scale: 2, x: 400, y: -100 });
});
it("allows Mermaid consumers to zoom below fit without inventing translation", () => {
expect(
clampTransform({ scale: 0.25, x: 100, y: 100 }, fittedContent, viewport, {
minScale: 0.25,
maxScale: 8,
}),
).toEqual({ scale: 0.25, x: 0, y: 0 });
});
});
@@ -0,0 +1,105 @@
export interface ViewportSize {
width: number;
height: number;
}
export interface ViewportPoint {
x: number;
y: number;
}
export interface ViewportTransform {
scale: number;
x: number;
y: number;
}
export interface ViewportScaleLimits {
minScale: number;
maxScale: number;
}
export const FIT_TRANSFORM: ViewportTransform = { scale: 1, x: 0, y: 0 };
export function fitContentSize(content: ViewportSize, viewport: ViewportSize): ViewportSize {
const fitScale = Math.min(viewport.width / content.width, viewport.height / content.height);
return {
width: content.width * fitScale,
height: content.height * fitScale,
};
}
function clampScale(scale: number, limits: ViewportScaleLimits): number {
return Math.min(limits.maxScale, Math.max(limits.minScale, scale));
}
function clampOffset(offset: number, maximum: number): number {
if (maximum === 0) {
return 0;
}
return Math.min(maximum, Math.max(-maximum, offset));
}
export function clampTransform(
transform: ViewportTransform,
fittedContent: ViewportSize,
viewport: ViewportSize,
limits: ViewportScaleLimits,
): ViewportTransform {
const scale = clampScale(transform.scale, limits);
const maxX = Math.max(0, (fittedContent.width * scale - viewport.width) / 2);
const maxY = Math.max(0, (fittedContent.height * scale - viewport.height) / 2);
return {
scale,
x: clampOffset(transform.x, maxX),
y: clampOffset(transform.y, maxY),
};
}
export function zoomContentAtPoint(input: {
transform: ViewportTransform;
scale: number;
focalPoint: ViewportPoint;
fittedContent: ViewportSize;
viewport: ViewportSize;
limits: ViewportScaleLimits;
}): ViewportTransform {
const scale = clampScale(input.scale, input.limits);
const viewportCenter = {
x: input.viewport.width / 2,
y: input.viewport.height / 2,
};
const contentPoint = {
x: (input.focalPoint.x - viewportCenter.x - input.transform.x) / input.transform.scale,
y: (input.focalPoint.y - viewportCenter.y - input.transform.y) / input.transform.scale,
};
return clampTransform(
{
scale,
x: input.focalPoint.x - viewportCenter.x - contentPoint.x * scale,
y: input.focalPoint.y - viewportCenter.y - contentPoint.y * scale,
},
input.fittedContent,
input.viewport,
input.limits,
);
}
export function panContent(input: {
transform: ViewportTransform;
delta: ViewportPoint;
fittedContent: ViewportSize;
viewport: ViewportSize;
limits: ViewportScaleLimits;
}): ViewportTransform {
return clampTransform(
{
...input.transform,
x: input.transform.x + input.delta.x,
y: input.transform.y + input.delta.y,
},
input.fittedContent,
input.viewport,
input.limits,
);
}
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from "react";
import { Image, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ViewportSize } from "./geometry";
import { ZoomableViewport } from "./index";
import type { ZoomableViewportAction } from "./types";
interface ZoomableImageProps {
uri: string;
accessibilityLabel?: string;
actions?: ZoomableViewportAction[];
contentSize?: ViewportSize;
maxScale?: number;
minScale?: number;
onError?: () => void;
testID?: string;
wheelActivation?: "always" | "modifier";
}
export function ZoomableImage({
uri,
accessibilityLabel,
actions,
contentSize,
maxScale,
minScale = 1,
onError,
testID = "zoomable-image",
wheelActivation = "always",
}: ZoomableImageProps) {
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
const resolvedSize = contentSize ?? loadedSize;
const source = useMemo(() => ({ uri }), [uri]);
useEffect(() => {
if (contentSize) return;
let active = true;
setLoadedSize(null);
Image.getSize(
uri,
(width, height) => {
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
},
() => {
if (active) onError?.();
},
);
return () => {
active = false;
};
}, [contentSize, onError, uri]);
if (!resolvedSize) {
return (
<View style={styles.root} testID={testID}>
<Image
accessibilityLabel={accessibilityLabel}
accessibilityRole="image"
onError={onError}
resizeMode="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
/>
</View>
);
}
return (
<ZoomableViewport
accessibilityLabel={accessibilityLabel}
actions={actions}
contentSize={resolvedSize}
maxScale={maxScale}
minScale={minScale}
testID={testID}
wheelActivation={wheelActivation}
>
<Image
onError={onError}
resizeMode="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
/>
</ZoomableViewport>
);
}
const styles = StyleSheet.create(() => ({
root: { flex: 1, minHeight: 0 },
image: { width: "100%", height: "100%" },
}));
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { FIT_TRANSFORM, fitContentSize, zoomContentAtPoint, type ViewportSize } from "./geometry";
import { ViewportToolbar } from "./toolbar";
import type { ZoomableViewportProps } from "./types";
const DEFAULT_MAX_SCALE = 8;
const DEFAULT_MIN_SCALE = 0.25;
export function ZoomableViewport({
contentSize,
children,
actions = [],
accessibilityLabel,
maxScale = DEFAULT_MAX_SCALE,
minScale = DEFAULT_MIN_SCALE,
style,
testID,
}: ZoomableViewportProps) {
const [viewport, setViewport] = useState<ViewportSize | null>(null);
const [toolbarScale, setToolbarScale] = useState(1);
const scale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const startScale = useSharedValue(1);
const startX = useSharedValue(0);
const startY = useSharedValue(0);
const pinchFocalX = useSharedValue(0);
const pinchFocalY = useSharedValue(0);
const pinchReady = useSharedValue(false);
const fittedContent = useMemo(
() => (viewport ? fitContentSize(contentSize, viewport) : null),
[contentSize, viewport],
);
const reset = useCallback(() => {
scale.value = FIT_TRANSFORM.scale;
translateX.value = FIT_TRANSFORM.x;
translateY.value = FIT_TRANSFORM.y;
setToolbarScale(FIT_TRANSFORM.scale);
}, [scale, translateX, translateY]);
useEffect(() => reset(), [contentSize.height, contentSize.width, reset, viewport]);
const panGesture = useMemo(
() =>
Gesture.Pan()
.maxPointers(1)
.onBegin(() => {
startX.value = translateX.value;
startY.value = translateY.value;
})
.onUpdate((event) => {
if (!fittedContent || !viewport || scale.value <= minScale) return;
const maxX = Math.max(0, (fittedContent.width * scale.value - viewport.width) / 2);
const maxY = Math.max(0, (fittedContent.height * scale.value - viewport.height) / 2);
translateX.value = Math.min(maxX, Math.max(-maxX, startX.value + event.translationX));
translateY.value = Math.min(maxY, Math.max(-maxY, startY.value + event.translationY));
}),
[fittedContent, minScale, scale, startX, startY, translateX, translateY, viewport],
);
const pinchGesture = useMemo(
() =>
Gesture.Pinch()
.onBegin(() => {
startScale.value = scale.value;
startX.value = translateX.value;
startY.value = translateY.value;
pinchReady.value = false;
})
.onUpdate((event) => {
if (!fittedContent || !viewport) return;
if (!pinchReady.value) {
pinchFocalX.value = event.focalX;
pinchFocalY.value = event.focalY;
pinchReady.value = true;
}
const nextScale = Math.min(maxScale, Math.max(minScale, startScale.value * event.scale));
const centerX = viewport.width / 2;
const centerY = viewport.height / 2;
const contentPointX = (pinchFocalX.value - centerX - startX.value) / startScale.value;
const contentPointY = (pinchFocalY.value - centerY - startY.value) / startScale.value;
const nextX = event.focalX - centerX - contentPointX * nextScale;
const nextY = event.focalY - centerY - contentPointY * nextScale;
const maxX = Math.max(0, (fittedContent.width * nextScale - viewport.width) / 2);
const maxY = Math.max(0, (fittedContent.height * nextScale - viewport.height) / 2);
scale.value = nextScale;
translateX.value = Math.min(maxX, Math.max(-maxX, nextX));
translateY.value = Math.min(maxY, Math.max(-maxY, nextY));
})
.onFinalize(() => runOnJS(setToolbarScale)(scale.value)),
[
fittedContent,
maxScale,
minScale,
pinchFocalX,
pinchFocalY,
pinchReady,
scale,
startScale,
startX,
startY,
translateX,
translateY,
viewport,
],
);
const gesture = useMemo(
() => Gesture.Simultaneous(panGesture, pinchGesture),
[panGesture, pinchGesture],
);
const zoomFromCenter = useCallback(
(factor: number) => {
if (!fittedContent || !viewport) return;
const next = zoomContentAtPoint({
transform: { scale: scale.value, x: translateX.value, y: translateY.value },
scale: scale.value * factor,
focalPoint: { x: viewport.width / 2, y: viewport.height / 2 },
fittedContent,
viewport,
limits: { minScale, maxScale },
});
scale.value = next.scale;
translateX.value = next.x;
translateY.value = next.y;
setToolbarScale(next.scale);
},
[fittedContent, maxScale, minScale, scale, translateX, translateY, viewport],
);
const zoomIn = useCallback(() => zoomFromCenter(1.25), [zoomFromCenter]);
const zoomOut = useCallback(() => zoomFromCenter(0.8), [zoomFromCenter]);
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
if (width > 0 && height > 0) setViewport({ width, height });
}, []);
const translationStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
}));
const scaleStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const frameStyle = useMemo(
() => [
styles.contentFrame,
{ width: fittedContent?.width ?? 0, height: fittedContent?.height ?? 0 },
translationStyle,
],
[fittedContent, translationStyle],
);
return (
<View onLayout={handleLayout} style={[styles.root, style]} testID={testID}>
<GestureDetector gesture={gesture}>
<View
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessibilityLabel ? "image" : undefined}
style={styles.canvas}
testID={testID ? `${testID}-canvas` : undefined}
>
{fittedContent ? (
<Animated.View style={frameStyle}>
<Animated.View style={[styles.content, scaleStyle]}>{children}</Animated.View>
</Animated.View>
) : null}
</View>
</GestureDetector>
<ViewportToolbar
actions={actions}
maxScale={maxScale}
minScale={minScale}
onReset={reset}
onZoomIn={zoomIn}
onZoomOut={zoomOut}
scale={toolbarScale}
visible
/>
</View>
);
}
const styles = StyleSheet.create(() => ({
root: { flex: 1, minHeight: 0, overflow: "hidden", position: "relative" },
canvas: {
flex: 1,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
contentFrame: { flexShrink: 0 },
content: { width: "100%", height: "100%" },
}));
@@ -0,0 +1,319 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
FIT_TRANSFORM,
fitContentSize,
panContent,
zoomContentAtPoint,
type ViewportPoint,
type ViewportSize,
type ViewportTransform,
} from "./geometry";
import { ViewportToolbar } from "./toolbar";
import type { ZoomableViewportProps } from "./types";
const DEFAULT_MAX_SCALE = 8;
const DEFAULT_MIN_SCALE = 0.25;
interface DragGesture {
type: "drag";
pointerId: number;
startPoint: ViewportPoint;
startTransform: ViewportTransform;
}
interface PinchGesture {
type: "pinch";
distance: number;
midpoint: ViewportPoint;
}
type ActiveGesture = DragGesture | PinchGesture;
function midpoint(points: ViewportPoint[]): ViewportPoint {
return {
x: (points[0].x + points[1].x) / 2,
y: (points[0].y + points[1].y) / 2,
};
}
function distance(points: ViewportPoint[]): number {
return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
}
export function ZoomableViewport({
contentSize,
children,
actions = [],
accessibilityLabel,
maxScale = DEFAULT_MAX_SCALE,
minScale = DEFAULT_MIN_SCALE,
style,
testID,
wheelActivation = "modifier",
}: ZoomableViewportProps) {
const canvasRef = useRef<HTMLDivElement | null>(null);
const pointersRef = useRef(new Map<number, ViewportPoint>());
const gestureRef = useRef<ActiveGesture | null>(null);
const transformRef = useRef<ViewportTransform>(FIT_TRANSFORM);
const [viewport, setViewport] = useState<ViewportSize | null>(null);
const [transform, setTransform] = useState<ViewportTransform>(FIT_TRANSFORM);
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isFocusWithin, setIsFocusWithin] = useState(false);
const [hasTouchInput, setHasTouchInput] = useState(false);
const fittedContent = useMemo(
() => (viewport ? fitContentSize(contentSize, viewport) : null),
[contentSize, viewport],
);
const limits = useMemo(() => ({ minScale, maxScale }), [maxScale, minScale]);
const commitTransform = useCallback((next: ViewportTransform) => {
transformRef.current = next;
setTransform(next);
}, []);
const reset = useCallback(() => commitTransform(FIT_TRANSFORM), [commitTransform]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new ResizeObserver(([entry]) => {
if (!entry || entry.contentRect.width <= 0 || entry.contentRect.height <= 0) return;
setViewport({ width: entry.contentRect.width, height: entry.contentRect.height });
});
observer.observe(canvas);
return () => observer.disconnect();
}, []);
useEffect(() => reset(), [contentSize.height, contentSize.width, reset, viewport]);
const zoomAt = useCallback(
(scale: number, focalPoint: ViewportPoint) => {
if (!fittedContent || !viewport) return;
commitTransform(
zoomContentAtPoint({
transform: transformRef.current,
scale,
focalPoint,
fittedContent,
viewport,
limits,
}),
);
},
[commitTransform, fittedContent, limits, viewport],
);
const zoomFromCenter = useCallback(
(factor: number) => {
if (!viewport) return;
zoomAt(transformRef.current.scale * factor, {
x: viewport.width / 2,
y: viewport.height / 2,
});
},
[viewport, zoomAt],
);
const zoomIn = useCallback(() => zoomFromCenter(1.25), [zoomFromCenter]);
const zoomOut = useCallback(() => zoomFromCenter(0.8), [zoomFromCenter]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const activeCanvas = canvas;
function zoomWithWheel(event: WheelEvent) {
const hasModifier = event.ctrlKey || event.metaKey;
if (wheelActivation === "modifier" && !hasModifier) return;
event.preventDefault();
const bounds = activeCanvas.getBoundingClientRect();
const factor = Math.min(1.25, Math.max(0.8, Math.exp(-event.deltaY * 0.01)));
zoomAt(transformRef.current.scale * factor, {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
});
}
activeCanvas.addEventListener("wheel", zoomWithWheel, { passive: false });
return () => activeCanvas.removeEventListener("wheel", zoomWithWheel);
}, [wheelActivation, zoomAt]);
const beginPointer = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
if (event.pointerType !== "mouse") setHasTouchInput(true);
event.currentTarget.setPointerCapture(event.pointerId);
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
const points = Array.from(pointersRef.current.values());
if (points.length >= 2) {
gestureRef.current = {
type: "pinch",
distance: distance(points),
midpoint: midpoint(points),
};
setIsDragging(true);
return;
}
gestureRef.current = {
type: "drag",
pointerId: event.pointerId,
startPoint: points[0],
startTransform: transformRef.current,
};
setIsDragging(transformRef.current.scale > 1);
}, []);
const movePointer = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!pointersRef.current.has(event.pointerId) || !fittedContent || !viewport) return;
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
const gesture = gestureRef.current;
const points = Array.from(pointersRef.current.values());
if (gesture?.type === "pinch" && points.length >= 2) {
const nextMidpoint = midpoint(points);
const nextDistance = distance(points);
const panned = panContent({
transform: transformRef.current,
delta: {
x: nextMidpoint.x - gesture.midpoint.x,
y: nextMidpoint.y - gesture.midpoint.y,
},
fittedContent,
viewport,
limits,
});
const next = zoomContentAtPoint({
transform: panned,
scale: panned.scale * (nextDistance / gesture.distance),
focalPoint: nextMidpoint,
fittedContent,
viewport,
limits,
});
gestureRef.current = { type: "pinch", distance: nextDistance, midpoint: nextMidpoint };
commitTransform(next);
return;
}
if (gesture?.type !== "drag" || gesture.pointerId !== event.pointerId) return;
commitTransform(
panContent({
transform: gesture.startTransform,
delta: {
x: event.clientX - gesture.startPoint.x,
y: event.clientY - gesture.startPoint.y,
},
fittedContent,
viewport,
limits,
}),
);
},
[commitTransform, fittedContent, limits, viewport],
);
const endPointer = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
pointersRef.current.delete(event.pointerId);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const remaining = Array.from(pointersRef.current.entries());
if (remaining.length === 1) {
const [pointerId, point] = remaining[0];
gestureRef.current = {
type: "drag",
pointerId,
startPoint: point,
startTransform: transformRef.current,
};
setIsDragging(transformRef.current.scale > 1);
return;
}
gestureRef.current = null;
setIsDragging(false);
}, []);
const renderedCanvasStyle = useMemo<React.CSSProperties>(() => {
let cursor: React.CSSProperties["cursor"] = "default";
if (transform.scale > 1) cursor = isDragging ? "grabbing" : "grab";
return { ...canvasDomStyle, cursor };
}, [isDragging, transform.scale]);
const renderedContentStyle = useMemo<React.CSSProperties>(
() => ({
...contentDomStyle,
width: fittedContent?.width ?? 0,
height: fittedContent?.height ?? 0,
transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
}),
[fittedContent, transform],
);
const controlsVisible = isHovered || isFocusWithin || hasTouchInput;
const handleFocus = useCallback(() => setIsFocusWithin(true), []);
const handleBlur = useCallback(() => setIsFocusWithin(false), []);
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
return (
<View style={[styles.root, style]} testID={testID}>
<div
aria-label={accessibilityLabel}
data-testid={testID ? `${testID}-canvas` : undefined}
onDoubleClick={reset}
onFocusCapture={handleFocus}
onBlurCapture={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerCancel={endPointer}
onPointerDown={beginPointer}
onPointerMove={movePointer}
onPointerUp={endPointer}
ref={canvasRef}
role={accessibilityLabel ? "img" : undefined}
style={renderedCanvasStyle}
>
<div style={renderedContentStyle}>{children}</div>
</div>
<div
onBlurCapture={handleBlur}
onFocusCapture={handleFocus}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={toolbarDomStyle}
>
<ViewportToolbar
actions={actions}
maxScale={maxScale}
minScale={minScale}
onReset={reset}
onZoomIn={zoomIn}
onZoomOut={zoomOut}
scale={transform.scale}
visible={controlsVisible}
/>
</div>
</View>
);
}
const styles = StyleSheet.create(() => ({
root: { flex: 1, minHeight: 0, overflow: "hidden", position: "relative" },
}));
const canvasDomStyle: React.CSSProperties = {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
touchAction: "none",
userSelect: "none",
};
const contentDomStyle: React.CSSProperties = {
flexShrink: 0,
transformOrigin: "center center",
};
const toolbarDomStyle: React.CSSProperties = {
position: "absolute",
top: 0,
right: 0,
width: 200,
height: 48,
zIndex: 1,
};
@@ -0,0 +1,126 @@
import React from "react";
import { Pressable, View } from "react-native";
import { Scan, ZoomIn, ZoomOut } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { ZoomableViewportAction } from "./types";
interface ViewportToolbarProps {
actions: ZoomableViewportAction[];
maxScale: number;
minScale: number;
onReset: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
scale: number;
visible: boolean;
}
interface ResolvedViewportAction extends ZoomableViewportAction {
disabled: boolean;
}
export const ViewportToolbar = React.memo(function ViewportToolbar({
actions,
maxScale,
minScale,
onReset,
onZoomIn,
onZoomOut,
scale,
visible,
}: ViewportToolbarProps) {
const { t } = useTranslation();
const viewportActions: ResolvedViewportAction[] = [
{
icon: ZoomIn,
label: t("message.diagram.zoomIn"),
onPress: onZoomIn,
disabled: scale >= maxScale,
},
{
icon: ZoomOut,
label: t("message.diagram.zoomOut"),
onPress: onZoomOut,
disabled: scale <= minScale,
},
{
icon: Scan,
label: t("message.diagram.resetZoom"),
onPress: onReset,
disabled: scale === 1,
},
...actions.map((action) => ({ ...action, disabled: false })),
];
return (
<View style={styles.cluster} testID="zoomable-viewport-toolbar">
{viewportActions.map((action) => (
<ViewportToolbarButton
key={action.label}
action={action}
disabled={action.disabled}
visible={visible}
/>
))}
</View>
);
});
function ViewportToolbarButton({
action,
disabled,
visible,
}: {
action: ZoomableViewportAction;
disabled: boolean;
visible: boolean;
}) {
const Icon = action.icon;
const buttonStyle = visible ? styles.button : styles.buttonHidden;
const resolvedStyle = disabled ? [buttonStyle, styles.disabled] : buttonStyle;
return (
<Pressable
accessibilityLabel={action.label}
accessibilityRole="button"
disabled={disabled}
hitSlop={4}
onPress={action.onPress}
style={resolvedStyle}
testID={action.testID}
>
{({ hovered }) => (
<Icon size={14} color={hovered ? styles.iconHovered.color : styles.icon.color} />
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
cluster: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[2],
zIndex: 1,
flexDirection: "row",
gap: theme.spacing[1],
pointerEvents: "box-none",
},
button: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: 1,
pointerEvents: "auto",
},
buttonHidden: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: 0,
pointerEvents: "none",
},
disabled: { opacity: theme.opacity[50] },
icon: { color: theme.colors.foregroundMuted },
iconHovered: { color: theme.colors.foreground },
}));
@@ -0,0 +1,22 @@
import type { ComponentType, ReactNode } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { ViewportSize } from "./geometry";
export interface ZoomableViewportAction {
icon: ComponentType<{ size?: number; color?: string }>;
label: string;
onPress: () => void;
testID?: string;
}
export interface ZoomableViewportProps {
contentSize: ViewportSize;
children: ReactNode;
actions?: ZoomableViewportAction[];
accessibilityLabel?: string;
maxScale?: number;
minScale?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
wheelActivation?: "always" | "modifier";
}
+6 -2
View File
@@ -124,7 +124,7 @@ import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
@@ -2141,6 +2141,10 @@ function ComposerContentImpl({
const handleLightboxClose = useCallback(() => {
setLightboxMetadata(null);
}, []);
const lightboxSource = useMemo<ImageLightboxSource | null>(
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
[lightboxMetadata],
);
const handleGithubPickerOpenChange = useCallback(
(open: boolean) => {
@@ -2263,7 +2267,7 @@ function ComposerContentImpl({
isMessageInputFocused={isMessageInputFocused}
/>
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
+3 -32
View File
@@ -9,7 +9,7 @@ import React, {
useSyncExternalStore,
} from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
import { ScrollView as RNScrollView, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -39,6 +39,7 @@ import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -146,11 +147,6 @@ function FilePreviewBody({
const previewScrollRef = useRef<RNScrollView>(null);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri],
);
if (isLoading && !preview) {
return (
<View style={styles.centerState} testID="file-preview-loading">
@@ -212,22 +208,7 @@ function FilePreviewBody({
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
showsVerticalScrollIndicator
>
<RNImage
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
</View>
);
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
}
return (
@@ -711,14 +692,4 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],
alignItems: "center",
justifyContent: "center",
},
previewImage: {
width: "100%",
height: 420,
},
}));