дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
b2c183dbd4
коміт
a5b7c154e1
7 files changed
+203
-32
No files matched your search
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача