web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: actions & input adapter

+1487 -55
+9 -9
TODO.txt
··· 218 218 Goal: normalize events into editor actions. 219 219 220 220 Input adapter (/apps/web/src/lib/input): 221 - [ ] Capture pointerdown/move/up 222 - [ ] Convert screen coords -> world coords using camera 223 - [ ] Track pointer state: isDown, startWorld, lastWorld, buttons 221 + [x] Capture pointerdown/move/up 222 + [x] Convert screen coords -> world coords using camera 223 + [x] Track pointer state: isDown, startWorld, lastWorld, buttons 224 224 225 225 Keyboard: 226 - [ ] Capture keydown/keyup 227 - [ ] Normalize modifiers (ctrl/cmd, shift, alt) 226 + [x] Capture keydown/keyup 227 + [x] Normalize modifiers (ctrl/cmd, shift, alt) 228 228 229 - Action bus (/packages/core/src/actions): 230 - [ ] Define Action union: 229 + Action bus (/packages/core/src/actions.ts): 230 + [x] Define Action union: 231 231 - PointerDown, PointerMove, PointerUp 232 232 - KeyDown, KeyUp 233 233 - Wheel (for zoom) 234 - [ ] dispatch(action) -> store updates via tool state machine (next milestone) 234 + [x] dispatch(action) -> store updates via tool state machine (next milestone) 235 235 236 236 (DoD): 237 237 - You can pan/zoom camera via wheel/drag with a temporary "camera tool" ··· 242 242 8. Milestone H: Tool state machine (foundation) *wb-H* 243 243 ============================================================================== 244 244 245 - Goal: tools are explicit, testable state machines. 245 + Goal: tools are explicit, testable state machines (RxJS based) 246 246 247 247 Tools (/packages/core/src/tools): 248 248 [ ] Define ToolId: "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen"
+1
apps/web/package.json
··· 15 15 "test": "npm run test:unit -- --run", 16 16 "format": "prettier --write ." 17 17 }, 18 + "dependencies": { "inkfinite-core": "workspace:*" }, 18 19 "devDependencies": { 19 20 "@eslint/compat": "^1.4.0", 20 21 "@eslint/js": "^9.39.1",
-7
apps/web/src/demo.spec.ts
··· 1 - import { describe, it, expect } from 'vitest'; 2 - 3 - describe('sum test', () => { 4 - it('adds 1 + 2 to equal 3', () => { 5 - expect(1 + 2).toBe(3); 6 - }); 7 - });
+324
apps/web/src/lib/input.ts
··· 1 + import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons, type Viewport } from "inkfinite-core"; 2 + 3 + /** 4 + * Pointer state tracked by the input adapter 5 + */ 6 + export type PointerState = { 7 + /** Whether any pointer button is currently down */ 8 + isDown: boolean; 9 + /** Last known world coordinates */ 10 + lastWorld: { x: number; y: number } | null; 11 + /** World coordinates where pointer was first pressed */ 12 + startWorld: { x: number; y: number } | null; 13 + /** Last known screen coordinates */ 14 + lastScreen: { x: number; y: number } | null; 15 + /** Screen coordinates where pointer was first pressed */ 16 + startScreen: { x: number; y: number } | null; 17 + /** Current button state */ 18 + buttons: PointerButtons; 19 + }; 20 + 21 + /** 22 + * Input adapter configuration 23 + */ 24 + export type InputAdapterConfig = { 25 + /** Canvas element to attach listeners to */ 26 + canvas: HTMLCanvasElement; 27 + /** Function to get current camera state */ 28 + getCamera: () => Camera; 29 + /** Function to get current viewport dimensions */ 30 + getViewport: () => Viewport; 31 + /** Callback for dispatching actions */ 32 + onAction: (action: ActionType) => void; 33 + /** Whether to prevent default browser behavior (default: true) */ 34 + preventDefault?: boolean; 35 + /** Whether to capture keyboard events on window (default: true) */ 36 + captureKeyboard?: boolean; 37 + }; 38 + 39 + /** 40 + * Input adapter for capturing and normalizing DOM input events 41 + * 42 + * Features: 43 + * - Captures pointer events (down, move, up) on canvas 44 + * - Captures wheel events for zooming 45 + * - Captures keyboard events (optionally on window) 46 + * - Converts screen coordinates to world coordinates 47 + * - Tracks pointer state 48 + * - Normalizes modifiers (ctrl/cmd, shift, alt) 49 + * - Dispatches normalized actions 50 + */ 51 + export class InputAdapter { 52 + private config: Required<InputAdapterConfig>; 53 + private pointerState: PointerState; 54 + private boundHandlers: { 55 + pointerDown: (e: PointerEvent) => void; 56 + pointerMove: (e: PointerEvent) => void; 57 + pointerUp: (e: PointerEvent) => void; 58 + wheel: (e: WheelEvent) => void; 59 + keyDown: (e: KeyboardEvent) => void; 60 + keyUp: (e: KeyboardEvent) => void; 61 + contextMenu: (e: Event) => void; 62 + }; 63 + 64 + constructor(config: InputAdapterConfig) { 65 + this.config = { 66 + ...config, 67 + preventDefault: config.preventDefault ?? true, 68 + captureKeyboard: config.captureKeyboard ?? true, 69 + }; 70 + 71 + this.pointerState = { 72 + isDown: false, 73 + lastWorld: null, 74 + startWorld: null, 75 + lastScreen: null, 76 + startScreen: null, 77 + buttons: PointerButtons.create(), 78 + }; 79 + 80 + this.boundHandlers = { 81 + pointerDown: this.handlePointerDown.bind(this), 82 + pointerMove: this.handlePointerMove.bind(this), 83 + pointerUp: this.handlePointerUp.bind(this), 84 + wheel: this.handleWheel.bind(this), 85 + keyDown: this.handleKeyDown.bind(this), 86 + keyUp: this.handleKeyUp.bind(this), 87 + contextMenu: this.handleContextMenu.bind(this), 88 + }; 89 + 90 + this.attach(); 91 + } 92 + 93 + /** 94 + * Get current pointer state 95 + */ 96 + getPointerState(): Readonly<PointerState> { 97 + return { ...this.pointerState }; 98 + } 99 + 100 + /** 101 + * Attach event listeners 102 + */ 103 + private attach(): void { 104 + const { canvas } = this.config; 105 + 106 + canvas.addEventListener("pointerdown", this.boundHandlers.pointerDown); 107 + canvas.addEventListener("pointermove", this.boundHandlers.pointerMove); 108 + canvas.addEventListener("pointerup", this.boundHandlers.pointerUp); 109 + canvas.addEventListener("wheel", this.boundHandlers.wheel, { passive: false }); 110 + canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu); 111 + 112 + if (this.config.captureKeyboard) { 113 + window.addEventListener("keydown", this.boundHandlers.keyDown); 114 + window.addEventListener("keyup", this.boundHandlers.keyUp); 115 + } 116 + } 117 + 118 + /** 119 + * Detach event listeners and cleanup 120 + */ 121 + dispose(): void { 122 + const { canvas } = this.config; 123 + 124 + canvas.removeEventListener("pointerdown", this.boundHandlers.pointerDown); 125 + canvas.removeEventListener("pointermove", this.boundHandlers.pointerMove); 126 + canvas.removeEventListener("pointerup", this.boundHandlers.pointerUp); 127 + canvas.removeEventListener("wheel", this.boundHandlers.wheel); 128 + canvas.removeEventListener("contextmenu", this.boundHandlers.contextMenu); 129 + 130 + if (this.config.captureKeyboard) { 131 + window.removeEventListener("keydown", this.boundHandlers.keyDown); 132 + window.removeEventListener("keyup", this.boundHandlers.keyUp); 133 + } 134 + } 135 + 136 + /** 137 + * Get screen coordinates from pointer event 138 + */ 139 + private getScreenCoords(e: PointerEvent): { x: number; y: number } { 140 + const rect = this.config.canvas.getBoundingClientRect(); 141 + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; 142 + } 143 + 144 + /** 145 + * Convert screen coordinates to world coordinates 146 + */ 147 + private screenToWorld(screen: { x: number; y: number }): { x: number; y: number } { 148 + const camera = this.config.getCamera(); 149 + const viewport = this.config.getViewport(); 150 + return Camera.screenToWorld(camera, screen, viewport); 151 + } 152 + 153 + /** 154 + * Handle pointer down event 155 + */ 156 + private handlePointerDown(e: PointerEvent): void { 157 + if (this.config.preventDefault) { 158 + e.preventDefault(); 159 + } 160 + 161 + const screen = this.getScreenCoords(e); 162 + const world = this.screenToWorld(screen); 163 + const buttons = PointerButtons.fromButtons(e.buttons); 164 + const modifiers = Modifiers.fromEvent(e); 165 + 166 + this.pointerState.isDown = true; 167 + this.pointerState.startWorld = world; 168 + this.pointerState.startScreen = screen; 169 + this.pointerState.lastWorld = world; 170 + this.pointerState.lastScreen = screen; 171 + this.pointerState.buttons = buttons; 172 + 173 + this.config.onAction(Action.pointerDown(screen, world, e.button, buttons, modifiers)); 174 + } 175 + 176 + /** 177 + * Handle pointer move event 178 + */ 179 + private handlePointerMove(e: PointerEvent): void { 180 + if (this.config.preventDefault && this.pointerState.isDown) { 181 + e.preventDefault(); 182 + } 183 + 184 + const screen = this.getScreenCoords(e); 185 + const world = this.screenToWorld(screen); 186 + const buttons = PointerButtons.fromButtons(e.buttons); 187 + const modifiers = Modifiers.fromEvent(e); 188 + 189 + this.pointerState.lastWorld = world; 190 + this.pointerState.lastScreen = screen; 191 + this.pointerState.buttons = buttons; 192 + 193 + this.config.onAction(Action.pointerMove(screen, world, buttons, modifiers)); 194 + } 195 + 196 + /** 197 + * Handle pointer up event 198 + */ 199 + private handlePointerUp(e: PointerEvent): void { 200 + if (this.config.preventDefault) { 201 + e.preventDefault(); 202 + } 203 + 204 + const screen = this.getScreenCoords(e); 205 + const world = this.screenToWorld(screen); 206 + const buttons = PointerButtons.fromButtons(e.buttons); 207 + const modifiers = Modifiers.fromEvent(e); 208 + 209 + this.pointerState.isDown = false; 210 + this.pointerState.lastWorld = world; 211 + this.pointerState.lastScreen = screen; 212 + this.pointerState.buttons = buttons; 213 + 214 + if (PointerButtons.isEmpty(buttons)) { 215 + this.pointerState.startWorld = null; 216 + this.pointerState.startScreen = null; 217 + } 218 + 219 + this.config.onAction(Action.pointerUp(screen, world, e.button, buttons, modifiers)); 220 + } 221 + 222 + /** 223 + * Handle wheel event 224 + */ 225 + private handleWheel(e: WheelEvent): void { 226 + if (this.config.preventDefault) { 227 + e.preventDefault(); 228 + } 229 + 230 + const rect = this.config.canvas.getBoundingClientRect(); 231 + const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; 232 + const world = this.screenToWorld(screen); 233 + const modifiers = Modifiers.fromEvent(e); 234 + 235 + this.config.onAction(Action.wheel(screen, world, e.deltaY, modifiers)); 236 + } 237 + 238 + /** 239 + * Handle key down event 240 + */ 241 + private handleKeyDown(e: KeyboardEvent): void { 242 + const target = e.target as HTMLElement; 243 + if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { 244 + return; 245 + } 246 + 247 + const modifiers = Modifiers.fromEvent(e); 248 + 249 + this.config.onAction(Action.keyDown(e.key, e.code, modifiers, e.repeat)); 250 + 251 + if (this.config.preventDefault && this.shouldPreventDefault(e)) { 252 + e.preventDefault(); 253 + } 254 + } 255 + 256 + /** 257 + * Handle key up event 258 + */ 259 + private handleKeyUp(e: KeyboardEvent): void { 260 + const target = e.target as HTMLElement; 261 + if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { 262 + return; 263 + } 264 + 265 + const modifiers = Modifiers.fromEvent(e); 266 + this.config.onAction(Action.keyUp(e.key, e.code, modifiers)); 267 + } 268 + 269 + /** 270 + * Handle context menu event (prevent default) 271 + */ 272 + private handleContextMenu(e: Event): void { 273 + if (this.config.preventDefault) { 274 + e.preventDefault(); 275 + } 276 + } 277 + 278 + /** 279 + * Determine if default behavior should be prevented for a key event 280 + * 281 + * Prevents default for: 282 + * - Space (scroll) 283 + * - Arrow keys (scroll) 284 + * - Backspace/Delete (navigation) 285 + * - Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) 286 + * - Tab (focus change) 287 + */ 288 + private shouldPreventDefault(e: KeyboardEvent): boolean { 289 + const key = e.key; 290 + const modifiers = Modifiers.fromEvent(e); 291 + 292 + if (key === " " || key.startsWith("Arrow")) { 293 + return true; 294 + } 295 + 296 + if (key === "Backspace" || key === "Delete") { 297 + return true; 298 + } 299 + 300 + if (key === "Tab") { 301 + return true; 302 + } 303 + 304 + if (Modifiers.isPrimaryModifier(modifiers) && (key === "z" || key === "Z")) { 305 + return true; 306 + } 307 + 308 + if (Modifiers.isPrimaryModifier(modifiers) && (key === "y" || key === "Y")) { 309 + return true; 310 + } 311 + 312 + return false; 313 + } 314 + } 315 + 316 + /** 317 + * Create an input adapter 318 + * 319 + * @param config - Input adapter configuration 320 + * @returns Input adapter instance 321 + */ 322 + export function createInputAdapter(config: InputAdapterConfig): InputAdapter { 323 + return new InputAdapter(config); 324 + }
+485
apps/web/src/lib/tests/input.test.ts
··· 1 + import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons } from "inkfinite-core"; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { createInputAdapter, InputAdapter, type PointerState } from "../input"; 4 + 5 + /** 6 + * Create a mock canvas element with getBoundingClientRect 7 + */ 8 + function 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 + */ 31 + function 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 + */ 61 + function 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 + */ 88 + function 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 + 113 + describe("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("wheel events", () => { 251 + it("should dispatch wheel action", () => { 252 + const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: -100 }); 253 + canvas.dispatchEvent(event); 254 + 255 + expect(actions).toHaveLength(1); 256 + expect(actions[0].type).toBe("wheel"); 257 + expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, deltaY: -100 }); 258 + }); 259 + 260 + it("should include modifiers in wheel events", () => { 261 + const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 100, ctrlKey: true }); 262 + canvas.dispatchEvent(event); 263 + 264 + expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); 265 + }); 266 + }); 267 + 268 + describe("keyboard events", () => { 269 + it("should dispatch key down action", () => { 270 + const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA" }); 271 + window.dispatchEvent(event); 272 + 273 + expect(actions).toHaveLength(1); 274 + expect(actions[0].type).toBe("key-down"); 275 + expect(actions[0]).toMatchObject({ key: "a", code: "KeyA", repeat: false }); 276 + }); 277 + 278 + it("should dispatch key up action", () => { 279 + const event = createKeyboardEvent("keyup", { key: "b", code: "KeyB" }); 280 + window.dispatchEvent(event); 281 + 282 + expect(actions).toHaveLength(1); 283 + expect(actions[0].type).toBe("key-up"); 284 + expect(actions[0]).toMatchObject({ key: "b", code: "KeyB" }); 285 + }); 286 + 287 + it("should handle repeat key events", () => { 288 + const event = createKeyboardEvent("keydown", { key: "a", code: "KeyA", repeat: true }); 289 + window.dispatchEvent(event); 290 + 291 + expect(actions[0]).toMatchObject({ repeat: true }); 292 + }); 293 + 294 + it("should include modifiers in keyboard events", () => { 295 + const event = createKeyboardEvent("keydown", { key: "z", code: "KeyZ", ctrlKey: true }); 296 + window.dispatchEvent(event); 297 + 298 + expect(actions[0]).toMatchObject({ modifiers: { ctrl: true, shift: false, alt: false, meta: false } }); 299 + }); 300 + 301 + it("should not capture keyboard events when captureKeyboard is false", () => { 302 + const testActions: ActionType[] = []; 303 + const testAdapter = new InputAdapter({ 304 + canvas, 305 + getCamera: () => camera, 306 + getViewport: () => ({ width: 800, height: 600 }), 307 + onAction: (action) => testActions.push(action), 308 + captureKeyboard: false, 309 + }); 310 + 311 + const event = createKeyboardEvent("keydown", { key: "a" }); 312 + window.dispatchEvent(event); 313 + 314 + expect(testActions).toHaveLength(0); 315 + testAdapter.dispose(); 316 + }); 317 + 318 + it("should ignore keyboard events from input elements", () => { 319 + const input = document.createElement("input"); 320 + document.body.appendChild(input); 321 + 322 + const event = new KeyboardEvent("keydown", { key: "a", code: "KeyA", bubbles: true, cancelable: true }); 323 + 324 + Object.defineProperty(event, "target", { value: input, enumerable: true }); 325 + window.dispatchEvent(event); 326 + 327 + expect(actions).toHaveLength(0); 328 + 329 + document.body.removeChild(input); 330 + }); 331 + }); 332 + 333 + describe("coordinate transformation", () => { 334 + it("should handle camera panning", () => { 335 + camera = Camera.create(100, 50, 1); 336 + 337 + const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); 338 + canvas.dispatchEvent(event); 339 + 340 + expect(actions[0]).toMatchObject({ screen: { x: 400, y: 300 }, world: { x: 100, y: 50 } }); 341 + }); 342 + 343 + it("should handle camera zoom", () => { 344 + camera = Camera.create(0, 0, 2); 345 + 346 + const event = createPointerEvent("pointerdown", { clientX: 500, clientY: 300, buttons: 1 }); 347 + canvas.dispatchEvent(event); 348 + expect(actions[0]).toMatchObject({ screen: { x: 500, y: 300 }, world: { x: 50, y: 0 } }); 349 + }); 350 + 351 + it("should handle combined camera transform", () => { 352 + camera = Camera.create(200, 100, 0.5); 353 + 354 + const event = createPointerEvent("pointerdown", { clientX: 600, clientY: 450, buttons: 1 }); 355 + canvas.dispatchEvent(event); 356 + 357 + expect(actions[0]).toMatchObject({ screen: { x: 600, y: 450 }, world: { x: 600, y: 400 } }); 358 + }); 359 + }); 360 + 361 + describe("edge cases", () => { 362 + it("should handle pointer events at canvas edges", () => { 363 + let event = createPointerEvent("pointerdown", { clientX: 0, clientY: 0, buttons: 1 }); 364 + canvas.dispatchEvent(event); 365 + expect(actions[0]).toMatchObject({ screen: { x: 0, y: 0 } }); 366 + 367 + actions.length = 0; 368 + 369 + event = createPointerEvent("pointerdown", { clientX: 800, clientY: 600, buttons: 1 }); 370 + canvas.dispatchEvent(event); 371 + expect(actions[0]).toMatchObject({ screen: { x: 800, y: 600 } }); 372 + }); 373 + 374 + it("should handle multiple pointer buttons simultaneously", () => { 375 + const event = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 2, buttons: 7 }); 376 + canvas.dispatchEvent(event); 377 + 378 + expect(actions[0]).toMatchObject({ button: 2, buttons: { left: true, middle: true, right: true } }); 379 + }); 380 + 381 + it("should handle zero deltaY in wheel events", () => { 382 + const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: 0 }); 383 + canvas.dispatchEvent(event); 384 + 385 + expect(actions[0]).toMatchObject({ deltaY: 0 }); 386 + }); 387 + 388 + it("should handle special keys", () => { 389 + const specialKeys = [ 390 + { key: "Escape", code: "Escape" }, 391 + { key: "Enter", code: "Enter" }, 392 + { key: " ", code: "Space" }, 393 + { key: "ArrowUp", code: "ArrowUp" }, 394 + { key: "Tab", code: "Tab" }, 395 + ]; 396 + 397 + specialKeys.forEach(({ key, code }) => { 398 + actions.length = 0; 399 + const event = createKeyboardEvent("keydown", { key, code }); 400 + window.dispatchEvent(event); 401 + 402 + expect(actions[0]).toMatchObject({ key, code }); 403 + }); 404 + }); 405 + 406 + it("should handle rapid pointer move events", () => { 407 + for (let i = 0; i < 100; i++) { 408 + const event = createPointerEvent("pointermove", { clientX: 100 + i, clientY: 100 + i, buttons: 1 }); 409 + canvas.dispatchEvent(event); 410 + } 411 + 412 + expect(actions).toHaveLength(100); 413 + expect(actions.every((action) => action.type === "pointer-move")).toBe(true); 414 + }); 415 + 416 + it("should update pointer state on each move", () => { 417 + const downEvent = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, buttons: 1 }); 418 + canvas.dispatchEvent(downEvent); 419 + 420 + const moveEvent = createPointerEvent("pointermove", { clientX: 200, clientY: 200, buttons: 1 }); 421 + canvas.dispatchEvent(moveEvent); 422 + 423 + const state = adapter.getPointerState(); 424 + expect(state.lastScreen).toEqual({ x: 200, y: 200 }); 425 + expect(state.startScreen).toEqual({ x: 100, y: 100 }); 426 + }); 427 + 428 + it("should preserve start position until all buttons released", () => { 429 + const down1 = createPointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, buttons: 1 }); 430 + canvas.dispatchEvent(down1); 431 + 432 + let state = adapter.getPointerState(); 433 + expect(state.startScreen).toEqual({ x: 100, y: 100 }); 434 + 435 + const down2 = createPointerEvent("pointerdown", { clientX: 150, clientY: 150, button: 1, buttons: 5 }); 436 + canvas.dispatchEvent(down2); 437 + 438 + const up1 = createPointerEvent("pointerup", { clientX: 200, clientY: 200, button: 0, buttons: 4 }); 439 + canvas.dispatchEvent(up1); 440 + 441 + state = adapter.getPointerState(); 442 + expect(state.startScreen).not.toBe(null); 443 + 444 + const up2 = createPointerEvent("pointerup", { clientX: 250, clientY: 250, button: 1, buttons: 0 }); 445 + canvas.dispatchEvent(up2); 446 + 447 + state = adapter.getPointerState(); 448 + expect(state.startScreen).toBe(null); 449 + }); 450 + 451 + it("should handle negative coordinates from camera transform", () => { 452 + camera = Camera.create(-1000, -1000, 1); 453 + 454 + const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); 455 + canvas.dispatchEvent(event); 456 + 457 + expect(actions[0]).toMatchObject({ world: { x: -1000, y: -1000 } }); 458 + }); 459 + 460 + it("should handle very large coordinates", () => { 461 + camera = Camera.create(1e10, 1e10, 0.001); 462 + 463 + const event = createPointerEvent("pointerdown", { clientX: 400, clientY: 300, buttons: 1 }); 464 + canvas.dispatchEvent(event); 465 + 466 + const action = actions[0] as { world: { x: number; y: number } }; 467 + expect(action.world.x).toBeCloseTo(1e10, -5); 468 + expect(action.world.y).toBeCloseTo(1e10, -5); 469 + }); 470 + }); 471 + 472 + describe("createInputAdapter", () => { 473 + it("should create and return an InputAdapter instance", () => { 474 + const testAdapter = createInputAdapter({ 475 + canvas, 476 + getCamera: () => camera, 477 + getViewport: () => ({ width: 800, height: 600 }), 478 + onAction: (action) => actions.push(action), 479 + }); 480 + 481 + expect(testAdapter).toBeInstanceOf(InputAdapter); 482 + testAdapter.dispose(); 483 + }); 484 + }); 485 + });
+31 -39
apps/web/vite.config.ts
··· 1 - import devtoolsJson from 'vite-plugin-devtools-json'; 2 - import { defineConfig } from 'vitest/config'; 3 - import { playwright } from '@vitest/browser-playwright'; 4 - import { sveltekit } from '@sveltejs/kit/vite'; 1 + import { sveltekit } from "@sveltejs/kit/vite"; 2 + import { playwright } from "@vitest/browser-playwright"; 3 + import devtoolsJson from "vite-plugin-devtools-json"; 4 + import { defineConfig } from "vitest/config"; 5 5 6 6 export default defineConfig({ 7 - plugins: [sveltekit(), devtoolsJson()], 8 - 9 - test: { 10 - expect: { requireAssertions: true }, 11 - 12 - projects: [ 13 - { 14 - extends: './vite.config.ts', 15 - 16 - test: { 17 - name: 'client', 18 - 19 - browser: { 20 - enabled: true, 21 - provider: playwright(), 22 - instances: [{ browser: 'chromium', headless: true }] 23 - }, 24 - 25 - include: ['src/**/*.svelte.{test,spec}.{js,ts}'], 26 - exclude: ['src/lib/server/**'] 27 - } 28 - }, 29 - 30 - { 31 - extends: './vite.config.ts', 32 - 33 - test: { 34 - name: 'server', 35 - environment: 'node', 36 - include: ['src/**/*.{test,spec}.{js,ts}'], 37 - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] 38 - } 39 - } 40 - ] 41 - } 7 + plugins: [sveltekit(), devtoolsJson()], 8 + test: { 9 + ui: false, 10 + expect: { requireAssertions: true }, 11 + projects: [{ 12 + extends: "./vite.config.ts", 13 + test: { 14 + name: "client", 15 + browser: { 16 + headless: true, 17 + enabled: true, 18 + provider: playwright(), 19 + instances: [{ browser: "chromium", headless: true }], 20 + }, 21 + include: ["src/**/*.svelte.{test,spec}.{js,ts}", "src/lib/tests/**/*.{test,spec}.{js,ts}"], 22 + exclude: ["src/lib/server/**"], 23 + }, 24 + }, { 25 + extends: "./vite.config.ts", 26 + test: { 27 + name: "server", 28 + environment: "node", 29 + include: ["src/**/*.{test,spec}.{js,ts}"], 30 + exclude: ["src/**/*.svelte.{test,spec}.{js,ts}", "src/lib/tests/**/*.{test,spec}.{js,ts}"], 31 + }, 32 + }], 33 + }, 42 34 });
+265
packages/core/src/actions.ts
··· 1 + import type { Vec2 } from "./math"; 2 + 3 + /** 4 + * Keyboard modifier keys state 5 + */ 6 + export type Modifiers = { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }; 7 + 8 + /** 9 + * Pointer button state 10 + * - left: 0 11 + * - middle: 1 12 + * - right: 2 13 + */ 14 + export type PointerButtons = { left: boolean; middle: boolean; right: boolean }; 15 + 16 + /** 17 + * Pointer down event - user pressed pointer button 18 + */ 19 + export type PointerDownAction = { 20 + type: "pointer-down"; 21 + /** Point in screen coordinates (pixels) */ 22 + screen: Vec2; 23 + /** Point in world coordinates */ 24 + world: Vec2; 25 + /** Which button was pressed */ 26 + button: number; 27 + /** State of all buttons after this event */ 28 + buttons: PointerButtons; 29 + /** Modifier keys state */ 30 + modifiers: Modifiers; 31 + /** Timestamp of the event */ 32 + timestamp: number; 33 + }; 34 + 35 + /** 36 + * Pointer move event - user moved pointer 37 + */ 38 + export type PointerMoveAction = { 39 + type: "pointer-move"; 40 + /** Point in screen coordinates (pixels) */ 41 + screen: Vec2; 42 + /** Point in world coordinates */ 43 + world: Vec2; 44 + /** State of all buttons */ 45 + buttons: PointerButtons; 46 + /** Modifier keys state */ 47 + modifiers: Modifiers; 48 + /** Timestamp of the event */ 49 + timestamp: number; 50 + }; 51 + 52 + /** 53 + * Pointer up event - user released pointer button 54 + */ 55 + export type PointerUpAction = { 56 + type: "pointer-up"; 57 + /** Point in screen coordinates (pixels) */ 58 + screen: Vec2; 59 + /** Point in world coordinates */ 60 + world: Vec2; 61 + /** Which button was released */ 62 + button: number; 63 + /** State of all buttons after this event */ 64 + buttons: PointerButtons; 65 + /** Modifier keys state */ 66 + modifiers: Modifiers; 67 + /** Timestamp of the event */ 68 + timestamp: number; 69 + }; 70 + 71 + /** 72 + * Wheel event - user scrolled wheel 73 + */ 74 + export type WheelAction = { 75 + type: "wheel"; 76 + /** Point in screen coordinates where wheel event occurred */ 77 + screen: Vec2; 78 + /** Point in world coordinates */ 79 + world: Vec2; 80 + /** Wheel delta (usually negative = zoom in, positive = zoom out) */ 81 + deltaY: number; 82 + /** Modifier keys state */ 83 + modifiers: Modifiers; 84 + /** Timestamp of the event */ 85 + timestamp: number; 86 + }; 87 + 88 + /** 89 + * Key down event - user pressed a key 90 + */ 91 + export type KeyDownAction = { 92 + type: "key-down"; 93 + /** The key that was pressed (e.g., "a", "Enter", "Escape") */ 94 + key: string; 95 + /** The code of the key (e.g., "KeyA", "Enter", "Escape") */ 96 + code: string; 97 + /** Modifier keys state */ 98 + modifiers: Modifiers; 99 + /** Whether this is a repeated key event (key held down) */ 100 + repeat: boolean; 101 + /** Timestamp of the event */ 102 + timestamp: number; 103 + }; 104 + 105 + /** 106 + * Key up event - user released a key 107 + */ 108 + export type KeyUpAction = { 109 + type: "key-up"; 110 + /** The key that was released */ 111 + key: string; 112 + /** The code of the key */ 113 + code: string; 114 + /** Modifier keys state */ 115 + modifiers: Modifiers; 116 + /** Timestamp of the event */ 117 + timestamp: number; 118 + }; 119 + 120 + /** 121 + * Union of all input actions 122 + */ 123 + export type Action = 124 + | PointerDownAction 125 + | PointerMoveAction 126 + | PointerUpAction 127 + | WheelAction 128 + | KeyDownAction 129 + | KeyUpAction; 130 + 131 + /** 132 + * Action namespace for helper functions 133 + */ 134 + export const Action = { 135 + /** 136 + * Create a PointerDownAction 137 + */ 138 + pointerDown( 139 + screen: Vec2, 140 + world: Vec2, 141 + button: number, 142 + buttons: PointerButtons, 143 + modifiers: Modifiers, 144 + timestamp = Date.now(), 145 + ): PointerDownAction { 146 + return { type: "pointer-down", screen, world, button, buttons, modifiers, timestamp }; 147 + }, 148 + 149 + /** 150 + * Create a PointerMoveAction 151 + */ 152 + pointerMove( 153 + screen: Vec2, 154 + world: Vec2, 155 + buttons: PointerButtons, 156 + modifiers: Modifiers, 157 + timestamp = Date.now(), 158 + ): PointerMoveAction { 159 + return { type: "pointer-move", screen, world, buttons, modifiers, timestamp }; 160 + }, 161 + 162 + /** 163 + * Create a PointerUpAction 164 + */ 165 + pointerUp( 166 + screen: Vec2, 167 + world: Vec2, 168 + button: number, 169 + buttons: PointerButtons, 170 + modifiers: Modifiers, 171 + timestamp = Date.now(), 172 + ): PointerUpAction { 173 + return { type: "pointer-up", screen, world, button, buttons, modifiers, timestamp }; 174 + }, 175 + 176 + /** 177 + * Create a WheelAction 178 + */ 179 + wheel(screen: Vec2, world: Vec2, deltaY: number, modifiers: Modifiers, timestamp = Date.now()): WheelAction { 180 + return { type: "wheel", screen, world, deltaY, modifiers, timestamp }; 181 + }, 182 + 183 + /** 184 + * Create a KeyDownAction 185 + */ 186 + keyDown(key: string, code: string, modifiers: Modifiers, repeat = false, timestamp = Date.now()): KeyDownAction { 187 + return { type: "key-down", key, code, modifiers, repeat, timestamp }; 188 + }, 189 + 190 + /** 191 + * Create a KeyUpAction 192 + */ 193 + keyUp(key: string, code: string, modifiers: Modifiers, timestamp = Date.now()): KeyUpAction { 194 + return { type: "key-up", key, code, modifiers, timestamp }; 195 + }, 196 + }; 197 + 198 + /** 199 + * Create Modifiers object from DOM event 200 + */ 201 + export const Modifiers = { 202 + /** 203 + * Create a Modifiers object with default values (all false) 204 + */ 205 + create(ctrl = false, shift = false, alt = false, meta = false): Modifiers { 206 + return { ctrl, shift, alt, meta }; 207 + }, 208 + 209 + /** 210 + * Create Modifiers from a keyboard or mouse event 211 + */ 212 + fromEvent(event: { ctrlKey: boolean; shiftKey: boolean; altKey: boolean; metaKey: boolean }): Modifiers { 213 + return { ctrl: event.ctrlKey, shift: event.shiftKey, alt: event.altKey, meta: event.metaKey }; 214 + }, 215 + 216 + /** 217 + * Check if no modifiers are active 218 + */ 219 + isEmpty(modifiers: Modifiers): boolean { 220 + return !modifiers.ctrl && !modifiers.shift && !modifiers.alt && !modifiers.meta; 221 + }, 222 + 223 + /** 224 + * Check if Cmd (Mac) or Ctrl (other platforms) is pressed 225 + */ 226 + isPrimaryModifier(modifiers: Modifiers): boolean { 227 + const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().includes("MAC"); 228 + return isMac ? modifiers.meta : modifiers.ctrl; 229 + }, 230 + }; 231 + 232 + /** 233 + * PointerButtons helpers 234 + */ 235 + export const PointerButtons = { 236 + /** 237 + * Create a PointerButtons object with default values (all false) 238 + */ 239 + create(left = false, middle = false, right = false): PointerButtons { 240 + return { left, middle, right }; 241 + }, 242 + 243 + /** 244 + * Create PointerButtons from DOM PointerEvent buttons bitmask 245 + * 246 + * @param buttons - Bitmask from PointerEvent.buttons 247 + */ 248 + fromButtons(buttons: number): PointerButtons { 249 + return { left: (buttons & 1) !== 0, right: (buttons & 2) !== 0, middle: (buttons & 4) !== 0 }; 250 + }, 251 + 252 + /** 253 + * Check if any button is pressed 254 + */ 255 + isAnyPressed(buttons: PointerButtons): boolean { 256 + return buttons.left || buttons.middle || buttons.right; 257 + }, 258 + 259 + /** 260 + * Check if no buttons are pressed 261 + */ 262 + isEmpty(buttons: PointerButtons): boolean { 263 + return !buttons.left && !buttons.middle && !buttons.right; 264 + }, 265 + };
+1
packages/core/src/index.ts
··· 1 + export * from "./actions"; 1 2 export * from "./camera"; 2 3 export * from "./geom"; 3 4 export * from "./math";
+367
packages/core/tests/actions.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { Action, Modifiers, PointerButtons } from "../src/actions"; 3 + 4 + describe("Modifiers", () => { 5 + describe("create", () => { 6 + it("should create modifiers with default values", () => { 7 + const modifiers = Modifiers.create(); 8 + expect(modifiers).toEqual({ ctrl: false, shift: false, alt: false, meta: false }); 9 + }); 10 + 11 + it("should create modifiers with custom values", () => { 12 + const modifiers = Modifiers.create(true, false, true, false); 13 + expect(modifiers).toEqual({ ctrl: true, shift: false, alt: true, meta: false }); 14 + }); 15 + }); 16 + 17 + describe("fromEvent", () => { 18 + it("should extract modifiers from event object", () => { 19 + const event = { ctrlKey: true, shiftKey: false, altKey: true, metaKey: false }; 20 + const modifiers = Modifiers.fromEvent(event); 21 + expect(modifiers).toEqual({ ctrl: true, shift: false, alt: true, meta: false }); 22 + }); 23 + 24 + it("should handle all modifiers pressed", () => { 25 + const event = { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }; 26 + const modifiers = Modifiers.fromEvent(event); 27 + expect(modifiers).toEqual({ ctrl: true, shift: true, alt: true, meta: true }); 28 + }); 29 + 30 + it("should handle no modifiers pressed", () => { 31 + const event = { ctrlKey: false, shiftKey: false, altKey: false, metaKey: false }; 32 + const modifiers = Modifiers.fromEvent(event); 33 + expect(modifiers).toEqual({ ctrl: false, shift: false, alt: false, meta: false }); 34 + }); 35 + }); 36 + 37 + describe("isEmpty", () => { 38 + it("should return true when no modifiers are active", () => { 39 + const modifiers = Modifiers.create(); 40 + expect(Modifiers.isEmpty(modifiers)).toBe(true); 41 + }); 42 + 43 + it("should return false when any modifier is active", () => { 44 + expect(Modifiers.isEmpty(Modifiers.create(true, false, false, false))).toBe(false); 45 + expect(Modifiers.isEmpty(Modifiers.create(false, true, false, false))).toBe(false); 46 + expect(Modifiers.isEmpty(Modifiers.create(false, false, true, false))).toBe(false); 47 + expect(Modifiers.isEmpty(Modifiers.create(false, false, false, true))).toBe(false); 48 + }); 49 + }); 50 + 51 + describe("isPrimaryModifier", () => { 52 + it("should detect primary modifier on different platforms", () => { 53 + const withCtrl = Modifiers.create(true, false, false, false); 54 + const withMeta = Modifiers.create(false, false, false, true); 55 + const ctrlIsPrimary = Modifiers.isPrimaryModifier(withCtrl); 56 + const metaIsPrimary = Modifiers.isPrimaryModifier(withMeta); 57 + expect(ctrlIsPrimary || metaIsPrimary).toBe(true); 58 + }); 59 + }); 60 + }); 61 + 62 + describe("PointerButtons", () => { 63 + describe("create", () => { 64 + it("should create button state with default values", () => { 65 + const buttons = PointerButtons.create(); 66 + expect(buttons).toEqual({ left: false, middle: false, right: false }); 67 + }); 68 + 69 + it("should create button state with custom values", () => { 70 + const buttons = PointerButtons.create(true, false, true); 71 + expect(buttons).toEqual({ left: true, middle: false, right: true }); 72 + }); 73 + }); 74 + 75 + describe("fromButtons", () => { 76 + it.each([ 77 + { description: "no buttons pressed", buttons: 0, expected: { left: false, middle: false, right: false } }, 78 + { description: "left button only", buttons: 1, expected: { left: true, middle: false, right: false } }, 79 + { description: "right button only", buttons: 2, expected: { left: false, middle: false, right: true } }, 80 + { description: "middle button only", buttons: 4, expected: { left: false, middle: true, right: false } }, 81 + { description: "left and right", buttons: 3, expected: { left: true, middle: false, right: true } }, 82 + { description: "left and middle", buttons: 5, expected: { left: true, middle: true, right: false } }, 83 + { description: "right and middle", buttons: 6, expected: { left: false, middle: true, right: true } }, 84 + { description: "all buttons", buttons: 7, expected: { left: true, middle: true, right: true } }, 85 + ])("should decode bitmask: $description", ({ buttons, expected }) => { 86 + expect(PointerButtons.fromButtons(buttons)).toEqual(expected); 87 + }); 88 + }); 89 + 90 + describe("isAnyPressed", () => { 91 + it("should return false when no buttons pressed", () => { 92 + expect(PointerButtons.isAnyPressed(PointerButtons.create())).toBe(false); 93 + }); 94 + 95 + it("should return true when any button is pressed", () => { 96 + expect(PointerButtons.isAnyPressed(PointerButtons.create(true, false, false))).toBe(true); 97 + expect(PointerButtons.isAnyPressed(PointerButtons.create(false, true, false))).toBe(true); 98 + expect(PointerButtons.isAnyPressed(PointerButtons.create(false, false, true))).toBe(true); 99 + }); 100 + }); 101 + 102 + describe("isEmpty", () => { 103 + it("should return true when no buttons pressed", () => { 104 + expect(PointerButtons.isEmpty(PointerButtons.create())).toBe(true); 105 + }); 106 + 107 + it("should return false when any button is pressed", () => { 108 + expect(PointerButtons.isEmpty(PointerButtons.create(true, false, false))).toBe(false); 109 + expect(PointerButtons.isEmpty(PointerButtons.create(false, true, false))).toBe(false); 110 + expect(PointerButtons.isEmpty(PointerButtons.create(false, false, true))).toBe(false); 111 + }); 112 + }); 113 + }); 114 + 115 + describe("Action", () => { 116 + const screen = { x: 100, y: 200 }; 117 + const world = { x: 50, y: 100 }; 118 + const modifiers = Modifiers.create(true, false, false, false); 119 + const buttons = PointerButtons.create(true, false, false); 120 + const timestamp = 1_234_567_890; 121 + 122 + describe("pointerDown", () => { 123 + it("should create pointer down action with all required fields", () => { 124 + const action = Action.pointerDown(screen, world, 0, buttons, modifiers, timestamp); 125 + 126 + expect(action).toEqual({ type: "pointer-down", screen, world, button: 0, buttons, modifiers, timestamp }); 127 + }); 128 + 129 + it("should use current timestamp when not provided", () => { 130 + const before = Date.now(); 131 + const action = Action.pointerDown(screen, world, 0, buttons, modifiers); 132 + const after = Date.now(); 133 + 134 + expect(action.timestamp).toBeGreaterThanOrEqual(before); 135 + expect(action.timestamp).toBeLessThanOrEqual(after); 136 + }); 137 + 138 + it("should handle different button values", () => { 139 + expect(Action.pointerDown(screen, world, 0, buttons, modifiers).button).toBe(0); 140 + expect(Action.pointerDown(screen, world, 1, buttons, modifiers).button).toBe(1); 141 + expect(Action.pointerDown(screen, world, 2, buttons, modifiers).button).toBe(2); 142 + }); 143 + }); 144 + 145 + describe("pointerMove", () => { 146 + it("should create pointer move action with all required fields", () => { 147 + const action = Action.pointerMove(screen, world, buttons, modifiers, timestamp); 148 + 149 + expect(action).toEqual({ type: "pointer-move", screen, world, buttons, modifiers, timestamp }); 150 + }); 151 + 152 + it("should use current timestamp when not provided", () => { 153 + const before = Date.now(); 154 + const action = Action.pointerMove(screen, world, buttons, modifiers); 155 + const after = Date.now(); 156 + 157 + expect(action.timestamp).toBeGreaterThanOrEqual(before); 158 + expect(action.timestamp).toBeLessThanOrEqual(after); 159 + }); 160 + }); 161 + 162 + describe("pointerUp", () => { 163 + it("should create pointer up action with all required fields", () => { 164 + const action = Action.pointerUp(screen, world, 0, buttons, modifiers, timestamp); 165 + 166 + expect(action).toEqual({ type: "pointer-up", screen, world, button: 0, buttons, modifiers, timestamp }); 167 + }); 168 + 169 + it("should use current timestamp when not provided", () => { 170 + const before = Date.now(); 171 + const action = Action.pointerUp(screen, world, 0, buttons, modifiers); 172 + const after = Date.now(); 173 + 174 + expect(action.timestamp).toBeGreaterThanOrEqual(before); 175 + expect(action.timestamp).toBeLessThanOrEqual(after); 176 + }); 177 + }); 178 + 179 + describe("wheel", () => { 180 + it("should create wheel action with all required fields", () => { 181 + const deltaY = -100; 182 + const action = Action.wheel(screen, world, deltaY, modifiers, timestamp); 183 + 184 + expect(action).toEqual({ type: "wheel", screen, world, deltaY, modifiers, timestamp }); 185 + }); 186 + 187 + it("should use current timestamp when not provided", () => { 188 + const before = Date.now(); 189 + const action = Action.wheel(screen, world, -100, modifiers); 190 + const after = Date.now(); 191 + 192 + expect(action.timestamp).toBeGreaterThanOrEqual(before); 193 + expect(action.timestamp).toBeLessThanOrEqual(after); 194 + }); 195 + 196 + it("should handle positive and negative deltaY", () => { 197 + expect(Action.wheel(screen, world, -100, modifiers).deltaY).toBe(-100); 198 + expect(Action.wheel(screen, world, 100, modifiers).deltaY).toBe(100); 199 + expect(Action.wheel(screen, world, 0, modifiers).deltaY).toBe(0); 200 + }); 201 + }); 202 + 203 + describe("keyDown", () => { 204 + it("should create key down action with all required fields", () => { 205 + const action = Action.keyDown("a", "KeyA", modifiers, false, timestamp); 206 + 207 + expect(action).toEqual({ type: "key-down", key: "a", code: "KeyA", modifiers, repeat: false, timestamp }); 208 + }); 209 + 210 + it("should use current timestamp when not provided", () => { 211 + const before = Date.now(); 212 + const action = Action.keyDown("a", "KeyA", modifiers); 213 + const after = Date.now(); 214 + 215 + expect(action.timestamp).toBeGreaterThanOrEqual(before); 216 + expect(action.timestamp).toBeLessThanOrEqual(after); 217 + }); 218 + 219 + it("should handle repeat flag", () => { 220 + expect(Action.keyDown("a", "KeyA", modifiers, false).repeat).toBe(false); 221 + expect(Action.keyDown("a", "KeyA", modifiers, true).repeat).toBe(true); 222 + }); 223 + 224 + it("should handle special keys", () => { 225 + expect(Action.keyDown("Escape", "Escape", modifiers).key).toBe("Escape"); 226 + expect(Action.keyDown("Enter", "Enter", modifiers).key).toBe("Enter"); 227 + expect(Action.keyDown(" ", "Space", modifiers).key).toBe(" "); 228 + }); 229 + }); 230 + 231 + describe("keyUp", () => { 232 + it("should create key up action with all required fields", () => { 233 + const action = Action.keyUp("a", "KeyA", modifiers, timestamp); 234 + 235 + expect(action).toEqual({ type: "key-up", key: "a", code: "KeyA", modifiers, timestamp }); 236 + }); 237 + 238 + it("should use current timestamp when not provided", () => { 239 + const before = Date.now(); 240 + const action = Action.keyUp("a", "KeyA", modifiers); 241 + const after = Date.now(); 242 + 243 + expect(action.timestamp).toBeGreaterThanOrEqual(before); 244 + expect(action.timestamp).toBeLessThanOrEqual(after); 245 + }); 246 + }); 247 + }); 248 + 249 + describe("Action edge cases", () => { 250 + describe("coordinate edge cases", () => { 251 + it("should handle zero coordinates", () => { 252 + const screen = { x: 0, y: 0 }; 253 + const world = { x: 0, y: 0 }; 254 + const action = Action.pointerDown(screen, world, 0, PointerButtons.create(), Modifiers.create()); 255 + 256 + expect(action.screen).toEqual({ x: 0, y: 0 }); 257 + expect(action.world).toEqual({ x: 0, y: 0 }); 258 + }); 259 + 260 + it("should handle negative coordinates", () => { 261 + const screen = { x: -100, y: -200 }; 262 + const world = { x: -50, y: -100 }; 263 + const action = Action.pointerMove(screen, world, PointerButtons.create(), Modifiers.create()); 264 + 265 + expect(action.screen).toEqual({ x: -100, y: -200 }); 266 + expect(action.world).toEqual({ x: -50, y: -100 }); 267 + }); 268 + 269 + it("should handle very large coordinates", () => { 270 + const screen = { x: 1e10, y: 1e10 }; 271 + const world = { x: 1e10, y: 1e10 }; 272 + const action = Action.pointerUp(screen, world, 0, PointerButtons.create(), Modifiers.create()); 273 + 274 + expect(action.screen).toEqual({ x: 1e10, y: 1e10 }); 275 + expect(action.world).toEqual({ x: 1e10, y: 1e10 }); 276 + }); 277 + 278 + it("should handle floating point coordinates", () => { 279 + const screen = { x: 100.5, y: 200.7 }; 280 + const world = { x: 50.3, y: 100.9 }; 281 + const action = Action.pointerMove(screen, world, PointerButtons.create(), Modifiers.create()); 282 + 283 + expect(action.screen).toEqual({ x: 100.5, y: 200.7 }); 284 + expect(action.world).toEqual({ x: 50.3, y: 100.9 }); 285 + }); 286 + }); 287 + 288 + describe("button edge cases", () => { 289 + it("should handle invalid button numbers gracefully", () => { 290 + const action = Action.pointerDown( 291 + { x: 0, y: 0 }, 292 + { x: 0, y: 0 }, 293 + 99, 294 + PointerButtons.create(), 295 + Modifiers.create(), 296 + ); 297 + expect(action.button).toBe(99); 298 + }); 299 + 300 + it("should handle negative button numbers", () => { 301 + const action = Action.pointerDown( 302 + { x: 0, y: 0 }, 303 + { x: 0, y: 0 }, 304 + -1, 305 + PointerButtons.create(), 306 + Modifiers.create(), 307 + ); 308 + expect(action.button).toBe(-1); 309 + }); 310 + }); 311 + 312 + describe("wheel deltaY edge cases", () => { 313 + it("should handle very large deltaY values", () => { 314 + const action = Action.wheel({ x: 0, y: 0 }, { x: 0, y: 0 }, 1e6, Modifiers.create()); 315 + expect(action.deltaY).toBe(1e6); 316 + }); 317 + 318 + it("should handle very small deltaY values", () => { 319 + const action = Action.wheel({ x: 0, y: 0 }, { x: 0, y: 0 }, -1e-10, Modifiers.create()); 320 + expect(action.deltaY).toBe(-1e-10); 321 + }); 322 + }); 323 + 324 + describe("keyboard edge cases", () => { 325 + it("should handle empty key string", () => { 326 + const action = Action.keyDown("", "", Modifiers.create()); 327 + expect(action.key).toBe(""); 328 + expect(action.code).toBe(""); 329 + }); 330 + 331 + it("should handle multi-character keys", () => { 332 + const action = Action.keyDown("ArrowUp", "ArrowUp", Modifiers.create()); 333 + expect(action.key).toBe("ArrowUp"); 334 + expect(action.code).toBe("ArrowUp"); 335 + }); 336 + 337 + it("should handle unicode characters", () => { 338 + const action = Action.keyDown("�", "Euro", Modifiers.create()); 339 + expect(action.key).toBe("�"); 340 + }); 341 + }); 342 + 343 + describe("timestamp edge cases", () => { 344 + it("should handle zero timestamp", () => { 345 + const action = Action.pointerDown( 346 + { x: 0, y: 0 }, 347 + { x: 0, y: 0 }, 348 + 0, 349 + PointerButtons.create(), 350 + Modifiers.create(), 351 + 0, 352 + ); 353 + expect(action.timestamp).toBe(0); 354 + }); 355 + 356 + it("should handle negative timestamp", () => { 357 + const action = Action.keyDown("a", "KeyA", Modifiers.create(), false, -100); 358 + expect(action.timestamp).toBe(-100); 359 + }); 360 + 361 + it("should handle very large timestamp", () => { 362 + const largeTimestamp = Number.MAX_SAFE_INTEGER; 363 + const action = Action.wheel({ x: 0, y: 0 }, { x: 0, y: 0 }, 0, Modifiers.create(), largeTimestamp); 364 + expect(action.timestamp).toBe(largeTimestamp); 365 + }); 366 + }); 367 + });
+4
pnpm-lock.yaml
··· 31 31 version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) 32 32 33 33 apps/web: 34 + dependencies: 35 + inkfinite-core: 36 + specifier: workspace:* 37 + version: link:../../packages/core 34 38 devDependencies: 35 39 '@eslint/compat': 36 40 specifier: ^1.4.0