mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
feat(ui): open videos like images and zoom into both (#57924)
This commit is contained in:
committed by
GitHub
Unverified
parent
316a688fe9
commit
95a525ea85
@@ -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([])
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user