diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index a395a517c2..15e3f21bc0 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -729,6 +729,10 @@ test.describe("CodeMirror workspace file editing", () => { const imagePath = path.join(workspace.repoPath, "pixel.png"); await writeFile(markdownPath, "# First heading\n", "utf8"); await writeFile(imagePath, RED_PIXEL); + await writeFile( + path.join(workspace.repoPath, "shape.svg"), + await readFile(path.resolve(__dirname, "../mobile/fixtures/native-preview.svg")), + ); await workspace.navigateTo(); await openWorkspaceFile(page, "notes.md"); @@ -774,6 +778,12 @@ test.describe("CodeMirror workspace file editing", () => { const initialSource = await image.getAttribute("src"); await writeFile(imagePath, BLUE_PIXEL); await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource); + + await openWorkspaceFile(page, "shape.svg"); + await expect(imageCanvas).toBeVisible(); + await expect(image).toBeVisible(); + await expect(image).toHaveJSProperty("complete", true); + await expect(image).toHaveJSProperty("naturalWidth", 240); }); test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => { diff --git a/packages/app/e2e/mobile/fixtures/native-preview-viewbox.svg b/packages/app/e2e/mobile/fixtures/native-preview-viewbox.svg new file mode 100644 index 0000000000..6b107b80e8 --- /dev/null +++ b/packages/app/e2e/mobile/fixtures/native-preview-viewbox.svg @@ -0,0 +1 @@ + diff --git a/packages/app/e2e/mobile/fixtures/native-preview.svg b/packages/app/e2e/mobile/fixtures/native-preview.svg new file mode 100644 index 0000000000..67c1c66ae8 --- /dev/null +++ b/packages/app/e2e/mobile/fixtures/native-preview.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app/e2e/mobile/svg-preview.md b/packages/app/e2e/mobile/svg-preview.md new file mode 100644 index 0000000000..514a5c8c6e --- /dev/null +++ b/packages/app/e2e/mobile/svg-preview.md @@ -0,0 +1,22 @@ +# Native SVG preview regression + +Copy `fixtures/native-preview.svg`, `fixtures/native-preview-viewbox.svg`, and +`../../assets/images/icon.png` into a workspace on a test daemon. In the Android +or iOS app, connect to that daemon, open the workspace's Files tab, and tap +`native-preview.svg`. The SVG should show a yellow circle on a green rectangle. + +With that preview open, run: + +```sh +node packages/app/e2e/mobile/svg-preview.mjs +``` + +Run it again with `native-preview-viewbox.svg` open to cover SVG dimensions +inferred from `viewBox`, then with the PNG open to check raster images. Close and +reopen the SVG and rerun to cover cached file previews. Use English app language +for the zoom button selectors. The script drives the existing native session; +it does not launch an app or daemon or change its connection. + +Before the fix the SVG remains blank and the canvas wait fails. The PNG passes. +The script checks successful native dimension decoding and zoom/reset controls; +visually verify the circle and rectangle as well. diff --git a/packages/app/e2e/mobile/svg-preview.mjs b/packages/app/e2e/mobile/svg-preview.mjs new file mode 100644 index 0000000000..2fa6eedfb7 --- /dev/null +++ b/packages/app/e2e/mobile/svg-preview.mjs @@ -0,0 +1,31 @@ +// Open an SVG file preview in a native app loaded from this checkout first. +// node packages/app/e2e/mobile/svg-preview.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +const [, , session] = process.argv; +assert(session, "Pass the agent-device session with an SVG preview open"); +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + timeout: 30_000, + }); +} + +// The canvas only mounts once the native decoder has returned image dimensions. +// A file name or an image placeholder alone does not establish a successful load. +ad("wait", 'id="image-file-preview-canvas"', "10000"); +ad("wait", 'role=button label="Zoom in"', "10000"); +ad("press", 'role=button label="Zoom in"', "--settle"); +assert.equal( + JSON.parse(ad("get", "attrs", 'role=button label="Reset view"', "--json")).data.node.enabled, + true, + "Zooming enables Reset view", +); +ad("press", 'role=button label="Reset view"', "--settle"); +assert.equal( + JSON.parse(ad("get", "attrs", 'role=button label="Reset view"', "--json")).data.node.enabled, + false, + "Reset returns to the fitted view", +); +console.log("PASS: native SVG decoded, zoomed, and reset"); diff --git a/packages/app/src/components/zoomable-viewport/image.tsx b/packages/app/src/components/zoomable-viewport/image.tsx index 3c59eee4bf..3e79041a94 100644 --- a/packages/app/src/components/zoomable-viewport/image.tsx +++ b/packages/app/src/components/zoomable-viewport/image.tsx @@ -1,10 +1,13 @@ -import { useEffect, useMemo, useState } from "react"; -import { Image, View, type StyleProp, type ViewStyle } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; +import { useCallback, useMemo, useState } from "react"; +import { Image, type ImageLoadEventData } from "expo-image"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { ViewportFitOptions, ViewportSize } from "./geometry"; import { ZoomableViewport } from "./index"; import type { ZoomableViewportAction } from "./types"; +const ViewportImage = withUnistyles(Image); + interface ZoomableImageProps { uri: string; accessibilityLabel?: string; @@ -34,36 +37,25 @@ export function ZoomableImage({ testID = "zoomable-image", wheelActivation = "always", }: ZoomableImageProps) { - const [loadedSize, setLoadedSize] = useState(null); - const resolvedSize = contentSize ?? loadedSize; + const [loadedImage, setLoadedImage] = useState<{ uri: string; size: ViewportSize } | null>(null); + const resolvedSize = contentSize ?? (loadedImage?.uri === uri ? loadedImage.size : null); 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]); + const handleLoad = useCallback( + ({ source: { width, height } }: ImageLoadEventData) => { + if (width > 0 && height > 0) setLoadedImage({ uri, size: { width, height } }); + }, + [uri], + ); if (!resolvedSize) { return ( - -