diff --git a/packages/ui/src/components/image-preview.css b/packages/ui/src/components/image-preview.css
index 3c47f7a2584..afea4e84696 100644
--- a/packages/ui/src/components/image-preview.css
+++ b/packages/ui/src/components/image-preview.css
@@ -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;
}
}
}
diff --git a/packages/ui/src/components/image-preview.stories.tsx b/packages/ui/src/components/image-preview.stories.tsx
index ea86c655cdd..60abe66ce49 100644
--- a/packages/ui/src/components/image-preview.stories.tsx
+++ b/packages/ui/src/components/image-preview.stories.tsx
@@ -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(() => )
+
+ onMount(open)
+
+ return (
+
+ Open screenshot preview
+
+ )
+ },
+}
+
+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")
+}
diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx
index 7eda4d0be9a..07cb3629788 100644
--- a/packages/ui/src/components/image-preview.tsx
+++ b/packages/ui/src/components/image-preview.tsx
@@ -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 (
-
+ {
+ // Open with focus on Close, as before, rather than on the zoom controls that now precede it.
+ event.preventDefault()
+ close?.focus()
+ }}
+ >
+
+ }
+ variant="ghost"
+ aria-label={i18n.t("ui.imagePreview.zoomOut")}
+ onClick={() => step(1 / STEP)}
+ />
+ ease(FIT_VIEW)}
+ >
+ {percent().format(view().scale * fitRatio())}
+
+ }
+ variant="ghost"
+ aria-label={i18n.t("ui.imagePreview.zoomIn")}
+ onClick={() => step(STEP)}
+ />
+
}
@@ -24,11 +194,63 @@ export function ImagePreview(props: ImagePreviewProps) {
aria-label={i18n.t("ui.common.close")}
/>
-
-
+
1 ? "" : undefined}
+ data-dragging={drag() ? "" : undefined}
+ onDblClick={(event) => {
+ if (goal.scale > 1) return ease(FIT_VIEW)
+ ease(zoomAt(goal, detailScale(fitRatio()), point(event), bounds()))
+ }}
+ onPointerDown={(event) => {
+ if (event.button !== 0 || view().scale <= 1) return
+ event.preventDefault()
+ viewport.setPointerCapture(event.pointerId)
+ setDrag({ id: event.pointerId, x: event.clientX, y: event.clientY })
+ }}
+ onPointerMove={(event) => {
+ const current = drag()
+
+ if (current?.id !== event.pointerId) return
+ setDrag({ id: current.id, x: event.clientX, y: event.clientY })
+ follow(panBy(view(), { x: event.clientX - current.x, y: event.clientY - current.y }, bounds()))
+ }}
+ onPointerUp={(event) => {
+ if (drag()?.id === event.pointerId) setDrag(undefined)
+ }}
+ onPointerCancel={(event) => {
+ if (drag()?.id === event.pointerId) setDrag(undefined)
+ }}
+ >
+
)
}
+
+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
+}
diff --git a/packages/ui/src/components/image-zoom.test.ts b/packages/ui/src/components/image-zoom.test.ts
new file mode 100644
index 00000000000..f9bbcd488d4
--- /dev/null
+++ b/packages/ui/src/components/image-zoom.test.ts
@@ -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)
+ })
+})
diff --git a/packages/ui/src/components/image-zoom.ts b/packages/ui/src/components/image-zoom.ts
new file mode 100644
index 00000000000..165bfab8917
--- /dev/null
+++ b/packages/ui/src/components/image-zoom.ts
@@ -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)
+}
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index ef90500ee82..d3087d9bbd4 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -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",
diff --git a/packages/ui/src/icons/catalog.ts b/packages/ui/src/icons/catalog.ts
index f40c80b25e5..cac5d3c1bcf 100644
--- a/packages/ui/src/icons/catalog.ts
+++ b/packages/ui/src/icons/catalog.ts
@@ -78,6 +78,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `
`,
},
+ minus: {
+ viewBox: "0 0 16 16",
+ body: `
`,
+ },
plus: {
viewBox: "0 0 16 16",
body: `
`,