feat(ui): zoom and pan in the image preview (#56685)

This commit is contained in:
Kit Langton
2026-10-17 22:22:50 -07:00
committed by GitHub
Unverified
parent e1aad7db4b
commit df97727380
7 changed files with 608 additions and 5 deletions
+26 -1
View File
@@ -37,19 +37,42 @@
[data-slot="image-preview-header"] {
display: flex;
gap: 8px;
padding: 8px 8px 0;
justify-content: flex-end;
align-items: center;
align-self: stretch;
}
[data-slot="image-preview-zoom"] {
display: flex;
align-items: center;
gap: 2px;
}
[data-slot="image-preview-zoom-level"] {
min-width: 52px;
justify-content: center;
font-variant-numeric: tabular-nums;
}
[data-slot="image-preview-body"] {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow: auto;
overflow: hidden;
user-select: none;
&[data-zoomed] {
cursor: grab;
touch-action: pinch-zoom;
}
&[data-dragging] {
cursor: grabbing;
}
}
[data-slot="image-preview-image"] {
@@ -57,6 +80,8 @@
max-height: calc(90vh - 100px);
object-fit: contain;
border-radius: var(--radius-md);
transform-origin: center;
-webkit-user-drag: none;
}
}
}
@@ -14,10 +14,12 @@ Use for full-size image inspection; keep images optimized.
- Optional: \`alt\` text.
### Variants and states
- Single layout with close action.
- Single layout with zoom controls and a close action.
### Behavior
- Intended to be used via \`useDialog().show\`.
- 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.
### Accessibility
- Uses localized aria-label for close button.
@@ -57,3 +59,194 @@ export const Basic = {
)
},
}
// A large, offline, screenshot-style bitmap with small text that is only legible once zoomed in.
export const Screenshot = {
render: () => {
const dialog = useDialog()
const src = screenshot()
const open = () => dialog.show(() => <mod.ImagePreview src={src} alt="Editor screenshot" />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open screenshot preview
</Button>
)
},
}
function screenshot() {
const canvas = document.createElement("canvas")
canvas.width = 2880
canvas.height = 1800
const ctx = canvas.getContext("2d")
ctx.scale(2, 2)
ctx.fillStyle = "#1d1e22"
ctx.fillRect(0, 0, 1440, 900)
ctx.fillStyle = "#2a2b31"
ctx.fillRect(0, 0, 1440, 38)
;["#ff5f57", "#febc2e", "#28c840"].forEach((color, index) => {
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(20 + index * 20, 19, 6, 0, Math.PI * 2)
ctx.fill()
})
ctx.fillStyle = "#a9abb3"
ctx.font = "13px -apple-system, system-ui, sans-serif"
ctx.textAlign = "center"
ctx.fillText("orchard.ts — lumen-garden", 720, 24)
ctx.textAlign = "left"
ctx.fillStyle = "#23242a"
ctx.fillRect(0, 38, 240, 836)
ctx.fillStyle = "#7d808a"
ctx.font = "600 10px -apple-system, system-ui, sans-serif"
ctx.fillText("EXPLORER", 16, 64)
;[
"src",
" orchard.ts",
" greenhouse.ts",
" sprinkler.ts",
" seeds",
" tomato.json",
" basil.json",
" marigold.json",
"test",
" orchard.test.ts",
"README.md",
"package.json",
].forEach((name, index) => {
if (index === 1) {
ctx.fillStyle = "#33353d"
ctx.fillRect(0, 76 + index * 24, 240, 24)
}
ctx.fillStyle = index === 1 ? "#e6e7eb" : "#a9abb3"
ctx.font = "12px -apple-system, system-ui, sans-serif"
ctx.fillText(name.trimStart(), 20 + (name.length - name.trimStart().length) * 6, 92 + index * 24)
})
const code = [
[
["#c792ea", "import "],
["#e6e7eb", "{ Sprinkler } "],
["#c792ea", "from "],
["#c3e88d", '"./sprinkler"'],
],
[
["#c792ea", "import "],
["#e6e7eb", "{ Greenhouse } "],
["#c792ea", "from "],
["#c3e88d", '"./greenhouse"'],
],
[],
[["#5c6370", "// Waters each bed once the soil sensor drops below its threshold."]],
[
["#c792ea", "export function "],
["#82aaff", "tend"],
["#e6e7eb", "(garden: Greenhouse, now: Date) {"],
],
[
["#c792ea", " const "],
["#e6e7eb", "dry = garden.beds."],
["#82aaff", "filter"],
["#e6e7eb", "((bed) => bed.moisture < bed.threshold)"],
],
[
["#c792ea", " if "],
["#e6e7eb", "(dry.length === "],
["#f78c6c", "0"],
["#e6e7eb", ") "],
["#c792ea", "return"],
],
[],
[
["#c792ea", " for "],
["#e6e7eb", "("],
["#c792ea", "const "],
["#e6e7eb", "bed "],
["#c792ea", "of "],
["#e6e7eb", "dry) {"],
],
[
["#c792ea", " const "],
["#e6e7eb", "minutes = Math."],
["#82aaff", "ceil"],
["#e6e7eb", "((bed.threshold - bed.moisture) * "],
["#f78c6c", "4"],
["#e6e7eb", ")"],
],
[
["#e6e7eb", " Sprinkler."],
["#82aaff", "open"],
["#e6e7eb", "(bed.valve, { minutes, startedAt: now })"],
],
[
["#e6e7eb", " garden.log."],
["#82aaff", "push"],
["#e6e7eb", "(`watered ${bed.name} for ${minutes}m`)"],
],
[["#e6e7eb", " }"]],
[],
[
["#c792ea", " return "],
["#e6e7eb", "dry."],
["#82aaff", "map"],
["#e6e7eb", "((bed) => bed.name)"],
],
[["#e6e7eb", "}"]],
]
ctx.font = "13px Menlo, ui-monospace, monospace"
code.forEach((line, index) => {
const y = 76 + index * 22
ctx.fillStyle = "#4b4e57"
ctx.textAlign = "right"
ctx.fillText(String(index + 1), 296, y)
ctx.textAlign = "left"
line.reduce((x, [color, text]) => {
ctx.fillStyle = color
ctx.fillText(text, x, y)
return x + ctx.measureText(text).width
}, 320)
})
ctx.fillStyle = "#26272d"
ctx.fillRect(1080, 38, 360, 836)
ctx.fillStyle = "#e6e7eb"
ctx.font = "600 12px -apple-system, system-ui, sans-serif"
ctx.fillText("Soil moisture", 1100, 70)
;[0.62, 0.31, 0.48, 0.27, 0.71, 0.55].forEach((value, index) => {
const y = 92 + index * 34
ctx.fillStyle = "#a9abb3"
ctx.font = "11px -apple-system, system-ui, sans-serif"
ctx.fillText(`Bed ${String.fromCharCode(65 + index)}`, 1100, y + 12)
ctx.fillStyle = "#33353d"
ctx.fillRect(1160, y + 3, 220, 10)
ctx.fillStyle = value < 0.35 ? "#f78c6c" : "#7fd88f"
ctx.fillRect(1160, y + 3, 220 * value, 10)
ctx.fillStyle = "#7d808a"
ctx.font = "8px -apple-system, system-ui, sans-serif"
ctx.fillText(`${Math.round(value * 100)}% · threshold 35%`, 1386 - 64, y + 26)
})
ctx.fillStyle = "#7d808a"
ctx.font = "7px -apple-system, system-ui, sans-serif"
;[
"Sensor readings refresh every 90 seconds.",
"Beds below their threshold are watered on the next tick.",
"Valve 4 was serviced on 12 March; calibration offset +0.02.",
].forEach((text, index) => ctx.fillText(text, 1100, 330 + index * 11))
ctx.fillStyle = "#3b5bdb"
ctx.fillRect(0, 874, 1440, 26)
ctx.fillStyle = "#ffffff"
ctx.font = "11px -apple-system, system-ui, sans-serif"
ctx.fillText("main · 0 problems · Ln 6, Col 42 · UTF-8 · TypeScript", 12, 891)
return canvas.toDataURL("image/png")
}
+225 -3
View File
@@ -1,22 +1,192 @@
import { Dialog as Kobalte } 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 { useI18n } from "../context/i18n"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { clampView, detailScale, FIT_VIEW, maxScale, panBy, wheelZoomFactor, zoomAt, type ZoomView } from "./image-zoom"
export interface ImagePreviewProps {
src: string
alt?: string
}
const STEP = Math.SQRT2
const CENTER = { x: 0, y: 0 }
export function ImagePreview(props: ImagePreviewProps) {
const i18n = useI18n()
let viewport!: HTMLDivElement
let image!: HTMLImageElement
let close: HTMLButtonElement | undefined
const [view, setView] = createSignal(FIT_VIEW)
const [fitRatio, setFitRatio] = createSignal(1)
const [drag, setDrag] = createSignal<{ id: number; x: number; y: number }>()
// Where an eased zoom is heading, so repeated steps build on the destination rather than the frame in flight.
let goal = FIT_VIEW
let animation: AnimationPlaybackControls | undefined
const percent = createMemo(() => new Intl.NumberFormat(i18n.locale(), { style: "percent", maximumFractionDigits: 0 }))
const bounds = () => ({
width: image.offsetWidth,
height: image.offsetHeight,
viewportWidth: viewport.clientWidth,
viewportHeight: viewport.clientHeight,
maxScale: maxScale(fitRatio()),
})
// Pinch, wheel and drag follow the hand directly and take over from an eased zoom mid-flight.
const follow = (next: ZoomView) => {
animation?.stop()
animation = undefined
goal = next
setView(next)
}
const ease = (target: ZoomView) => {
animation?.stop()
goal = target
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return setView(target)
const from = view()
animation = animate(0, 1, {
type: "spring",
visualDuration: 0.25,
bounce: 0,
onUpdate: (progress) =>
setView({
scale: from.scale + (target.scale - from.scale) * progress,
x: from.x + (target.x - from.x) * progress,
y: from.y + (target.y - from.y) * progress,
}),
})
}
const step = (factor: number) => ease(zoomAt(goal, goal.scale * factor, CENTER, bounds()))
const point = (event: MouseEvent) => {
const rect = viewport.getBoundingClientRect()
return { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 }
}
const measure = () => {
if (!image.naturalWidth) return
setFitRatio(image.offsetWidth / image.naturalWidth)
follow(clampView(view(), bounds()))
}
createResizeObserver(() => [viewport, image], measure)
createEffect(
on(
() => props.src,
() => follow(FIT_VIEW),
{ defer: true },
),
)
onCleanup(() => animation?.stop())
// The preview owns zoom gestures while it is open: the page behind must neither scroll nor zoom the whole window.
makeEventListener(
window,
"wheel",
(event: WheelEvent) => {
const inside = event.target instanceof Node && viewport.contains(event.target)
const zoom = event.ctrlKey || event.metaKey
if (!inside && !zoom) return
event.preventDefault()
event.stopPropagation()
if (!inside) return
const current = view()
if (zoom) {
follow(zoomAt(current, current.scale * wheelZoomFactor(pixels(event, event.deltaY)), point(event), bounds()))
return
}
const horizontal = event.shiftKey && event.deltaX === 0
follow(
panBy(
current,
{
x: -pixels(event, horizontal ? event.deltaY : event.deltaX),
y: horizontal ? 0 : -pixels(event, event.deltaY),
},
bounds(),
),
)
},
{ passive: false, capture: true },
)
// Captured ahead of the desktop shell, which zooms the whole window on the same shortcuts.
makeEventListener(
window,
"keydown",
(event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey) return
const action = shortcut(event.key)
if (!action) return
event.preventDefault()
event.stopPropagation()
if (action === "fit") return ease(FIT_VIEW)
step(action === "in" ? STEP : 1 / STEP)
},
{ capture: true },
)
return (
<div data-component="image-preview">
<div data-slot="image-preview-container">
<Kobalte.Content data-slot="image-preview-content">
<Kobalte.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.
event.preventDefault()
close?.focus()
}}
>
<div data-slot="image-preview-header">
<div data-slot="image-preview-zoom">
<IconButton
data-slot="image-preview-zoom-out"
icon={<Icon name="minus" />}
variant="ghost"
aria-label={i18n.t("ui.imagePreview.zoomOut")}
onClick={() => step(1 / STEP)}
/>
<Button
data-slot="image-preview-zoom-level"
variant="ghost-muted"
size="small"
aria-label={i18n.t("ui.imagePreview.zoomToFit")}
title={i18n.t("ui.imagePreview.zoomToFit")}
onClick={() => ease(FIT_VIEW)}
>
{percent().format(view().scale * fitRatio())}
</Button>
<IconButton
data-slot="image-preview-zoom-in"
icon={<Icon name="plus" />}
variant="ghost"
aria-label={i18n.t("ui.imagePreview.zoomIn")}
onClick={() => step(STEP)}
/>
</div>
<Kobalte.CloseButton
ref={close}
data-slot="image-preview-close"
as={IconButton}
icon={<Icon name="close" />}
@@ -24,11 +194,63 @@ export function ImagePreview(props: ImagePreviewProps) {
aria-label={i18n.t("ui.common.close")}
/>
</div>
<div data-slot="image-preview-body">
<img src={props.src} alt={props.alt ?? i18n.t("ui.imagePreview.alt")} data-slot="image-preview-image" />
<div
ref={viewport}
data-slot="image-preview-body"
data-zoomed={view().scale > 1 ? "" : undefined}
data-dragging={drag() ? "" : undefined}
onDblClick={(event) => {
if (goal.scale > 1) return ease(FIT_VIEW)
ease(zoomAt(goal, detailScale(fitRatio()), point(event), bounds()))
}}
onPointerDown={(event) => {
if (event.button !== 0 || view().scale <= 1) return
event.preventDefault()
viewport.setPointerCapture(event.pointerId)
setDrag({ id: event.pointerId, x: event.clientX, y: event.clientY })
}}
onPointerMove={(event) => {
const current = drag()
if (current?.id !== event.pointerId) return
setDrag({ id: current.id, x: event.clientX, y: event.clientY })
follow(panBy(view(), { x: event.clientX - current.x, y: event.clientY - current.y }, bounds()))
}}
onPointerUp={(event) => {
if (drag()?.id === event.pointerId) setDrag(undefined)
}}
onPointerCancel={(event) => {
if (drag()?.id === event.pointerId) setDrag(undefined)
}}
>
<img
ref={image}
src={props.src}
alt={props.alt ?? i18n.t("ui.imagePreview.alt")}
data-slot="image-preview-image"
draggable={false}
onLoad={measure}
style={{ transform: `translate(${view().x}px, ${view().y}px) scale(${view().scale})` }}
/>
</div>
</Kobalte.Content>
</div>
</div>
)
}
function shortcut(key: string) {
if (key === "=" || key === "+") return "in"
if (key === "-" || key === "_") return "out"
if (key === "0") return "fit"
}
function pixels(event: WheelEvent, delta: number) {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * 16
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * window.innerHeight
return delta
}
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import { clampView, detailScale, FIT_VIEW, maxScale, panBy, wheelZoomFactor, zoomAt } from "./image-zoom"
// A 1000×500 image fitted into a 1000×600 viewport at half its natural size.
const bounds = { width: 1000, height: 500, viewportWidth: 1000, viewportHeight: 600, maxScale: maxScale(0.5) }
describe("zoomAt", () => {
test("keeps the image point under the cursor in place", () => {
const anchor = { x: 300, y: -100 }
const view = zoomAt(FIT_VIEW, 2, anchor, bounds)
// The fitted point under the anchor sits at (anchor - offset) / scale in image space.
const before = { x: (anchor.x - FIT_VIEW.x) / FIT_VIEW.scale, y: (anchor.y - FIT_VIEW.y) / FIT_VIEW.scale }
expect(view).toEqual({ scale: 2, x: -300, y: 100 })
expect({ x: before.x * view.scale + view.x, y: before.y * view.scale + view.y }).toEqual(anchor)
})
test("zooms about the center without moving it", () => {
expect(zoomAt({ scale: 2, x: 120, y: -40 }, 4, { x: 0, y: 0 }, bounds)).toEqual({ scale: 4, x: 240, y: -80 })
})
test("never shrinks below fit or grows past the limit", () => {
expect(zoomAt({ scale: 3, x: 400, y: 200 }, 0.2, { x: 100, y: 100 }, bounds)).toEqual(FIT_VIEW)
expect(zoomAt(FIT_VIEW, 100, { x: 0, y: 0 }, bounds).scale).toBe(16)
})
test("pulls an anchor near the edge back so no empty space shows", () => {
expect(zoomAt(FIT_VIEW, 2, { x: 500, y: 0 }, bounds)).toEqual({ scale: 2, x: -500, y: 0 })
expect(zoomAt(FIT_VIEW, 1.1, { x: 500, y: 300 }, bounds)).toEqual({ scale: 1.1, x: -50, y: 0 })
})
})
describe("panBy", () => {
test("moves freely inside the overflow", () => {
expect(panBy({ scale: 2, x: 0, y: 0 }, { x: 120, y: -80 }, bounds)).toEqual({ scale: 2, x: 120, y: -80 })
})
test("stops at each edge of the zoomed image", () => {
// At 2x the image is 2000×1000, so it can travel 500px horizontally and 200px vertically each way.
expect(panBy({ scale: 2, x: 450, y: 150 }, { x: 900, y: 900 }, bounds)).toEqual({ scale: 2, x: 500, y: 200 })
expect(panBy({ scale: 2, x: 0, y: 0 }, { x: -900, y: -900 }, bounds)).toEqual({ scale: 2, x: -500, y: -200 })
})
test("keeps an axis that still fits centered", () => {
expect(panBy(FIT_VIEW, { x: 40, y: 40 }, bounds)).toEqual(FIT_VIEW)
expect(panBy({ scale: 1.1, x: 0, y: 0 }, { x: 0, y: 40 }, bounds)).toEqual({ scale: 1.1, x: 0, y: 0 })
})
})
describe("clampView", () => {
test("re-centers a pan that a smaller zoom or larger viewport no longer allows", () => {
expect(clampView({ scale: 2, x: 500, y: 200 }, { ...bounds, viewportWidth: 1600, viewportHeight: 1200 })).toEqual({
scale: 2,
x: 200,
y: 0,
})
})
})
describe("detailScale", () => {
test("opens a downscaled image at 100%", () => {
expect(detailScale(0.25)).toBe(4)
})
test("doubles an image already shown near its natural size", () => {
expect(detailScale(1)).toBe(2)
expect(detailScale(0.8)).toBe(2)
})
})
describe("wheelZoomFactor", () => {
test("zooms in for upward deltas and out for downward ones by the same ratio", () => {
expect(wheelZoomFactor(-10)).toBeGreaterThan(1)
expect(wheelZoomFactor(-10) * wheelZoomFactor(10)).toBeCloseTo(1)
})
test("bounds a coarse mouse-wheel notch", () => {
expect(wheelZoomFactor(-400)).toBe(wheelZoomFactor(-100))
expect(wheelZoomFactor(-400)).toBeLessThan(1.5)
})
})
+75
View File
@@ -0,0 +1,75 @@
/**
* Zoom and pan math for a fitted image inside a clipping viewport.
*
* `scale` is relative to the fitted size, so 1 is "fit". `x` and `y` offset the image center from the viewport
* center in screen pixels, and anchors use the same viewport-center coordinates.
*/
export type ZoomView = { scale: number; x: number; y: number }
export type ZoomBounds = {
/** The image's fitted size, before any zoom. */
width: number
height: number
viewportWidth: number
viewportHeight: number
maxScale: number
}
export type ZoomPoint = { x: number; y: number }
/** Upper zoom limit, relative to the image's natural pixel size. */
export const MAX_NATURAL_ZOOM = 8
export const FIT_VIEW: ZoomView = { scale: 1, x: 0, y: 0 }
/** The largest fitted-relative scale, given how far the fitted image is shrunk from its natural size. */
export function maxScale(fitRatio: number) {
return Math.max(1, MAX_NATURAL_ZOOM / fitRatio)
}
/** Scales the view while the image point under `anchor` stays where it is on screen. */
export function zoomAt(view: ZoomView, scale: number, anchor: ZoomPoint, bounds: ZoomBounds): ZoomView {
const next = clamp(scale, 1, bounds.maxScale)
const ratio = next / view.scale
return clampView(
{ scale: next, x: anchor.x - (anchor.x - view.x) * ratio, y: anchor.y - (anchor.y - view.y) * ratio },
bounds,
)
}
export function panBy(view: ZoomView, delta: ZoomPoint, bounds: ZoomBounds): ZoomView {
return clampView({ scale: view.scale, x: view.x + delta.x, y: view.y + delta.y }, bounds)
}
/** Keeps the zoomed image covering the viewport on each axis it overflows, and centered on each axis it fits. */
export function clampView(view: ZoomView, bounds: ZoomBounds): ZoomView {
return {
scale: view.scale,
x: clampAxis(view.x, bounds.width * view.scale, bounds.viewportWidth),
y: clampAxis(view.y, bounds.height * view.scale, bounds.viewportHeight),
}
}
/** The double-click target: 100% for a downscaled image, or twice the fitted size when 100% is barely larger. */
export function detailScale(fitRatio: number) {
const actual = 1 / fitRatio
return actual >= 1.5 ? actual : 2
}
/** The zoom factor for one wheel event, bounded so a coarse mouse-wheel notch is a step rather than a jump. */
export function wheelZoomFactor(deltaY: number) {
return Math.exp(-clamp(deltaY, -30, 30) / 100)
}
function clampAxis(offset: number, size: number, viewport: number) {
const limit = Math.max(0, (size - viewport) / 2)
// Adding zero turns -0 into 0.
return clamp(offset, -limit, limit) + 0
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
+3
View File
@@ -178,6 +178,9 @@ const source = {
"ui.textField.copied": "Copied",
"ui.imagePreview.alt": "Image preview",
"ui.imagePreview.zoomIn": "Zoom in",
"ui.imagePreview.zoomOut": "Zoom out",
"ui.imagePreview.zoomToFit": "Zoom to fit",
"ui.scrollView.ariaLabel": "scrollable content",
"ui.tool.read": "Read",
+4
View File
@@ -78,6 +78,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M2 8H14M2 4.664H14M2 11.336H14" stroke="currentColor"/>`,
},
minus: {
viewBox: "0 0 16 16",
body: `<path d="M2.88867 8H13.1109" stroke="currentColor" stroke-linejoin="round"/>`,
},
plus: {
viewBox: "0 0 16 16",
body: `<path d="M8 2.88867V13.1109" stroke="currentColor" stroke-linejoin="round"/><path d="M2.88867 8H13.1109" stroke="currentColor" stroke-linejoin="round"/>`,