feat(ui): open images from their place in the message (#56896)

This commit is contained in:
Kit Langton
2026-10-18 22:37:03 -07:00
committed by GitHub
Unverified
parent ac75a36daf
commit 834dfef54f
13 changed files with 838 additions and 24 deletions
+2 -2
View File
@@ -584,7 +584,7 @@ export function ComposerAttachments(props: {
files?: Extract<ComposerComment, { type: "file" }>[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: ComposerAttachment) => void
onAttachmentClick?: (attachment: ComposerAttachment, origin: HTMLElement) => void
onAttachmentRemove: (attachment: ComposerAttachment) => void
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
@@ -696,7 +696,7 @@ export function ComposerAttachments(props: {
src={url() ?? ""}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
onClick={(event) => props.onAttachmentClick?.(attachment, event.currentTarget)}
/>
</Suspense>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
@@ -63,7 +63,7 @@ export function createComposerEditor(input: {
commands: Accessor<ComposerSuggestion[]>
context: Accessor<ComposerSuggestion[]>
searchContextFiles: (query: string) => ComposerSuggestion[] | Promise<ComposerSuggestion[]>
openAttachment?: (attachment: ComposerAttachment) => void
openAttachment?: (attachment: ComposerAttachment, origin?: HTMLElement) => void
openContext?: (key: string) => void
onContextRemove?: (item: ComposerComment) => void
onEditor?: (element: HTMLElement) => void
@@ -407,8 +407,8 @@ export function createComposerEditor(input: {
if (state.activeContextID === id) dispatch({ type: "context.active", id })
},
openAttachment(attachment: ComposerAttachment) {
input.openAttachment?.(attachment)
openAttachment(attachment: ComposerAttachment, origin?: HTMLElement) {
input.openAttachment?.(attachment, origin)
},
removeAttachment(id: string) {
draft.removeAttachment(id)
+2 -2
View File
@@ -330,10 +330,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onContextRemove(item) {
if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) => {
openAttachment: (attachment, origin) => {
if (attachment.type !== "image") return
void resolveBlobUrl(attachment.blob).then((src) => {
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename, origin }))
})
},
openContext(key) {
@@ -1,20 +1,23 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { useI18n } from "@opencode/ui/context/i18n"
import { ImagePreview } from "@opencode/ui/image-preview"
import { ImagePreview, replayImagePreview } from "@opencode/ui/image-preview"
export function createImagePreview() {
const dialog = useDialog()
const i18n = useI18n()
replayImagePreview()
const open = (event: MouseEvent | KeyboardEvent) => {
if (!(event.currentTarget instanceof HTMLImageElement)) return
const src = event.currentTarget.currentSrc || event.currentTarget.getAttribute("src")
const image = event.currentTarget
if (!(image instanceof HTMLImageElement)) return
const src = image.currentSrc || image.getAttribute("src")
if (!src) return
const alt = event.currentTarget.alt || i18n.t("ui.imagePreview.alt")
const alt = image.alt || i18n.t("ui.imagePreview.alt")
event.preventDefault()
event.stopPropagation()
dialog.show(() => <ImagePreview src={src} alt={alt} />)
dialog.show(() => <ImagePreview src={src} alt={alt} origin={image} />)
}
return (root: HTMLElement) => {
@@ -356,7 +356,10 @@ export function CurrentUserMessageDisplay(props: {
data-slot="user-message-attachment"
data-type="image"
data-clickable="true"
onClick={() => dialog.show(() => <ImagePreview src={url()} alt={name()} />)}
onClick={(event) => {
const origin = event.currentTarget
dialog.show(() => <ImagePreview src={url()} alt={name()} origin={origin} />)
}}
>
<img data-slot="user-message-attachment-image" src={url()} alt={name()} />
</div>
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test"
import { createFlight, elementFrame, fittedImage, flightStyle, KICK, type FlightFrame } from "./image-flight"
// A 1600×900 screenshot.
const natural = { width: 1600, height: 900 }
describe("fittedImage", () => {
test("covers a box of another shape, overflowing it evenly", () => {
// A 58×46 thumbnail is taller than 16:9, so the image fills its height and overflows its width.
expect(fittedImage({ x: 100, y: 200, width: 58, height: 46 }, natural, "cover")).toEqual({
x: 100 - (46 * (16 / 9) - 58) / 2,
y: 200,
width: 46 * (16 / 9),
height: 46,
})
})
test("contains within a box, letterboxed evenly", () => {
expect(fittedImage({ x: 0, y: 0, width: 400, height: 400 }, natural, "contain")).toEqual({
x: 0,
y: 87.5,
width: 400,
height: 225,
})
})
test("matches the box when the shapes agree", () => {
const box = { x: 10, y: 20, width: 320, height: 180 }
expect(fittedImage(box, natural, "fill")).toEqual(box)
expect(fittedImage(box, natural, "contain")).toEqual(box)
})
})
describe("flightStyle", () => {
const base = { x: 200, y: 100, width: 800, height: 450 }
test("is the identity at the base frame", () => {
expect(flightStyle(base, { image: base, clip: base, radius: 6 })).toEqual({
transform: "translate(0px, 0px) scale(1)",
clipPath: "inset(0px 0px 0px 0px round 6px)",
})
})
test("shrinks uniformly onto an uncropped thumbnail, keeping its on-screen radius", () => {
const thumbnail = { x: 40, y: 600, width: 320, height: 180 }
expect(flightStyle(base, { image: thumbnail, clip: thumbnail, radius: 6 })).toEqual({
transform: "translate(-160px, 500px) scale(0.4)",
clipPath: "inset(0px 0px 0px 0px round 15px)",
})
})
test("crops to a cover thumbnail instead of squashing the image", () => {
const frame = elementFrame({ x: 100, y: 200, width: 40, height: 45 }, natural, "cover", 6)
const style = flightStyle(base, frame)
// The image is 80×45 on screen, half the base, and 20px of it hangs past each side of the box.
expect(frame.image).toEqual({ x: 80, y: 200, width: 80, height: 45 })
expect(style).toEqual({
transform: "translate(-120px, 100px) scale(0.1)",
clipPath: "inset(0px 200px 0px 200px round 60px)",
})
})
test("clips a zoomed image to the viewport it is seen through", () => {
const image = { x: -200, y: -125, width: 1600, height: 900 }
const clip = { x: 0, y: 0, width: 1200, height: 650 }
expect(flightStyle(base, { image, clip, radius: 0 })).toEqual({
transform: "translate(-400px, -225px) scale(2)",
clipPath: "inset(62.5px 100px 62.5px 100px round 0px)",
})
})
})
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 => ({
image: box(x, width),
clip: box(x, width),
radius,
})
const thumbnail = frame(495, 285, 8)
const preview = frame(314, 651, 6)
const progress = (flight: ReturnType<typeof createFlight>) =>
(flight.frame().image.width - thumbnail.image.width) / (preview.image.width - thumbnail.image.width)
test("opens fast and settles without overshoot, like Claude's measured preview", () => {
const flight = createFlight(thumbnail)
flight.to(preview, KICK.open)
const path = Array.from({ length: 30 }, () => (flight.step(1 / 60), progress(flight)))
// Measured: 25% after one frame, 61% after three, 91% after seven.
expect(path[0]).toBeCloseTo(0.25, 1)
expect(path[2]).toBeCloseTo(0.61, 1)
expect(path[6]).toBeCloseTo(0.91, 1)
path.forEach((value) => expect(value).toBeLessThanOrEqual(1))
expect(flight.frame()).toEqual(preview)
})
test("turns around mid-flight from where it is, carrying its velocity", () => {
const flight = createFlight(thumbnail)
flight.to(preview, KICK.open)
Array.from({ length: 4 }, () => flight.step(1 / 60))
const turn = flight.frame()
flight.to(thumbnail, KICK.close)
expect(flight.frame()).toEqual(turn)
// A fresh close launched from the same frame, at rest, moves further on its first frame than the reversed flight,
// which first has to cancel the outward velocity it still carries.
const fresh = createFlight(turn)
fresh.to(thumbnail, KICK.close)
flight.step(1 / 60)
fresh.step(1 / 60)
expect(turn.image.width - flight.frame().image.width).toBeGreaterThan(0)
expect(turn.image.width - flight.frame().image.width).toBeLessThan(turn.image.width - fresh.frame().image.width)
while (flight.step(1 / 60));
expect(flight.frame()).toEqual(thumbnail)
})
})
+128
View File
@@ -0,0 +1,128 @@
import { Spring, type DurationOptions } from "../motion"
/**
* Geometry and motion for flying a preview image between its place on the page and the open preview.
*
* 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.
*/
export type Box = { x: number; y: number; width: number; height: number }
export type FlightFrame = { image: Box; clip: Box; radius: number }
/**
* Claude's preview, fitted frame by frame: one critically damped spring for both directions, launched with a push of
* this many times the remaining distance per second. A spring from rest starts too slowly to match.
*/
export const FLIGHT_SPRING = { duration: 0.2, bounce: 0 }
export const KICK = { open: 14, close: 32.5 }
/** A flight frame with one spring per edge, so heading somewhere new mid-flight keeps the velocity it already has. */
export function createFlight(frame: FlightFrame) {
const springs = values(frame).map((value) => Spring.fromDuration(value, FLIGHT_SPRING))
return {
/** Heads for `frame` on `tuning`, adding a launch velocity of `kick` times the remaining distance per second. */
to(frame: FlightFrame, kick: number, tuning: DurationOptions = FLIGHT_SPRING) {
values(frame).forEach((target, index) => {
const spring = springs[index]
spring.omega = (2 * Math.PI) / tuning.duration
spring.zeta = 1 - tuning.bounce
spring.set(target, { velocity: spring.velocity + kick * (target - spring.value) })
})
},
/** Advances `dt` seconds; true until every edge has landed. */
step(dt: number) {
springs.forEach((spring) => spring.step(dt))
// Within a quarter pixel and barely moving is landed: the rest of the way would not show, so snap it.
if (springs.some((spring) => Math.abs(spring.value - spring.target) > 0.25 || Math.abs(spring.velocity) > 4))
return true
springs.forEach((spring) => spring.snap(spring.target))
return false
},
frame(): FlightFrame {
const [x, y, width, height, clipX, clipY, clipWidth, clipHeight, radius] = springs.map((spring) => spring.value)
return {
image: { x, y, width, height },
clip: { x: clipX, y: clipY, width: clipWidth, height: clipHeight },
radius,
}
},
}
}
/** Where an element draws its whole image for an `object-fit`, centered as with the default `object-position`. */
export function fittedImage(box: Box, natural: { width: number; height: number }, fit: string): Box {
const ratio = natural.width / natural.height
// `fill` stretches the image; the flight keeps its aspect ratio, so it covers the box instead.
const contain = fit === "contain" || fit === "scale-down"
const wide = box.width / box.height > ratio
const width = contain === wide ? box.height * ratio : box.width
const height = contain === wide ? box.height : box.width / ratio
return {
x: box.x + (box.width - width) / 2,
y: box.y + (box.height - height) / 2,
width,
height,
}
}
/** 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) {
const image = fittedImage(box, natural, fit)
return { image, clip: intersect(image, box), radius }
}
/** Styles that move an image laid out at `base` to `frame`, with `transform-origin: 0 0`. */
export function flightStyle(base: Box, frame: FlightFrame) {
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
const bottom = (frame.image.y + frame.image.height - frame.clip.y - frame.clip.height) / scale
const left = (frame.clip.x - frame.image.x) / scale
return {
transform: `translate(${frame.image.x - base.x}px, ${frame.image.y - base.y}px) scale(${scale})`,
clipPath: `inset(${px(top)} ${px(right)} ${px(bottom)} ${px(left)} round ${px(frame.radius / scale)})`,
}
}
export function intersect(a: Box, b: Box): Box {
const x = Math.max(a.x, b.x)
const y = Math.max(a.y, b.y)
return {
x,
y,
width: Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x),
height: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y),
}
}
function values(frame: FlightFrame) {
return [
frame.image.x,
frame.image.y,
frame.image.width,
frame.image.height,
frame.clip.x,
frame.clip.y,
frame.clip.width,
frame.clip.height,
frame.radius,
]
}
function px(value: number) {
return `${Math.max(0, Math.round(value * 100) / 100)}px`
}
@@ -6,6 +6,39 @@
align-items: center;
justify-content: center;
/* 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"] {
position: fixed;
inset: 0;
background-color: var(--v2-overlay-simple-overlay-scrim);
pointer-events: none;
}
[data-slot="image-preview-flight"] {
display: none;
position: fixed;
z-index: 51;
max-width: none;
max-height: none;
transform-origin: 0 0;
pointer-events: none;
user-select: none;
}
&[data-stage="pending"] :is([data-slot="image-preview-scrim"], [data-slot="image-preview-content"]) {
opacity: 0;
}
&[data-stage="flying"] {
[data-slot="image-preview-flight"] {
display: block;
}
[data-slot="image-preview-image"] {
visibility: hidden;
}
}
[data-slot="image-preview-container"] {
position: relative;
z-index: 50;
@@ -35,6 +68,11 @@
outline: none;
}
/* Kobalte unmounts closed content once its CSS animation ends, so this holds it for the closing flight. */
&[data-closed] {
animation: image-preview-exit var(--image-preview-exit);
}
[data-slot="image-preview-header"] {
display: flex;
gap: 8px;
@@ -86,3 +124,17 @@
}
}
}
[data-component="dialog-overlay"]:has(+ [data-dialog-layer] > [data-component="image-preview"]) {
background-color: transparent;
}
[data-image-preview-origin] {
visibility: hidden;
}
@keyframes image-preview-exit {
to {
visibility: visible;
}
}
@@ -11,13 +11,14 @@ Use for full-size image inspection; keep images optimized.
### API
- Required: \`src\`.
- Optional: \`alt\` text.
- 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.
### 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.
@@ -78,6 +79,83 @@ export const Screenshot = {
},
}
// Images in a message open from where they sit and close back into place: a full-width image and a cropped thumbnail.
export const FromMessage = {
render: () => {
const dialog = useDialog()
const src = poster()
const open = (event: MouseEvent & { currentTarget: HTMLImageElement }) => {
const origin = event.currentTarget
dialog.show(() => <mod.ImagePreview src={src} alt={origin.alt} origin={origin} />)
}
return (
<div style={{ width: "640px", margin: "48px auto", display: "flex", "flex-direction": "column", gap: "16px" }}>
<div style={{ display: "flex", "justify-content": "flex-end" }}>
<img
data-story-image="thumbnail"
src={src}
alt="Poster thumbnail"
onClick={open}
style={{ width: "58px", height: "46px", "object-fit": "cover", "border-radius": "6px", cursor: "pointer" }}
/>
</div>
<p>Here is the poster for the garden open day, with the new colours.</p>
<img
data-story-image="inline"
src={src}
alt="Garden open day poster"
onClick={open}
style={{ width: "100%", "border-radius": "8px", cursor: "zoom-in" }}
/>
<p>The hills use the brand greens, and the date sits on the sun.</p>
</div>
)
},
}
// A bright, offline poster, so the image reads clearly against the dimmed page while it moves.
function poster() {
const canvas = document.createElement("canvas")
canvas.width = 1600
canvas.height = 1000
const ctx = canvas.getContext("2d")
const sky = ctx.createLinearGradient(0, 0, 0, 1000)
sky.addColorStop(0, "#ff8a5b")
sky.addColorStop(1, "#ffd27a")
ctx.fillStyle = sky
ctx.fillRect(0, 0, 1600, 1000)
ctx.fillStyle = "#fff4d6"
ctx.beginPath()
ctx.arc(1120, 360, 170, 0, Math.PI * 2)
ctx.fill()
;[
["#3f9d5a", 700, 0.0],
["#2f7d48", 790, 1.7],
["#1f5c36", 880, 3.1],
].forEach(([color, base, phase]) => {
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(0, 1000)
for (let x = 0; x <= 1600; x += 20) ctx.lineTo(x, base - 70 * Math.sin(x / 260 + phase))
ctx.lineTo(1600, 1000)
ctx.fill()
})
ctx.fillStyle = "#1f2a44"
ctx.font = "800 120px -apple-system, system-ui, sans-serif"
ctx.fillText("Garden open day", 110, 230)
ctx.font = "600 52px -apple-system, system-ui, sans-serif"
ctx.fillText("Lumen Garden · 12 April", 116, 310)
ctx.fillStyle = "#c2410c"
ctx.font = "800 64px -apple-system, system-ui, sans-serif"
ctx.textAlign = "center"
ctx.fillText("12.4", 1120, 382)
return canvas.toDataURL("image/png")
}
function screenshot() {
const canvas = document.createElement("canvas")
canvas.width = 2880
+385 -4
View File
@@ -1,28 +1,146 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
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 { useDialog, useDialogLayer } from "../context/dialog"
import { useI18n } from "../context/i18n"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { frameLoop, prefersReducedMotion, Spring, type DurationOptions } from "../motion"
import { motionTuning } from "../motion-tuning"
import {
createFlight,
elementFrame,
FLIGHT_SPRING,
flightStyle,
intersect,
KICK,
type Box,
type FlightFrame,
} from "./image-flight"
import { clampView, detailScale, FIT_VIEW, maxScale, panBy, 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
}
const STEP = Math.SQRT2
const group = "Image open"
// Claude's backdrop dims from rest, more gently than the image flies. Without a flight (reduced motion, or no thumbnail
// to fly to), the preview only fades.
const claude = {
springs: {
imageOpen: FLIGHT_SPRING,
imageClose: FLIGHT_SPRING,
imageScrimOpen: { duration: 0.34, bounce: 0 },
imageScrimClose: { duration: 0.2, bounce: 0 },
},
numbers: { imageOpenKick: KICK.open, imageCloseKick: KICK.close, imageFade: 0.15 },
}
const presets = {
"Claude match": claude,
Calm: {
springs: {
imageOpen: { duration: 0.32, bounce: 0 },
imageClose: { duration: 0.26, bounce: 0 },
imageScrimOpen: { duration: 0.45, bounce: 0 },
imageScrimClose: { duration: 0.28, bounce: 0 },
},
numbers: { imageOpenKick: 6, imageCloseKick: 16, imageFade: 0.2 },
},
}
const kick = { group, min: 0, max: 60, step: 0.5, unit: "×/s", time: "rate" } as const
const tuning = {
open: motionTuning.spring("imageOpen", { label: "Open spring", group, ...claude.springs.imageOpen }),
openKick: motionTuning.number("imageOpenKick", {
...kick,
label: "Open launch velocity",
value: claude.numbers.imageOpenKick,
}),
close: motionTuning.spring("imageClose", { label: "Close spring", group, ...claude.springs.imageClose }),
closeKick: motionTuning.number("imageCloseKick", {
...kick,
label: "Close launch velocity",
value: claude.numbers.imageCloseKick,
}),
scrimOpen: motionTuning.spring("imageScrimOpen", { label: "Backdrop in", group, ...claude.springs.imageScrimOpen }),
scrimClose: motionTuning.spring("imageScrimClose", {
label: "Backdrop out",
group,
...claude.springs.imageScrimClose,
}),
fade: motionTuning.number("imageFade", {
label: "Reduced-motion fade",
group,
value: claude.numbers.imageFade,
min: 0.05,
max: 1,
step: 0.01,
unit: "s",
time: "duration",
}),
}
Object.entries(presets).forEach(([name, preset]) =>
motionTuning.action({
id: `imagePreview.preset.${name}`,
label: `Preset: ${name}`,
group,
run: () => {
const knobs = motionTuning.list()
Object.entries(preset.springs).forEach(([id, value]) => {
const knob = knobs.find((item) => item.id === id)
if (knob?.kind === "spring") motionTuning.set(knob, value)
})
Object.entries(preset.numbers).forEach(([id, value]) => {
const knob = knobs.find((item) => item.id === id)
if (knob?.kind === "number") motionTuning.set(knob, value)
})
},
}),
)
const fade = () => ({ duration: Math.max(0.03, tuning.fade()), 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.close().duration, tuning.scrimClose().duration, tuning.fade()) * 1800
const CENTER = { x: 0, y: 0 }
export function ImagePreview(props: ImagePreviewProps) {
const i18n = useI18n()
const dialog = useDialogContext()
let scrim!: HTMLDivElement
let content!: HTMLDivElement
let viewport!: HTMLDivElement
let image!: HTMLImageElement
let standIn!: HTMLImageElement
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.
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
const dim = Spring.fromDuration(0, claude.springs.imageScrimOpen)
// 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.
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 }>()
@@ -52,7 +170,7 @@ export function ImagePreview(props: ImagePreviewProps) {
animation?.stop()
goal = target
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return setView(target)
if (prefersReducedMotion()) return setView(target)
const from = view()
animation = animate(0, 1, {
type: "spring",
@@ -69,6 +187,177 @@ export function ImagePreview(props: ImagePreviewProps) {
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()
const [exit, setExit] = createSignal(0)
const holdExit = () => {
setExit(exitDuration())
layer?.setExitDuration(exit())
}
holdExit()
const reveal = async () => {
await Promise.all([image.decode(), standIn.decode()]).catch(() => undefined)
if (stage() !== "pending" || !dialog.isOpen()) return
const from = prefersReducedMotion() ? undefined : originFrame()
holdExit()
dimTo(1, from ? tuning.scrimOpen() : fade())
if (!from) {
plain = true
paint()
setStage("rest")
return run()
}
const flight = createFlight(from)
flight.to(restFrame(), tuning.openKick(), tuning.open())
course = { at: place(fitted()), flight, landed: () => setStage("rest") }
hideOrigin()
paint()
setStage("flying")
run()
}
const dismiss = () => {
animation?.stop()
if (stage() === "pending") return
const to = prefersReducedMotion() ? undefined : originFrame()
dimTo(0, to ? tuning.scrimClose() : fade())
if (!to) {
plain = true
return run()
}
// A close mid-open turns the flight around where it is, keeping its velocity.
course ??= { at: place(fitted()), flight: createFlight(restFrame()), landed: showOrigin }
course.flight.to(to, tuning.closeKick(), tuning.close())
course.landed = showOrigin
hideOrigin()
paint()
setStage("flying")
run()
}
const dimTo = (target: number, spring: DurationOptions) => {
dim.omega = (2 * Math.PI) / spring.duration
dim.zeta = 1 - spring.bounce
dim.set(target)
}
// One job on the shared frame loop steps the flight and the backdrop together and writes them straight to the DOM.
const run = () => {
stop ??= frameLoop.add((dt) => {
const flying = course?.flight.step(dt)
dim.step(dt)
paint()
// The real image takes over only once the panel holding it is fully opaque, so its brightness doesn't step.
if (course && !flying && dim.resting) {
const landed = course.landed
course = undefined
landed()
}
if (course || !dim.resting) return true
stop = undefined
return false
})
}
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)
if (!course) return
const style = flightStyle(course.at, course.flight.frame())
standIn.style.transform = style.transform
standIn.style.clipPath = style.clipPath
}
const place = (box: Box) => {
Object.assign(standIn.style, {
left: `${box.x}px`,
top: `${box.y}px`,
width: `${box.width}px`,
height: `${box.height}px`,
})
return box
}
// The image as shown now: zoomed and panned, and cropped to the viewport.
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
return {
image: shown,
clip,
radius: cropped ? 0 : parseFloat(getComputedStyle(image).borderTopLeftRadius) * view().scale,
}
}
// The origin's frame, or nothing when it is gone or scrolled out of sight.
const originFrame = () => {
const origin = props.origin
if (!origin?.isConnected || !image.naturalWidth) return
const rect = origin.getBoundingClientRect()
if (!rect.width || !rect.height) return
if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) return
const style = getComputedStyle(origin)
return elementFrame(
rect,
{ width: image.naturalWidth, height: image.naturalHeight },
style.objectFit,
parseFloat(style.borderTopLeftRadius) || 0,
)
}
// The image's fitted layout box, wherever zoom has moved it.
const fitted = (): Box => {
const shown = image.getBoundingClientRect()
return {
x: shown.x + shown.width / 2 - view().x - image.offsetWidth / 2,
y: shown.y + shown.height / 2 - view().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")
onCleanup(() => {
stop?.()
showOrigin()
})
createEffect(
on(
() => dialog.isOpen(),
(open) => {
if (!open) dismiss()
},
{ defer: true },
),
)
const point = (event: MouseEvent) => {
const rect = viewport.getBoundingClientRect()
@@ -148,9 +437,11 @@ export function ImagePreview(props: ImagePreviewProps) {
)
return (
<div data-component="image-preview">
<div 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
ref={content}
data-slot="image-preview-content"
onOpenAutoFocus={(event) => {
// Open with focus on Close, as before, rather than on the zoom controls that now precede it.
@@ -229,16 +520,106 @@ export function ImagePreview(props: ImagePreviewProps) {
alt={props.alt ?? i18n.t("ui.imagePreview.alt")}
data-slot="image-preview-image"
draggable={false}
onLoad={measure}
onLoad={() => {
measure()
void reveal()
}}
onError={() => void reveal()}
style={{ transform: `translate(${view().x}px, ${view().y}px) scale(${view().scale})` }}
/>
</div>
</Kobalte.Content>
</div>
<img data-slot="image-preview-flight" src={props.src} alt="" aria-hidden="true" ref={standIn} />
</div>
)
}
// Dialogs of the mounted surfaces that can show a preview; the Motion workspace's buttons use the newest.
const replayers: ReturnType<typeof useDialog>[] = []
let removeReplay: (() => void) | undefined
/**
* Adds buttons to the Motion workspace's Image open section while any caller is mounted: they open the newest image in
* view, or a sample thumbnail when there is none, and close the preview, also mid-flight.
*/
export function replayImagePreview() {
const dialog = useDialog()
replayers.push(dialog)
removeReplay ??= addReplayActions()
onCleanup(() => {
replayers.splice(replayers.indexOf(dialog), 1)
if (replayers.length > 0) return
removeReplay?.()
removeReplay = undefined
})
}
function addReplayActions() {
const open = () => {
const image = [
...document.querySelectorAll<HTMLElement>(
'[data-slot="user-message-attachment"][data-type="image"], img[aria-haspopup="dialog"]',
),
]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.bottom > 0 && rect.top < window.innerHeight
})
.at(-1)
if (image) return image.click()
const sample = document.createElement("img")
sample.src = SAMPLE
sample.alt = "Sample image"
sample.style.cssText =
"position: fixed; left: 24px; bottom: 24px; z-index: 40; width: 160px; height: 100px; object-fit: cover; border-radius: 8px;"
document.body.append(sample)
replayers.at(-1)?.show(
() => <ImagePreview src={SAMPLE} alt={sample.alt} origin={sample} />,
() => setTimeout(() => sample.remove(), exitDuration() + 100),
)
}
const close = () => {
if (document.querySelector('[data-component="image-preview"]')) replayers.at(-1)?.close()
}
const removals = [
motionTuning.action({ id: "imagePreview.open", label: "Open", group, run: open }),
motionTuning.action({ id: "imagePreview.close", label: "Close", group, run: close }),
motionTuning.action({
id: "imagePreview.reverse",
label: "Open, close mid-flight",
group,
run: () => {
open()
const start = performance.now()
// The flight starts once the image has decoded; close a little way into it.
const wait = () => {
if (document.querySelector('[data-component="image-preview"][data-stage="flying"]'))
return void setTimeout(close, tuning.open().duration * 350)
if (performance.now() - start < 2000) requestAnimationFrame(wait)
}
requestAnimationFrame(wait)
},
}),
]
return () => removals.forEach((remove) => remove())
}
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>',
)}`
function shortcut(key: string) {
if (key === "=" || key === "+") return "in"
+13 -4
View File
@@ -27,6 +27,8 @@ type Active = {
owner: Owner
onClose?: () => void
setClosing: (closing: boolean) => void
/** How long the dialog stays mounted once it starts closing, so its exit animation can finish. */
exit: () => number
/** Where focus was before this dialog opened, or before the dialogs it replaced opened. */
focus?: FocusReturn
returnFocus: () => void
@@ -34,8 +36,11 @@ type Active = {
const Context = createContext<ReturnType<typeof init>>()
// Lets the dialog rendered in a layer opt out of closing on a backdrop click.
const LayerContext = createContext<{ setBackdropDismiss: (value: boolean) => void }>()
// Lets the dialog rendered in a layer opt out of closing on a backdrop click, or ask for a longer exit animation.
const LayerContext = createContext<{
setBackdropDismiss: (value: boolean) => void
setExitDuration: (ms: number) => void
}>()
export function useDialogLayer() {
return useContext(LayerContext)
@@ -84,7 +89,7 @@ function init() {
setExiting((ids) => new Set([...ids].filter((id) => id !== current.id)))
setStack((items) => items.filter((item) => item.id !== current.id))
current.dispose()
}, 100),
}, current.exit()),
)
setExiting((ids) => new Set([...ids, current.id]))
isolate(() => current.onClose?.())
@@ -153,6 +158,7 @@ function init() {
let dispose: (() => void) | undefined
let setClosing: ((closing: boolean) => void) | undefined
let layerRef: HTMLDivElement | undefined
let exit = 100
// Stacked dialogs render as sibling portals, so only the top layer may own the focus trap.
const node = runWithOwner(owner, () =>
@@ -191,7 +197,9 @@ function init() {
"pointer-events": "none",
}}
>
<LayerContext.Provider value={{ setBackdropDismiss }}>{element()}</LayerContext.Provider>
<LayerContext.Provider value={{ setBackdropDismiss, setExitDuration: (ms) => (exit = ms) }}>
{element()}
</LayerContext.Provider>
</div>
</Kobalte.Portal>
</Kobalte>
@@ -208,6 +216,7 @@ function init() {
owner,
onClose,
setClosing,
exit: () => exit,
focus,
returnFocus: () => {
// A close inside a reactive update unmounts the dialog, and its focus trap, only when the update ends.
+1 -1
View File
@@ -37,7 +37,7 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
if (item.kind === "toggle") return toggle(item.initial, meta)
return knob(item.initial, { ...meta, min: item.min, max: item.max, step: item.step, unit: "×" })
return knob(item.initial, { ...meta, min: item.min, max: item.max, step: item.step, unit: item.unit ?? "×" })
}
const apply = (item: MotionKnob, value: SpringValue | boolean | number) => {
+35 -1
View File
@@ -16,7 +16,7 @@ type Knob<Kind extends string, Value> = {
export type MotionKnob =
| Knob<"spring", DurationOptions>
| Knob<"toggle", boolean>
| (Knob<"number", number> & { min: number; max: number; step: number })
| (Knob<"number", number> & { min: number; max: number; step: number; unit?: string })
export type MotionAction = { id: string; label: string; group: string; run: () => void }
@@ -64,6 +64,35 @@ export const motionTuning = {
return () => knob.value
},
/**
* A tuned number. A `time` of "duration" is seconds, stretched by the global speed and slow motion; "rate" is per
* second, so it shrinks by the same factor.
*/
number(
id: string,
meta: Meta & { value: number; min: number; max: number; step: number; unit?: string; time?: "duration" | "rate" },
) {
const knob = register({
kind: "number",
id,
label: meta.label,
group: meta.group,
initial: meta.value,
value: number(id, meta.value),
min: meta.min,
max: meta.max,
step: meta.step,
unit: meta.unit,
})
return () => {
if (meta.time === "duration") return knob.value * timeScale()
if (meta.time === "rate") return knob.value / timeScale()
return knob.value
}
},
/** A button that replays the motion it names. Returns its removal, for actions bound to a mounted surface. */
action(action: MotionAction) {
actions.set(action.id, action)
@@ -115,6 +144,11 @@ const slow = register({
export const motionGlobals = { speed, slow }
// How many times longer motion takes than tuned.
function timeScale() {
return (slow.value ? 4 : 1) / speed.value
}
function register<K extends MotionKnob>(knob: K) {
knobs.set(knob.id, knob)
notify()