diff --git a/packages/ui/src/components/image-preview.css b/packages/ui/src/components/image-preview.css index c895f24f467..00b4937b69a 100644 --- a/packages/ui/src/components/image-preview.css +++ b/packages/ui/src/components/image-preview.css @@ -2,9 +2,8 @@ position: fixed; inset: 0; z-index: 50; - display: flex; - align-items: center; - justify-content: center; + /* Only the open preview takes clicks, so nothing left on screen while it closes blocks the page. */ + pointer-events: none; --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); @@ -17,14 +16,12 @@ 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. */ + /* While the image flies, a shadow flies under it, deepest mid-flight as it lifts between the page and the preview, + where the image lies flat on the backdrop. */ [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 { @@ -33,11 +30,11 @@ inset: 0; border-radius: inherit; box-shadow: var(--image-preview-shadow); - opacity: var(--image-preview-lift, 1); + opacity: calc(4 * var(--image-preview-lift, 0) * (1 - var(--image-preview-lift, 0))); } } - /* The origin's outline, carried over the flying image until it becomes the panel. */ + /* The origin's outline, carried over the flying image until it lifts off the page. */ [data-slot="image-preview-edge"] { display: none; position: fixed; @@ -68,96 +65,87 @@ [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; + /* The image is the preview: no panel around it, fitted to the window with room for the controls under it. */ + [data-slot="image-preview-content"] { + position: absolute; + inset: 0; + z-index: 50; + pointer-events: auto; + + &:focus-visible { + outline: none; + } + + /* Kobalte unmounts closed content once its CSS animation ends, so this holds it for the closing flight. Held, it + must not take the page's clicks; Kobalte's inline style is otherwise auto. */ + &[data-closed] { + animation: image-preview-exit var(--image-preview-exit); + pointer-events: none !important; } } - [data-slot="image-preview-container"] { - position: relative; - z-index: 50; - width: min(calc(100vw - 32px), 90vw); - max-width: 1200px; - height: min(calc(100vh - 32px), 90vh); + [data-slot="image-preview-body"] { + position: absolute; + inset: 44px clamp(8px, 3vw, 36px); display: flex; - flex-direction: column; align-items: center; justify-content: center; + padding: 12px; + overflow: hidden; + user-select: none; + /* A zoomed image fades out in the padding instead of being cut at the edge; a fitted one never reaches it. */ + mask-image: + linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent), + linear-gradient(to bottom, transparent, #000 12px, #000 calc(100% - 12px), transparent); + mask-composite: intersect; - [data-slot="image-preview-content"] { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - max-height: 100%; - border-radius: var(--radius-lg); - background: var(--surface-raised-stronger-non-alpha); - box-shadow: var(--image-preview-shadow); - overflow: hidden; - - &:focus-visible { - outline: none; - } - - /* Kobalte unmounts closed content once its CSS animation ends, so this holds it for the closing flight. Held, it - must not take the page's clicks; Kobalte's inline style is otherwise auto. */ - &[data-closed] { - animation: image-preview-exit var(--image-preview-exit); - pointer-events: none !important; - } - - [data-slot="image-preview-header"] { - display: flex; - gap: 8px; - padding: 8px 8px 0; - justify-content: flex-end; - align-items: center; - align-self: stretch; - } - - [data-slot="image-preview-zoom"] { - display: flex; - align-items: center; - gap: 2px; - } - - [data-slot="image-preview-zoom-level"] { - min-width: 52px; - justify-content: center; - font-variant-numeric: tabular-nums; - } - - [data-slot="image-preview-body"] { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - overflow: hidden; - user-select: none; - - &[data-zoomed] { - cursor: grab; - touch-action: pinch-zoom; - } - - &[data-dragging] { - cursor: grabbing; - } - } - - [data-slot="image-preview-image"] { - max-width: 100%; - max-height: calc(90vh - 100px); - object-fit: contain; - border-radius: var(--radius-md); - transform-origin: center; - -webkit-user-drag: none; - } + &[data-zoomed] { + cursor: grab; + touch-action: pinch-zoom; } + + &[data-dragging] { + cursor: grabbing; + } + } + + [data-slot="image-preview-image"] { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius-md); + transform-origin: center; + -webkit-user-drag: none; + } + + [data-slot="image-preview-controls"] { + position: absolute; + bottom: 12px; + inset-inline: 0; + width: fit-content; + margin-inline: auto; + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 8px; + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-floating); + } + + [data-slot="image-preview-zoom-level"] { + min-width: 52px; + justify-content: center; + font-variant-numeric: tabular-nums; + } + + [data-slot="image-preview-divider"] { + width: 1px; + height: 16px; + margin-inline: 2px; + background: var(--v2-border-border-base); } } diff --git a/packages/ui/src/components/image-preview.stories.tsx b/packages/ui/src/components/image-preview.stories.tsx index 40ee8661214..7d4405f6675 100644 --- a/packages/ui/src/components/image-preview.stories.tsx +++ b/packages/ui/src/components/image-preview.stories.tsx @@ -14,13 +14,14 @@ Use for full-size image inspection; keep images optimized. - Optional: \`alt\` text, and \`origin\`, the image on the page that the preview grows out of and shrinks back into. ### Variants and states -- Single layout with zoom controls and a close action. +- Single layout: the image fills the window without a panel, with zoom controls and a close action in one strip under it. ### Behavior - 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. +- A click on the backdrop around the image closes it, unless it ends a pan. ### Accessibility - Uses localized aria-label for close button. diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 87e45bb6a42..72137c78bc5 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -65,32 +65,32 @@ export function ImagePreview(props: ImagePreviewProps) { 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 and - // the panel's surface travel above the dialog in place of the real ones. + // its shadow travel above the dialog in place of the real one. 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), 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 + // The flight under way: where the stand-in is laid out (the flight only transforms and clips it), the springs, and + // what happens when it lands. + let course: { at: Box; flight: ReturnType; landed: () => void } | undefined const dim = Spring.fromDuration(0, FLIGHT_SPRING) // 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. + // How far the flight has lifted off the page, 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. + // Without a flight (reduced motion, or nothing on the page to fly to) the preview only fades with the backdrop. let plain = false let stop: (() => void) | undefined const [view, setView] = createSignal(FIT_VIEW) const [fitRatio, setFitRatio] = createSignal(1) const [drag, setDrag] = createSignal<{ id: number; x: number; y: number }>() + // 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 // 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 @@ -164,7 +164,7 @@ export function ImagePreview(props: ImagePreviewProps) { const flight = createFlight(from) const rest = restFrame() flight.to(rest, KICK.open, tuning()) - course = { at: place(fitted()), ...panel(rest), flight, landed: () => setStage("rest") } + course = { at: place(fitted()), flight, landed: () => setStage("rest") } hideOrigin() paint() setStage("flying") @@ -187,7 +187,7 @@ export function ImagePreview(props: ImagePreviewProps) { // A close mid-open turns the flight around where it is, keeping its velocity. if (!course) { const rest = restFrame() - course = { at: place(fitted()), ...panel(rest), flight: createFlight(rest), landed: showOrigin } + course = { at: place(fitted()), flight: createFlight(rest), landed: showOrigin } } course.flight.to(to, KICK.close, tuning()) @@ -234,18 +234,14 @@ export function ImagePreview(props: ImagePreviewProps) { const paint = () => { scrim.style.opacity = String(dim.value) 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) { 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 + // 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) surface.style.setProperty("--image-preview-lift", String(frame.lift)) @@ -253,8 +249,7 @@ export function ImagePreview(props: ImagePreviewProps) { 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. + // Flying, the controls arrive as the image lifts; 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) } @@ -270,24 +265,16 @@ export function ImagePreview(props: ImagePreviewProps) { return box } - // The image as shown now, zoomed and panned and cropped to the viewport, in its panel. + // 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 - return { - image: shown, - clip, - radius: cropped ? 0 : parseFloat(getComputedStyle(image).borderTopLeftRadius) * view().scale, - surface: content.getBoundingClientRect(), - surfaceRadius: parseFloat(getComputedStyle(content).borderTopLeftRadius) || 0, - lift: 1, - } + return { image: shown, clip, radius, surface: clip, surfaceRadius: radius, lift: 1 } } - 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 @@ -430,97 +417,103 @@ export function ImagePreview(props: ImagePreviewProps) { style={{ "--image-preview-exit": `${exit()}ms` }} >
-
- { - // Open with focus on Close, as before, rather than on the zoom controls that now precede it. + { + // Open with focus on Close, as before, rather than on the zoom controls that precede it. + event.preventDefault() + close?.focus() + }} + onPointerDown={(event: PointerEvent) => { + backdrop = event.target === content || event.target === viewport + }} + onClick={(event: MouseEvent) => { + // The preview fills the window, so a click on the backdrop around the image lands here rather than outside. + if (!backdrop || (event.target !== content && event.target !== viewport)) return + dialog.close() + }} + > +
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() - close?.focus() + 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) }} > -
-
- } - variant="ghost" - aria-label={i18n.t("ui.imagePreview.zoomOut")} - onClick={() => step(1 / STEP)} - /> - - } - variant="ghost" - aria-label={i18n.t("ui.imagePreview.zoomIn")} - onClick={() => step(STEP)} - /> -
- } - variant="ghost" - aria-label={i18n.t("ui.common.close")} - /> -
-
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 - 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) + {props.alt { + measure() + void reveal() }} + onError={() => void reveal()} + style={{ transform: `translate(${view().x}px, ${view().y}px) scale(${view().scale})` }} + /> +
+
+ } + variant="ghost" + aria-label={i18n.t("ui.imagePreview.zoomOut")} + onClick={() => step(1 / STEP)} + /> +
- -
+ {percent().format(view().scale * fitRatio())} + + } + variant="ghost" + aria-label={i18n.t("ui.imagePreview.zoomIn")} + onClick={() => step(STEP)} + /> +
+ } + variant="ghost" + aria-label={i18n.t("ui.common.close")} + /> +
+
@@ -675,10 +668,6 @@ function covered(rows: number, shows: (row: number) => boolean) { 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`,