feat(ui): open videos like images and zoom into both (#57924)

This commit is contained in:
Kit Langton
2026-10-21 15:59:21 -07:00
committed by GitHub
Unverified
parent 316a688fe9
commit 95a525ea85
10 changed files with 489 additions and 217 deletions
@@ -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([])
},
)
+11 -15
View File
@@ -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.
@@ -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;
@@ -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<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
@@ -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
@@ -564,46 +573,13 @@ 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={() => {
@@ -611,15 +587,7 @@ function ArtifactVideo(props: MediaProps) {
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"
@@ -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
@@ -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)}
@@ -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>
@@ -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",
@@ -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;
}
@@ -11,7 +11,8 @@ Use for full-size image inspection; keep images optimized.
### API
- Required: \`src\`.
- Optional: \`alt\` text, and \`origin\`, the image on the page that the preview grows out of and shrinks back into.
- Optional: \`alt\` text, \`origin\`, the image on the page that the preview grows out of and shrinks back into, and
\`video\`, a video on the page that the preview borrows and shows instead, still playing.
### Variants and states
- Single layout: the image fills the window without a panel, with zoom controls and a close action in one strip under it.
@@ -20,7 +21,8 @@ Use for full-size image inspection; keep images optimized.
- Intended to be used via \`useDialog().show\`.
- With an \`origin\`, the image flies from its place on the page to the preview while the page dims, and back on close.
- Pinch or Cmd/Ctrl + wheel zooms at the cursor; wheel and drag pan while zoomed.
- Double-click toggles between fit and 100%; Cmd/Ctrl + =, - and 0 zoom in, out and to fit.
- Double-click toggles between fit and 100%; Cmd/Ctrl + =, - and 0 zoom in, out and to fit, as do 0 and a first Escape.
- A video zooms inside its own frame, under its controls; Space plays and pauses it.
- A click on the backdrop around the image closes it, unless it ends a pan.
### Accessibility
@@ -116,6 +118,54 @@ export const FromMessage = {
},
}
// A video in a pane opens into the preview still playing, and goes back when it closes.
export const FromVideo = {
render: () => {
const dialog = useDialog()
let video
onMount(() => {
const canvas = document.createElement("canvas")
canvas.width = 640
canvas.height = 400
const ctx = canvas.getContext("2d")
const draw = (time) => {
ctx.fillStyle = "#1f2a44"
ctx.fillRect(0, 0, 640, 400)
ctx.fillStyle = "#ffd27a"
ctx.beginPath()
ctx.arc(320 + 200 * Math.sin(time / 700), 200, 60, 0, Math.PI * 2)
ctx.fill()
requestAnimationFrame(draw)
}
requestAnimationFrame(draw)
video.srcObject = canvas.captureStream(30)
})
const open = () => dialog.show(() => <mod.ImagePreview src="" alt="Orchard board tour" video={video} />)
return (
<div
data-story-pane
style={{ width: "640px", margin: "48px auto", display: "flex", "justify-content": "center" }}
>
<video
ref={video}
data-story-video
muted
autoplay
playsinline
controls
onDblClick={open}
style={{ width: "100%", "border-radius": "8px", background: "black" }}
/>
</div>
)
},
}
// A bright, offline poster, so the image reads clearly against the dimmed page while it moves.
function poster() {
const canvas = document.createElement("canvas")
+290 -104
View File
@@ -1,8 +1,7 @@
import { Dialog as Kobalte, useDialogContext } from "@kobalte/core/dialog"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { animate, type AnimationPlaybackControls } from "motion"
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js"
import { useDialogLayer } from "../context/dialog"
import { useI18n } from "../context/i18n"
import { Button } from "@opencode/ui/button"
@@ -19,17 +18,35 @@ import {
type Box,
type FlightFrame,
} from "./image-flight"
import { clampView, detailScale, FIT_VIEW, maxScale, panBy, wheelZoomFactor, zoomAt, type ZoomView } from "./image-zoom"
import {
clampView,
detailScale,
FIT_VIEW,
maxScale,
panBy,
viewBox,
wheelZoomFactor,
zoomAt,
type ZoomView,
} from "./image-zoom"
export interface ImagePreviewProps {
src: string
alt?: string
/** The image on the page that the preview grows out of and shrinks back into. */
origin?: HTMLElement
/**
* A video on the page to show instead of the image. The preview borrows this element, so it keeps playing as it
* flies out of its place and back, and returns it once the close lands.
*/
video?: HTMLVideoElement
}
const STEP = Math.SQRT2
// How far up from its bottom edge a video keeps its own controls, whose drag scrubs rather than pans.
const CONTROLS = 56
// Without a flight (reduced motion, or no thumbnail to fly to), the preview only fades.
const fade = { duration: 0.15, bounce: 0 }
@@ -46,8 +63,11 @@ export function ImagePreview(props: ImagePreviewProps) {
let scrim!: HTMLDivElement
let content!: HTMLDivElement
let viewport!: HTMLDivElement
let image!: HTMLImageElement
let standIn!: HTMLImageElement
// For a video, an empty canvas of its size: the box it fits to in the window.
let image!: HTMLImageElement | HTMLCanvasElement
let standIn: HTMLImageElement | undefined
// Where a borrowed video was on the page, holding its size until it comes back.
let holder: HTMLElement | undefined
let surface!: HTMLDivElement
let edge!: HTMLDivElement
let close: HTMLButtonElement | undefined
@@ -75,48 +95,77 @@ export function ImagePreview(props: ImagePreviewProps) {
// Whether the press that ends in a click began on the backdrop around the image and stayed put. A pan captures the
// pointer, so its click lands on the backdrop wherever it began.
let backdrop = false
// Whether the pan under way has moved, so the click that ends it neither closes the preview nor plays the video.
let panned = false
// Where an eased zoom is heading, so repeated steps build on the destination rather than the frame in flight.
let goal = FIT_VIEW
let animation: AnimationPlaybackControls | undefined
const [goal, setGoal] = createSignal(FIT_VIEW)
const glide = [1, 0, 0].map((value) => Spring.fromDuration(value, FLIGHT_SPRING))
let gliding: (() => void) | undefined
const percent = createMemo(() => new Intl.NumberFormat(i18n.locale(), { style: "percent", maximumFractionDigits: 0 }))
const natural = () =>
image instanceof HTMLImageElement
? { width: image.naturalWidth, height: image.naturalHeight }
: { width: image.width, height: image.height }
// A video zooms inside its own frame, so its controls stay where they are; an image zooms out across the window.
const bounds = () => ({
width: image.offsetWidth,
height: image.offsetHeight,
viewportWidth: viewport.clientWidth,
viewportHeight: viewport.clientHeight,
viewportWidth: props.video ? image.offsetWidth : viewport.clientWidth,
viewportHeight: props.video ? image.offsetHeight : viewport.clientHeight,
maxScale: maxScale(fitRatio()),
})
// Pinch, wheel and drag follow the hand directly and take over from an eased zoom mid-flight.
const follow = (next: ZoomView) => {
animation?.stop()
animation = undefined
goal = next
// The fitted size, read when the preview lays out rather than on every frame of a glide.
let size = { width: 0, height: 0 }
const show = (next: ZoomView) => {
setView(next)
if (props.video) {
props.video.style.setProperty("object-view-box", viewBox(next, size, natural()))
return
}
image.style.transform = `translate(${next.x}px, ${next.y}px) scale(${next.scale})`
}
const ease = (target: ZoomView) => {
animation?.stop()
goal = target
const settle = () => {
gliding?.()
gliding = undefined
}
if (prefersReducedMotion()) return setView(target)
const from = view()
animation = animate(0, 1, {
type: "spring",
visualDuration: 0.25,
bounce: 0,
onUpdate: (progress) =>
setView({
scale: from.scale + (target.scale - from.scale) * progress,
x: from.x + (target.x - from.x) * progress,
y: from.y + (target.y - from.y) * progress,
}),
// Pinch, wheel and drag follow the hand directly and take over from an eased zoom mid-flight.
const follow = (next: ZoomView) => {
settle()
setGoal(next)
show(next)
}
// Scale and offsets ride springs of one shape, so the point under the pointer stays put; a new target mid-glide
// keeps the velocity it has.
const ease = (target: ZoomView) => {
setGoal(target)
if (prefersReducedMotion()) return follow(target)
if (!gliding) [view().scale, view().x, view().y].forEach((value, index) => glide[index].snap(value))
;[target.scale, target.x, target.y].forEach((value, index) => glide[index].set(value))
gliding ??= frameLoop.add((dt) => {
const [scale, x, y] = glide.map((spring) => spring.step(dt))
show({ scale, x, y })
if (!glide.every((spring) => spring.resting)) return true
gliding = undefined
return false
})
}
const step = (factor: number) => ease(zoomAt(goal, goal.scale * factor, CENTER, bounds()))
const step = (factor: number) => ease(zoomAt(goal(), goal().scale * factor, CENTER, bounds()))
// Kobalte keeps the content mounted while an exit animation runs; the dialog stack waits for the flight too.
const layer = useDialogLayer()
@@ -130,15 +179,19 @@ export function ImagePreview(props: ImagePreviewProps) {
holdExit()
const reveal = async () => {
await Promise.all([image.decode(), standIn.decode()]).catch(() => undefined)
if (image instanceof HTMLImageElement) await Promise.all([image.decode(), standIn?.decode()]).catch(() => undefined)
if (stage() !== "pending" || !dialog.isOpen()) return
const from = prefersReducedMotion() ? undefined : originFrame()
if (props.video) borrow(props.video)
holdExit()
dimTo(1, from ? FLIGHT_SPRING : fade)
if (!from) {
plain = true
if (props.video) place(fitted())
paint()
setStage("rest")
@@ -156,7 +209,9 @@ export function ImagePreview(props: ImagePreviewProps) {
}
const dismiss = () => {
animation?.stop()
// A video eases back to its whole frame on the way home; an image flies as it is shown.
if (props.video) ease(FIT_VIEW)
else settle()
if (stage() === "pending") return
const to = prefersReducedMotion() ? undefined : originFrame()
@@ -177,6 +232,8 @@ export function ImagePreview(props: ImagePreviewProps) {
course.flight.to(to, KICK.close, FLIGHT_SPRING)
course.landed = () => {
showOrigin()
if (props.video) return giveBack()
veil.omega = (2 * Math.PI) / (FLIGHT_SPRING.duration * 0.75)
veil.set(0)
}
@@ -211,20 +268,32 @@ export function ImagePreview(props: ImagePreviewProps) {
if (course || !dim.resting || !veil.resting) return true
stop = undefined
// A video that faded out rather than flying home goes back once it has.
if (!dialog.isOpen()) giveBack()
return false
})
}
// The image flies as a stand-in over the preview; a video flies itself and stays where it lands.
const flyer = () => (props.video ? holder && props.video : standIn)
const paint = () => {
const flying = flyer()
scrim.style.opacity = String(dim.value)
standIn.style.opacity = String(plain ? dim.value : veil.value)
surface.style.opacity = course ? "1" : "0"
if (flying) flying.style.opacity = String(plain ? dim.value : veil.value)
if (course) {
const frame = course.flight.frame()
const style = flightStyle(course.at, frame)
standIn.style.transform = style.transform
standIn.style.clipPath = style.clipPath
if (flying) {
flying.style.transform = style.transform
flying.style.clipPath = style.clipPath
}
// The shadow and the outline are leaf boxes, so sizing them every frame stays cheap; scaling them instead would
// stretch their corners and shadow.
layout(surface, frame.surface, frame.surfaceRadius)
@@ -239,31 +308,63 @@ export function ImagePreview(props: ImagePreviewProps) {
}
const place = (box: Box) => {
Object.assign(standIn.style, {
left: `${box.x}px`,
top: `${box.y}px`,
width: `${box.width}px`,
height: `${box.height}px`,
})
const flying = flyer()
if (flying)
Object.assign(flying.style, {
left: `${box.x}px`,
top: `${box.y}px`,
width: `${box.width}px`,
height: `${box.height}px`,
})
return box
}
// Moves the video into the preview, leaving a box of its size in its place to fly back to.
const borrow = (video: HTMLVideoElement) => {
const rect = video.getBoundingClientRect()
holder = document.createElement("div")
Object.assign(holder.style, {
flex: "none",
width: `${rect.width}px`,
height: `${rect.height}px`,
borderRadius: getComputedStyle(video).borderTopLeftRadius,
})
video.before(holder)
root.moveBefore(video, null)
}
const giveBack = () => {
const video = props.video
if (!video || !holder) return
// A page that closed under the preview, such as its tab, has nowhere to take the video back: let it go.
if (holder.isConnected && holder.parentElement) holder.parentElement.moveBefore(video, holder)
else dispose(video)
holder.remove()
holder = undefined
;["left", "top", "width", "height", "opacity", "transform", "clip-path", "object-view-box"].forEach((property) =>
video.style.removeProperty(property),
)
}
// The image as shown now, zoomed and panned and cropped to the viewport. There is no panel: the image is the surface.
const restFrame = (): FlightFrame => {
const shown = image.getBoundingClientRect()
const clip = intersect(shown, viewport.getBoundingClientRect())
const cropped = clip.width < shown.width - 0.5 || clip.height < shown.height - 0.5
const radius = cropped ? 0 : parseFloat(getComputedStyle(image).borderTopLeftRadius) * view().scale
const radius = cropped ? 0 : parseFloat(getComputedStyle(image).borderTopLeftRadius) * scale()
return { image: shown, clip, radius, surface: clip, surfaceRadius: radius, lift: 1 }
}
// The origin's frame, cropped to the part the page shows, or nothing when it is gone or out of sight.
const originFrame = () => {
const origin = props.origin
const origin = home()
if (!origin?.isConnected || !image.naturalWidth) return
if (!origin?.isConnected || !natural().width) return
const rect = origin.getBoundingClientRect()
if (!rect.width || !rect.height) return
@@ -272,37 +373,38 @@ export function ImagePreview(props: ImagePreviewProps) {
if (!visible) return
const style = getComputedStyle(origin)
// An image's outline is often an overlay, since an inset shadow does not paint over an <img>.
edge.style.boxShadow =
[style.boxShadow, getComputedStyle(origin, "::after").boxShadow].find((shadow) => shadow !== "none") ?? "none"
// An image's outline is often an overlay, since an inset shadow does not paint over an <img>. A video brings its
// own.
edge.style.boxShadow = props.video
? "none"
: ([style.boxShadow, getComputedStyle(origin, "::after").boxShadow].find((shadow) => shadow !== "none") ?? "none")
return elementFrame(
rect,
{ width: image.naturalWidth, height: image.naturalHeight },
style.objectFit,
parseFloat(style.borderTopLeftRadius) || 0,
visible,
)
return elementFrame(rect, natural(), style.objectFit, parseFloat(style.borderTopLeftRadius) || 0, visible)
}
// The image's fitted layout box, wherever zoom has moved it.
// The image's fitted layout box, wherever zoom has moved it. A video zooms inside its box, which stays put.
const fitted = (): Box => {
const shown = image.getBoundingClientRect()
const offset = props.video ? CENTER : view()
return {
x: shown.x + shown.width / 2 - view().x - image.offsetWidth / 2,
y: shown.y + shown.height / 2 - view().y - image.offsetHeight / 2,
x: shown.x + shown.width / 2 - offset.x - image.offsetWidth / 2,
y: shown.y + shown.height / 2 - offset.y - image.offsetHeight / 2,
width: image.offsetWidth,
height: image.offsetHeight,
}
}
const hideOrigin = () => props.origin?.setAttribute("data-image-preview-origin", "")
const showOrigin = () => props.origin?.removeAttribute("data-image-preview-origin")
const scale = () => (props.video ? 1 : view().scale)
const home = () => holder ?? props.origin ?? props.video
const hideOrigin = () => home()?.setAttribute("data-image-preview-origin", "")
const showOrigin = () => home()?.removeAttribute("data-image-preview-origin")
onCleanup(() => {
stop?.()
settle()
showOrigin()
giveBack()
})
createEffect(
@@ -315,16 +417,25 @@ export function ImagePreview(props: ImagePreviewProps) {
),
)
// From the center of the box zoom is measured in: the window's for an image, the frame's for a video.
const point = (event: MouseEvent) => {
const rect = viewport.getBoundingClientRect()
const rect = (props.video ? image : viewport).getBoundingClientRect()
return { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 }
}
const measure = () => {
if (!image.naturalWidth) return
setFitRatio(image.offsetWidth / image.naturalWidth)
if (!natural().width || !image.offsetWidth) return
size = { width: image.offsetWidth, height: image.offsetHeight }
setFitRatio(size.width / natural().width)
follow(clampView(view(), bounds()))
if (!props.video) return
// A video is ready as soon as the preview has laid out its box.
if (stage() === "pending") return void reveal()
if (holder && !course) place(fitted())
}
createResizeObserver(() => [viewport, image], measure)
@@ -337,14 +448,64 @@ export function ImagePreview(props: ImagePreviewProps) {
),
)
onCleanup(() => animation?.stop())
const detail = (event: MouseEvent) => {
if (goal().scale > 1) return ease(FIT_VIEW)
ease(zoomAt(goal(), detailScale(fitRatio()), point(event), bounds()))
}
const grab = (event: PointerEvent, target: HTMLElement) => {
if (event.button !== 0 || view().scale <= 1) return
event.preventDefault()
target.setPointerCapture(event.pointerId)
setDrag({ id: event.pointerId, x: event.clientX, y: event.clientY })
}
const pan = (event: PointerEvent) => {
const current = drag()
if (current?.id !== event.pointerId) return
backdrop = false
panned = true
setDrag({ id: current.id, x: event.clientX, y: event.clientY })
follow(panBy(view(), { x: event.clientX - current.x, y: event.clientY - current.y }, bounds()))
}
const release = (event: PointerEvent) => {
if (drag()?.id === event.pointerId) setDrag(undefined)
}
if (props.video) {
const video = props.video
makeEventListener(video, "pointerdown", (event: PointerEvent) => {
panned = false
if (holder && event.clientY < video.getBoundingClientRect().bottom - CONTROLS) grab(event, video)
})
makeEventListener(video, "pointermove", pan)
makeEventListener(video, "pointerup", release)
makeEventListener(video, "pointercancel", release)
makeEventListener(video, "dblclick", (event: MouseEvent) => holder && detail(event))
makeEventListener(
video,
"click",
(event: MouseEvent) => {
if (!panned) return
panned = false
event.preventDefault()
event.stopImmediatePropagation()
},
{ capture: true },
)
}
// The preview owns zoom gestures while it is open: the page behind must neither scroll nor zoom the whole window.
makeEventListener(
window,
"wheel",
(event: WheelEvent) => {
const inside = event.target instanceof Node && viewport.contains(event.target)
const inside =
event.target instanceof Node && (viewport.contains(event.target) || (!!holder && event.target === props.video))
const zoom = event.ctrlKey || event.metaKey
if (!inside && !zoom) return
@@ -380,8 +541,18 @@ export function ImagePreview(props: ImagePreviewProps) {
window,
"keydown",
(event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey) return
const action = shortcut(event.key)
const bare = !event.metaKey && !event.ctrlKey && !event.altKey
if (bare && event.key === " " && props.video && holder) {
event.preventDefault()
event.stopPropagation()
if (props.video.paused) return void props.video.play().catch(() => undefined)
return props.video.pause()
}
const action = bare ? (event.key === "0" ? "fit" : undefined) : event.altKey ? undefined : shortcut(event.key)
if (!action) return
event.preventDefault()
@@ -398,6 +569,8 @@ export function ImagePreview(props: ImagePreviewProps) {
ref={root}
data-component="image-preview"
data-stage={stage()}
// Zoomed in, the first Escape zooms back out; the dialog stack leaves it to the preview.
data-owns-escape={goal().scale > 1 ? "" : undefined}
style={{ "--image-preview-exit": `${exit()}ms` }}
>
<div ref={scrim} data-slot="image-preview-scrim" />
@@ -409,6 +582,15 @@ export function ImagePreview(props: ImagePreviewProps) {
event.preventDefault()
close?.focus()
}}
onEscapeKeyDown={(event) => {
if (goal().scale <= 1) return
event.preventDefault()
ease(FIT_VIEW)
}}
// A borrowed video sits over the preview rather than in it; using its controls keeps the preview open.
onInteractOutside={(event) => {
if (props.video && event.target === props.video) event.preventDefault()
}}
onPointerDown={(event: PointerEvent) => {
backdrop = event.target === content || event.target === viewport
}}
@@ -423,44 +605,38 @@ export function ImagePreview(props: ImagePreviewProps) {
data-slot="image-preview-body"
data-zoomed={view().scale > 1 ? "" : undefined}
data-dragging={drag() ? "" : undefined}
onDblClick={(event) => {
if (goal.scale > 1) return ease(FIT_VIEW)
ease(zoomAt(goal, detailScale(fitRatio()), point(event), bounds()))
}}
onPointerDown={(event) => {
if (event.button !== 0 || view().scale <= 1) return
event.preventDefault()
viewport.setPointerCapture(event.pointerId)
setDrag({ id: event.pointerId, x: event.clientX, y: event.clientY })
}}
onPointerMove={(event) => {
const current = drag()
if (current?.id !== event.pointerId) return
backdrop = false
setDrag({ id: current.id, x: event.clientX, y: event.clientY })
follow(panBy(view(), { x: event.clientX - current.x, y: event.clientY - current.y }, bounds()))
}}
onPointerUp={(event) => {
if (drag()?.id === event.pointerId) setDrag(undefined)
}}
onPointerCancel={(event) => {
if (drag()?.id === event.pointerId) setDrag(undefined)
}}
onDblClick={detail}
onPointerDown={(event) => grab(event, viewport)}
onPointerMove={pan}
onPointerUp={release}
onPointerCancel={release}
>
<img
ref={image}
src={props.src}
alt={props.alt ?? i18n.t("ui.imagePreview.alt")}
data-slot="image-preview-image"
draggable={false}
onLoad={() => {
measure()
void reveal()
}}
onError={() => void reveal()}
style={{ transform: `translate(${view().x}px, ${view().y}px) scale(${view().scale})` }}
/>
<Show
when={props.video}
fallback={
<img
ref={(element) => (image = element)}
src={props.src}
alt={props.alt ?? i18n.t("ui.imagePreview.alt")}
data-slot="image-preview-image"
draggable={false}
onLoad={() => {
measure()
void reveal()
}}
onError={() => void reveal()}
/>
}
>
{(video) => (
<canvas
ref={(element) => (image = element)}
data-slot="image-preview-image"
width={video().videoWidth}
height={video().videoHeight}
/>
)}
</Show>
</div>
<div data-slot="image-preview-controls">
<IconButton
@@ -499,7 +675,9 @@ export function ImagePreview(props: ImagePreviewProps) {
</div>
</Kobalte.Content>
<div data-slot="image-preview-surface" ref={surface} />
<img data-slot="image-preview-flight" src={props.src} alt="" aria-hidden="true" ref={standIn} />
<Show when={!props.video}>
<img data-slot="image-preview-flight" src={props.src} alt="" aria-hidden="true" ref={standIn} />
</Show>
<div data-slot="image-preview-edge" ref={edge} />
</div>
)
@@ -567,6 +745,14 @@ function covered(rows: number, shows: (row: number) => boolean) {
return low
}
function dispose(video: HTMLVideoElement) {
video.pause()
video.remove()
video.removeAttribute("src")
video.srcObject = null
video.load()
}
function layout(element: HTMLElement, frame: Box, radius: number) {
Object.assign(element.style, {
left: `${frame.x}px`,
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { clampView, detailScale, FIT_VIEW, maxScale, panBy, wheelZoomFactor, zoomAt } from "./image-zoom"
import { clampView, detailScale, FIT_VIEW, maxScale, panBy, viewBox, wheelZoomFactor, zoomAt } from "./image-zoom"
// A 1000×500 image fitted into a 1000×600 viewport at half its natural size.
const bounds = { width: 1000, height: 500, viewportWidth: 1000, viewportHeight: 600, maxScale: maxScale(0.5) }
@@ -79,3 +79,18 @@ describe("wheelZoomFactor", () => {
expect(wheelZoomFactor(-400)).toBeLessThan(1.5)
})
})
describe("viewBox", () => {
// An 800×450 video fitted at half its natural 1600×900.
const size = { width: 800, height: 450 }
const natural = { width: 1600, height: 900 }
test("shows the whole frame at fit", () => {
expect(viewBox(FIT_VIEW, size, natural)).toBe("none")
})
test("shows the middle half at 2x, and more of the left once panned right", () => {
expect(viewBox({ scale: 2, x: 0, y: 0 }, size, natural)).toBe("inset(225px 400px 225px 400px)")
expect(viewBox({ scale: 2, x: 200, y: 0 }, size, natural)).toBe("inset(225px 600px 225px 200px)")
})
})
+23
View File
@@ -58,6 +58,29 @@ export function detailScale(fitRatio: number) {
return actual >= 1.5 ? actual : 2
}
/**
* The `object-view-box` that shows `view` of media fitted at `size`, for media that must zoom inside its own box, such
* as a video under its controls. The inset is in the media's natural pixels.
*/
export function viewBox(
view: ZoomView,
size: { width: number; height: number },
natural: { width: number; height: number },
) {
if (view.scale <= 1) return "none"
const edge = (offset: number, fitted: number) => 0.5 - 0.5 / view.scale - offset / (view.scale * fitted)
const inset = [
edge(view.y, size.height) * natural.height,
edge(-view.x, size.width) * natural.width,
edge(-view.y, size.height) * natural.height,
edge(view.x, size.width) * natural.width,
]
return `inset(${inset.map((value) => `${Math.max(0, Math.round(value * 100) / 100)}px`).join(" ")})`
}
/** The zoom factor for one wheel event, bounded so a coarse mouse-wheel notch is a step rather than a jump. */
export function wheelZoomFactor(deltaY: number) {
return Math.exp(-clamp(deltaY, -30, 30) / 100)