From e5f58baf6a2d397b74de909ac495f137445b2814 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 12:47:41 -0700 Subject: [PATCH 1/2] feat(ui): open videos like images and zoom into both --- .../app/component-tests/image-preview.spec.ts | 17 + packages/app/e2e/regression/review.spec.ts | 26 +- .../gui-extensions/src/file/artifact-view.css | 15 - .../gui-extensions/src/file/artifact-view.tsx | 110 ++--- packages/gui-extensions/src/file/i18n/en.ts | 1 - packages/ui/src/components/image-preview.css | 26 ++ .../src/components/image-preview.stories.tsx | 6 +- packages/ui/src/components/image-preview.tsx | 384 +++++++++++++----- packages/ui/src/components/image-zoom.test.ts | 17 +- packages/ui/src/components/image-zoom.ts | 23 ++ 10 files changed, 408 insertions(+), 217 deletions(-) diff --git a/packages/app/component-tests/image-preview.spec.ts b/packages/app/component-tests/image-preview.spec.ts index 34f7b5757362..5d599c955dd8 100644 --- a/packages/app/component-tests/image-preview.spec.ts +++ b/packages/app/component-tests/image-preview.spec.ts @@ -42,3 +42,20 @@ async function clickPage(page: Page) { return text.evaluate((element) => element.hasAttribute("data-clicked")) } + +story("the first Escape zooms the image preview back to fit and the next closes it", async ({ mount, page }) => { + await mount("components-image-preview--from-message") + await open(page) + const preview = page.locator('[data-component="image-preview"]') + const level = page.locator('[data-slot="image-preview-zoom-level"]') + await expect(preview).toHaveAttribute("data-stage", "rest") + const fit = (await level.textContent()) ?? "" + + await page.locator('[data-slot="image-preview-image"]').dblclick() + await expect(level).not.toHaveText(fit) + await page.keyboard.press("Escape") + await expect(level).toHaveText(fit) + await expect(preview).toHaveAttribute("data-stage", "rest") + await page.keyboard.press("Escape") + await expect(preview).toHaveCount(0) +}) diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index 38e1d33b0dfa..25cc1a71f869 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -858,7 +858,7 @@ test("image files load into the review panel and keep it painted while loading", ).toEqual([]) }) -test("video files stream into the viewer behind a loading state, expand into the theatre overlay, and collapse on Escape without remounting", async ({ +test("video files stream into the viewer behind a loading state, open into the preview, and come back on Escape without remounting", async ({ page, }) => { const directory = "C:/OpenCode/ReviewVideo" @@ -915,7 +915,7 @@ test("video files stream into the viewer behind a loading state, expand into the const stage = panel.locator('[data-slot="artifact-stage"]') const video = stage.locator('video[data-slot="artifact-media"]') const fullscreen = stage.getByRole("button", { name: "Enter full screen", exact: true }) - const exitFullscreen = stage.getByRole("button", { name: "Exit full screen", exact: true }) + const preview = page.locator('[data-component="image-preview"]') const loading = stage.getByRole("status", { name: "Loading", exact: true }) await ranged.promise @@ -928,7 +928,6 @@ test("video files stream into the viewer behind a loading state, expand into the await expect(toolbar).toContainText("0:01") await expect(toolbar).toContainText("38.4 MB") await expect(panel.getByText(/Binary file/)).toHaveCount(0) - await expect(fullscreen).toHaveAttribute("aria-pressed", "false") const inlineBounds = (await stage.boundingBox())! expect(inlineBounds.width).toBeLessThan(1000) @@ -936,26 +935,23 @@ test("video files stream into the viewer behind a loading state, expand into the element.dataset.e2eProbe = "retained" }) + // The preview borrows the same element, so playback never restarts. await fullscreen.click() - await expect(exitFullscreen).toHaveAttribute("aria-pressed", "true") - await expect(stage).toHaveAttribute("data-expanded", "") - expect(await stage.evaluate((element) => element.matches(":popover-open"))).toBe(true) + await expect(preview).toHaveAttribute("data-stage", "rest") + await expect(preview.locator('video[data-e2e-probe="retained"]')).toBeVisible() + await expect(video).toHaveCount(0) expect(await page.evaluate(() => document.fullscreenElement)).toBeNull() - expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained") - await expect.poll(() => stage.boundingBox()).toEqual({ x: 0, y: 0, width: 1440, height: 900 }) await page.keyboard.press("Escape") - await expect(fullscreen).toHaveAttribute("aria-pressed", "false") - await expect(stage).not.toHaveAttribute("data-expanded") - expect(await stage.evaluate((element) => element.matches(":popover-open"))).toBe(false) - expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained") + await expect(preview).toHaveCount(0) + await expect(video).toHaveAttribute("data-e2e-probe", "retained") await expect.poll(() => stage.boundingBox()).toEqual(inlineBounds) await video.dblclick() - await expect(exitFullscreen).toHaveAttribute("aria-pressed", "true") + await expect(preview).toHaveAttribute("data-stage", "rest") await page.mouse.click(8, 8) - await expect(fullscreen).toHaveAttribute("aria-pressed", "false") - expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained") + await expect(preview).toHaveCount(0) + await expect(video).toHaveAttribute("data-e2e-probe", "retained") // Chromium's controls also answer a double click with native fullscreen, which covers the backdrop. Opting out with // controlslist="nofullscreen" would end their click-to-play as well. diff --git a/packages/gui-extensions/src/file/artifact-view.css b/packages/gui-extensions/src/file/artifact-view.css index 9a079ddf3747..7e6df8c7303e 100644 --- a/packages/gui-extensions/src/file/artifact-view.css +++ b/packages/gui-extensions/src/file/artifact-view.css @@ -33,21 +33,6 @@ -8px 0; } -[data-slot="artifact-stage"][data-expanded] { - position: fixed; - inset: 0; - z-index: 50; - width: 100vw; - height: 100vh; - max-width: none; - max-height: none; - margin: 0; - padding: 0; - border: 0; - background-color: color-mix(in oklch, black 88%, transparent); - backdrop-filter: blur(8px); -} - [data-slot="artifact-media"] { display: block; border-radius: 4px; diff --git a/packages/gui-extensions/src/file/artifact-view.tsx b/packages/gui-extensions/src/file/artifact-view.tsx index 7bfff4f24745..b2626ade5856 100644 --- a/packages/gui-extensions/src/file/artifact-view.tsx +++ b/packages/gui-extensions/src/file/artifact-view.tsx @@ -12,10 +12,11 @@ import { } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" -import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" import { FileIcon } from "@opencode/ui/file-icon" +import { ImagePreview } from "@opencode/ui/image-preview" import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control" import { ScrollView } from "@opencode/ui/scroll-view" import { Spinner } from "@opencode/ui/spinner" @@ -518,14 +519,13 @@ function ImageStage(props: MediaProps & { src: string }) { function ArtifactVideo(props: MediaProps) { const ctx = useExtension() + const dialog = useDialog() const url = createMediaUrl(() => props.content) - // Fullscreen belongs to one video: a new file starts fitted in the pane. - const [expandedUrl, setExpandedUrl] = createSignal() const [loadedUrl, setLoadedUrl] = createSignal() const [paused, setPaused] = createSignal(true) const [active, setActive] = createSignal(false) + const [open, setOpen] = createSignal(false) const [box, setBox] = createStore({ left: 0, top: 0, ready: false }) - const expanded = () => expandedUrl() === url() let stage: HTMLDivElement | undefined let video: HTMLVideoElement | undefined @@ -544,8 +544,17 @@ function ArtifactVideo(props: MediaProps) { onCleanup(clearIdle) - const close = () => setExpandedUrl(undefined) - const toggle = () => setExpandedUrl(expanded() ? undefined : url()) + // The preview fills the window and borrows this