This commit is contained in:
Haapy
2026-05-14 22:00:34 +00:00
parent 259959d713
commit fe9f3681fd
6 changed files with 59 additions and 30 deletions

View File

@@ -35,28 +35,47 @@ export function Canvas({ initialCards }: CanvasProps) {
};
}, []);
const onWheel = useCallback(
(e: React.WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const rect = containerRef.current!.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setVp((prev) => {
const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY);
const next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * factor));
const k = next / prev.scale;
return { x: mx - (mx - prev.x) * k, y: my - (my - prev.y) * k, scale: next };
});
},
[],
);
// Suppress WebKit/GTK middle-click defaults (autoscroll on some platforms,
// paste primary selection on Linux). Must be a native listener — React's
// synthetic onPointerDown.preventDefault doesn't reliably stop these.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const stopMiddle = (e: MouseEvent) => {
if (e.button === 1) e.preventDefault();
};
const stopAux = (e: MouseEvent) => {
if (e.button === 1) e.preventDefault();
};
el.addEventListener("mousedown", stopMiddle);
el.addEventListener("auxclick", stopAux);
return () => {
el.removeEventListener("mousedown", stopMiddle);
el.removeEventListener("auxclick", stopAux);
};
}, []);
const onWheel = useCallback((e: React.WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const rect = containerRef.current!.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setVp((prev) => {
const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY);
const next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * factor));
const k = next / prev.scale;
return { x: mx - (mx - prev.x) * k, y: my - (my - prev.y) * k, scale: next };
});
}, []);
const onPointerDown = (e: React.PointerEvent) => {
const isPan = e.button === 1 || (e.button === 0 && spaceHeld);
if (!isPan) return;
e.preventDefault();
(e.target as Element).setPointerCapture(e.pointerId);
// Capture on the container, not e.target — survives card re-renders and
// avoids odd behavior when the click lands on a textarea or child element.
containerRef.current?.setPointerCapture(e.pointerId);
panState.current = { startX: e.clientX, startY: e.clientY, vpX: vp.x, vpY: vp.y };
};
@@ -69,7 +88,11 @@ export function Canvas({ initialCards }: CanvasProps) {
const onPointerUp = (e: React.PointerEvent) => {
if (panState.current) {
(e.target as Element).releasePointerCapture(e.pointerId);
try {
containerRef.current?.releasePointerCapture(e.pointerId);
} catch {
// capture may already be released if pointercancel fired
}
panState.current = null;
}
};
@@ -81,7 +104,7 @@ export function Canvas({ initialCards }: CanvasProps) {
return (
<div
ref={containerRef}
className={`canvas-container ${spaceHeld ? "pan-mode" : ""}`}
className={`canvas-container ${spaceHeld ? "pan-mode" : ""} ${panState.current ? "panning" : ""}`}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}

View File

@@ -4,12 +4,18 @@
overflow: hidden;
cursor: default;
background: var(--bg);
touch-action: none;
overscroll-behavior: contain;
}
.canvas-container.pan-mode {
cursor: grab;
}
.canvas-container.panning {
cursor: grabbing;
}
.canvas-grid {
position: absolute;
inset: 0;