Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/app/component-tests/image-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,43 @@ 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)
})

story(
"a video whose pane closes under the preview is let go on close instead of moved back",
async ({ mount, page }) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
await mount("components-image-preview--from-video")
const video = page.locator("[data-story-video]")
await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.videoWidth)).toBeGreaterThan(0)
await video.dblclick()
const preview = page.locator('[data-component="image-preview"]')
await expect(preview).toHaveAttribute("data-stage", "rest")
await expect(preview.locator("video")).toHaveCount(1)

// The pane goes the way a closed tab does, while the preview still shows its video.
await page.locator("[data-story-pane]").evaluate((element) => element.remove())
await expect(preview.locator("video")).toHaveCount(1)
await page.keyboard.press("Escape")
await expect(preview).toHaveCount(0)
await expect(page.locator("video")).toHaveCount(0)
expect(errors).toEqual([])
},
)
26 changes: 11 additions & 15 deletions packages/app/e2e/regression/review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -928,34 +928,30 @@ 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)

await video.evaluate((element) => {
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.
Expand Down
15 changes: 0 additions & 15 deletions packages/gui-extensions/src/file/artifact-view.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
110 changes: 31 additions & 79 deletions packages/gui-extensions/src/file/artifact-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string>()
const [loadedUrl, setLoadedUrl] = createSignal<string>()
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
Expand All @@ -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 <video> element, so playback carries on as it flies out and back.
const expand = () => {
const el = video

if (!el?.videoWidth || open() || !stage?.contains(el)) return
setOpen(true)
dialog.show(
() => <ImagePreview src={el.currentSrc} alt={getFilename(props.path)} video={el} />,
() => setOpen(false),
)
}

const measure = () => {
if (!video || video.offsetWidth === 0 || video.offsetHeight === 0) return
Expand All @@ -564,62 +573,21 @@ function ArtifactVideo(props: MediaProps) {
() => measure(),
)

// Top-layer promotion escapes the side panel's stacking context and <main>'s content containment
// while keeping the same <video> element mounted so playback never resets.
createKeyed(expanded, (open) => {
const el = stage

if (!el || !open) return

el.setAttribute("popover", "manual")
el.showPopover?.()
measure()

makeEventListener(
window,
"keydown",
(event: KeyboardEvent) => {
if (event.key !== "Escape") return

event.preventDefault()
event.stopPropagation()
close()
},
{ capture: true },
)

onCleanup(() => {
if (el.isConnected) el.hidePopover?.()
el.removeAttribute("popover")
measure()
})
})

const label = () => ctx.t(expanded() ? "view.exitFullscreen" : "view.fullscreen")
const controlsVisible = () => paused() || active()
const controlsVisible = () => !open() && (paused() || active())

return (
<div
ref={stage}
data-slot="artifact-stage"
data-zoom="fit"
data-expanded={expanded() ? "" : undefined}
class="relative min-h-0 flex-1 overflow-hidden"
onPointerMove={wake}
onPointerLeave={() => {
clearIdle()
setActive(false)
}}
>
<div
classList={{
"absolute inset-0 flex items-center justify-center p-6": !expanded(),
"absolute inset-0 flex items-center justify-center px-8 pt-12 pb-8": expanded(),
}}
onClick={(event) => {
if (expanded() && event.target === event.currentTarget) close()
}}
>
<div class="absolute inset-0 flex items-center justify-center p-6">
<video
ref={video}
data-slot="artifact-media"
Expand All @@ -631,7 +599,7 @@ function ArtifactVideo(props: MediaProps) {
src={url()}
onClick={(event) => {
// Chromium's controls answer the second click of a double click with a native fullscreen request, which
// covers the theatre the double click opens. Opting out with controlslist="nofullscreen" would also end
// covers the preview the double click opens. Opting out with controlslist="nofullscreen" would also end
// their click-to-play. The fullscreen element ready check refuses an open popover, so the video is one
// while its controls handle this click, and is released before the next frame renders.
const el = event.currentTarget
Expand All @@ -642,7 +610,7 @@ function ArtifactVideo(props: MediaProps) {
el.showPopover?.()
requestAnimationFrame(() => el.removeAttribute("popover"))
}}
onDblClick={toggle}
onDblClick={expand}
onPlay={() => setPaused(false)}
onPause={() => setPaused(true)}
onEnded={() => setPaused(true)}
Expand All @@ -660,41 +628,25 @@ function ArtifactVideo(props: MediaProps) {
<button
type="button"
data-slot="artifact-video-fullscreen"
aria-label={label()}
aria-pressed={expanded()}
title={label()}
onClick={toggle}
aria-label={ctx.t("view.fullscreen")}
title={ctx.t("view.fullscreen")}
onClick={expand}
style={box.ready ? { left: `${box.left}px`, top: `${box.top}px` } : { right: "72px", bottom: "48px" }}
classList={{
"absolute z-10 flex size-12 items-center justify-center transition-opacity focus-visible:opacity-100 focus-visible:outline-none":
true,
"absolute z-10 flex size-12 items-center justify-center transition-opacity focus-visible:opacity-100 focus-visible:outline-none": true,
"opacity-100": controlsVisible(),
"pointer-events-none opacity-0": !controlsVisible(),
}}
>
<span class="flex size-9 items-center justify-center rounded-full transition-colors">
<Show
when={expanded()}
fallback={
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 6V3H6M10 3H13V6M13 10V13H10M6 13H3V10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 5.5H5.5V3M13 5.5H10.5V3M10.5 13V10.5H13M5.5 13V10.5H3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</Show>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 6V3H6M10 3H13V6M13 10V13H10M6 13H3V10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</span>
</button>
</div>
Expand Down
1 change: 0 additions & 1 deletion packages/gui-extensions/src/file/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ export default {
"view.source": "Source",
"view.openInBrowser": "Open in browser",
"view.fullscreen": "Enter full screen",
"view.exitFullscreen": "Exit full screen",
"view.binary": "Binary file · {{size}}",
"view.loadFailed": "Couldn't load this file.",
"view.table.rows.one": "{{count}} row",
Expand Down
26 changes: 26 additions & 0 deletions packages/ui/src/components/image-preview.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@
user-select: none;
}

/* A borrowed video flies itself and stays where it lands, over the preview, so its own controls keep working. Its
page styles and the popover it briefly becomes to refuse a double click's fullscreen must not move it. */
> video {
position: fixed;
z-index: 51;
right: auto;
bottom: auto;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
overflow: visible;
border-radius: var(--radius-md);
transform-origin: 0 0;
pointer-events: auto;
}

&[data-stage="flying"] > video {
pointer-events: none;
}

canvas[data-slot="image-preview-image"] {
visibility: hidden;
}

&[data-stage="pending"] :is([data-slot="image-preview-scrim"], [data-slot="image-preview-content"]) {
opacity: 0;
}
Expand Down
Loading
Loading