web based infinite canvas
1import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons } from "inkfinite-core";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import { createInputAdapter, InputAdapter, type PointerState } from "../input";
4
5/**
6 * Create a mock canvas element with getBoundingClientRect
7 */
8function createMockCanvas(): HTMLCanvasElement {
9 const canvas = document.createElement("canvas");
10 canvas.width = 800;
11 canvas.height = 600;
12
13 vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue({
14 left: 0,
15 top: 0,
16 right: 800,
17 bottom: 600,
18 width: 800,
19 height: 600,
20 x: 0,
21 y: 0,
22 toJSON: () => ({}),
23 });
24
25 return canvas;
26}
27
28/**
29 * Create a mock pointer event
30 */
31function createPointerEvent(
32 type: string,
33 options: {
34 clientX?: number;
35 clientY?: number;
36 button?: number;
37 buttons?: number;
38 ctrlKey?: boolean;
39 shiftKey?: boolean;
40 altKey?: boolean;
41 metaKey?: boolean;
42 } = {},
43): PointerEvent {
44 return new PointerEvent(type, {
45 clientX: options.clientX ?? 0,
46 clientY: options.clientY ?? 0,
47 button: options.button ?? 0,
48 buttons: options.buttons ?? 0,
49 ctrlKey: options.ctrlKey ?? false,
50 shiftKey: options.shiftKey ?? false,
51 altKey: options.altKey ?? false,
52 metaKey: options.metaKey ?? false,
53 bubbles: true,
54 cancelable: true,
55 });
56}
57
58/**
59 * Create a mock wheel event
60 */
61function createWheelEvent(
62 options: {
63 clientX?: number;
64 clientY?: number;
65 deltaY?: number;
66 ctrlKey?: boolean;
67 shiftKey?: boolean;
68 altKey?: boolean;
69 metaKey?: boolean;
70 } = {},
71): WheelEvent {
72 return new WheelEvent("wheel", {
73 clientX: options.clientX ?? 0,
74 clientY: options.clientY ?? 0,
75 deltaY: options.deltaY ?? 0,
76 ctrlKey: options.ctrlKey ?? false,
77 shiftKey: options.shiftKey ?? false,
78 altKey: options.altKey ?? false,
79 metaKey: options.metaKey ?? false,
80 bubbles: true,
81 cancelable: true,
82 });
83}
84
85/**
86 * Create a mock keyboard event
87 */
88function createKeyboardEvent(
89 type: string,
90 options: {
91 key?: string;
92 code?: string;
93 ctrlKey?: boolean;
94 shiftKey?: boolean;
95 altKey?: boolean;
96 metaKey?: boolean;
97 repeat?: boolean;
98 } = {},
99): KeyboardEvent {
100 return new KeyboardEvent(type, {
101 key: options.key ?? "a",
102 code: options.code ?? "KeyA",
103 ctrlKey: options.ctrlKey ?? false,
104 shiftKey: options.shiftKey ?? false,
105 altKey: options.altKey ?? false,
106 metaKey: options.metaKey ?? false,
107 repeat: options.repeat ?? false,
108 bubbles: true,
109 cancelable: true,
110 });
111}
112
113describe("InputAdapter", () => {
114 let canvas: HTMLCanvasElement;
115 let camera: Camera;
116 let actions: ActionType[];
117 let adapter: InputAdapter;
118
119 beforeEach(() => {
120 canvas = createMockCanvas();
121 camera = Camera.create(0, 0, 1);
122 actions = [];
123
124 adapter = new InputAdapter({
125 canvas,
126 getCamera: () => camera,
127 getViewport: () => ({ width: 800, height: 600 }),
128 onAction: (action) => actions.push(action),
129 preventDefault: true,
130 captureKeyboard: true,
131 });
132 });
133
134 afterEach(() => {
135 adapter.dispose();
136 });
137
138 describe("constructor and disposal", () => {
139 it("should create adapter and attach event listeners", () => {
140 const onAction = vi.fn();
141 const testAdapter = new InputAdapter({
142 canvas,
143 getCamera: () => camera,
144 getViewport: () => ({ width: 800, height: 600 }),
145 onAction,
146 });
147
148 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100 });
149 canvas.dispatchEvent(event);
150
151 expect(onAction).toHaveBeenCalled();
152 testAdapter.dispose();
153 });
154
155 it("should remove event listeners on dispose", () => {
156 const onAction = vi.fn();
157 const testAdapter = new InputAdapter({
158 canvas,
159 getCamera: () => camera,
160 getViewport: () => ({ width: 800, height: 600 }),
161 onAction,
162 });
163
164 testAdapter.dispose();
165
166 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100 });
167 canvas.dispatchEvent(event);
168
169 expect(onAction).not.toHaveBeenCalled();
170 });
171 });
172
173 describe("pointer events", () => {
174 it("should dispatch pointer down action", () => {
175 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 200, button: 0, buttons: 1 });
176 canvas.dispatchEvent(event);
177
178 expect(actions).toHaveLength(1);
179 expect(actions[0].type).toBe("pointer-down");
180 expect(actions[0]).toMatchObject({ screen: { x: 100, y: 200 }, button: 0 });
181 });
182
183 it("should dispatch pointer move action", () => {
184 const event = createPointerEvent("pointermove", { clientX: 150, clientY: 250, buttons: 1 });
185 canvas.dispatchEvent(event);
186
187 expect(actions).toHaveLength(1);
188 expect(actions[0].type).toBe("pointer-move");
189 expect(actions[0]).toMatchObject({ screen: { x: 150, y: 250 } });
190 });
191
192 it("should dispatch pointer up action", () => {
193 const event = createPointerEvent("pointerup", { clientX: 100, clientY: 200, button: 0, buttons: 0 });
194 canvas.dispatchEvent(event);
195
196 expect(actions).toHaveLength(1);
197 expect(actions[0].type).toBe("pointer-up");
198 expect(actions[0]).toMatchObject({ screen: { x: 100, y: 200 }, button: 0 });
199 });
200
201 it("should convert screen coordinates to world coordinates", () => {
202 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 });
203 canvas.dispatchEvent(event);
204
205 expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, world: { x: 0, y: 0 } });
206 });
207
208 it("should track pointer state", () => {
209 let state = adapter.getPointerState();
210 expect(state.isDown).toBe(false);
211 expect(state.startWorld).toBe(null);
212
213 const downEvent = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, buttons: 1 });
214 canvas.dispatchEvent(downEvent);
215
216 state = adapter.getPointerState();
217 expect(state.isDown).toBe(true);
218 expect(state.startWorld).not.toBe(null);
219 expect(state.startScreen).toEqual({ x: 100, y: 100 });
220
221 const upEvent = createPointerEvent("pointerup", { clientX: 150, clientY: 150, buttons: 0 });
222 canvas.dispatchEvent(upEvent);
223
224 state = adapter.getPointerState();
225 expect(state.isDown).toBe(false);
226 expect(state.startWorld).toBe(null);
227 });
228
229 it("should handle modifier keys in pointer events", () => {
230 const event = createPointerEvent("pointerdown", {
231 clientX: 100,
232 clientY: 100,
233 buttons: 1,
234 ctrlKey: true,
235 shiftKey: false,
236 });
237 canvas.dispatchEvent(event);
238
239 expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } });
240 });
241
242 it("should decode pointer buttons bitmask", () => {
243 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, buttons: 5 });
244 canvas.dispatchEvent(event);
245
246 expect(actions[0]).toMatchObject({ buttons: { left: true, middle: true, right: false } });
247 });
248 });
249
250 describe("cursor updates", () => {
251 it("throttles onCursorUpdate via requestAnimationFrame", () => {
252 adapter.dispose();
253 const rafCallbacks: FrameRequestCallback[] = [];
254 const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
255 rafCallbacks.push(cb);
256 return rafCallbacks.length;
257 });
258 const cancelSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
259 const onCursorUpdate = vi.fn();
260
261 adapter = new InputAdapter({
262 canvas,
263 getCamera: () => camera,
264 getViewport: () => ({ width: 800, height: 600 }),
265 onAction: (action) => actions.push(action),
266 onCursorUpdate,
267 });
268
269 canvas.dispatchEvent(createPointerEvent("pointermove", { clientX: 10, clientY: 20 }));
270 canvas.dispatchEvent(createPointerEvent("pointermove", { clientX: 50, clientY: 60 }));
271
272 expect(onCursorUpdate).not.toHaveBeenCalled();
273 expect(rafCallbacks).toHaveLength(1);
274
275 rafCallbacks[0](16);
276
277 expect(onCursorUpdate).toHaveBeenCalledTimes(1);
278 expect(onCursorUpdate).toHaveBeenCalledWith({ x: -350, y: -240 }, { x: 50, y: 60 });
279
280 rafSpy.mockRestore();
281 cancelSpy.mockRestore();
282 });
283 });
284
285 describe("wheel events", () => {
286 it("should dispatch wheel action", () => {
287 const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: -100 });
288 canvas.dispatchEvent(event);
289
290 expect(actions).toHaveLength(1);
291 expect(actions[0].type).toBe("wheel");
292 expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, deltaY: -100 });
293 });
294
295 it("should include modifiers in wheel events", () => {
296 const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 100, ctrlKey: true });
297 canvas.dispatchEvent(event);
298
299 expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } });
300 });
301 });
302
303 describe("keyboard events", () => {
304 it("should dispatch key down action", () => {
305 const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA" });
306 window.dispatchEvent(event);
307
308 expect(actions).toHaveLength(1);
309 expect(actions[0].type).toBe("key-down");
310 expect(actions[0]).toMatchObject({ key: "a", code: "KeyA", repeat: false });
311 });
312
313 it("should dispatch key up action", () => {
314 const event = createKeyboardEvent("keyup", { key: "b", code: "KeyB" });
315 window.dispatchEvent(event);
316
317 expect(actions).toHaveLength(1);
318 expect(actions[0].type).toBe("key-up");
319 expect(actions[0]).toMatchObject({ key: "b", code: "KeyB" });
320 });
321
322 it("should handle repeat key events", () => {
323 const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA", repeat: true });
324 window.dispatchEvent(event);
325
326 expect(actions[0]).toMatchObject({ repeat: true });
327 });
328
329 it("should include modifiers in keyboard events", () => {
330 const event = createKeyboardEvent("keydown", { key: "z", code: "KeyZ", ctrlKey: true });
331 window.dispatchEvent(event);
332
333 expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } });
334 });
335
336 it("should not capture keyboard events when captureKeyboard is false", () => {
337 const testActions: ActionType[] = [];
338 const testAdapter = new InputAdapter({
339 canvas,
340 getCamera: () => camera,
341 getViewport: () => ({ width: 800, height: 600 }),
342 onAction: (action) => testActions.push(action),
343 captureKeyboard: false,
344 });
345
346 const event = createKeyboardEvent("keydown", { key: "a" });
347 window.dispatchEvent(event);
348
349 expect(testActions).toHaveLength(0);
350 testAdapter.dispose();
351 });
352
353 it("should ignore keyboard events from input elements", () => {
354 const input = document.createElement("input");
355 document.body.appendChild(input);
356
357 const event = new KeyboardEvent("keydown", { key: "a", code: "KeyA", bubbles: true, cancelable: true });
358
359 Object.defineProperty(event, "target", { value: input, enumerable: true });
360 window.dispatchEvent(event);
361
362 expect(actions).toHaveLength(0);
363
364 document.body.removeChild(input);
365 });
366 });
367
368 describe("coordinate transformation", () => {
369 it("should handle camera panning", () => {
370 camera = Camera.create(100, 50, 1);
371
372 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 });
373 canvas.dispatchEvent(event);
374
375 expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, world: { x: 100, y: 50 } });
376 });
377
378 it("should handle camera zoom", () => {
379 camera = Camera.create(0, 0, 2);
380
381 const event = createPointerEvent("pointerdown", { clientX: 500, clientY: 300, buttons: 1 });
382 canvas.dispatchEvent(event);
383 expect(actions[0]).toMatchObject({ screen: { x: 500, y: 300 }, world: { x: 50, y: 0 } });
384 });
385
386 it("should handle combined camera transform", () => {
387 camera = Camera.create(200, 100, 0.5);
388
389 const event = createPointerEvent("pointerdown", { clientX: 600, clientY: 450, buttons: 1 });
390 canvas.dispatchEvent(event);
391
392 expect(actions[0]).toMatchObject({ screen: { x: 600, y: 450 }, world: { x: 600, y: 400 } });
393 });
394 });
395
396 describe("edge cases", () => {
397 it("should handle pointer events at canvas edges", () => {
398 let event = createPointerEvent("pointerdown", { clientX: 0, clientY: 0, buttons: 1 });
399 canvas.dispatchEvent(event);
400 expect(actions[0]).toMatchObject({ screen: { x: 0, y: 0 } });
401
402 actions.length = 0;
403
404 event = createPointerEvent("pointerdown", { clientX: 800, clientY: 600, buttons: 1 });
405 canvas.dispatchEvent(event);
406 expect(actions[0]).toMatchObject({ screen: { x: 800, y: 600 } });
407 });
408
409 it("should handle multiple pointer buttons simultaneously", () => {
410 const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 2, buttons: 7 });
411 canvas.dispatchEvent(event);
412
413 expect(actions[0]).toMatchObject({ button: 2, buttons: { left: true, middle: true, right: true } });
414 });
415
416 it("should handle zero deltaY in wheel events", () => {
417 const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 0 });
418 canvas.dispatchEvent(event);
419
420 expect(actions[0]).toMatchObject({ deltaY: 0 });
421 });
422
423 it("should handle special keys", () => {
424 const specialKeys = [
425 { key: "Escape", code: "Escape" },
426 { key: "Enter", code: "Enter" },
427 { key: " ", code: "Space" },
428 { key: "ArrowUp", code: "ArrowUp" },
429 { key: "Tab", code: "Tab" },
430 ];
431
432 specialKeys.forEach(({ key, code }) => {
433 actions.length = 0;
434 const event = createKeyboardEvent("keydown", { key, code });
435 window.dispatchEvent(event);
436
437 expect(actions[0]).toMatchObject({ key, code });
438 });
439 });
440
441 it("should handle rapid pointer move events", () => {
442 for (let i = 0; i < 100; i++) {
443 const event = createPointerEvent("pointermove", { clientX: 100 + i, clientY: 100 + i, buttons: 1 });
444 canvas.dispatchEvent(event);
445 }
446
447 expect(actions).toHaveLength(100);
448 expect(actions.every((action) => action.type === "pointer-move")).toBe(true);
449 });
450
451 it("should update pointer state on each move", () => {
452 const downEvent = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, buttons: 1 });
453 canvas.dispatchEvent(downEvent);
454
455 const moveEvent = createPointerEvent("pointermove", { clientX: 200, clientY: 200, buttons: 1 });
456 canvas.dispatchEvent(moveEvent);
457
458 const state = adapter.getPointerState();
459 expect(state.lastScreen).toEqual({ x: 200, y: 200 });
460 expect(state.startScreen).toEqual({ x: 100, y: 100 });
461 });
462
463 it("should preserve start position until all buttons released", () => {
464 const down1 = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, buttons: 1 });
465 canvas.dispatchEvent(down1);
466
467 let state = adapter.getPointerState();
468 expect(state.startScreen).toEqual({ x: 100, y: 100 });
469
470 const down2 = createPointerEvent("pointerdown", { clientX: 150, clientY: 150, button: 1, buttons: 5 });
471 canvas.dispatchEvent(down2);
472
473 const up1 = createPointerEvent("pointerup", { clientX: 200, clientY: 200, button: 0, buttons: 4 });
474 canvas.dispatchEvent(up1);
475
476 state = adapter.getPointerState();
477 expect(state.startScreen).not.toBe(null);
478
479 const up2 = createPointerEvent("pointerup", { clientX: 250, clientY: 250, button: 1, buttons: 0 });
480 canvas.dispatchEvent(up2);
481
482 state = adapter.getPointerState();
483 expect(state.startScreen).toBe(null);
484 });
485
486 it("should handle negative coordinates from camera transform", () => {
487 camera = Camera.create(-1000, -1000, 1);
488
489 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 });
490 canvas.dispatchEvent(event);
491
492 expect(actions[0]).toMatchObject({ world: { x: -1000, y: -1000 } });
493 });
494
495 it("should handle very large coordinates", () => {
496 camera = Camera.create(1e10, 1e10, 0.001);
497
498 const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 });
499 canvas.dispatchEvent(event);
500
501 const action = actions[0] as { world: { x: number; y: number } };
502 expect(action.world.x).toBeCloseTo(1e10, -5);
503 expect(action.world.y).toBeCloseTo(1e10, -5);
504 });
505 });
506
507 describe("createInputAdapter", () => {
508 it("should create and return an InputAdapter instance", () => {
509 const testAdapter = createInputAdapter({
510 canvas,
511 getCamera: () => camera,
512 getViewport: () => ({ width: 800, height: 600 }),
513 onAction: (action) => actions.push(action),
514 });
515
516 expect(testAdapter).toBeInstanceOf(InputAdapter);
517 testAdapter.dispose();
518 });
519 });
520});