358 lines
12 KiB
TypeScript
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),
|
|
);
|
|
}
|