feat(viewers): smooth zoom and drag-to-pan for images and Mermaid diagrams (#431)

The image viewer zoomed in fixed steps around the top-left and scrolled to move. It now zooms smoothly around the pointer with Cmd/Ctrl+wheel or pinch, pans with drag or the wheel, and the zoom actions keep the view centred. Mermaid diagrams in the Markdown preview zoom the same way with Cmd/Ctrl+wheel, pan by dragging once zoomed in, and reset on double-click; plain wheel still scrolls the preview.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 19:46:57 +08:00
1 джерело b2c183dbd4
коміт a5b7c154e1
7 files changed
+203 -32

No files matched your search

+90 -20
Переглянути файл
@@ -3,7 +3,15 @@
* ⌘/Ctrl+wheel zoom, transparency chessboard, dimensions and file size.
* Images are read by the backend as `data:` URLs (the CSP allows those).
*/
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type WheelEvent } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import clsx from "clsx";
import { create } from "zustand";
import { call, commands } from "@/platform/ipc";
@@ -11,7 +19,7 @@ import { setContext } from "@/platform/context";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { IconButton, T } from "@/ui";
import { formatSize, isSvgPath } from "./fileKinds";
import { fitZoom, initialZoom, stepZoom, zoomLabel } from "./imageZoom";
import { clampZoom, fitZoom, initialZoom, stepZoom, zoomAt, zoomLabel, type View } from "./imageZoom";
import { useForwardFocus } from "./forwardFocus";
import styles from "./Viewers.module.css";
import { tx } from "@/platform/i18n";
@@ -58,7 +66,9 @@ export function ImageViewer({ input }: { input: EditorInput }) {
const [error, setError] = useState<string | null>(null);
const [size, setSize] = useState<number | null>(null);
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
const [zoom, setZoom] = useState<number | null>(null);
const [view, setView] = useState<View | null>(null);
const [dragging, setDragging] = useState(false);
const drag = useRef<{ id: number; x: number; y: number } | null>(null);
const [reload, setReload] = useState(0);
const chessboard = useImageViewers((s) => s.chessboard);
const rootRef = useRef<HTMLDivElement>(null);
@@ -83,17 +93,32 @@ export function ImageViewer({ input }: { input: EditorInput }) {
return el ? { w: el.clientWidth - PADDING, h: el.clientHeight - PADDING } : { w: 0, h: 0 };
}, []);
const centered = useCallback(
(z: number): View => {
const el = canvasRef.current;
const w = (natural?.w ?? 0) * z;
const h = (natural?.h ?? 0) * z;
return { z, x: ((el?.clientWidth ?? 0) - w) / 2, y: ((el?.clientHeight ?? 0) - h) / 2 };
},
[natural],
);
const handle: ImageViewerHandle = useMemo(
() => ({
zoom: (dir) => setZoom((z) => stepZoom(z ?? 1, dir)),
actualSize: () => setZoom(1),
zoom: (dir) =>
setView((v) => {
const el = canvasRef.current;
if (!v || !el) return v;
return zoomAt(v, stepZoom(v.z, dir), el.clientWidth / 2, el.clientHeight / 2);
}),
actualSize: () => setView(centered(1)),
fit: () => {
if (!natural) return;
const a = available();
setZoom(fitZoom(natural.w, natural.h, a.w, a.h));
setView(centered(fitZoom(natural.w, natural.h, a.w, a.h)));
},
}),
[natural, available],
[natural, available, centered],
);
// The viewer of the active tab is the actions' target as soon as it is shown.
@@ -116,22 +141,52 @@ export function ImageViewer({ input }: { input: EditorInput }) {
const h = img.naturalHeight || 150;
setNatural({ w, h });
const a = available();
setZoom((z) => z ?? initialZoom(w, h, a.w, a.h));
const el = canvasRef.current;
const z = initialZoom(w, h, a.w, a.h);
setView((v) => v ?? { z, x: ((el?.clientWidth ?? 0) - w * z) / 2, y: ((el?.clientHeight ?? 0) - h * z) / 2 });
};
const onWheel = (e: WheelEvent) => {
if (!(e.metaKey || e.ctrlKey) || !e.deltaY) return;
e.preventDefault();
setZoom((z) => stepZoom(z ?? 1, e.deltaY < 0 ? 1 : -1));
// Native listener: React's wheel handler is passive, so preventDefault would be ignored.
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: globalThis.WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
if (e.metaKey || e.ctrlKey) {
const factor = Math.exp(-e.deltaY * (e.deltaMode === 0 ? 0.01 : 0.33));
setView((v) => v && zoomAt(v, clampZoom(v.z * factor), e.clientX - rect.left, e.clientY - rect.top));
} else {
setView((v) => v && { ...v, x: v.x - e.deltaX, y: v.y - e.deltaY });
}
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [error, src]);
const onPointerDown = (e: ReactPointerEvent) => {
if (e.button !== 0 && e.button !== 1) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY };
setDragging(true);
};
const onPointerMove = (e: ReactPointerEvent) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
drag.current = { ...d, x: e.clientX, y: e.clientY };
setView((v) => v && { ...v, x: v.x + dx, y: v.y + dy });
};
const endDrag = (e: ReactPointerEvent) => {
if (drag.current?.id !== e.pointerId) return;
drag.current = null;
setDragging(false);
};
const z = zoom ?? 1;
const z = view?.z ?? 1;
const svg = isSvgPath(path);
const info = [
natural && `${natural.w}×${natural.h}`,
size !== null && formatSize(size),
zoom !== null && zoomLabel(z),
]
const info = [natural && `${natural.w}×${natural.h}`, size !== null && formatSize(size), view && zoomLabel(z)]
.filter(Boolean)
.join(" · ");
@@ -179,14 +234,29 @@ export function ImageViewer({ input }: { input: EditorInput }) {
{error ? (
<div className={styles.message}>{tx`Cannot show the image: ${error}`}</div>
) : (
<div ref={canvasRef} className={styles.canvas} onWheel={onWheel}>
<div
ref={canvasRef}
className={clsx(styles.canvas, dragging && styles.dragging)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{src && (
<img
src={src}
alt={path.slice(path.lastIndexOf("/") + 1)}
draggable={false}
className={clsx(styles.image, chessboard && styles.chessboard, z >= 4 && styles.pixelated)}
style={natural ? { width: natural.w * z, height: natural.h * z } : { visibility: "hidden" }}
style={
natural && view
? {
width: natural.w,
height: natural.h,
transform: `translate(${view.x}px, ${view.y}px) scale(${z})`,
}
: { visibility: "hidden" }
}
onLoad={(e) => onLoad(e.currentTarget)}
onError={() => setError("the file is not a valid image")}
/>
+13 -6
Переглянути файл
@@ -64,14 +64,21 @@
.canvas {
flex: 1;
min-height: 0;
overflow: auto;
display: grid;
/* `safe` keeps the top-left reachable when the image overflows. */
place-items: safe center;
padding: 16px;
position: relative;
overflow: hidden;
cursor: grab;
touch-action: none;
user-select: none;
}
.dragging {
cursor: grabbing;
}
.image {
display: block;
position: absolute;
top: 0;
left: 0;
max-width: none;
transform-origin: 0 0;
image-rendering: auto;
box-shadow: 0 0 0 1px var(--border-strong);
}
+16 -3
Переглянути файл
@@ -4,19 +4,19 @@ export const MIN_ZOOM = 1 / 32;
export const MAX_ZOOM = 64;
const STEP = 1.25;
const clamp = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
export const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
/** Next zoom step; snaps to 100% when crossing it so actual size is always reachable. */
export function stepZoom(zoom: number, dir: 1 | -1): number {
const next = dir > 0 ? zoom * STEP : zoom / STEP;
if ((zoom < 1 && next > 1) || (zoom > 1 && next < 1)) return 1;
return clamp(next);
return clampZoom(next);
}
/** Largest zoom showing the whole image inside `availW × availH`. */
export function fitZoom(width: number, height: number, availW: number, availH: number): number {
if (width <= 0 || height <= 0 || availW <= 0 || availH <= 0) return 1;
return clamp(Math.min(availW / width, availH / height));
return clampZoom(Math.min(availW / width, availH / height));
}
/** Initial zoom: actual size, unless the image does not fit. */
@@ -25,3 +25,16 @@ export function initialZoom(width: number, height: number, availW: number, avail
}
export const zoomLabel = (zoom: number) => `${Math.round(zoom * 100)}%`;
/** Pan/zoom state: image top-left offset (px, relative to the canvas) and scale. */
export interface View {
z: number;
x: number;
y: number;
}
/** Zoom to `z` keeping the image point under canvas position (px, py) fixed. */
export function zoomAt(v: View, z: number, px: number, py: number): View {
const k = z / v.z;
return { z, x: px - (px - v.x) * k, y: py - (py - v.y) * k };
}
+9 -1
Переглянути файл
@@ -200,9 +200,17 @@
display: none;
}
.preview :global(.mermaid-output) {
overflow-x: auto;
overflow: hidden;
text-align: center;
}
.preview :global(.mermaid-output[data-zoomed="true"]) {
cursor: grab;
touch-action: none;
user-select: none;
}
.preview :global(.mermaid-output[data-dragging="true"]) {
cursor: grabbing;
}
.preview :global([data-mermaid-state="error"] > .mermaid-output) {
margin-top: 6px;
padding: 6px 10px;
+4 -1
Переглянути файл
@@ -5,6 +5,7 @@
*/
import type { Mermaid } from "mermaid";
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
import { attachPanZoom } from "../panZoom";
export const MERMAID_SELECTOR = "[data-mermaid]";
const OUTPUT_CLASS = "mermaid-output";
@@ -146,6 +147,7 @@ function show(block: HTMLElement, result: Result | null, stale?: Node) {
return;
}
block.append(out);
if (block.getAttribute(RENDERED_ATTR) === "ok") attachPanZoom(out);
}
export const hasMermaid = (root: ParentNode) => root.querySelector(MERMAID_SELECTOR) !== null;
@@ -175,7 +177,8 @@ export function renderMermaidIn(root: HTMLElement, previous: Node[] = []): Merma
const cached = cache.get(k);
const keep = () => {
const out = block.querySelector(`:scope > .${OUTPUT_CLASS}`);
if (out && block.getAttribute(RENDERED_ATTR) === "ok") previous[i] = out.firstChild ?? out;
if (out && block.getAttribute(RENDERED_ATTR) === "ok")
previous[i] = out.querySelector("svg") ?? out.firstChild ?? out;
};
if (cached) {
show(block, cached);
+61
Переглянути файл
@@ -0,0 +1,61 @@
/**
* Pan/zoom for imperatively injected content (Mermaid output in the Markdown
* preview). The preview scrolls, so only ⌘/Ctrl+wheel (and pinch) zooms; drag
* pans once zoomed in, double-click resets. Listeners live as long as `viewport`.
*/
import { clampZoom, zoomAt, type View } from "./imageZoom";
const IDENTITY: View = { z: 1, x: 0, y: 0 };
export function attachPanZoom(viewport: HTMLElement): void {
const pan = document.createElement("div");
pan.className = "pan-zoom-content";
pan.style.transformOrigin = "0 0";
pan.append(...viewport.childNodes);
viewport.append(pan);
let view = IDENTITY;
let drag: { id: number; x: number; y: number } | null = null;
const apply = (next: View) => {
if (next.z === 1 && view.z === 1) return;
// Lock the height on first zoom so the page doesn't reflow while the content scales.
if (view.z === 1 && !viewport.style.height) viewport.style.height = `${viewport.offsetHeight}px`;
view = next;
pan.style.transform =
next.z === 1 && !next.x && !next.y ? "" : `translate(${next.x}px, ${next.y}px) scale(${next.z})`;
viewport.dataset.zoomed = next.z === 1 ? "" : "true";
if (next.z === 1 && !next.x && !next.y) viewport.style.height = "";
};
viewport.addEventListener(
"wheel",
(e) => {
if (!(e.metaKey || e.ctrlKey) || !e.deltaY) return;
e.preventDefault();
const rect = viewport.getBoundingClientRect();
const factor = Math.exp(-e.deltaY * (e.deltaMode === 0 ? 0.01 : 0.33));
apply(zoomAt(view, clampZoom(view.z * factor), e.clientX - rect.left, e.clientY - rect.top));
},
{ passive: false },
);
viewport.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || view.z <= 1) return;
viewport.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, x: e.clientX, y: e.clientY };
viewport.dataset.dragging = "true";
});
viewport.addEventListener("pointermove", (e) => {
if (!drag || drag.id !== e.pointerId) return;
apply({ ...view, x: view.x + e.clientX - drag.x, y: view.y + e.clientY - drag.y });
drag = { ...drag, x: e.clientX, y: e.clientY };
});
const end = (e: PointerEvent) => {
if (drag?.id !== e.pointerId) return;
drag = null;
delete viewport.dataset.dragging;
};
viewport.addEventListener("pointerup", end);
viewport.addEventListener("pointercancel", end);
viewport.addEventListener("dblclick", () => apply(IDENTITY));
}
+10 -1
Переглянути файл
@@ -14,7 +14,7 @@ import {
rowCount,
scrollTopForRow,
} from "./hex";
import { fitZoom, initialZoom, stepZoom, zoomLabel } from "./imageZoom";
import { fitZoom, initialZoom, stepZoom, zoomAt, zoomLabel } from "./imageZoom";
describe("file kinds", () => {
it("routes by extension", () => {
@@ -96,4 +96,13 @@ describe("image zoom", () => {
expect(initialZoom(400, 100, 100, 100)).toBe(0.25);
expect(zoomLabel(0.333)).toBe("33%");
});
it("keeps the point under the cursor fixed when zooming", () => {
const v = { z: 1, x: 10, y: 20 };
const next = zoomAt(v, 2, 110, 70);
// Image point under (110, 70) before: ((110-10)/1, (70-20)/1) = (100, 50).
expect(next).toEqual({ z: 2, x: -90, y: -30 });
expect(next.x + 100 * next.z).toBe(110);
expect(next.y + 50 * next.z).toBe(70);
});
});