diff --git a/packages/ui/package.json b/packages/ui/package.json index a93ec5c3a5..60d7dea2b9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -95,6 +95,7 @@ "react-hotkeys-hook": "^4.4.4", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", + "react-zoom-pan-pinch": "^4.0.3", "reflect-metadata": "catalog:", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx b/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx index aa7a904c15..4946346cf3 100644 --- a/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx +++ b/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx @@ -1,8 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ArtifactPreview } from "./ArtifactPreview"; import { artifactHtmlDocument, + artifactPreviewBlob, markdownDocument, } from "./artifactPreviewDocument"; @@ -102,6 +103,118 @@ describe("ArtifactPreview", () => { expect(frame).toHaveAttribute("sandbox", ""); }); + it.each([ + ["image.png", "image/png"], + ["image.jpg", "image/jpeg"], + ["image.gif", "image/gif"], + ["image.webp", "image/webp"], + ["image.bmp", "image/bmp"], + ["image.ico", "image/x-icon"], + ["image.tiff", "image/tiff"], + ["image.avif", "image/avif"], + ])("normalizes %s served as octet-stream", async (name, mimeType) => { + const blob = await artifactPreviewBlob( + new Blob(["image"], { type: "application/octet-stream" }), + name, + ); + + expect(blob.type).toBe(mimeType); + }); + + it("shows working image controls instead of an iframe", () => { + useQuery.mockReturnValue({ + data: new Blob(["image"], { type: "image/png" }), + isLoading: false, + isError: false, + }); + + render( + , + ); + + expect(screen.getByRole("img", { name: "image.png" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Zoom in" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Zoom out" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Fit to view" }), + ).toBeInTheDocument(); + expect(screen.queryByTitle("Preview of image.png")).not.toBeInTheDocument(); + + const zoomOut = screen.getByRole("button", { name: "Zoom out" }); + fireEvent.click(zoomOut); + fireEvent.click(zoomOut); + fireEvent.click(zoomOut); + fireEvent.click(zoomOut); + expect(screen.getByText("10%")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Fit to view" })); + expect(screen.getByText("100%")).toBeInTheDocument(); + }); + + it("zooms with a trackpad pinch gesture", async () => { + useQuery.mockReturnValue({ + data: new Blob(["image"], { type: "image/png" }), + isLoading: false, + isError: false, + }); + + render( + , + ); + + const image = screen.getByRole("img", { name: "image.png" }); + const viewport = image.closest(".react-transform-wrapper"); + expect(viewport).not.toBeNull(); + fireEvent.wheel(viewport as Element, { + ctrlKey: true, + deltaY: -100, + clientX: 100, + clientY: 100, + }); + + await waitFor(() => { + const percentage = Number.parseInt( + screen.getByText(/%$/).textContent ?? "0", + 10, + ); + expect(percentage).toBeGreaterThan(100); + }); + }); + + it("shows the preview error when an image cannot be decoded", () => { + useQuery.mockReturnValue({ + data: new Blob(["not an image"], { type: "image/png" }), + isLoading: false, + isError: false, + }); + + render( + , + ); + + fireEvent.error(screen.getByRole("img", { name: "broken.png" })); + expect( + screen.getByText("This artifact can’t be previewed."), + ).toBeInTheDocument(); + }); + it("renders GFM Markdown while escaping embedded HTML", () => { const document = markdownDocument( "# Report\n\n**Ready**\n\n| Name | Value |\n| --- | --- |\n| Cost | 12 |\n\n", diff --git a/packages/ui/src/features/sessions/components/ArtifactPreview.tsx b/packages/ui/src/features/sessions/components/ArtifactPreview.tsx index ba1d4ccb26..c13bb02d41 100644 --- a/packages/ui/src/features/sessions/components/ArtifactPreview.tsx +++ b/packages/ui/src/features/sessions/components/ArtifactPreview.tsx @@ -4,22 +4,38 @@ import { } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; import { Spinner } from "@posthog/quill"; +import { isAllowedImageMimeType } from "@posthog/shared"; import { getAuthIdentity, useAuthStateValue, } from "@posthog/ui/features/auth/store"; import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo } from "react"; -import { - artifactHtmlDocument, - markdownDocument, -} from "./artifactPreviewDocument"; +import { useEffect, useMemo, useState } from "react"; +import { ZoomableImage } from "../../../primitives/SafeImagePreview"; +import { artifactPreviewBlob } from "./artifactPreviewDocument"; + +function ArtifactPreviewError() { + return ( +
+ This artifact can’t be previewed. +
+ ); +} -const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]); +function ArtifactImagePreview({ src, name }: { src: string; name: string }) { + const [hasError, setHasError] = useState(false); -function extension(filename: string): string { - return filename.split(".").pop()?.toLowerCase() ?? ""; + if (hasError) return ; + return ( + setHasError(true)} + /> + ); } export function ArtifactPreview({ @@ -46,18 +62,7 @@ export function ArtifactPreview({ if (!url) throw new Error("Artifact is unavailable"); const response = await fetch(url); if (!response.ok) throw new Error("Artifact preview failed"); - const blob = await response.blob(); - if (MARKDOWN_EXTENSIONS.has(extension(name))) { - return new Blob([markdownDocument(await blob.text())], { - type: "text/html", - }); - } - if (extension(name) === "html" || extension(name) === "htm") { - return new Blob([artifactHtmlDocument(await blob.text())], { - type: "text/html", - }); - } - return blob; + return artifactPreviewBlob(await response.blob(), name); }, enabled: authIdentity !== null, staleTime: Infinity, @@ -83,11 +88,10 @@ export function ArtifactPreview({ ); } if (isError || !previewUrl) { - return ( -
- This artifact can’t be previewed. -
- ); + return ; + } + if (data && isAllowedImageMimeType(data.type)) { + return ; } return (