Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons, type Vec2, type Viewport} from './runtime.js';import { shouldPreventKeyboardDefault, type PrimaryModifierPlatform } from './shortcuts.js';
/** * Pointer state tracked by the input adapter */export type PointerState = { /** Whether any pointer button is currently down */ isDown: boolean; /** Last known world coordinates */ lastWorld: { x: number; y: number } | null; /** World coordinates where pointer was first pressed */ startWorld: { x: number; y: number } | null; /** Last known screen coordinates */ lastScreen: { x: number; y: number } | null; /** Screen coordinates where pointer was first pressed */ startScreen: { x: number; y: number } | null; /** Current button state */ buttons: PointerButtons;};
/** * Input adapter configuration */export type InputAdapterConfig = { /** Canvas element to attach listeners to */ canvas: HTMLCanvasElement; /** Function to get current camera state */ getCamera: () => Camera; /** Function to get current viewport dimensions */ getViewport: () => Viewport; /** Callback for dispatching actions */ onAction: (action: ActionType) => void; /** Optional callback for raw cursor updates */ onCursorUpdate?: (world: Vec2, screen: Vec2) => void; /** Whether to prevent default browser behavior (default: true) */ preventDefault?: boolean; /** Whether to capture keyboard events on window (default: true) */ captureKeyboard?: boolean;};
/** * Input adapter for capturing and normalizing DOM input events * * Features: * - Captures pointer events (down, move, up) on canvas * - Captures wheel and trackpad events for camera movement * - Captures keyboard events (optionally on window) * - Converts screen coordinates to world coordinates * - Tracks pointer state * - Normalizes modifiers (ctrl/cmd, shift, alt) * - Dispatches normalized actions */export class InputAdapter { private config: InputAdapterConfig & { preventDefault: boolean; captureKeyboard: boolean }; private readonly primaryModifierPlatform: PrimaryModifierPlatform; private pointerState: PointerState; private boundHandlers: { pointerDown: (e: PointerEvent) => void; pointerMove: (e: PointerEvent) => void; pointerUp: (e: PointerEvent) => void; pointerCancel: (e: PointerEvent) => void; lostPointerCapture: (e: PointerEvent) => void; wheel: (e: WheelEvent) => void; keyDown: (e: KeyboardEvent) => void; keyUp: (e: KeyboardEvent) => void; contextMenu: (e: Event) => void; }; private cursorUpdateFrame: number | null; private pendingCursorWorld: Vec2 | null; private pendingCursorScreen: Vec2 | null; private activePointerId: number | null; private activeButton: number | null;
constructor(config: InputAdapterConfig) { this.config = { ...config, preventDefault: config.preventDefault ?? true, captureKeyboard: config.captureKeyboard ?? true }; this.primaryModifierPlatform = navigator.platform.toUpperCase().includes('MAC') ? 'mac' : 'other';
this.pointerState = { isDown: false, lastWorld: null, startWorld: null, lastScreen: null, startScreen: null, buttons: PointerButtons.create() };
this.boundHandlers = { pointerDown: this.handlePointerDown.bind(this), pointerMove: this.handlePointerMove.bind(this), pointerUp: this.handlePointerUp.bind(this), pointerCancel: this.handlePointerCancel.bind(this), lostPointerCapture: this.handleLostPointerCapture.bind(this), wheel: this.handleWheel.bind(this), keyDown: this.handleKeyDown.bind(this), keyUp: this.handleKeyUp.bind(this), contextMenu: this.handleContextMenu.bind(this) }; this.cursorUpdateFrame = null; this.pendingCursorWorld = null; this.pendingCursorScreen = null; this.activePointerId = null; this.activeButton = null;
this.attach(); }
/** * Get current pointer state */ getPointerState(): Readonly<PointerState> { return { ...this.pointerState }; }
/** * Attach event listeners */ private attach(): void { const { canvas } = this.config;
canvas.addEventListener('pointerdown', this.boundHandlers.pointerDown); canvas.addEventListener('pointermove', this.boundHandlers.pointerMove); canvas.addEventListener('pointerup', this.boundHandlers.pointerUp); canvas.addEventListener('pointercancel', this.boundHandlers.pointerCancel); canvas.addEventListener('lostpointercapture', this.boundHandlers.lostPointerCapture); canvas.addEventListener('wheel', this.boundHandlers.wheel, { passive: false }); canvas.addEventListener('contextmenu', this.boundHandlers.contextMenu); window.addEventListener('pointerup', this.boundHandlers.pointerUp, true); window.addEventListener('pointercancel', this.boundHandlers.pointerCancel, true);
if (this.config.captureKeyboard) { window.addEventListener('keydown', this.boundHandlers.keyDown); window.addEventListener('keyup', this.boundHandlers.keyUp); } }
/** * Detach event listeners and cleanup */ dispose(): void { const { canvas } = this.config;
canvas.removeEventListener('pointerdown', this.boundHandlers.pointerDown); canvas.removeEventListener('pointermove', this.boundHandlers.pointerMove); canvas.removeEventListener('pointerup', this.boundHandlers.pointerUp); canvas.removeEventListener('pointercancel', this.boundHandlers.pointerCancel); canvas.removeEventListener('lostpointercapture', this.boundHandlers.lostPointerCapture); canvas.removeEventListener('wheel', this.boundHandlers.wheel); canvas.removeEventListener('contextmenu', this.boundHandlers.contextMenu); window.removeEventListener('pointerup', this.boundHandlers.pointerUp, true); window.removeEventListener('pointercancel', this.boundHandlers.pointerCancel, true);
if (this.config.captureKeyboard) { window.removeEventListener('keydown', this.boundHandlers.keyDown); window.removeEventListener('keyup', this.boundHandlers.keyUp); }
if ( this.cursorUpdateFrame !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function' ) { window.cancelAnimationFrame(this.cursorUpdateFrame); this.cursorUpdateFrame = null; } }
/** * Get screen coordinates from pointer event */ private getScreenCoords(e: PointerEvent): { x: number; y: number } { const rect = this.config.canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }
/** * Convert screen coordinates to world coordinates */ private screenToWorld(screen: { x: number; y: number }): { x: number; y: number } { const camera = this.config.getCamera(); const viewport = this.config.getViewport(); return Camera.screenToWorld(camera, screen, viewport); }
/** Return hardware pressure when the pointer type exposes a useful value. */ private pointerPressure(e: PointerEvent): number | undefined { if ((e.pointerType !== 'pen' && e.pointerType !== 'touch') || !Number.isFinite(e.pressure)) return undefined; return Math.max(0, Math.min(1, e.pressure)); }
/** * Handle pointer down event */ private handlePointerDown(e: PointerEvent): void { if (this.config.preventDefault) { e.preventDefault(); }
const screen = this.getScreenCoords(e); const world = this.screenToWorld(screen); const buttons = PointerButtons.fromButtons(e.buttons); const modifiers = Modifiers.fromEvent(e);
this.pointerState.isDown = true; this.pointerState.startWorld = world; this.pointerState.startScreen = screen; this.pointerState.lastWorld = world; this.pointerState.lastScreen = screen; this.pointerState.buttons = buttons; this.activePointerId = e.pointerId; this.activeButton = e.button;
try { this.config.canvas.setPointerCapture(e.pointerId); } catch { // Window-level release listeners remain as a fallback when capture is // unavailable, including synthetic events and detached test canvases. }
this.config.onAction( Action.pointerDown(screen, world, e.button, buttons, modifiers, Date.now(), this.pointerPressure(e)) ); this.queueCursorUpdate(world, screen); }
/** * Handle pointer move event */ private handlePointerMove(e: PointerEvent): void { if (this.config.preventDefault && this.pointerState.isDown) { e.preventDefault(); }
const screen = this.getScreenCoords(e); const world = this.screenToWorld(screen); const buttons = PointerButtons.fromButtons(e.buttons); const modifiers = Modifiers.fromEvent(e);
this.pointerState.lastWorld = world; this.pointerState.lastScreen = screen; this.pointerState.buttons = buttons;
this.config.onAction( Action.pointerMove(screen, world, buttons, modifiers, Date.now(), this.pointerPressure(e)) ); this.queueCursorUpdate(world, screen); }
/** * Handle pointer up event */ private handlePointerUp(e: PointerEvent): void { this.finishPointer(e, false); }
private handlePointerCancel(e: PointerEvent): void { this.finishPointer(e, true); }
private handleLostPointerCapture(e: PointerEvent): void { this.finishPointer(e, true); }
private finishPointer(e: PointerEvent, forceRelease: boolean): void { if (!this.pointerState.isDown || this.activePointerId !== e.pointerId) { return; }
if (this.config.preventDefault) { e.preventDefault(); }
const screen = this.getScreenCoords(e); const world = this.screenToWorld(screen); const buttons = forceRelease ? PointerButtons.create() : PointerButtons.fromButtons(e.buttons); const modifiers = Modifiers.fromEvent(e); const released = PointerButtons.isEmpty(buttons); const button = forceRelease ? (this.activeButton ?? e.button) : e.button;
this.pointerState.isDown = !released; this.pointerState.lastWorld = world; this.pointerState.lastScreen = screen; this.pointerState.buttons = buttons;
if (released) { this.pointerState.startWorld = null; this.pointerState.startScreen = null; this.activePointerId = null; this.activeButton = null; }
this.config.onAction( Action.pointerUp(screen, world, button, buttons, modifiers, Date.now(), this.pointerPressure(e)) ); this.queueCursorUpdate(world, screen);
if (released) { try { if (this.config.canvas.hasPointerCapture(e.pointerId)) { this.config.canvas.releasePointerCapture(e.pointerId); } } catch { // Capture may already have been released by the browser. } } }
/** * Handle wheel event */ private handleWheel(e: WheelEvent): void { if (this.config.preventDefault) { e.preventDefault(); }
const rect = this.config.canvas.getBoundingClientRect(); const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const world = this.screenToWorld(screen); const modifiers = Modifiers.fromEvent(e);
this.config.onAction(Action.wheel(screen, world, { x: e.deltaX, y: e.deltaY }, modifiers)); }
private handleKeyDown(e: KeyboardEvent): void { const target = e.target as HTMLElement; if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) { return; }
const modifiers = Modifiers.fromEvent(e);
this.config.onAction(Action.keyDown(e.key, e.code, modifiers, e.repeat));
if ( this.config.preventDefault && shouldPreventKeyboardDefault(e.key, e.code, modifiers, this.primaryModifierPlatform) ) { e.preventDefault(); } }
private handleKeyUp(e: KeyboardEvent): void { const target = e.target as HTMLElement; if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) { return; }
const modifiers = Modifiers.fromEvent(e); this.config.onAction(Action.keyUp(e.key, e.code, modifiers)); }
private handleContextMenu(e: Event): void { if (this.config.preventDefault) { e.preventDefault(); } }
/** * Throttle cursor updates using requestAnimationFrame. */ private queueCursorUpdate(world: Vec2, screen: Vec2): void { if (!this.config.onCursorUpdate) { return; }
this.pendingCursorWorld = { x: world.x, y: world.y }; this.pendingCursorScreen = { x: screen.x, y: screen.y };
if (this.cursorUpdateFrame !== null) { return; }
const schedule = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : null;
if (!schedule) { this.flushCursorUpdate(); return; }
this.cursorUpdateFrame = schedule(() => { this.cursorUpdateFrame = null; this.flushCursorUpdate(); }); }
private flushCursorUpdate(): void { if (!this.config.onCursorUpdate || !this.pendingCursorWorld || !this.pendingCursorScreen) { this.pendingCursorWorld = null; this.pendingCursorScreen = null; return; }
this.config.onCursorUpdate(this.pendingCursorWorld, this.pendingCursorScreen); this.pendingCursorWorld = null; this.pendingCursorScreen = null; }}
/** * Create an input adapter * * @param config - Input adapter configuration * @returns Input adapter instance */export function createInputAdapter(config: InputAdapterConfig): InputAdapter { return new InputAdapter(config);}