Skip to content

Commit

Permalink
some viewport limit loose ends
Browse files Browse the repository at this point in the history
  • Loading branch information
a-type committed Oct 1, 2024
1 parent f3509ce commit d13cf4f
Show file tree
Hide file tree
Showing 6 changed files with 454 additions and 340 deletions.
4 changes: 2 additions & 2 deletions src/components/canvas/CanvasRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const baseStyle: CSSProperties = {
const centererStyle: CSSProperties = {
overflow: 'visible',
position: 'absolute',
top: '50%',
left: '50%',
top: 'calc(-1 * var(--canvas-min-y,0))',
left: 'calc(-1 * var(--canvas-min-y,0))',
width: 0,
height: 0,
};
Expand Down
8 changes: 5 additions & 3 deletions src/components/viewport/ViewportRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Viewport, ViewportEventOrigin } from '../../logic/Viewport.js';
import {
useKeyboardControls,
useViewportGestureControls,
} from './viewportHooks.js';
} from './viewportInternals.js';
import { useMergedRef } from '../../hooks.js';
import { to, useSpring } from '@react-spring/web';
import { SPRINGS } from '../../constants.js';
Expand Down Expand Up @@ -150,12 +150,14 @@ function ViewportSurface({
zoomValue: number,
origin: ViewportEventOrigin,
) {
await zoomSpring.start({
zoomSpring.start({
zoom: zoomValue,
immediate: origin === 'direct',
config: VIEWPORT_ORIGIN_SPRINGS[origin],
})[0];
});
}
handleCenterChanged(viewport.center, 'direct');
handleZoomChanged(viewport.zoomValue, 'direct');
const unsubs = [
viewport.subscribe('centerChanged', handleCenterChanged),
viewport.subscribe('zoomChanged', handleZoomChanged),
Expand Down
326 changes: 19 additions & 307 deletions src/components/viewport/viewportHooks.ts
Original file line number Diff line number Diff line change
@@ -1,314 +1,26 @@
import { useGesture } from '@use-gesture/react';
import {
KeyboardEvent as ReactKeyboardEvent,
RefObject,
useCallback,
useEffect,
useRef,
} from 'react';
import { useStableCallback } from '../../hooks.js';
import { Viewport } from '../../logic/Viewport.js';
import { Vector2 } from '../../types.js';
import { gestureState } from '../gestures/useGestureState.js';

/**
* Tracks cursor position and sends updates to the socket connection
*/
export function useTrackCursor(
viewport: Viewport,
handleMove: (pos: Vector2, active: boolean) => void,
) {
const lastKnownPositionRef = useRef<Vector2>({ x: 0, y: 0 });
const stableHandleMove = useStableCallback(handleMove);

const onMove = useCallback(
(pos: Vector2) => {
lastKnownPositionRef.current = pos;
stableHandleMove(
viewport.viewportToWorld(lastKnownPositionRef.current),
true,
);
},
[lastKnownPositionRef, stableHandleMove, viewport],
);

useEffect(() => {
const handleWindowBlur = () => {
stableHandleMove(
viewport.viewportToWorld(lastKnownPositionRef.current),
false,
);
};
window.addEventListener('blur', handleWindowBlur);
return () => {
window.removeEventListener('blur', handleWindowBlur);
};
}, [lastKnownPositionRef, stableHandleMove]);
import { useEffect, useState } from 'react';
import { useViewport } from './ViewportRoot.js';

export function useZoom(config: { instant?: boolean } = {}) {
const viewport = useViewport();
const [zoom, setZoom] = useState(viewport.zoomValue);
useEffect(() => {
const unsubs = [
viewport.subscribe('centerChanged', () => {
onMove(lastKnownPositionRef.current);
}),
viewport.subscribe('sizeChanged', () => {
onMove(lastKnownPositionRef.current);
}),
viewport.subscribe('zoomChanged', () => {
onMove(lastKnownPositionRef.current);
}),
];
return () => {
unsubs.forEach((fn) => fn());
};
}, [viewport, onMove]);

return onMove;
}

const PINCH_GESTURE_DAMPING = 150;
const WHEEL_GESTURE_DAMPING = 1000;
const TRACKPAD_GESTURE_DAMPING = 100;

const MOUSE_WHEEL_DETECT_THRESHOLD = 100;

function dampenedWheelZoom(value: number) {
if (Math.abs(value) > MOUSE_WHEEL_DETECT_THRESHOLD) {
return { type: 'wheel', value: value / WHEEL_GESTURE_DAMPING };
} else {
return { type: 'trackpad', value: value / TRACKPAD_GESTURE_DAMPING };
}
}

export interface ViewportGestureConfig {
initialZoom: number;
}

function noop() {}

export function useViewportGestureControls(
viewport: Viewport,
ref: RefObject<HTMLElement>,
handleCursorMove?: (pos: Vector2, active: boolean) => void,
) {
const initialZoom = viewport.config.defaultZoom;
// active is required to prevent default behavior, which
// we want to do for zoom.
useGesture(
{
// this only works with touchscreen direct pinching, not trackpad.
onPinch: ({ da: [d], origin, memo, last }) => {
if (memo === undefined) return d;
const diff = d - memo;
if (diff !== 0) {
viewport.relativeZoom(diff / PINCH_GESTURE_DAMPING, {
origin: 'direct',
centroid: { x: origin[0], y: origin[1] },
gestureComplete: last,
});
}
return d;
},
onWheel: ({ delta: [x, y], event, last, metaKey, ctrlKey }) => {
if (ctrlKey || metaKey) {
const { value, type } = dampenedWheelZoom(-y);
viewport.relativeZoom(value, {
origin: type === 'wheel' ? 'control' : 'direct',
centroid: { x: event.clientX, y: event.clientY },
gestureComplete: last,
});
} else {
viewport.relativePan(
viewport.viewportDeltaToWorld({
x,
y,
}),
{
origin: 'direct',
gestureComplete: true,
},
);
}
},
},
{
target: ref,
// keeps the pinch gesture within our min/max zoom bounds,
// without this you can pinch 'more' than the zoom allows,
// creating weird deadzones at min and max values where
// you have to keep pinching to get 'back' into the allowed range
pinch: {
scaleBounds: {
min: (viewport.zoomMin - initialZoom) * PINCH_GESTURE_DAMPING,
max: (viewport.zoomMax - initialZoom) * PINCH_GESTURE_DAMPING,
},
preventDefault: true,
},
wheel: {
preventDefault: true,
},
eventOptions: {
passive: false,
},
},
);

const onCursorMove = useTrackCursor(viewport, handleCursorMove || noop);

const bindPassiveGestures = useGesture(
{
onDrag: (state) => {
// ignore gestures claimed by upstream canvas objects
if (gestureState.claimType) {
return;
}

// by default, viewport pans on middle click drags.
viewport.relativePan(
viewport.viewportDeltaToWorld({
x: -state.delta[0],
y: -state.delta[1],
}),
{
origin: 'direct',
gestureComplete: state.last,
},
);
},
onPointerMoveCapture: ({ event }) => {
onCursorMove({ x: event.clientX, y: event.clientY });
},
onContextMenu: ({ event }) => {
event.preventDefault();
},
},
{
drag: {
pointer: {
buttons: [1, 2, 4],
},
},
},
);

return bindPassiveGestures();
return viewport.subscribe(
config.instant ? 'zoomChanged' : 'zoomSettled',
setZoom,
);
}, [viewport]);
return [zoom, viewport.zoom] as const;
}

const CONTROLLED_KEYS = [
'=',
'+',
'-',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
];
const PAN_SPEED = 1;
const ZOOM_SPEED = 0.001;

export function useKeyboardControls(viewport: Viewport) {
const elementRef = useRef<HTMLDivElement>(null);
const activeKeysRef = useRef({
pressed: new Set<string>(),
released: new Set<string>(),
});

// global zoom default prevention - this is best-effort and not
// guaranteed to work.
useEffect(() => {
const onGlobalKeyDown = (ev: KeyboardEvent) => {
if ((ev.metaKey || ev.ctrlKey) && (ev.key === '=' || ev.key === '-')) {
ev.preventDefault();
}
};
window.addEventListener('keydown', onGlobalKeyDown);
return () => {
window.removeEventListener('keydown', onGlobalKeyDown);
};
}, []);

const handleKeyDown = useCallback((ev: ReactKeyboardEvent<HTMLElement>) => {
if (CONTROLLED_KEYS.includes(ev.key)) {
ev.preventDefault();
// ignoring presses with metaKey because of behavior with MacOS -
// if meta key is down, keyup is never fired and the zoom never
// ends.
if (!ev.metaKey) {
activeKeysRef.current.pressed.add(ev.key);
}
}
}, []);

const handleKeyUp = useCallback((ev: ReactKeyboardEvent<HTMLElement>) => {
if (CONTROLLED_KEYS.includes(ev.key)) {
ev.preventDefault();
activeKeysRef.current.pressed.delete(ev.key);
activeKeysRef.current.released.add(ev.key);
queueMicrotask(() => {
activeKeysRef.current.released.delete(ev.key);
});
}
}, []);

export function usePan(config: { instant?: boolean } = {}) {
const viewport = useViewport();
const [pan, setPan] = useState(viewport.center);
useEffect(() => {
const { current: el } = elementRef;
if (!el) return;

// begin a loop which tracks delta time and applies it to
// pan velocity for smooth panning regardless of framerate
let lastFrameTime: number | null = null;
let animationFrame: number | null = null;

// extracted to reduce memory allocation in tight loop
const velocity: Vector2 = { x: 0, y: 0 };

function loop() {
const activeKeys = activeKeysRef.current;
const now = Date.now();
const delta = lastFrameTime ? now - lastFrameTime : 0;
lastFrameTime = now;

if (activeKeys.pressed.has('=') || activeKeys.pressed.has('+')) {
viewport.relativeZoom(delta * ZOOM_SPEED, {
origin: 'direct',
gestureComplete: true,
});
} else if (activeKeys.pressed.has('-')) {
viewport.relativeZoom(delta * -ZOOM_SPEED, {
origin: 'direct',
gestureComplete: true,
});
}
const xInput =
activeKeys.pressed.has('ArrowLeft') ? -1
: activeKeys.pressed.has('ArrowRight') ? 1
: 0;
const yInput =
activeKeys.pressed.has('ArrowUp') ? -1
: activeKeys.pressed.has('ArrowDown') ? 1
: 0;
velocity.x = delta * xInput * PAN_SPEED;
velocity.y = delta * yInput * PAN_SPEED;
if (velocity.x !== 0 || velocity.y !== 0) {
viewport.relativePan(velocity, {
origin: 'direct',
gestureComplete: true,
});
}

animationFrame = requestAnimationFrame(loop);
}
// start the loop
animationFrame = requestAnimationFrame(loop);

return () => {
animationFrame && cancelAnimationFrame(animationFrame);
};
return viewport.subscribe(
config.instant ? 'centerChanged' : 'centerSettled',
setPan,
);
}, [viewport]);

return {
tabIndex: 1,
ref: elementRef,
onKeyUp: handleKeyUp,
onKeyDown: handleKeyDown,
};
return [pan, viewport.pan] as const;
}
Loading

0 comments on commit d13cf4f

Please sign in to comment.