From eef9be8863b991d89d1c72395d96f92c4cd16f35 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 29 Aug 2026 22:08:17 +0200 Subject: [PATCH] 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. --- .../browser/agent-tab-image-stability.spec.ts | 31 ++ packages/app/e2e/browser/file-editing.spec.ts | 10 + .../components/attachment-lightbox.test.tsx | 50 +-- .../src/components/attachment-lightbox.tsx | 57 ++- .../markdown/fence/mermaid/host.web.tsx | 417 ++++-------------- packages/app/src/components/message.tsx | 61 ++- .../zoomable-viewport/geometry.test.ts | 64 +++ .../components/zoomable-viewport/geometry.ts | 105 +++++ .../components/zoomable-viewport/image.tsx | 93 ++++ .../components/zoomable-viewport/index.tsx | 195 ++++++++ .../zoomable-viewport/index.web.tsx | 319 ++++++++++++++ .../components/zoomable-viewport/toolbar.tsx | 126 ++++++ .../src/components/zoomable-viewport/types.ts | 22 + packages/app/src/composer/index.tsx | 8 +- packages/app/src/file-pane/pane.tsx | 35 +- 15 files changed, 1155 insertions(+), 438 deletions(-) create mode 100644 packages/app/src/components/zoomable-viewport/geometry.test.ts create mode 100644 packages/app/src/components/zoomable-viewport/geometry.ts create mode 100644 packages/app/src/components/zoomable-viewport/image.tsx create mode 100644 packages/app/src/components/zoomable-viewport/index.tsx create mode 100644 packages/app/src/components/zoomable-viewport/index.web.tsx create mode 100644 packages/app/src/components/zoomable-viewport/toolbar.tsx create mode 100644 packages/app/src/components/zoomable-viewport/types.ts diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 08cf98d3c9..f03bd66f1e 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -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, diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 9da7687b98..bd834d6f75 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -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); diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index 8948cf9db7..35a4e62db6 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -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) => { - 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) => { 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(); + it("renders nothing when the source is null", () => { + render(); expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull(); expect(queryByTestId("attachment-lightbox-image")).toBeNull(); }); it("renders the image when metadata is provided", () => { - render(); + render(); 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(); + it("renders a direct timeline image without resolving attachment storage", () => { + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); act(() => { window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" })); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index d916bddd23..2425136fa1 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -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} /> - - + + {hasError ? ( {t("message.attachments.imageLoadFailed")} ) : ( - - + - + )} @@ -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", diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx index c51ab75428..f99b5e6630 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx @@ -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(null); - const contentRef = useRef(null); - const controlsRegionRef = useRef(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) => { - 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) => { - 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) => { - 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 = 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 = ( - - ); return ( - + <> {sourceVisible ? ( - + + + {showSource && canShowDiagram ? ( + + {({ hovered }) => ( + + )} + + ) : null} + ) : null} -
-
- {runtime} -
-
- {diagramVisible ? ( - - - - - - - ) : null} - {showSource && canShowDiagram ? ( - - - - ) : null} -
- ); -} - -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 ( - - {({ hovered }) => ( - - )} - + + ); -}); +} +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); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 9b1b90528d..1658076273 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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(null); const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []); + const lightboxSource = useMemo( + () => (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({
) : null}
- + ); }); @@ -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>( () => ({ marginTop: hasLeadingContent ? 16 : 0, @@ -848,6 +856,14 @@ function AssistantMarkdownImage({ () => [assistantMessageStylesheet.imageSurface, imageSizeStyle], [imageSizeStyle], ); + const lightboxSource = useMemo(() => { + if (!viewerOpen || !imageUri) return null; + return { + type: "uri", + uri: imageUri, + contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined, + }; + }, [aspectRatio, imageUri, viewerOpen]); const stateFrameStyle = useMemo>( () => [ @@ -877,21 +893,34 @@ function AssistantMarkdownImage({ return ( - - + - {image.status === "loading" ? ( - - - - ) : null} - + accessibilityRole="image" + accessibilityLabel={alt} + > + + {image.status === "loading" ? ( + + + + ) : null} + + + ); } diff --git a/packages/app/src/components/zoomable-viewport/geometry.test.ts b/packages/app/src/components/zoomable-viewport/geometry.test.ts new file mode 100644 index 0000000000..11a810e5ca --- /dev/null +++ b/packages/app/src/components/zoomable-viewport/geometry.test.ts @@ -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 }); + }); +}); diff --git a/packages/app/src/components/zoomable-viewport/geometry.ts b/packages/app/src/components/zoomable-viewport/geometry.ts new file mode 100644 index 0000000000..fa46873073 --- /dev/null +++ b/packages/app/src/components/zoomable-viewport/geometry.ts @@ -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, + ); +} diff --git a/packages/app/src/components/zoomable-viewport/image.tsx b/packages/app/src/components/zoomable-viewport/image.tsx new file mode 100644 index 0000000000..d933449308 --- /dev/null +++ b/packages/app/src/components/zoomable-viewport/image.tsx @@ -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(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 ( + + + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create(() => ({ + root: { flex: 1, minHeight: 0 }, + image: { width: "100%", height: "100%" }, +})); diff --git a/packages/app/src/components/zoomable-viewport/index.tsx b/packages/app/src/components/zoomable-viewport/index.tsx new file mode 100644 index 0000000000..416ad83980 --- /dev/null +++ b/packages/app/src/components/zoomable-viewport/index.tsx @@ -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(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 ( + + + + {fittedContent ? ( + + {children} + + ) : null} + + + + + ); +} + +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%" }, +})); diff --git a/packages/app/src/components/zoomable-viewport/index.web.tsx b/packages/app/src/components/zoomable-viewport/index.web.tsx new file mode 100644 index 0000000000..c77b24691a --- /dev/null +++ b/packages/app/src/components/zoomable-viewport/index.web.tsx @@ -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(null); + const pointersRef = useRef(new Map()); + const gestureRef = useRef(null); + const transformRef = useRef(FIT_TRANSFORM); + const [viewport, setViewport] = useState(null); + const [transform, setTransform] = useState(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) => { + 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) => { + 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) => { + 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(() => { + let cursor: React.CSSProperties["cursor"] = "default"; + if (transform.scale > 1) cursor = isDragging ? "grabbing" : "grab"; + return { ...canvasDomStyle, cursor }; + }, [isDragging, transform.scale]); + const renderedContentStyle = useMemo( + () => ({ + ...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 ( + +
+
{children}
+
+
+ +
+
+ ); +} + +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, +}; diff --git a/packages/app/src/components/zoomable-viewport/toolbar.tsx b/packages/app/src/components/zoomable-viewport/toolbar.tsx new file mode 100644 index 0000000000..9f25bf4733 --- /dev/null +++ b/packages/app/src/components/zoomable-viewport/toolbar.tsx @@ -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 ( + + {viewportActions.map((action) => ( + + ))} + + ); +}); + +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 ( + + {({ hovered }) => ( + + )} + + ); +} + +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 }, +})); diff --git a/packages/app/src/components/zoomable-viewport/types.ts b/packages/app/src/components/zoomable-viewport/types.ts new file mode 100644 index 0000000000..981698677b --- /dev/null +++ b/packages/app/src/components/zoomable-viewport/types.ts @@ -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; + testID?: string; + wheelActivation?: "always" | "modifier"; +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a16fdb7087..9f23daed87 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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( + () => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null), + [lightboxMetadata], + ); const handleGithubPickerOpenChange = useCallback( (open: boolean) => { @@ -2263,7 +2267,7 @@ function ComposerContentImpl({ isMessageInputFocused={isMessageInputFocused} /> - + {/* Input area */} diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 0cce3b82f4..a5630ed82f 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -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(null); - const imageSource = useMemo( - () => (imagePreviewUri ? { uri: imagePreviewUri } : null), - [imagePreviewUri], - ); - if (isLoading && !preview) { return ( @@ -212,22 +208,7 @@ function FilePreviewBody({ ); } - return ( - - - - - - ); + return ; } 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, - }, }));