rspace-online/shared/canvas-interaction.ts

358 lines
12 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
* • Space (opt-in) → grab cursor; rApp drives pointer pan
* • Keyboard shortcuts (opt-in) → 0 fit, +/- zoom, arrows pan
*
* Pointer-based pan and marquee selection remain owned by each rApp
* because they depend on per-rApp hit-testing and selection state —
* but the controller exposes `isSpaceHeld()` so rApps can branch on it.
*
* Usage:
* const controller = new CanvasInteractionController({
* target: svgEl,
* 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(),
* enableKeyboardShortcuts: { fit: () => this.fitView() },
* enableSpaceToGrab: true,
* });
* // ...on teardown:
* controller.destroy();
*/
export interface Viewport {
x: number;
y: number;
zoom: number;
}
export interface KeyboardShortcutHandlers {
/** Called on `0`. rApp fits all nodes into view. */
fit?: () => void;
/** Called on `Ctrl/Cmd+Z`. */
undo?: () => void;
/** Called on `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`. */
redo?: () => void;
/** Called on `Delete`/`Backspace` when target is focused. */
deleteSelected?: () => void;
}
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 | KeyboardEvent): boolean;
/**
* Opt-in: listen for Space on `document` and flip the target cursor
* to `grab`. The rApp's own pointerdown handler should check
* `controller.isSpaceHeld()` to decide whether to enter pan mode.
*/
enableSpaceToGrab?: boolean;
/**
* Opt-in: keyboard shortcuts for fit, undo/redo, delete, and zoom
* (`0`, `Ctrl+Z`, `Ctrl+Shift+Z`, `Delete`, `+`, `-`, arrow keys).
* Arrow keys pan the viewport by `ARROW_PAN_STEP` px. `+`/`-` zoom
* around the viewport center.
*/
enableKeyboardShortcuts?: KeyboardShortcutHandlers | boolean;
}
const ARROW_PAN_STEP = 40;
const BUTTON_ZOOM_FACTOR = 1.25;
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;
// Space-to-grab state
private spaceHeld = false;
private previousCursor = "";
// 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;
private readonly onKeyDown: ((e: KeyboardEvent) => void) | null;
private readonly onKeyUp: ((e: KeyboardEvent) => void) | null;
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);
// Keyboard is on `document` because targets often lack focus.
const wantKeys = !!opts.enableKeyboardShortcuts;
const wantSpace = !!opts.enableSpaceToGrab;
if (wantKeys || wantSpace) {
this.onKeyDown = this.handleKeyDown.bind(this);
this.onKeyUp = this.handleKeyUp.bind(this);
document.addEventListener("keydown", this.onKeyDown);
document.addEventListener("keyup", this.onKeyUp);
} else {
this.onKeyDown = null;
this.onKeyUp = null;
}
}
/** 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);
if (this.onKeyDown) document.removeEventListener("keydown", this.onKeyDown);
if (this.onKeyUp) document.removeEventListener("keyup", this.onKeyUp);
// Restore cursor if we still had it set
if (this.spaceHeld) {
(this.target as HTMLElement).style.cursor = this.previousCursor;
}
}
/**
* 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?.();
}
/** Zoom centered on the target element. Used by chrome +/- buttons. */
zoomByFactor(factor: number): void {
const rect = this.target.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, factor);
}
/** Is Space currently held down? rApps can check this in pointerdown. */
isSpaceHeld(): boolean {
return this.spaceHeld;
}
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;
}
}
private handleKeyDown(e: KeyboardEvent): void {
// Bail on text inputs (rApps often have inline editors).
if (isInTextInput(e)) return;
if (this.opts.isEnabled && !this.opts.isEnabled(e)) return;
// Space-to-grab (opt-in).
if (this.opts.enableSpaceToGrab && e.code === "Space" && !this.spaceHeld) {
e.preventDefault();
this.spaceHeld = true;
this.previousCursor = (this.target as HTMLElement).style.cursor || "";
(this.target as HTMLElement).style.cursor = "grab";
}
const ks = this.opts.enableKeyboardShortcuts;
if (!ks) return;
const handlers: KeyboardShortcutHandlers = ks === true ? {} : ks;
// Fit view: `0`
if (e.key === "0" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
e.preventDefault();
handlers.fit?.();
return;
}
// Zoom: `+` / `=` / `-`
if ((e.key === "+" || e.key === "=") && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.zoomByFactor(BUTTON_ZOOM_FACTOR);
return;
}
if (e.key === "-" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.zoomByFactor(1 / BUTTON_ZOOM_FACTOR);
return;
}
// Arrow pan
if (e.key === "ArrowLeft") { e.preventDefault(); this.panBy(ARROW_PAN_STEP, 0); return; }
if (e.key === "ArrowRight") { e.preventDefault(); this.panBy(-ARROW_PAN_STEP, 0); return; }
if (e.key === "ArrowUp") { e.preventDefault(); this.panBy(0, ARROW_PAN_STEP); return; }
if (e.key === "ArrowDown") { e.preventDefault(); this.panBy(0, -ARROW_PAN_STEP); return; }
// Undo / Redo
if ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (e.shiftKey) handlers.redo?.();
else handlers.undo?.();
return;
}
if ((e.key === "y" || e.key === "Y") && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handlers.redo?.();
return;
}
// Delete selected
if ((e.key === "Delete" || e.key === "Backspace") && handlers.deleteSelected) {
e.preventDefault();
handlers.deleteSelected();
return;
}
}
private handleKeyUp(e: KeyboardEvent): void {
if (e.code === "Space" && this.spaceHeld) {
this.spaceHeld = false;
(this.target as HTMLElement).style.cursor = this.previousCursor;
}
}
}
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);
}
/**
* Shadow-DOM-aware text input detector. Keyboard events are retargeted
* to the shadow host, so we walk `composedPath()` for true targets.
*/
function isInTextInput(e: KeyboardEvent): boolean {
return e.composedPath().some(el =>
el instanceof HTMLElement && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable),
);
}