diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index defbf666d0e..7e688b0674c 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -584,7 +584,7 @@ export function ComposerAttachments(props: { files?: Extract[] 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)} />
diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index bde7d860d51..0ca62831a05 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -63,7 +63,7 @@ export function createComposerEditor(input: { commands: Accessor context: Accessor searchContextFiles: (query: string) => ComposerSuggestion[] | Promise - 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) diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 7b1a3c87595..75fbe7c312d 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -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) { diff --git a/packages/session-ui/src/components/image-preview.tsx b/packages/session-ui/src/components/image-preview.tsx index 0d5c08aba32..3071ed6bc57 100644 --- a/packages/session-ui/src/components/image-preview.tsx +++ b/packages/session-ui/src/components/image-preview.tsx @@ -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(() => ) + dialog.show(() => ) } return (root: HTMLElement) => { diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 8b06a8e3917..04766c75404 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -356,7 +356,10 @@ export function CurrentUserMessageDisplay(props: { data-slot="user-message-attachment" data-type="image" data-clickable="true" - onClick={() => dialog.show(() => )} + onClick={(event) => { + const origin = event.currentTarget + dialog.show(() => ) + }} > {name()}
diff --git a/packages/ui/src/components/image-flight.test.ts b/packages/ui/src/components/image-flight.test.ts new file mode 100644 index 00000000000..464fe6df4fb --- /dev/null +++ b/packages/ui/src/components/image-flight.test.ts @@ -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) => + (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) + }) +}) diff --git a/packages/ui/src/components/image-flight.ts b/packages/ui/src/components/image-flight.ts new file mode 100644 index 00000000000..6ca2de2cc1b --- /dev/null +++ b/packages/ui/src/components/image-flight.ts @@ -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` +} diff --git a/packages/ui/src/components/image-preview.css b/packages/ui/src/components/image-preview.css index afea4e84696..371f048210d 100644 --- a/packages/ui/src/components/image-preview.css +++ b/packages/ui/src/components/image-preview.css @@ -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; + } +} diff --git a/packages/ui/src/components/image-preview.stories.tsx b/packages/ui/src/components/image-preview.stories.tsx index 60abe66ce49..40ee8661214 100644 --- a/packages/ui/src/components/image-preview.stories.tsx +++ b/packages/ui/src/components/image-preview.stories.tsx @@ -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(() => ) + } + + return ( +
+
+ Poster thumbnail +
+

Here is the poster for the garden open day, with the new colours.

+ Garden open day poster +

The hills use the brand greens, and the date sits on the sun.

+
+ ) + }, +} + +// 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 diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 07cb3629788..550fee33abb 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -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; 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 ( -
+
+
{ // 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})` }} />
+
) } +// Dialogs of the mounted surfaces that can show a preview; the Motion workspace's buttons use the newest. +const replayers: ReturnType[] = [] + +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( + '[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( + () => , + () => 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( + '', +)}` + function shortcut(key: string) { if (key === "=" || key === "+") return "in" diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index b6fcfc34c45..612ee89e973 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -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>() -// 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", }} > - {element()} + (exit = ms) }}> + {element()} +
@@ -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. diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 64b7bdcd895..600a5c3740c 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -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) => { diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index 8e44320cbf5..e105b19e821 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -16,7 +16,7 @@ type Knob = { 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(knob: K) { knobs.set(knob.id, knob) notify()