fix(ui): grow the image preview's panel with the image and land without a cut (#56997)

This commit is contained in:
Kit Langton
2026-10-19 12:48:25 -07:00
committed by GitHub
Unverified
parent 538ef7f78d
commit c7bd580124
4 changed files with 276 additions and 42 deletions
@@ -73,18 +73,35 @@ describe("flightStyle", () => {
})
})
describe("elementFrame", () => {
test("starts from the part the page shows, under a sticky bar, with the panel lying flat on it", () => {
const box = { x: 100, y: 20, width: 640, height: 360 }
const frame = elementFrame(box, natural, "fill", 4, { x: 0, y: 48, width: 1280, height: 752 })
expect(frame.image).toEqual(box)
expect(frame.clip).toEqual({ x: 100, y: 48, width: 640, height: 332 })
expect(frame.surface).toEqual(frame.clip)
expect(frame.surfaceRadius).toBe(4)
expect(frame.lift).toBe(0)
})
})
describe("createFlight", () => {
const box = (x: number, width: number) => ({ x, y: 100, width, height: width * 0.75 })
const frame = (x: number, width: number, radius: number): FlightFrame => ({
const frame = (x: number, width: number, radius: number, lift: number): FlightFrame => ({
image: box(x, width),
clip: box(x, width),
radius,
// On the page the panel is the image itself; open, it is a wider panel around it.
surface: lift ? { x: x - 120, y: 60, width: width + 240, height: width * 0.75 + 96 } : box(x, width),
surfaceRadius: lift ? 10 : radius,
lift,
})
const thumbnail = frame(495, 285, 8)
const thumbnail = frame(495, 285, 8, 0)
const preview = frame(314, 651, 6)
const preview = frame(314, 651, 6, 1)
const progress = (flight: ReturnType<typeof createFlight>) =>
(flight.frame().image.width - thumbnail.image.width) / (preview.image.width - thumbnail.image.width)
@@ -102,6 +119,25 @@ describe("createFlight", () => {
expect(flight.frame()).toEqual(preview)
})
test("grows the panel out of the image on the same flight, lifting it as it lands", () => {
const flight = createFlight(thumbnail)
flight.to(preview, KICK.open)
const path = Array.from({ length: 30 }, () => (flight.step(1 / 60), flight.frame()))
path.slice(1).forEach((frame, index) => {
expect(frame.lift).toBeGreaterThanOrEqual(path[index].lift)
expect(frame.surface.width).toBeGreaterThanOrEqual(path[index].surface.width)
})
// The lift keeps pace with the image's growth, so the shadow rises as the panel does.
path.forEach((frame) =>
expect(frame.lift).toBeCloseTo(
(frame.image.width - thumbnail.image.width) / (preview.image.width - thumbnail.image.width),
2,
),
)
expect(flight.frame()).toEqual(preview)
})
test("keeps its launch shape on a slower spring, stretched in time", () => {
const snappy = createFlight(thumbnail)
const calm = createFlight(thumbnail)
+34 -8
View File
@@ -5,13 +5,15 @@ import { Spring, type DurationOptions } from "../motion"
*
* The flying image is laid out once at `base` and only ever transformed, so the flight runs on transform and
* clip-path alone. A frame says where the whole image would be drawn (`image`, always the image's own aspect ratio),
* which part of it shows (`clip`), and the corner radius of that visible part, all in viewport pixels. Each of those
* numbers rides its own spring, so the visible corners and crop move evenly on screen.
* which part of it shows (`clip`), and the corner radius of that visible part, all in viewport pixels. The preview's
* panel flies with it as one surface (`surface`, with its own corner radius) that grows out of the visible image, and
* `lift` says how far it has become the panel: 0 on the page, 1 open. Each of those numbers rides its own spring, so
* the visible corners, crop and panel move evenly on screen.
*/
export type Box = { x: number; y: number; width: number; height: number }
export type FlightFrame = { image: Box; clip: Box; radius: number }
export type FlightFrame = { image: Box; clip: Box; radius: number; surface: Box; surfaceRadius: number; lift: number }
/**
* Claude's preview, fitted frame by frame: one critically damped spring for both directions, launched with a push of
@@ -50,12 +52,19 @@ export function createFlight(frame: FlightFrame) {
return false
},
frame(): FlightFrame {
const [x, y, width, height, clipX, clipY, clipWidth, clipHeight, radius] = springs.map((spring) => spring.value)
const [x, y, width, height, clipX, clipY, clipWidth, clipHeight, radius, ...panel] = springs.map(
(spring) => spring.value,
)
const [surfaceX, surfaceY, surfaceWidth, surfaceHeight, surfaceRadius, lift] = panel
return {
image: { x, y, width, height },
clip: { x: clipX, y: clipY, width: clipWidth, height: clipHeight },
radius,
surface: { x: surfaceX, y: surfaceY, width: surfaceWidth, height: surfaceHeight },
surfaceRadius,
lift: lift / 100,
}
},
}
@@ -79,15 +88,25 @@ export function fittedImage(box: Box, natural: { width: number; height: number }
}
}
/** The frame of an element showing an image, cropped to the element's box. */
export function elementFrame(box: Box, natural: { width: number; height: number }, fit: string, radius: number) {
/**
* The frame of an element showing an image, cropped to the element's box and to `visible`, the part of it the page
* shows. The panel starts as that visible part.
*/
export function elementFrame(
box: Box,
natural: { width: number; height: number },
fit: string,
radius: number,
visible: Box = box,
): FlightFrame {
const image = fittedImage(box, natural, fit)
const clip = intersect(intersect(image, box), visible)
return { image, clip: intersect(image, box), radius }
return { image, clip, radius, surface: clip, surfaceRadius: radius, lift: 0 }
}
/** Styles that move an image laid out at `base` to `frame`, with `transform-origin: 0 0`. */
export function flightStyle(base: Box, frame: FlightFrame) {
export function flightStyle(base: Box, frame: Pick<FlightFrame, "image" | "clip" | "radius">) {
const scale = frame.image.width / base.width
const top = (frame.clip.y - frame.image.y) / scale
const right = (frame.image.x + frame.image.width - frame.clip.x - frame.clip.width) / scale
@@ -123,6 +142,13 @@ function values(frame: FlightFrame) {
frame.clip.width,
frame.clip.height,
frame.radius,
frame.surface.x,
frame.surface.y,
frame.surface.width,
frame.surface.height,
frame.surfaceRadius,
// In percent, so the landing threshold, a quarter of a unit, is as fine for it as for the pixels.
frame.lift * 100,
]
}
+42 -6
View File
@@ -5,6 +5,9 @@
display: flex;
align-items: center;
justify-content: center;
--image-preview-shadow:
0 15px 45px 0 rgba(19, 16, 16, 0.35), 0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
/* The preview dims the page itself, in step with the image's flight, instead of the dialog stack's overlay. */
[data-slot="image-preview-scrim"] {
@@ -14,6 +17,34 @@
pointer-events: none;
}
/* While the image flies, the panel's surface flies with it, from the image's place on the page to the panel; its
shadow rises as it lifts off the page. */
[data-slot="image-preview-surface"] {
display: none;
position: fixed;
/* Under the panel, whose controls ride it, and over the backdrop. */
z-index: 49;
background: var(--surface-raised-stronger-non-alpha);
pointer-events: none;
&::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: var(--image-preview-shadow);
opacity: var(--image-preview-lift, 1);
}
}
/* The origin's outline, carried over the flying image until it becomes the panel. */
[data-slot="image-preview-edge"] {
display: none;
position: fixed;
z-index: 51;
pointer-events: none;
}
[data-slot="image-preview-flight"] {
display: none;
position: fixed;
@@ -30,13 +61,19 @@
}
&[data-stage="flying"] {
[data-slot="image-preview-flight"] {
:is([data-slot="image-preview-surface"], [data-slot="image-preview-flight"], [data-slot="image-preview-edge"]) {
display: block;
}
[data-slot="image-preview-image"] {
visibility: hidden;
}
/* The surface stands in for the panel, so only its controls show, fading in as it lands. */
[data-slot="image-preview-container"] [data-slot="image-preview-content"] {
background: transparent;
box-shadow: none;
}
}
[data-slot="image-preview-container"] {
@@ -58,10 +95,7 @@
max-height: 100%;
border-radius: var(--radius-lg);
background: var(--surface-raised-stronger-non-alpha);
box-shadow:
0 15px 45px 0 rgba(19, 16, 16, 0.35),
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
box-shadow: var(--image-preview-shadow);
overflow: hidden;
&:focus-visible {
@@ -125,8 +159,10 @@
}
}
/* The dialog stack's overlay rule is unlayered, so it outranks this layered one unless marked important; without it the
overlay's full dim appears in one frame under the preview's own scrim. */
[data-component="dialog-overlay"]:has(+ [data-dialog-layer] > [data-component="image-preview"]) {
background-color: transparent;
background-color: transparent !important;
}
[data-image-preview-origin] {
+161 -25
View File
@@ -47,29 +47,43 @@ const tuning = motionTuning.spring("imagePreview", {
// Without a flight (reduced motion, or no thumbnail to fly to), the preview only fades.
const fade = { duration: 0.15, bounce: 0 }
// How long the dialog stays mounted after a close starts, in milliseconds: the flight lands well within this.
const exitDuration = () => Math.max(tuning().duration, fade.duration) * 1800
// How long the dialog stays mounted after a close starts, in milliseconds: the flight lands and hands the image back
// to the page well within this.
const exitDuration = () => Math.max(tuning().duration, fade.duration) * 3200
const CENTER = { x: 0, y: 0 }
export function ImagePreview(props: ImagePreviewProps) {
const i18n = useI18n()
const dialog = useDialogContext()
let root!: HTMLDivElement
let scrim!: HTMLDivElement
let content!: HTMLDivElement
let viewport!: HTMLDivElement
let image!: HTMLImageElement
let standIn!: HTMLImageElement
let surface!: HTMLDivElement
let header!: HTMLDivElement
let edge!: HTMLDivElement
let close: HTMLButtonElement | undefined
// Nothing shows until the image has loaded and the preview knows where it lands; while flying, a stand-in image
// travels above the dialog in place of the real one.
// Nothing shows until the image has loaded and the preview knows where it lands; while flying, a stand-in image and
// the panel's surface travel above the dialog in place of the real ones.
const [stage, setStage] = createSignal<"pending" | "flying" | "rest">("pending")
// The flight under way: where the stand-in is laid out (the flight only transforms and clips it), its springs, and
// what happens when it lands.
let course: { at: Box; flight: ReturnType<typeof createFlight>; landed: () => void } | undefined
// The flight under way: where the stand-in is laid out (the flight only transforms and clips it), the panel at rest
// and whether its controls sit at its left, the springs, and what happens when it lands.
let course:
| { at: Box; panel: Box; rtl: boolean; flight: ReturnType<typeof createFlight>; landed: () => void }
| undefined
const dim = Spring.fromDuration(0, FLIGHT_SPRING)
// Without a flight the panel and any stand-in fade with the backdrop; with one, the panel follows it more steeply,
// so it settles in around the image as it lands and clears before the image leaves.
// Once a close lands, the stand-in fades out over the image the page shows again, so whatever the page draws over
// its own image (a fade under a sticky bar, say) comes in softly instead of in one frame.
const veil = Spring.fromDuration(1, FLIGHT_SPRING)
// How far the flight has become the panel, as last painted: the origin's outline fades out as it rises.
let lift = 0
// Without a flight (reduced motion, or nothing on the page to fly to) the panel only fades with the backdrop.
let plain = false
let stop: (() => void) | undefined
const [view, setView] = createSignal(FIT_VIEW)
@@ -146,8 +160,9 @@ export function ImagePreview(props: ImagePreviewProps) {
}
const flight = createFlight(from)
flight.to(restFrame(), KICK.open, tuning())
course = { at: place(fitted()), flight, landed: () => setStage("rest") }
const rest = restFrame()
flight.to(rest, KICK.open, tuning())
course = { at: place(fitted()), ...panel(rest), flight, landed: () => setStage("rest") }
hideOrigin()
paint()
setStage("flying")
@@ -168,9 +183,18 @@ export function ImagePreview(props: ImagePreviewProps) {
}
// A close mid-open turns the flight around where it is, keeping its velocity.
course ??= { at: place(fitted()), flight: createFlight(restFrame()), landed: showOrigin }
if (!course) {
const rest = restFrame()
course = { at: place(fitted()), ...panel(rest), flight: createFlight(rest), landed: showOrigin }
}
course.flight.to(to, KICK.close, tuning())
course.landed = showOrigin
course.landed = () => {
showOrigin()
veil.omega = (2 * Math.PI) / (tuning().duration * 0.75)
veil.set(0)
}
hideOrigin()
paint()
setStage("flying")
@@ -188,6 +212,7 @@ export function ImagePreview(props: ImagePreviewProps) {
stop ??= frameLoop.add((dt) => {
const flying = course?.flight.step(dt)
dim.step(dt)
veil.step(dt)
paint()
// The real image takes over only once the panel holding it is fully opaque, so its brightness doesn't step.
@@ -197,7 +222,7 @@ export function ImagePreview(props: ImagePreviewProps) {
landed()
}
if (course || !dim.resting) return true
if (course || !dim.resting || !veil.resting) return true
stop = undefined
return false
@@ -206,13 +231,30 @@ export function ImagePreview(props: ImagePreviewProps) {
const paint = () => {
scrim.style.opacity = String(dim.value)
content.style.opacity = String(plain ? dim.value : dim.value ** 2)
standIn.style.opacity = String(plain ? dim.value : 1)
standIn.style.opacity = String(plain ? dim.value : veil.value)
// Landed, the surface lies under the stand-in's own outline, so it leaves at once rather than fading in between.
surface.style.opacity = course ? "1" : "0"
if (!course) return
const style = flightStyle(course.at, course.flight.frame())
standIn.style.transform = style.transform
standIn.style.clipPath = style.clipPath
if (course) {
const frame = course.flight.frame()
// The controls ride the panel's top corner on their side, fading in as it lifts.
const side = course.rtl ? frame.surface.x - course.panel.x : right(frame.surface) - right(course.panel)
header.style.transform = `translate(${side}px, ${frame.surface.y - course.panel.y}px)`
const style = flightStyle(course.at, frame)
standIn.style.transform = style.transform
standIn.style.clipPath = style.clipPath
// The surface 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)
surface.style.setProperty("--image-preview-lift", String(frame.lift))
layout(edge, frame.clip, frame.radius)
lift = frame.lift
}
if (!course) header.style.transform = ""
// Flying, the panel's controls arrive with the surface; without a flight they fade with the backdrop.
content.style.opacity = String(plain ? dim.value : course ? lift ** 2 : dim.value ** 2)
edge.style.opacity = String((1 - lift) * veil.value)
}
const place = (box: Box) => {
@@ -226,7 +268,7 @@ export function ImagePreview(props: ImagePreviewProps) {
return box
}
// The image as shown now: zoomed and panned, and cropped to the viewport.
// The image as shown now, zoomed and panned and cropped to the viewport, in its panel.
const restFrame = (): FlightFrame => {
const shown = image.getBoundingClientRect()
const clip = intersect(shown, viewport.getBoundingClientRect())
@@ -236,10 +278,15 @@ export function ImagePreview(props: ImagePreviewProps) {
image: shown,
clip,
radius: cropped ? 0 : parseFloat(getComputedStyle(image).borderTopLeftRadius) * view().scale,
surface: content.getBoundingClientRect(),
surfaceRadius: parseFloat(getComputedStyle(content).borderTopLeftRadius) || 0,
lift: 1,
}
}
// The origin's frame, or nothing when it is gone or scrolled out of sight.
const panel = (rest: FlightFrame) => ({ panel: rest.surface, rtl: getComputedStyle(content).direction === "rtl" })
// 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
@@ -247,15 +294,21 @@ export function ImagePreview(props: ImagePreviewProps) {
const rect = origin.getBoundingClientRect()
if (!rect.width || !rect.height) return
// The dialog stack portals the preview with its overlay; both sit over the page it looks through.
const visible = visibleBox(origin, root.closest("[data-dialog-layer]")?.parentElement ?? root)
if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) return
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"
return elementFrame(
rect,
{ width: image.naturalWidth, height: image.naturalHeight },
style.objectFit,
parseFloat(style.borderTopLeftRadius) || 0,
visible,
)
}
@@ -368,7 +421,12 @@ export function ImagePreview(props: ImagePreviewProps) {
)
return (
<div data-component="image-preview" data-stage={stage()} style={{ "--image-preview-exit": `${exit()}ms` }}>
<div
ref={root}
data-component="image-preview"
data-stage={stage()}
style={{ "--image-preview-exit": `${exit()}ms` }}
>
<div ref={scrim} data-slot="image-preview-scrim" />
<div data-slot="image-preview-container">
<Kobalte.Content
@@ -380,7 +438,7 @@ export function ImagePreview(props: ImagePreviewProps) {
close?.focus()
}}
>
<div data-slot="image-preview-header">
<div ref={header} data-slot="image-preview-header">
<div data-slot="image-preview-zoom">
<IconButton
data-slot="image-preview-zoom-out"
@@ -461,7 +519,9 @@ export function ImagePreview(props: ImagePreviewProps) {
</div>
</Kobalte.Content>
</div>
<div data-slot="image-preview-surface" ref={surface} />
<img data-slot="image-preview-flight" src={props.src} alt="" aria-hidden="true" ref={standIn} />
<div data-slot="image-preview-edge" ref={edge} />
</div>
)
}
@@ -551,6 +611,82 @@ const SAMPLE = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><defs><linearGradient id="sky" x2="0" y2="1"><stop offset="0" stop-color="#ff8a5b"/><stop offset="1" stop-color="#ffd27a"/></linearGradient></defs><rect width="1600" height="1000" fill="url(#sky)"/><circle cx="1120" cy="360" r="170" fill="#fff4d6"/><path d="M0 1000V720Q400 580 800 720T1600 700V1000Z" fill="#2f7d48"/></svg>',
)}`
// The part of `origin` the page under `preview` shows: inside its clipping ancestors and the window, and clear of
// anything drawn over its top or bottom, such as a sticky title bar. Nothing when none of it shows.
function visibleBox(origin: HTMLElement, preview: Element): Box | undefined {
const clipped = ancestors(origin).reduce(
(box: Box, element) => {
const style = getComputedStyle(element)
if (style.overflowX === "visible" && style.overflowY === "visible") return box
return intersect(box, element.getBoundingClientRect())
},
intersect(origin.getBoundingClientRect(), { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }),
)
const rows = Math.floor(clipped.height)
if (!clipped.width || rows < 1) return
const x = clipped.x + clipped.width / 2
// The origin is hidden while the preview flies, so the page shows it where the topmost element under the preview
// holds it.
const shows = (y: number) =>
document
.elementsFromPoint(x, y)
.find((element) => !preview.contains(element))
?.contains(origin) ?? false
const top = covered(rows, (row) => shows(clipped.y + row + 0.5))
if (top === rows) return
const bottom = covered(rows - top, (row) => shows(clipped.y + clipped.height - row - 0.5))
return { x: clipped.x, y: clipped.y + top, width: clipped.width, height: clipped.height - top - bottom }
}
function ancestors(element: HTMLElement) {
const list: HTMLElement[] = []
for (let parent = element.parentElement; parent; parent = parent.parentElement) list.push(parent)
return list
}
// How many of `rows`, counted in from an edge, are covered before the first that shows; a covering bar is one piece.
function covered(rows: number, shows: (row: number) => boolean) {
let low = 0
let high = rows
while (low < high) {
const middle = Math.floor((low + high) / 2)
if (shows(middle)) {
high = middle
continue
}
low = middle + 1
}
return low
}
function right(box: Box) {
return box.x + box.width
}
function layout(element: HTMLElement, frame: Box, radius: number) {
Object.assign(element.style, {
left: `${frame.x}px`,
top: `${frame.y}px`,
width: `${frame.width}px`,
height: `${frame.height}px`,
borderRadius: `${radius}px`,
})
}
function shortcut(key: string) {
if (key === "=" || key === "+") return "in"