From c7bd5801241f9434e9dc3c42e1e7283322256833 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 19 Oct 2026 12:48:25 -0700 Subject: [PATCH] fix(ui): grow the image preview's panel with the image and land without a cut (#56997) --- .../ui/src/components/image-flight.test.ts | 42 +++- packages/ui/src/components/image-flight.ts | 42 +++- packages/ui/src/components/image-preview.css | 48 ++++- packages/ui/src/components/image-preview.tsx | 186 +++++++++++++++--- 4 files changed, 276 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/components/image-flight.test.ts b/packages/ui/src/components/image-flight.test.ts index 43591ff3e59..1c057f57d65 100644 --- a/packages/ui/src/components/image-flight.test.ts +++ b/packages/ui/src/components/image-flight.test.ts @@ -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) => (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) diff --git a/packages/ui/src/components/image-flight.ts b/packages/ui/src/components/image-flight.ts index 1ad9d8e72f0..de8d43a5f2f 100644 --- a/packages/ui/src/components/image-flight.ts +++ b/packages/ui/src/components/image-flight.ts @@ -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) { 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, ] } diff --git a/packages/ui/src/components/image-preview.css b/packages/ui/src/components/image-preview.css index 371f048210d..01100417a11 100644 --- a/packages/ui/src/components/image-preview.css +++ b/packages/ui/src/components/image-preview.css @@ -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] { diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 7fa015cd81f..ed8d7179575 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -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; 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; 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 . + 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 ( -
+
-
+
+
+
) } @@ -551,6 +611,82 @@ const SAMPLE = `data:image/svg+xml,${encodeURIComponent( '', )}` +// 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"