diff --git a/src/features/viewers/ImageViewer.tsx b/src/features/viewers/ImageViewer.tsx index a54a1ee22..0c81c7614 100644 --- a/src/features/viewers/ImageViewer.tsx +++ b/src/features/viewers/ImageViewer.tsx @@ -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(null); const [size, setSize] = useState(null); const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); - const [zoom, setZoom] = useState(null); + const [view, setView] = useState(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(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 ? (
{tx`Cannot show the image: ${error}`}
) : ( -
+
{src && ( {path.slice(path.lastIndexOf("/")= 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")} /> diff --git a/src/features/viewers/Viewers.module.css b/src/features/viewers/Viewers.module.css index 1ee36e3cb..bcf8d2c86 100644 --- a/src/features/viewers/Viewers.module.css +++ b/src/features/viewers/Viewers.module.css @@ -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); } diff --git a/src/features/viewers/imageZoom.ts b/src/features/viewers/imageZoom.ts index 583fc86b9..8effd9470 100644 --- a/src/features/viewers/imageZoom.ts +++ b/src/features/viewers/imageZoom.ts @@ -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 }; +} diff --git a/src/features/viewers/markdown/Markdown.module.css b/src/features/viewers/markdown/Markdown.module.css index 817ece1ea..f36111227 100644 --- a/src/features/viewers/markdown/Markdown.module.css +++ b/src/features/viewers/markdown/Markdown.module.css @@ -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; diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts index 750e1e856..903d190ca 100644 --- a/src/features/viewers/markdown/mermaid.ts +++ b/src/features/viewers/markdown/mermaid.ts @@ -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); diff --git a/src/features/viewers/panZoom.ts b/src/features/viewers/panZoom.ts new file mode 100644 index 000000000..c6ab748a0 --- /dev/null +++ b/src/features/viewers/panZoom.ts @@ -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)); +} diff --git a/src/features/viewers/viewers.test.ts b/src/features/viewers/viewers.test.ts index 4e57480c0..1c643ed2a 100644 --- a/src/features/viewers/viewers.test.ts +++ b/src/features/viewers/viewers.test.ts @@ -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); + }); });