211 lines
6.6 KiB
TypeScript
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);
|
|
}
|