web based infinite canvas
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}