web based infinite canvas
at main 383 lines 11 kB view raw
1import { 2 Action, 3 type Action as ActionType, 4 Camera, 5 Modifiers, 6 PointerButtons, 7 type Vec2, 8 type Viewport, 9} from "inkfinite-core"; 10 11/** 12 * Pointer state tracked by the input adapter 13 */ 14export type PointerState = { 15 /** Whether any pointer button is currently down */ 16 isDown: boolean; 17 /** Last known world coordinates */ 18 lastWorld: { x: number; y: number } | null; 19 /** World coordinates where pointer was first pressed */ 20 startWorld: { x: number; y: number } | null; 21 /** Last known screen coordinates */ 22 lastScreen: { x: number; y: number } | null; 23 /** Screen coordinates where pointer was first pressed */ 24 startScreen: { x: number; y: number } | null; 25 /** Current button state */ 26 buttons: PointerButtons; 27}; 28 29/** 30 * Input adapter configuration 31 */ 32export type InputAdapterConfig = { 33 /** Canvas element to attach listeners to */ 34 canvas: HTMLCanvasElement; 35 /** Function to get current camera state */ 36 getCamera: () => Camera; 37 /** Function to get current viewport dimensions */ 38 getViewport: () => Viewport; 39 /** Callback for dispatching actions */ 40 onAction: (action: ActionType) => void; 41 /** Optional callback for raw cursor updates */ 42 onCursorUpdate?: (world: Vec2, screen: Vec2) => void; 43 /** Whether to prevent default browser behavior (default: true) */ 44 preventDefault?: boolean; 45 /** Whether to capture keyboard events on window (default: true) */ 46 captureKeyboard?: boolean; 47}; 48 49/** 50 * Input adapter for capturing and normalizing DOM input events 51 * 52 * Features: 53 * - Captures pointer events (down, move, up) on canvas 54 * - Captures wheel events for zooming 55 * - Captures keyboard events (optionally on window) 56 * - Converts screen coordinates to world coordinates 57 * - Tracks pointer state 58 * - Normalizes modifiers (ctrl/cmd, shift, alt) 59 * - Dispatches normalized actions 60 */ 61export class InputAdapter { 62 private config: InputAdapterConfig & { preventDefault: boolean; captureKeyboard: boolean }; 63 private pointerState: PointerState; 64 private boundHandlers: { 65 pointerDown: (e: PointerEvent) => void; 66 pointerMove: (e: PointerEvent) => void; 67 pointerUp: (e: PointerEvent) => void; 68 wheel: (e: WheelEvent) => void; 69 keyDown: (e: KeyboardEvent) => void; 70 keyUp: (e: KeyboardEvent) => void; 71 contextMenu: (e: Event) => void; 72 }; 73 private cursorUpdateFrame: number | null; 74 private pendingCursorWorld: Vec2 | null; 75 private pendingCursorScreen: Vec2 | null; 76 77 constructor(config: InputAdapterConfig) { 78 this.config = { 79 ...config, 80 preventDefault: config.preventDefault ?? true, 81 captureKeyboard: config.captureKeyboard ?? true, 82 }; 83 84 this.pointerState = { 85 isDown: false, 86 lastWorld: null, 87 startWorld: null, 88 lastScreen: null, 89 startScreen: null, 90 buttons: PointerButtons.create(), 91 }; 92 93 this.boundHandlers = { 94 pointerDown: this.handlePointerDown.bind(this), 95 pointerMove: this.handlePointerMove.bind(this), 96 pointerUp: this.handlePointerUp.bind(this), 97 wheel: this.handleWheel.bind(this), 98 keyDown: this.handleKeyDown.bind(this), 99 keyUp: this.handleKeyUp.bind(this), 100 contextMenu: this.handleContextMenu.bind(this), 101 }; 102 this.cursorUpdateFrame = null; 103 this.pendingCursorWorld = null; 104 this.pendingCursorScreen = null; 105 106 this.attach(); 107 } 108 109 /** 110 * Get current pointer state 111 */ 112 getPointerState(): Readonly<PointerState> { 113 return { ...this.pointerState }; 114 } 115 116 /** 117 * Attach event listeners 118 */ 119 private attach(): void { 120 const { canvas } = this.config; 121 122 canvas.addEventListener("pointerdown", this.boundHandlers.pointerDown); 123 canvas.addEventListener("pointermove", this.boundHandlers.pointerMove); 124 canvas.addEventListener("pointerup", this.boundHandlers.pointerUp); 125 canvas.addEventListener("wheel", this.boundHandlers.wheel, { passive: false }); 126 canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu); 127 128 if (this.config.captureKeyboard) { 129 window.addEventListener("keydown", this.boundHandlers.keyDown); 130 window.addEventListener("keyup", this.boundHandlers.keyUp); 131 } 132 } 133 134 /** 135 * Detach event listeners and cleanup 136 */ 137 dispose(): void { 138 const { canvas } = this.config; 139 140 canvas.removeEventListener("pointerdown", this.boundHandlers.pointerDown); 141 canvas.removeEventListener("pointermove", this.boundHandlers.pointerMove); 142 canvas.removeEventListener("pointerup", this.boundHandlers.pointerUp); 143 canvas.removeEventListener("wheel", this.boundHandlers.wheel); 144 canvas.removeEventListener("contextmenu", this.boundHandlers.contextMenu); 145 146 if (this.config.captureKeyboard) { 147 window.removeEventListener("keydown", this.boundHandlers.keyDown); 148 window.removeEventListener("keyup", this.boundHandlers.keyUp); 149 } 150 151 if ( 152 this.cursorUpdateFrame !== null 153 && typeof window !== "undefined" 154 && typeof window.cancelAnimationFrame === "function" 155 ) { 156 window.cancelAnimationFrame(this.cursorUpdateFrame); 157 this.cursorUpdateFrame = null; 158 } 159 } 160 161 /** 162 * Get screen coordinates from pointer event 163 */ 164 private getScreenCoords(e: PointerEvent): { x: number; y: number } { 165 const rect = this.config.canvas.getBoundingClientRect(); 166 return { x: e.clientX - rect.left, y: e.clientY - rect.top }; 167 } 168 169 /** 170 * Convert screen coordinates to world coordinates 171 */ 172 private screenToWorld(screen: { x: number; y: number }): { x: number; y: number } { 173 const camera = this.config.getCamera(); 174 const viewport = this.config.getViewport(); 175 return Camera.screenToWorld(camera, screen, viewport); 176 } 177 178 /** 179 * Handle pointer down event 180 */ 181 private handlePointerDown(e: PointerEvent): void { 182 if (this.config.preventDefault) { 183 e.preventDefault(); 184 } 185 186 const screen = this.getScreenCoords(e); 187 const world = this.screenToWorld(screen); 188 const buttons = PointerButtons.fromButtons(e.buttons); 189 const modifiers = Modifiers.fromEvent(e); 190 191 this.pointerState.isDown = true; 192 this.pointerState.startWorld = world; 193 this.pointerState.startScreen = screen; 194 this.pointerState.lastWorld = world; 195 this.pointerState.lastScreen = screen; 196 this.pointerState.buttons = buttons; 197 198 this.config.onAction(Action.pointerDown(screen, world, e.button, buttons, modifiers)); 199 } 200 201 /** 202 * Handle pointer move event 203 */ 204 private handlePointerMove(e: PointerEvent): void { 205 if (this.config.preventDefault && this.pointerState.isDown) { 206 e.preventDefault(); 207 } 208 209 const screen = this.getScreenCoords(e); 210 const world = this.screenToWorld(screen); 211 const buttons = PointerButtons.fromButtons(e.buttons); 212 const modifiers = Modifiers.fromEvent(e); 213 214 this.pointerState.lastWorld = world; 215 this.pointerState.lastScreen = screen; 216 this.pointerState.buttons = buttons; 217 218 this.config.onAction(Action.pointerMove(screen, world, buttons, modifiers)); 219 this.queueCursorUpdate(world, screen); 220 } 221 222 /** 223 * Handle pointer up event 224 */ 225 private handlePointerUp(e: PointerEvent): void { 226 if (this.config.preventDefault) { 227 e.preventDefault(); 228 } 229 230 const screen = this.getScreenCoords(e); 231 const world = this.screenToWorld(screen); 232 const buttons = PointerButtons.fromButtons(e.buttons); 233 const modifiers = Modifiers.fromEvent(e); 234 235 this.pointerState.isDown = false; 236 this.pointerState.lastWorld = world; 237 this.pointerState.lastScreen = screen; 238 this.pointerState.buttons = buttons; 239 240 if (PointerButtons.isEmpty(buttons)) { 241 this.pointerState.startWorld = null; 242 this.pointerState.startScreen = null; 243 } 244 245 this.config.onAction(Action.pointerUp(screen, world, e.button, buttons, modifiers)); 246 } 247 248 /** 249 * Handle wheel event 250 */ 251 private handleWheel(e: WheelEvent): void { 252 if (this.config.preventDefault) { 253 e.preventDefault(); 254 } 255 256 const rect = this.config.canvas.getBoundingClientRect(); 257 const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; 258 const world = this.screenToWorld(screen); 259 const modifiers = Modifiers.fromEvent(e); 260 261 this.config.onAction(Action.wheel(screen, world, e.deltaY, modifiers)); 262 } 263 264 private handleKeyDown(e: KeyboardEvent): void { 265 const target = e.target as HTMLElement; 266 if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { 267 return; 268 } 269 270 const modifiers = Modifiers.fromEvent(e); 271 272 this.config.onAction(Action.keyDown(e.key, e.code, modifiers, e.repeat)); 273 274 if (this.config.preventDefault && this.shouldPreventDefault(e)) { 275 e.preventDefault(); 276 } 277 } 278 279 private handleKeyUp(e: KeyboardEvent): void { 280 const target = e.target as HTMLElement; 281 if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { 282 return; 283 } 284 285 const modifiers = Modifiers.fromEvent(e); 286 this.config.onAction(Action.keyUp(e.key, e.code, modifiers)); 287 } 288 289 private handleContextMenu(e: Event): void { 290 if (this.config.preventDefault) { 291 e.preventDefault(); 292 } 293 } 294 295 /** 296 * Throttle cursor updates using requestAnimationFrame. 297 */ 298 private queueCursorUpdate(world: Vec2, screen: Vec2): void { 299 if (!this.config.onCursorUpdate) { 300 return; 301 } 302 303 this.pendingCursorWorld = { x: world.x, y: world.y }; 304 this.pendingCursorScreen = { x: screen.x, y: screen.y }; 305 306 if (this.cursorUpdateFrame !== null) { 307 return; 308 } 309 310 const schedule = typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" 311 ? window.requestAnimationFrame.bind(window) 312 : null; 313 314 if (!schedule) { 315 this.flushCursorUpdate(); 316 return; 317 } 318 319 this.cursorUpdateFrame = schedule(() => { 320 this.cursorUpdateFrame = null; 321 this.flushCursorUpdate(); 322 }); 323 } 324 325 private flushCursorUpdate(): void { 326 if (!this.config.onCursorUpdate || !this.pendingCursorWorld || !this.pendingCursorScreen) { 327 this.pendingCursorWorld = null; 328 this.pendingCursorScreen = null; 329 return; 330 } 331 332 this.config.onCursorUpdate(this.pendingCursorWorld, this.pendingCursorScreen); 333 this.pendingCursorWorld = null; 334 this.pendingCursorScreen = null; 335 } 336 337 /** 338 * Determine if default behavior should be prevented for a key event 339 * 340 * Prevents default for: 341 * - Space (scroll) 342 * - Arrow keys (scroll) 343 * - Backspace/Delete (navigation) 344 * - Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) 345 * - Tab (focus change) 346 */ 347 private shouldPreventDefault(e: KeyboardEvent): boolean { 348 const key = e.key; 349 const modifiers = Modifiers.fromEvent(e); 350 351 if (key === " " || key.startsWith("Arrow")) { 352 return true; 353 } 354 355 if (key === "Backspace" || key === "Delete") { 356 return true; 357 } 358 359 if (key === "Tab") { 360 return true; 361 } 362 363 if (Modifiers.isPrimaryModifier(modifiers) && (key === "z" || key === "Z")) { 364 return true; 365 } 366 367 if (Modifiers.isPrimaryModifier(modifiers) && (key === "y" || key === "Y")) { 368 return true; 369 } 370 371 return false; 372 } 373} 374 375/** 376 * Create an input adapter 377 * 378 * @param config - Input adapter configuration 379 * @returns Input adapter instance 380 */ 381export function createInputAdapter(config: InputAdapterConfig): InputAdapter { 382 return new InputAdapter(config); 383}