mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user