From 504d82e3a96d5df040b5b79b49ff5448c2a19207 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 22 Dec 2025 00:10:28 +0000 Subject: [PATCH] feat: actions & input adapter --- TODO.txt | 18 +++++++++--------- pnpm-lock.yaml | 4 ++++ apps/web/package.json | 1 + apps/web/vite.config.ts | 70 +++++++++++++++++++++++++++++++--------------------------------------- apps/web/src/demo.spec.ts | 7 ------- packages/core/src/actions.ts | 265 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/tests/actions.test.ts | 367 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/web/src/lib/input.ts | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/web/src/lib/tests/input.test.ts | 485 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 10 file(s) changed, 1487 insertion(s)(+), 55 deletion(s)(-) diff --git a/TODO.txt b/TODO.txt --- a/TODO.txt +++ b/TODO.txt @@ -218,20 +218,20 @@ Goal: normalize events into editor actions. Input adapter (/apps/web/src/lib/input): -[ ] Capture pointerdown/move/up -[ ] Convert screen coords -> world coords using camera -[ ] Track pointer state: isDown, startWorld, lastWorld, buttons +[x] Capture pointerdown/move/up +[x] Convert screen coords -> world coords using camera +[x] Track pointer state: isDown, startWorld, lastWorld, buttons Keyboard: -[ ] Capture keydown/keyup -[ ] Normalize modifiers (ctrl/cmd, shift, alt) +[x] Capture keydown/keyup +[x] Normalize modifiers (ctrl/cmd, shift, alt) -Action bus (/packages/core/src/actions): -[ ] Define Action union: +Action bus (/packages/core/src/actions.ts): +[x] Define Action union: - PointerDown, PointerMove, PointerUp - KeyDown, KeyUp - Wheel (for zoom) -[ ] dispatch(action) -> store updates via tool state machine (next milestone) +[x] dispatch(action) -> store updates via tool state machine (next milestone) (DoD): - You can pan/zoom camera via wheel/drag with a temporary "camera tool" @@ -242,7 +242,7 @@ 8. Milestone H: Tool state machine (foundation) *wb-H* ============================================================================== -Goal: tools are explicit, testable state machines. +Goal: tools are explicit, testable state machines (RxJS based) Tools (/packages/core/src/tools): [ ] Define ToolId: "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,10 @@ version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) apps/web: + dependencies: + inkfinite-core: + specifier: workspace:* + version: link:../../packages/core devDependencies: '@eslint/compat': specifier: ^1.4.0 diff --git a/apps/web/package.json b/apps/web/package.json --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "test": "npm run test:unit -- --run", "format": "prettier --write ." }, + "dependencies": { "inkfinite-core": "workspace:*" }, "devDependencies": { "@eslint/compat": "^1.4.0", "@eslint/js": "^9.39.1", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,42 +1,34 @@ -import devtoolsJson from 'vite-plugin-devtools-json'; -import { defineConfig } from 'vitest/config'; -import { playwright } from '@vitest/browser-playwright'; -import { sveltekit } from '@sveltejs/kit/vite'; +import { sveltekit } from "@sveltejs/kit/vite"; +import { playwright } from "@vitest/browser-playwright"; +import devtoolsJson from "vite-plugin-devtools-json"; +import { defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [sveltekit(), devtoolsJson()], - - test: { - expect: { requireAssertions: true }, - - projects: [ - { - extends: './vite.config.ts', - - test: { - name: 'client', - - browser: { - enabled: true, - provider: playwright(), - instances: [{ browser: 'chromium', headless: true }] - }, - - include: ['src/**/*.svelte.{test,spec}.{js,ts}'], - exclude: ['src/lib/server/**'] - } - }, - - { - extends: './vite.config.ts', - - test: { - name: 'server', - environment: 'node', - include: ['src/**/*.{test,spec}.{js,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] - } - } - ] - } + plugins: [sveltekit(), devtoolsJson()], + test: { + ui: false, + expect: { requireAssertions: true }, + projects: [{ + extends: "./vite.config.ts", + test: { + name: "client", + browser: { + headless: true, + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium", headless: true }], + }, + include: ["src/**/*.svelte.{test,spec}.{js,ts}", "src/lib/tests/**/*.{test,spec}.{js,ts}"], + exclude: ["src/lib/server/**"], + }, + }, { + extends: "./vite.config.ts", + test: { + name: "server", + environment: "node", + include: ["src/**/*.{test,spec}.{js,ts}"], + exclude: ["src/**/*.svelte.{test,spec}.{js,ts}", "src/lib/tests/**/*.{test,spec}.{js,ts}"], + }, + }], + }, }); diff --git a/apps/web/src/demo.spec.ts b/apps/web/src/demo.spec.ts deleted file mode 100644 --- a/apps/web/src/demo.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/packages/core/src/actions.ts b/packages/core/src/actions.ts new file mode 100644 --- /dev/null +++ b/packages/core/src/actions.ts @@ -0,0 +1,265 @@ +import type { Vec2 } from "./math"; + +/** + * Keyboard modifier keys state + */ +export type Modifiers = { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }; + +/** + * Pointer button state + * - left: 0 + * - middle: 1 + * - right: 2 + */ +export type PointerButtons = { left: boolean; middle: boolean; right: boolean }; + +/** + * Pointer down event - user pressed pointer button + */ +export type PointerDownAction = { + type: "pointer-down"; + /** Point in screen coordinates (pixels) */ + screen: Vec2; + /** Point in world coordinates */ + world: Vec2; + /** Which button was pressed */ + button: number; + /** State of all buttons after this event */ + buttons: PointerButtons; + /** Modifier keys state */ + modifiers: Modifiers; + /** Timestamp of the event */ + timestamp: number; +}; + +/** + * Pointer move event - user moved pointer + */ +export type PointerMoveAction = { + type: "pointer-move"; + /** Point in screen coordinates (pixels) */ + screen: Vec2; + /** Point in world coordinates */ + world: Vec2; + /** State of all buttons */ + buttons: PointerButtons; + /** Modifier keys state */ + modifiers: Modifiers; + /** Timestamp of the event */ + timestamp: number; +}; + +/** + * Pointer up event - user released pointer button + */ +export type PointerUpAction = { + type: "pointer-up"; + /** Point in screen coordinates (pixels) */ + screen: Vec2; + /** Point in world coordinates */ + world: Vec2; + /** Which button was released */ + button: number; + /** State of all buttons after this event */ + buttons: PointerButtons; + /** Modifier keys state */ + modifiers: Modifiers; + /** Timestamp of the event */ + timestamp: number; +}; + +/** + * Wheel event - user scrolled wheel + */ +export type WheelAction = { + type: "wheel"; + /** Point in screen coordinates where wheel event occurred */ + screen: Vec2; + /** Point in world coordinates */ + world: Vec2; + /** Wheel delta (usually negative = zoom in, positive = zoom out) */ + deltaY: number; + /** Modifier keys state */ + modifiers: Modifiers; + /** Timestamp of the event */ + timestamp: number; +}; + +/** + * Key down event - user pressed a key + */ +export type KeyDownAction = { + type: "key-down"; + /** The key that was pressed (e.g., "a", "Enter", "Escape") */ + key: string; + /** The code of the key (e.g., "KeyA", "Enter", "Escape") */ + code: string; + /** Modifier keys state */ + modifiers: Modifiers; + /** Whether this is a repeated key event (key held down) */ + repeat: boolean; + /** Timestamp of the event */ + timestamp: number; +}; + +/** + * Key up event - user released a key + */ +export type KeyUpAction = { + type: "key-up"; + /** The key that was released */ + key: string; + /** The code of the key */ + code: string; + /** Modifier keys state */ + modifiers: Modifiers; + /** Timestamp of the event */ + timestamp: number; +}; + +/** + * Union of all input actions + */ +export type Action = + | PointerDownAction + | PointerMoveAction + | PointerUpAction + | WheelAction + | KeyDownAction + | KeyUpAction; + +/** + * Action namespace for helper functions + */ +export const Action = { + /** + * Create a PointerDownAction + */ + pointerDown( + screen: Vec2, + world: Vec2, + button: number, + buttons: PointerButtons, + modifiers: Modifiers, + timestamp = Date.now(), + ): PointerDownAction { + return { type: "pointer-down", screen, world, button, buttons, modifiers, timestamp }; + }, + + /** + * Create a PointerMoveAction + */ + pointerMove( + screen: Vec2, + world: Vec2, + buttons: PointerButtons, + modifiers: Modifiers, + timestamp = Date.now(), + ): PointerMoveAction { + return { type: "pointer-move", screen, world, buttons, modifiers, timestamp }; + }, + + /** + * Create a PointerUpAction + */ + pointerUp( + screen: Vec2, + world: Vec2, + button: number, + buttons: PointerButtons, + modifiers: Modifiers, + timestamp = Date.now(), + ): PointerUpAction { + return { type: "pointer-up", screen, world, button, buttons, modifiers, timestamp }; + }, + + /** + * Create a WheelAction + */ + wheel(screen: Vec2, world: Vec2, deltaY: number, modifiers: Modifiers, timestamp = Date.now()): WheelAction { + return { type: "wheel", screen, world, deltaY, modifiers, timestamp }; + }, + + /** + * Create a KeyDownAction + */ + keyDown(key: string, code: string, modifiers: Modifiers, repeat = false, timestamp = Date.now()): KeyDownAction { + return { type: "key-down", key, code, modifiers, repeat, timestamp }; + }, + + /** + * Create a KeyUpAction + */ + keyUp(key: string, code: string, modifiers: Modifiers, timestamp = Date.now()): KeyUpAction { + return { type: "key-up", key, code, modifiers, timestamp }; + }, +}; + +/** + * Create Modifiers object from DOM event + */ +export const Modifiers = { + /** + * Create a Modifiers object with default values (all false) + */ + create(ctrl = false, shift = false, alt = false, meta = false): Modifiers { + return { ctrl, shift, alt, meta }; + }, + + /** + * Create Modifiers from a keyboard or mouse event + */ + fromEvent(event: { ctrlKey: boolean; shiftKey: boolean; altKey: boolean; metaKey: boolean }): Modifiers { + return { ctrl: event.ctrlKey, shift: event.shiftKey, alt: event.altKey, meta: event.metaKey }; + }, + + /** + * Check if no modifiers are active + */ + isEmpty(modifiers: Modifiers): boolean { + return !modifiers.ctrl && !modifiers.shift && !modifiers.alt && !modifiers.meta; + }, + + /** + * Check if Cmd (Mac) or Ctrl (other platforms) is pressed + */ + isPrimaryModifier(modifiers: Modifiers): boolean { + const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().includes("MAC"); + return isMac ? modifiers.meta : modifiers.ctrl; + }, +}; + +/** + * PointerButtons helpers + */ +export const PointerButtons = { + /** + * Create a PointerButtons object with default values (all false) + */ + create(left = false, middle = false, right = false): PointerButtons { + return { left, middle, right }; + }, + + /** + * Create PointerButtons from DOM PointerEvent buttons bitmask + * + * @param buttons - Bitmask from PointerEvent.buttons + */ + fromButtons(buttons: number): PointerButtons { + return { left: (buttons & 1) !== 0, right: (buttons & 2) !== 0, middle: (buttons & 4) !== 0 }; + }, + + /** + * Check if any button is pressed + */ + isAnyPressed(buttons: PointerButtons): boolean { + return buttons.left || buttons.middle || buttons.right; + }, + + /** + * Check if no buttons are pressed + */ + isEmpty(buttons: PointerButtons): boolean { + return !buttons.left && !buttons.middle && !buttons.right; + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export * from "./actions"; export * from "./camera"; export * from "./geom"; export * from "./math"; diff --git a/packages/core/tests/actions.test.ts b/packages/core/tests/actions.test.ts new file mode 100644 --- /dev/null +++ b/packages/core/tests/actions.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from "vitest"; +import { Action, Modifiers, PointerButtons } from "../src/actions"; + +describe("Modifiers", () => { + describe("create", () => { + it("should create modifiers with default values", () => { + const modifiers = Modifiers.create(); + expect(modifiers).toEqual({ ctrl: false, shift: false, alt: false, meta: false }); + }); + + it("should create modifiers with custom values", () => { + const modifiers = Modifiers.create(true, false, true, false); + expect(modifiers).toEqual({ ctrl: true, shift: false, alt: true, meta: false }); + }); + }); + + describe("fromEvent", () => { + it("should extract modifiers from event object", () => { + const event = { ctrlKey: true, shiftKey: false, altKey: true, metaKey: false }; + const modifiers = Modifiers.fromEvent(event); + expect(modifiers).toEqual({ ctrl: true, shift: false, alt: true, meta: false }); + }); + + it("should handle all modifiers pressed", () => { + const event = { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }; + const modifiers = Modifiers.fromEvent(event); + expect(modifiers).toEqual({ ctrl: true, shift: true, alt: true, meta: true }); + }); + + it("should handle no modifiers pressed", () => { + const event = { ctrlKey: false, shiftKey: false, altKey: false, metaKey: false }; + const modifiers = Modifiers.fromEvent(event); + expect(modifiers).toEqual({ ctrl: false, shift: false, alt: false, meta: false }); + }); + }); + + describe("isEmpty", () => { + it("should return true when no modifiers are active", () => { + const modifiers = Modifiers.create(); + expect(Modifiers.isEmpty(modifiers)).toBe(true); + }); + + it("should return false when any modifier is active", () => { + expect(Modifiers.isEmpty(Modifiers.create(true, false, false, false))).toBe(false); + expect(Modifiers.isEmpty(Modifiers.create(false, true, false, false))).toBe(false); + expect(Modifiers.isEmpty(Modifiers.create(false, false, true, false))).toBe(false); + expect(Modifiers.isEmpty(Modifiers.create(false, false, false, true))).toBe(false); + }); + }); + + describe("isPrimaryModifier", () => { + it("should detect primary modifier on different platforms", () => { + const withCtrl = Modifiers.create(true, false, false, false); + const withMeta = Modifiers.create(false, false, false, true); + const ctrlIsPrimary = Modifiers.isPrimaryModifier(withCtrl); + const metaIsPrimary = Modifiers.isPrimaryModifier(withMeta); + expect(ctrlIsPrimary || metaIsPrimary).toBe(true); + }); + }); +}); + +describe("PointerButtons", () => { + describe("create", () => { + it("should create button state with default values", () => { + const buttons = PointerButtons.create(); + expect(buttons).toEqual({ left: false, middle: false, right: false }); + }); + + it("should create button state with custom values", () => { + const buttons = PointerButtons.create(true, false, true); + expect(buttons).toEqual({ left: true, middle: false, right: true }); + }); + }); + + describe("fromButtons", () => { + it.each([ + { description: "no buttons pressed", buttons: 0, expected: { left: false, middle: false, right: false } }, + { description: "left button only", buttons: 1, expected: { left: true, middle: false, right: false } }, + { description: "right button only", buttons: 2, expected: { left: false, middle: false, right: true } }, + { description: "middle button only", buttons: 4, expected: { left: false, middle: true, right: false } }, + { description: "left and right", buttons: 3, expected: { left: true, middle: false, right: true } }, + { description: "left and middle", buttons: 5, expected: { left: true, middle: true, right: false } }, + { description: "right and middle", buttons: 6, expected: { left: false, middle: true, right: true } }, + { description: "all buttons", buttons: 7, expected: { left: true, middle: true, right: true } }, + ])("should decode bitmask: $description", ({ buttons, expected }) => { + expect(PointerButtons.fromButtons(buttons)).toEqual(expected); + }); + }); + + describe("isAnyPressed", () => { + it("should return false when no buttons pressed", () => { + expect(PointerButtons.isAnyPressed(PointerButtons.create())).toBe(false); + }); + + it("should return true when any button is pressed", () => { + expect(PointerButtons.isAnyPressed(PointerButtons.create(true, false, false))).toBe(true); + expect(PointerButtons.isAnyPressed(PointerButtons.create(false, true, false))).toBe(true); + expect(PointerButtons.isAnyPressed(PointerButtons.create(false, false, true))).toBe(true); + }); + }); + + describe("isEmpty", () => { + it("should return true when no buttons pressed", () => { + expect(PointerButtons.isEmpty(PointerButtons.create())).toBe(true); + }); + + it("should return false when any button is pressed", () => { + expect(PointerButtons.isEmpty(PointerButtons.create(true, false, false))).toBe(false); + expect(PointerButtons.isEmpty(PointerButtons.create(false, true, false))).toBe(false); + expect(PointerButtons.isEmpty(PointerButtons.create(false, false, true))).toBe(false); + }); + }); +}); + +describe("Action", () => { + const screen = { x: 100, y: 200 }; + const world = { x: 50, y: 100 }; + const modifiers = Modifiers.create(true, false, false, false); + const buttons = PointerButtons.create(true, false, false); + const timestamp = 1_234_567_890; + + describe("pointerDown", () => { + it("should create pointer down action with all required fields", () => { + const action = Action.pointerDown(screen, world, 0, buttons, modifiers, timestamp); + + expect(action).toEqual({ type: "pointer-down", screen, world, button: 0, buttons, modifiers, timestamp }); + }); + + it("should use current timestamp when not provided", () => { + const before = Date.now(); + const action = Action.pointerDown(screen, world, 0, buttons, modifiers); + const after = Date.now(); + + expect(action.timestamp).toBeGreaterThanOrEqual(before); + expect(action.timestamp).toBeLessThanOrEqual(after); + }); + + it("should handle different button values", () => { + expect(Action.pointerDown(screen, world, 0, buttons, modifiers).button).toBe(0); + expect(Action.pointerDown(screen, world, 1, buttons, modifiers).button).toBe(1); + expect(Action.pointerDown(screen, world, 2, buttons, modifiers).button).toBe(2); + }); + }); + + describe("pointerMove", () => { + it("should create pointer move action with all required fields", () => { + const action = Action.pointerMove(screen, world, buttons, modifiers, timestamp); + + expect(action).toEqual({ type: "pointer-move", screen, world, buttons, modifiers, timestamp }); + }); + + it("should use current timestamp when not provided", () => { + const before = Date.now(); + const action = Action.pointerMove(screen, world, buttons, modifiers); + const after = Date.now(); + + expect(action.timestamp).toBeGreaterThanOrEqual(before); + expect(action.timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe("pointerUp", () => { + it("should create pointer up action with all required fields", () => { + const action = Action.pointerUp(screen, world, 0, buttons, modifiers, timestamp); + + expect(action).toEqual({ type: "pointer-up", screen, world, button: 0, buttons, modifiers, timestamp }); + }); + + it("should use current timestamp when not provided", () => { + const before = Date.now(); + const action = Action.pointerUp(screen, world, 0, buttons, modifiers); + const after = Date.now(); + + expect(action.timestamp).toBeGreaterThanOrEqual(before); + expect(action.timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe("wheel", () => { + it("should create wheel action with all required fields", () => { + const deltaY = -100; + const action = Action.wheel(screen, world, deltaY, modifiers, timestamp); + + expect(action).toEqual({ type: "wheel", screen, world, deltaY, modifiers, timestamp }); + }); + + it("should use current timestamp when not provided", () => { + const before = Date.now(); + const action = Action.wheel(screen, world, -100, modifiers); + const after = Date.now(); + + expect(action.timestamp).toBeGreaterThanOrEqual(before); + expect(action.timestamp).toBeLessThanOrEqual(after); + }); + + it("should handle positive and negative deltaY", () => { + expect(Action.wheel(screen, world, -100, modifiers).deltaY).toBe(-100); + expect(Action.wheel(screen, world, 100, modifiers).deltaY).toBe(100); + expect(Action.wheel(screen, world, 0, modifiers).deltaY).toBe(0); + }); + }); + + describe("keyDown", () => { + it("should create key down action with all required fields", () => { + const action = Action.keyDown("a", "KeyA", modifiers, false, timestamp); + + expect(action).toEqual({ type: "key-down", key: "a", code: "KeyA", modifiers, repeat: false, timestamp }); + }); + + it("should use current timestamp when not provided", () => { + const before = Date.now(); + const action = Action.keyDown("a", "KeyA", modifiers); + const after = Date.now(); + + expect(action.timestamp).toBeGreaterThanOrEqual(before); + expect(action.timestamp).toBeLessThanOrEqual(after); + }); + + it("should handle repeat flag", () => { + expect(Action.keyDown("a", "KeyA", modifiers, false).repeat).toBe(false); + expect(Action.keyDown("a", "KeyA", modifiers, true).repeat).toBe(true); + }); + + it("should handle special keys", () => { + expect(Action.keyDown("Escape", "Escape", modifiers).key).toBe("Escape"); + expect(Action.keyDown("Enter", "Enter", modifiers).key).toBe("Enter"); + expect(Action.keyDown(" ", "Space", modifiers).key).toBe(" "); + }); + }); + + describe("keyUp", () => { + it("should create key up action with all required fields", () => { + const action = Action.keyUp("a", "KeyA", modifiers, timestamp); + + expect(action).toEqual({ type: "key-up", key: "a", code: "KeyA", modifiers, timestamp }); + }); + + it("should use current timestamp when not provided", () => { + const before = Date.now(); + const action = Action.keyUp("a", "KeyA", modifiers); + const after = Date.now(); + + expect(action.timestamp).toBeGreaterThanOrEqual(before); + expect(action.timestamp).toBeLessThanOrEqual(after); + }); + }); +}); + +describe("Action edge cases", () => { + describe("coordinate edge cases", () => { + it("should handle zero coordinates", () => { + const screen = { x: 0, y: 0 }; + const world = { x: 0, y: 0 }; + const action = Action.pointerDown(screen, world, 0, PointerButtons.create(), Modifiers.create()); + + expect(action.screen).toEqual({ x: 0, y: 0 }); + expect(action.world).toEqual({ x: 0, y: 0 }); + }); + + it("should handle negative coordinates", () => { + const screen = { x: -100, y: -200 }; + const world = { x: -50, y: -100 }; + const action = Action.pointerMove(screen, world, PointerButtons.create(), Modifiers.create()); + + expect(action.screen).toEqual({ x: -100, y: -200 }); + expect(action.world).toEqual({ x: -50, y: -100 }); + }); + + it("should handle very large coordinates", () => { + const screen = { x: 1e10, y: 1e10 }; + const world = { x: 1e10, y: 1e10 }; + const action = Action.pointerUp(screen, world, 0, PointerButtons.create(), Modifiers.create()); + + expect(action.screen).toEqual({ x: 1e10, y: 1e10 }); + expect(action.world).toEqual({ x: 1e10, y: 1e10 }); + }); + + it("should handle floating point coordinates", () => { + const screen = { x: 100.5, y: 200.7 }; + const world = { x: 50.3, y: 100.9 }; + const action = Action.pointerMove(screen, world, PointerButtons.create(), Modifiers.create()); + + expect(action.screen).toEqual({ x: 100.5, y: 200.7 }); + expect(action.world).toEqual({ x: 50.3, y: 100.9 }); + }); + }); + + describe("button edge cases", () => { + it("should handle invalid button numbers gracefully", () => { + const action = Action.pointerDown( + { x: 0, y: 0 }, + { x: 0, y: 0 }, + 99, + PointerButtons.create(), + Modifiers.create(), + ); + expect(action.button).toBe(99); + }); + + it("should handle negative button numbers", () => { + const action = Action.pointerDown( + { x: 0, y: 0 }, + { x: 0, y: 0 }, + -1, + PointerButtons.create(), + Modifiers.create(), + ); + expect(action.button).toBe(-1); + }); + }); + + describe("wheel deltaY edge cases", () => { + it("should handle very large deltaY values", () => { + const action = Action.wheel({ x: 0, y: 0 }, { x: 0, y: 0 }, 1e6, Modifiers.create()); + expect(action.deltaY).toBe(1e6); + }); + + it("should handle very small deltaY values", () => { + const action = Action.wheel({ x: 0, y: 0 }, { x: 0, y: 0 }, -1e-10, Modifiers.create()); + expect(action.deltaY).toBe(-1e-10); + }); + }); + + describe("keyboard edge cases", () => { + it("should handle empty key string", () => { + const action = Action.keyDown("", "", Modifiers.create()); + expect(action.key).toBe(""); + expect(action.code).toBe(""); + }); + + it("should handle multi-character keys", () => { + const action = Action.keyDown("ArrowUp", "ArrowUp", Modifiers.create()); + expect(action.key).toBe("ArrowUp"); + expect(action.code).toBe("ArrowUp"); + }); + + it("should handle unicode characters", () => { + const action = Action.keyDown("�", "Euro", Modifiers.create()); + expect(action.key).toBe("�"); + }); + }); + + describe("timestamp edge cases", () => { + it("should handle zero timestamp", () => { + const action = Action.pointerDown( + { x: 0, y: 0 }, + { x: 0, y: 0 }, + 0, + PointerButtons.create(), + Modifiers.create(), + 0, + ); + expect(action.timestamp).toBe(0); + }); + + it("should handle negative timestamp", () => { + const action = Action.keyDown("a", "KeyA", Modifiers.create(), false, -100); + expect(action.timestamp).toBe(-100); + }); + + it("should handle very large timestamp", () => { + const largeTimestamp = Number.MAX_SAFE_INTEGER; + const action = Action.wheel({ x: 0, y: 0 }, { x: 0, y: 0 }, 0, Modifiers.create(), largeTimestamp); + expect(action.timestamp).toBe(largeTimestamp); + }); + }); +}); diff --git a/apps/web/src/lib/input.ts b/apps/web/src/lib/input.ts new file mode 100644 --- /dev/null +++ b/apps/web/src/lib/input.ts @@ -0,0 +1,324 @@ +import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons, type Viewport } from "inkfinite-core"; + +/** + * 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; + /** 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 events for zooming + * - 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: Required; + private pointerState: PointerState; + private boundHandlers: { + pointerDown: (e: PointerEvent) => void; + pointerMove: (e: PointerEvent) => void; + pointerUp: (e: PointerEvent) => void; + wheel: (e: WheelEvent) => void; + keyDown: (e: KeyboardEvent) => void; + keyUp: (e: KeyboardEvent) => void; + contextMenu: (e: Event) => void; + }; + + constructor(config: InputAdapterConfig) { + this.config = { + ...config, + preventDefault: config.preventDefault ?? true, + captureKeyboard: config.captureKeyboard ?? true, + }; + + 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), + wheel: this.handleWheel.bind(this), + keyDown: this.handleKeyDown.bind(this), + keyUp: this.handleKeyUp.bind(this), + contextMenu: this.handleContextMenu.bind(this), + }; + + this.attach(); + } + + /** + * Get current pointer state + */ + getPointerState(): Readonly { + 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("wheel", this.boundHandlers.wheel, { passive: false }); + canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu); + + 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("wheel", this.boundHandlers.wheel); + canvas.removeEventListener("contextmenu", this.boundHandlers.contextMenu); + + if (this.config.captureKeyboard) { + window.removeEventListener("keydown", this.boundHandlers.keyDown); + window.removeEventListener("keyup", this.boundHandlers.keyUp); + } + } + + /** + * 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); + } + + /** + * 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.config.onAction(Action.pointerDown(screen, world, e.button, buttons, modifiers)); + } + + /** + * 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)); + } + + /** + * Handle pointer up event + */ + private handlePointerUp(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 = false; + this.pointerState.lastWorld = world; + this.pointerState.lastScreen = screen; + this.pointerState.buttons = buttons; + + if (PointerButtons.isEmpty(buttons)) { + this.pointerState.startWorld = null; + this.pointerState.startScreen = null; + } + + this.config.onAction(Action.pointerUp(screen, world, e.button, buttons, modifiers)); + } + + /** + * 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, e.deltaY, modifiers)); + } + + /** + * Handle key down event + */ + 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 && this.shouldPreventDefault(e)) { + e.preventDefault(); + } + } + + /** + * Handle key up event + */ + 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)); + } + + /** + * Handle context menu event (prevent default) + */ + private handleContextMenu(e: Event): void { + if (this.config.preventDefault) { + e.preventDefault(); + } + } + + /** + * Determine if default behavior should be prevented for a key event + * + * Prevents default for: + * - Space (scroll) + * - Arrow keys (scroll) + * - Backspace/Delete (navigation) + * - Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) + * - Tab (focus change) + */ + private shouldPreventDefault(e: KeyboardEvent): boolean { + const key = e.key; + const modifiers = Modifiers.fromEvent(e); + + if (key === " " || key.startsWith("Arrow")) { + return true; + } + + if (key === "Backspace" || key === "Delete") { + return true; + } + + if (key === "Tab") { + return true; + } + + if (Modifiers.isPrimaryModifier(modifiers) && (key === "z" || key === "Z")) { + return true; + } + + if (Modifiers.isPrimaryModifier(modifiers) && (key === "y" || key === "Y")) { + return true; + } + + return false; + } +} + +/** + * Create an input adapter + * + * @param config - Input adapter configuration + * @returns Input adapter instance + */ +export function createInputAdapter(config: InputAdapterConfig): InputAdapter { + return new InputAdapter(config); +} diff --git a/apps/web/src/lib/tests/input.test.ts b/apps/web/src/lib/tests/input.test.ts new file mode 100644 --- /dev/null +++ b/apps/web/src/lib/tests/input.test.ts @@ -0,0 +1,485 @@ +import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons } from "inkfinite-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createInputAdapter, InputAdapter, type PointerState } from "../input"; + +/** + * Create a mock canvas element with getBoundingClientRect + */ +function createMockCanvas(): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = 800; + canvas.height = 600; + + vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + return canvas; +} + +/** + * Create a mock pointer event + */ +function createPointerEvent( + type: string, + options: { + clientX?: number; + clientY?: number; + button?: number; + buttons?: number; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + } = {}, +): PointerEvent { + return new PointerEvent(type, { + clientX: options.clientX ?? 0, + clientY: options.clientY ?? 0, + button: options.button ?? 0, + buttons: options.buttons ?? 0, + ctrlKey: options.ctrlKey ?? false, + shiftKey: options.shiftKey ?? false, + altKey: options.altKey ?? false, + metaKey: options.metaKey ?? false, + bubbles: true, + cancelable: true, + }); +} + +/** + * Create a mock wheel event + */ +function createWheelEvent( + options: { + clientX?: number; + clientY?: number; + deltaY?: number; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + } = {}, +): WheelEvent { + return new WheelEvent("wheel", { + clientX: options.clientX ?? 0, + clientY: options.clientY ?? 0, + deltaY: options.deltaY ?? 0, + ctrlKey: options.ctrlKey ?? false, + shiftKey: options.shiftKey ?? false, + altKey: options.altKey ?? false, + metaKey: options.metaKey ?? false, + bubbles: true, + cancelable: true, + }); +} + +/** + * Create a mock keyboard event + */ +function createKeyboardEvent( + type: string, + options: { + key?: string; + code?: string; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + repeat?: boolean; + } = {}, +): KeyboardEvent { + return new KeyboardEvent(type, { + key: options.key ?? "a", + code: options.code ?? "KeyA", + ctrlKey: options.ctrlKey ?? false, + shiftKey: options.shiftKey ?? false, + altKey: options.altKey ?? false, + metaKey: options.metaKey ?? false, + repeat: options.repeat ?? false, + bubbles: true, + cancelable: true, + }); +} + +describe("InputAdapter", () => { + let canvas: HTMLCanvasElement; + let camera: Camera; + let actions: ActionType[]; + let adapter: InputAdapter; + + beforeEach(() => { + canvas = createMockCanvas(); + camera = Camera.create(0, 0, 1); + actions = []; + + adapter = new InputAdapter({ + canvas, + getCamera: () => camera, + getViewport: () => ({ width: 800, height: 600 }), + onAction: (action) => actions.push(action), + preventDefault: true, + captureKeyboard: true, + }); + }); + + afterEach(() => { + adapter.dispose(); + }); + + describe("constructor and disposal", () => { + it("should create adapter and attach event listeners", () => { + const onAction = vi.fn(); + const testAdapter = new InputAdapter({ + canvas, + getCamera: () => camera, + getViewport: () => ({ width: 800, height: 600 }), + onAction, + }); + + const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100 }); + canvas.dispatchEvent(event); + + expect(onAction).toHaveBeenCalled(); + testAdapter.dispose(); + }); + + it("should remove event listeners on dispose", () => { + const onAction = vi.fn(); + const testAdapter = new InputAdapter({ + canvas, + getCamera: () => camera, + getViewport: () => ({ width: 800, height: 600 }), + onAction, + }); + + testAdapter.dispose(); + + const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100 }); + canvas.dispatchEvent(event); + + expect(onAction).not.toHaveBeenCalled(); + }); + }); + + describe("pointer events", () => { + it("should dispatch pointer down action", () => { + const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 200, button: 0, buttons: 1 }); + canvas.dispatchEvent(event); + + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe("pointer-down"); + expect(actions[0]).toMatchObject({ screen: { x: 100, y: 200 }, button: 0 }); + }); + + it("should dispatch pointer move action", () => { + const event = createPointerEvent("pointermove", { clientX: 150, clientY: 250, buttons: 1 }); + canvas.dispatchEvent(event); + + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe("pointer-move"); + expect(actions[0]).toMatchObject({ screen: { x: 150, y: 250 } }); + }); + + it("should dispatch pointer up action", () => { + const event = createPointerEvent("pointerup", { clientX: 100, clientY: 200, button: 0, buttons: 0 }); + canvas.dispatchEvent(event); + + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe("pointer-up"); + expect(actions[0]).toMatchObject({ screen: { x: 100, y: 200 }, button: 0 }); + }); + + it("should convert screen coordinates to world coordinates", () => { + const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, world: { x: 0, y: 0 } }); + }); + + it("should track pointer state", () => { + let state = adapter.getPointerState(); + expect(state.isDown).toBe(false); + expect(state.startWorld).toBe(null); + + const downEvent = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, buttons: 1 }); + canvas.dispatchEvent(downEvent); + + state = adapter.getPointerState(); + expect(state.isDown).toBe(true); + expect(state.startWorld).not.toBe(null); + expect(state.startScreen).toEqual({ x: 100, y: 100 }); + + const upEvent = createPointerEvent("pointerup", { clientX: 150, clientY: 150, buttons: 0 }); + canvas.dispatchEvent(upEvent); + + state = adapter.getPointerState(); + expect(state.isDown).toBe(false); + expect(state.startWorld).toBe(null); + }); + + it("should handle modifier keys in pointer events", () => { + const event = createPointerEvent("pointerdown", { + clientX: 100, + clientY: 100, + buttons: 1, + ctrlKey: true, + shiftKey: false, + }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); + }); + + it("should decode pointer buttons bitmask", () => { + const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, buttons: 5 }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ buttons: { left: true, middle: true, right: false } }); + }); + }); + + describe("wheel events", () => { + it("should dispatch wheel action", () => { + const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: -100 }); + canvas.dispatchEvent(event); + + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe("wheel"); + expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, deltaY: -100 }); + }); + + it("should include modifiers in wheel events", () => { + const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 100, ctrlKey: true }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); + }); + }); + + describe("keyboard events", () => { + it("should dispatch key down action", () => { + const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA" }); + window.dispatchEvent(event); + + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe("key-down"); + expect(actions[0]).toMatchObject({ key: "a", code: "KeyA", repeat: false }); + }); + + it("should dispatch key up action", () => { + const event = createKeyboardEvent("keyup", { key: "b", code: "KeyB" }); + window.dispatchEvent(event); + + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe("key-up"); + expect(actions[0]).toMatchObject({ key: "b", code: "KeyB" }); + }); + + it("should handle repeat key events", () => { + const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA", repeat: true }); + window.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ repeat: true }); + }); + + it("should include modifiers in keyboard events", () => { + const event = createKeyboardEvent("keydown", { key: "z", code: "KeyZ", ctrlKey: true }); + window.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); + }); + + it("should not capture keyboard events when captureKeyboard is false", () => { + const testActions: ActionType[] = []; + const testAdapter = new InputAdapter({ + canvas, + getCamera: () => camera, + getViewport: () => ({ width: 800, height: 600 }), + onAction: (action) => testActions.push(action), + captureKeyboard: false, + }); + + const event = createKeyboardEvent("keydown", { key: "a" }); + window.dispatchEvent(event); + + expect(testActions).toHaveLength(0); + testAdapter.dispose(); + }); + + it("should ignore keyboard events from input elements", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + + const event = new KeyboardEvent("keydown", { key: "a", code: "KeyA", bubbles: true, cancelable: true }); + + Object.defineProperty(event, "target", { value: input, enumerable: true }); + window.dispatchEvent(event); + + expect(actions).toHaveLength(0); + + document.body.removeChild(input); + }); + }); + + describe("coordinate transformation", () => { + it("should handle camera panning", () => { + camera = Camera.create(100, 50, 1); + + const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, world: { x: 100, y: 50 } }); + }); + + it("should handle camera zoom", () => { + camera = Camera.create(0, 0, 2); + + const event = createPointerEvent("pointerdown", { clientX: 500, clientY: 300, buttons: 1 }); + canvas.dispatchEvent(event); + expect(actions[0]).toMatchObject({ screen: { x: 500, y: 300 }, world: { x: 50, y: 0 } }); + }); + + it("should handle combined camera transform", () => { + camera = Camera.create(200, 100, 0.5); + + const event = createPointerEvent("pointerdown", { clientX: 600, clientY: 450, buttons: 1 }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ screen: { x: 600, y: 450 }, world: { x: 600, y: 400 } }); + }); + }); + + describe("edge cases", () => { + it("should handle pointer events at canvas edges", () => { + let event = createPointerEvent("pointerdown", { clientX: 0, clientY: 0, buttons: 1 }); + canvas.dispatchEvent(event); + expect(actions[0]).toMatchObject({ screen: { x: 0, y: 0 } }); + + actions.length = 0; + + event = createPointerEvent("pointerdown", { clientX: 800, clientY: 600, buttons: 1 }); + canvas.dispatchEvent(event); + expect(actions[0]).toMatchObject({ screen: { x: 800, y: 600 } }); + }); + + it("should handle multiple pointer buttons simultaneously", () => { + const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 2, buttons: 7 }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ button: 2, buttons: { left: true, middle: true, right: true } }); + }); + + it("should handle zero deltaY in wheel events", () => { + const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 0 }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ deltaY: 0 }); + }); + + it("should handle special keys", () => { + const specialKeys = [ + { key: "Escape", code: "Escape" }, + { key: "Enter", code: "Enter" }, + { key: " ", code: "Space" }, + { key: "ArrowUp", code: "ArrowUp" }, + { key: "Tab", code: "Tab" }, + ]; + + specialKeys.forEach(({ key, code }) => { + actions.length = 0; + const event = createKeyboardEvent("keydown", { key, code }); + window.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ key, code }); + }); + }); + + it("should handle rapid pointer move events", () => { + for (let i = 0; i < 100; i++) { + const event = createPointerEvent("pointermove", { clientX: 100 + i, clientY: 100 + i, buttons: 1 }); + canvas.dispatchEvent(event); + } + + expect(actions).toHaveLength(100); + expect(actions.every((action) => action.type === "pointer-move")).toBe(true); + }); + + it("should update pointer state on each move", () => { + const downEvent = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, buttons: 1 }); + canvas.dispatchEvent(downEvent); + + const moveEvent = createPointerEvent("pointermove", { clientX: 200, clientY: 200, buttons: 1 }); + canvas.dispatchEvent(moveEvent); + + const state = adapter.getPointerState(); + expect(state.lastScreen).toEqual({ x: 200, y: 200 }); + expect(state.startScreen).toEqual({ x: 100, y: 100 }); + }); + + it("should preserve start position until all buttons released", () => { + const down1 = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, buttons: 1 }); + canvas.dispatchEvent(down1); + + let state = adapter.getPointerState(); + expect(state.startScreen).toEqual({ x: 100, y: 100 }); + + const down2 = createPointerEvent("pointerdown", { clientX: 150, clientY: 150, button: 1, buttons: 5 }); + canvas.dispatchEvent(down2); + + const up1 = createPointerEvent("pointerup", { clientX: 200, clientY: 200, button: 0, buttons: 4 }); + canvas.dispatchEvent(up1); + + state = adapter.getPointerState(); + expect(state.startScreen).not.toBe(null); + + const up2 = createPointerEvent("pointerup", { clientX: 250, clientY: 250, button: 1, buttons: 0 }); + canvas.dispatchEvent(up2); + + state = adapter.getPointerState(); + expect(state.startScreen).toBe(null); + }); + + it("should handle negative coordinates from camera transform", () => { + camera = Camera.create(-1000, -1000, 1); + + const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); + canvas.dispatchEvent(event); + + expect(actions[0]).toMatchObject({ world: { x: -1000, y: -1000 } }); + }); + + it("should handle very large coordinates", () => { + camera = Camera.create(1e10, 1e10, 0.001); + + const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); + canvas.dispatchEvent(event); + + const action = actions[0] as { world: { x: number; y: number } }; + expect(action.world.x).toBeCloseTo(1e10, -5); + expect(action.world.y).toBeCloseTo(1e10, -5); + }); + }); + + describe("createInputAdapter", () => { + it("should create and return an InputAdapter instance", () => { + const testAdapter = createInputAdapter({ + canvas, + getCamera: () => camera, + getViewport: () => ({ width: 800, height: 600 }), + onAction: (action) => actions.push(action), + }); + + expect(testAdapter).toBeInstanceOf(InputAdapter); + testAdapter.dispose(); + }); + }); +}); -- tangled.sh