mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(ui): open images from their place in the message (#56896)
This commit is contained in:
committed by
GitHub
Unverified
parent
ac75a36daf
commit
834dfef54f
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user