rspace-online/shared/canvas-interaction.ts

211 lines
6.6 KiB
TypeScript

/**
* CanvasInteractionController — shared pan/zoom for rApp mini-canvases.
*
* Mirrors the interaction model of the main rSpace canvas
* (website/canvas.html):
* • Wheel / two-finger scroll → PAN
* • Ctrl/Cmd+wheel or pinch → ZOOM at cursor
* • Two-finger touch → PAN + pinch-zoom at gesture center
*
* Pointer-based pan (middle-click, space-drag) and marquee selection
* remain owned by each rApp because they depend on per-rApp hit-testing
* and selection state.
*
* Usage:
* const controller = new CanvasInteractionController({
* target: svgEl, // element that receives wheel/touch events
* getViewport: () => ({ x: this.panX, y: this.panY, zoom: this.scale }),
* setViewport: v => { this.panX = v.x; this.panY = v.y; this.scale = v.zoom; },
* onChange: () => this.updateTransform(),
* });
* // ...on teardown:
* controller.destroy();
*/
export interface Viewport {
x: number;
y: number;
zoom: number;
}
export interface CanvasInteractionOptions {
/** Element that wheel + touch events attach to. */
target: HTMLElement | SVGElement;
/** Current viewport getter. */
getViewport(): Viewport;
/** Apply a new viewport. */
setViewport(v: Viewport): void;
/** Called after viewport mutates; consumer updates transform. */
onChange?(): void;
/** Clamp bounds (default 0.1 .. 4). */
minZoom?: number;
maxZoom?: number;
/** Per-wheel-tick zoom factor (default 0.01, scaled by deltaY). */
wheelZoomStep?: number;
/**
* Gate predicate. Return false to ignore an event (e.g. a tool overlay is
* active). Receives the event for inspection.
*/
isEnabled?(e: WheelEvent | TouchEvent): boolean;
}
export class CanvasInteractionController {
private target: HTMLElement | SVGElement;
private opts: CanvasInteractionOptions;
private minZoom: number;
private maxZoom: number;
private wheelZoomStep: number;
// Touch gesture state
private lastTouchCenter: { x: number; y: number } | null = null;
private lastTouchDist: number | null = null;
private isTouchGesture = false;
// Bound handlers (so we can remove them)
private readonly onWheel: (e: WheelEvent) => void;
private readonly onTouchStart: (e: TouchEvent) => void;
private readonly onTouchMove: (e: TouchEvent) => void;
private readonly onTouchEnd: (e: TouchEvent) => void;
constructor(opts: CanvasInteractionOptions) {
this.target = opts.target;
this.opts = opts;
this.minZoom = opts.minZoom ?? 0.1;
this.maxZoom = opts.maxZoom ?? 4;
this.wheelZoomStep = opts.wheelZoomStep ?? 0.01;
this.onWheel = this.handleWheel.bind(this);
this.onTouchStart = this.handleTouchStart.bind(this);
this.onTouchMove = this.handleTouchMove.bind(this);
this.onTouchEnd = this.handleTouchEnd.bind(this);
this.target.addEventListener("wheel", this.onWheel as EventListener, { passive: false });
this.target.addEventListener("touchstart", this.onTouchStart as EventListener, { passive: false });
this.target.addEventListener("touchmove", this.onTouchMove as EventListener, { passive: false });
this.target.addEventListener("touchend", this.onTouchEnd as EventListener);
}
/** Detach all event listeners. */
destroy(): void {
this.target.removeEventListener("wheel", this.onWheel as EventListener);
this.target.removeEventListener("touchstart", this.onTouchStart as EventListener);
this.target.removeEventListener("touchmove", this.onTouchMove as EventListener);
this.target.removeEventListener("touchend", this.onTouchEnd as EventListener);
}
/**
* Programmatic zoom around a screen-space point (relative to target).
* Useful for +/- UI buttons and fit-view transitions.
*/
zoomAt(screenX: number, screenY: number, factor: number): void {
const v = this.opts.getViewport();
const newZoom = this.clampZoom(v.zoom * factor);
if (newZoom === v.zoom) return;
const ratio = newZoom / v.zoom;
const next: Viewport = {
x: screenX - (screenX - v.x) * ratio,
y: screenY - (screenY - v.y) * ratio,
zoom: newZoom,
};
this.opts.setViewport(next);
this.opts.onChange?.();
}
/** Programmatic pan (screen-space delta). */
panBy(dx: number, dy: number): void {
const v = this.opts.getViewport();
this.opts.setViewport({ x: v.x + dx, y: v.y + dy, zoom: v.zoom });
this.opts.onChange?.();
}
private clampZoom(z: number): number {
return Math.min(this.maxZoom, Math.max(this.minZoom, z));
}
private handleWheel(e: WheelEvent): void {
if (this.opts.isEnabled && !this.opts.isEnabled(e)) return;
e.preventDefault();
const rect = this.target.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Ctrl/Cmd+wheel or trackpad pinch (ctrlKey synthesized by browsers) = zoom
if (e.ctrlKey || e.metaKey) {
const factor = 1 - e.deltaY * this.wheelZoomStep;
this.zoomAt(mx, my, factor);
return;
}
// Regular two-finger scroll / wheel = pan
this.panBy(-e.deltaX, -e.deltaY);
}
private handleTouchStart(e: TouchEvent): void {
if (this.opts.isEnabled && !this.opts.isEnabled(e)) return;
if (e.touches.length === 2) {
e.preventDefault();
this.isTouchGesture = true;
this.lastTouchCenter = touchCenter(e.touches);
this.lastTouchDist = touchDist(e.touches);
}
}
private handleTouchMove(e: TouchEvent): void {
if (this.opts.isEnabled && !this.opts.isEnabled(e)) return;
if (e.touches.length !== 2 || !this.isTouchGesture) return;
e.preventDefault();
const center = touchCenter(e.touches);
const dist = touchDist(e.touches);
const v = this.opts.getViewport();
let nx = v.x;
let ny = v.y;
let nz = v.zoom;
if (this.lastTouchCenter) {
nx += center.x - this.lastTouchCenter.x;
ny += center.y - this.lastTouchCenter.y;
}
if (this.lastTouchDist && this.lastTouchDist > 0) {
const zoomDelta = dist / this.lastTouchDist;
const newZoom = this.clampZoom(nz * zoomDelta);
const rect = this.target.getBoundingClientRect();
const cx = center.x - rect.left;
const cy = center.y - rect.top;
const ratio = newZoom / nz;
nx = cx - (cx - nx) * ratio;
ny = cy - (cy - ny) * ratio;
nz = newZoom;
}
this.opts.setViewport({ x: nx, y: ny, zoom: nz });
this.opts.onChange?.();
this.lastTouchCenter = center;
this.lastTouchDist = dist;
}
private handleTouchEnd(e: TouchEvent): void {
if (e.touches.length < 2) {
this.isTouchGesture = false;
this.lastTouchCenter = null;
this.lastTouchDist = null;
}
}
}
function touchCenter(ts: TouchList): { x: number; y: number } {
return {
x: (ts[0].clientX + ts[1].clientX) / 2,
y: (ts[0].clientY + ts[1].clientY) / 2,
};
}
function touchDist(ts: TouchList): number {
const dx = ts[0].clientX - ts[1].clientX;
const dy = ts[0].clientY - ts[1].clientY;
return Math.hypot(dx, dy);
}