diff --git a/apps/web/src/lib/tests/input.test.ts b/apps/web/src/lib/tests/input.test.ts
index 1bbd953..a306cde 100644
--- a/apps/web/src/lib/tests/input.test.ts
+++ b/apps/web/src/lib/tests/input.test.ts
@@ -64,6 +64,7 @@ function createWheelEvent(
options: {
clientX?: number;
clientY?: number;
+ deltaX?: number;
deltaY?: number;
ctrlKey?: boolean;
shiftKey?: boolean;
@@ -74,6 +75,7 @@ function createWheelEvent(
return new WheelEvent('wheel', {
clientX: options.clientX ?? 0,
clientY: options.clientY ?? 0,
+ deltaX: options.deltaX ?? 0,
deltaY: options.deltaY ?? 0,
ctrlKey: options.ctrlKey ?? false,
shiftKey: options.shiftKey ?? false,
@@ -404,12 +406,21 @@ describe('InputAdapter', () => {
describe('wheel events', () => {
it('should dispatch wheel action', () => {
- const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: -100 });
+ const event = createWheelEvent({
+ clientX: 400,
+ clientY: 300,
+ deltaX: 25,
+ 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 });
+ expect(actions[0]).toMatchObject({
+ screen: { x: 400, y: 300 },
+ deltaX: 25,
+ deltaY: -100
+ });
});
it('should include modifiers in wheel events', () => {
@@ -466,6 +477,19 @@ describe('InputAdapter', () => {
});
});
+ it.each([
+ { key: '+', code: 'Equal', shiftKey: true },
+ { key: '-', code: 'Minus' },
+ { key: '0', code: 'Digit0' },
+ { key: '!', code: 'Digit1', shiftKey: true },
+ { key: '@', code: 'Digit2', shiftKey: true }
+ ])('prevents browser behavior for camera shortcut $key', (options) => {
+ const event = createKeyboardEvent('keydown', options);
+ window.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
it('should not capture keyboard events when captureKeyboard is false', () => {
const testActions: ActionType[] = [];
const testAdapter = new InputAdapter({
diff --git a/packages/core/src/actions.ts b/packages/core/src/actions.ts
index 850fc33..b843124 100644
--- a/packages/core/src/actions.ts
+++ b/packages/core/src/actions.ts
@@ -1,4 +1,4 @@
-import type { Vec2 } from "./math";
+import type { Vec2 } from './math';
/**
* Keyboard modifier keys state
@@ -17,249 +17,253 @@ 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;
+ 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;
+ 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;
+ 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;
+ type: 'wheel';
+ /** Point in screen coordinates where wheel event occurred */
+ screen: Vec2;
+ /** Point in world coordinates */
+ world: Vec2;
+ /** Horizontal wheel or trackpad delta in screen pixels */
+ deltaX: number;
+ /** Vertical wheel or trackpad delta in screen pixels */
+ 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;
+ 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;
+ 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;
+ | 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 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 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 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 WheelAction
+ */
+ wheel(screen: Vec2, world: Vec2, delta: number | Vec2, modifiers: Modifiers, timestamp = Date.now()): WheelAction {
+ const deltaX = typeof delta === 'number' ? 0 : delta.x;
+ const deltaY = typeof delta === 'number' ? delta : delta.y;
+ return { type: 'wheel', screen, world, deltaX, 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 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 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 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 };
- },
+ /**
+ * 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 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;
- },
+ /**
+ * 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 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 };
- },
+ /**
+ * 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 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;
- },
+ /**
+ * Check if no buttons are pressed
+ */
+ isEmpty(buttons: PointerButtons): boolean {
+ return !buttons.left && !buttons.middle && !buttons.right;
+ }
};
diff --git a/packages/core/tests/actions.test.ts b/packages/core/tests/actions.test.ts
index 48227bd..63ce4a2 100644
--- a/packages/core/tests/actions.test.ts
+++ b/packages/core/tests/actions.test.ts
@@ -1,367 +1,373 @@
-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);
- });
- });
+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('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', () => {
+ 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, deltaX: 0, deltaY, modifiers, timestamp });
+ });
+
+ it('should preserve two-dimensional trackpad deltas', () => {
+ const action = Action.wheel(screen, world, { x: 24, y: -48 }, modifiers, timestamp);
+
+ expect(action).toMatchObject({ deltaX: 24, deltaY: -48 });
+ });
+
+ 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);
- });
- });
+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/packages/input-dom/src/index.ts b/packages/input-dom/src/index.ts
index 12df779..9d9d6e8 100644
--- a/packages/input-dom/src/index.ts
+++ b/packages/input-dom/src/index.ts
@@ -51,7 +51,7 @@ export type InputAdapterConfig = {
*
* Features:
* - Captures pointer events (down, move, up) on canvas
- * - Captures wheel events for zooming
+ * - Captures wheel and trackpad events for camera movement
* - Captures keyboard events (optionally on window)
* - Converts screen coordinates to world coordinates
* - Tracks pointer state
@@ -315,7 +315,7 @@ export class InputAdapter {
const world = this.screenToWorld(screen);
const modifiers = Modifiers.fromEvent(e);
- this.config.onAction(Action.wheel(screen, world, e.deltaY, modifiers));
+ this.config.onAction(Action.wheel(screen, world, { x: e.deltaX, y: e.deltaY }, modifiers));
}
private handleKeyDown(e: KeyboardEvent): void {
@@ -400,6 +400,7 @@ export class InputAdapter {
* - Arrow keys (scroll)
* - Backspace/Delete (navigation)
* - Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo)
+ * - Camera shortcuts (+, -, 0, Shift+1, Shift+2)
* - Tab (focus change)
*/
private shouldPreventDefault(e: KeyboardEvent): boolean {
@@ -418,6 +419,14 @@ export class InputAdapter {
return true;
}
+ if (key === '+' || key === '=' || key === '-' || key === '_' || key === '0') {
+ return true;
+ }
+
+ if (modifiers.shift && (e.code === 'Digit1' || e.code === 'Digit2')) {
+ return true;
+ }
+
if (Modifiers.isPrimaryModifier(modifiers) && (key === 'z' || key === 'Z')) {
return true;
}
diff --git a/packages/ui/src/lib/components/BrushPopover.svelte b/packages/ui/src/lib/components/BrushPopover.svelte
index c279f79..e6d2c52 100644
--- a/packages/ui/src/lib/components/BrushPopover.svelte
+++ b/packages/ui/src/lib/components/BrushPopover.svelte
@@ -14,11 +14,12 @@
brush: BrushSettings;
onBrushChange: (brush: BrushSettings) => void;
disabled?: boolean;
+ align?: 'start' | 'end';
}
+
+
+
+
diff --git a/packages/ui/src/lib/editor/canvas/NavigationControls.svelte.test.ts b/packages/ui/src/lib/editor/canvas/NavigationControls.svelte.test.ts
new file mode 100644
index 0000000..e5fdc56
--- /dev/null
+++ b/packages/ui/src/lib/editor/canvas/NavigationControls.svelte.test.ts
@@ -0,0 +1,24 @@
+import { Store } from '@inkfinite/core';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+import { CameraController } from './controllers/camera-controller';
+import NavigationControls from './NavigationControls.svelte';
+
+describe('NavigationControls', () => {
+ it('offers zoom and fit controls with an updating zoom readout', async () => {
+ const store = new Store();
+ const camera = new CameraController(store, () => ({ width: 800, height: 600 }));
+ const screen = render(NavigationControls, { store, camera });
+
+ await expect
+ .element(screen.getByRole('navigation', { name: 'Canvas navigation' }))
+ .toBeInTheDocument();
+ await screen.getByRole('button', { name: 'Zoom in' }).click();
+ await expect
+ .element(screen.getByRole('button', { name: 'Reset zoom to 100%' }))
+ .toHaveTextContent('120%');
+ await screen.getByRole('button', { name: 'Reset zoom to 100%' }).click();
+ expect(store.getState().camera.zoom).toBe(1);
+ });
+});
diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts
index 35c6e94..c5a88b2 100644
--- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts
+++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts
@@ -40,6 +40,7 @@ import { createRenderer, type Renderer } from '@inkfinite/renderer';
import { onDestroy, onMount } from 'svelte';
import { computeCursor } from './canvas-helpers';
import { ArrowLabelEditorController } from './controllers/arrowlabel-controller.svelte';
+import { CameraController } from './controllers/camera-controller';
import { DesktopFileController } from './controllers/desktop-file-controller.svelte';
import { FileBrowserController } from './controllers/filebrowser-controller.svelte';
import { HistoryController } from './controllers/history-controller';
@@ -139,6 +140,8 @@ export function createCanvasController(
return measureViewport(canvas);
}
+ const camera = new CameraController(store, getViewport);
+
function getOverlayViewport(): Viewport {
return overlayViewport;
}
@@ -300,6 +303,10 @@ export function createCanvasController(
markdownEditor.commit();
}
+ if (camera.handleAction(action)) {
+ return;
+ }
+
runtime.handleAction(action);
}
@@ -361,6 +368,19 @@ export function createCanvasController(
}
}
}
+
+ const clickedShape = shapes.some((shape) => {
+ const bounds = shapeBounds(shape);
+ return (
+ world.x >= bounds.min.x &&
+ world.x <= bounds.max.x &&
+ world.y >= bounds.min.y &&
+ world.y <= bounds.max.y
+ );
+ });
+ if (!clickedShape) {
+ camera.reset();
+ }
}
function handlePointerLeave() {
@@ -498,6 +518,7 @@ export function createCanvasController(
fileBrowser,
tools: toolController,
history,
+ camera,
textEditor,
arrowLabelEditor,
markdownEditor,
diff --git a/packages/ui/src/lib/editor/canvas/controllers/camera-controller.test.ts b/packages/ui/src/lib/editor/canvas/controllers/camera-controller.test.ts
new file mode 100644
index 0000000..8f39620
--- /dev/null
+++ b/packages/ui/src/lib/editor/canvas/controllers/camera-controller.test.ts
@@ -0,0 +1,81 @@
+import { Action, Camera, Modifiers, PageRecord, ShapeRecord, Store } from '@inkfinite/core';
+import { describe, expect, it } from 'vitest';
+
+import { CameraController } from './camera-controller';
+
+const viewport = { width: 800, height: 600 };
+
+describe('CameraController', () => {
+ it('zooms toward the cursor without moving the anchored world point', () => {
+ const store = new Store();
+ const controller = new CameraController(store, () => viewport);
+ const anchor = { x: 180, y: 140 };
+ const before = Camera.screenToWorld(store.getState().camera, anchor, viewport);
+
+ expect(
+ controller.handleAction(
+ Action.wheel(anchor, before, -120, Modifiers.create(true, false, false, false))
+ )
+ ).toBe(true);
+
+ const after = Camera.screenToWorld(store.getState().camera, anchor, viewport);
+ expect(store.getState().camera.zoom).toBeGreaterThan(1);
+ expect(after.x).toBeCloseTo(before.x);
+ expect(after.y).toBeCloseTo(before.y);
+ });
+
+ it('pans in both axes for ordinary wheel and trackpad input', () => {
+ const store = new Store();
+ const controller = new CameraController(store, () => viewport);
+
+ controller.handleAction(
+ Action.wheel({ x: 400, y: 300 }, { x: 0, y: 0 }, { x: 40, y: -60 }, Modifiers.create())
+ );
+
+ expect(store.getState().camera).toEqual({ x: 40, y: -60, zoom: 1 });
+ });
+
+ it('supports bounded keyboard zoom and a 100% reset', () => {
+ const store = new Store();
+ const controller = new CameraController(store, () => viewport);
+
+ controller.handleAction(
+ Action.keyDown('+', 'Equal', Modifiers.create(false, true, false, false))
+ );
+ expect(store.getState().camera.zoom).toBeGreaterThan(1);
+
+ controller.handleAction(Action.keyDown('0', 'Digit0', Modifiers.create()));
+ expect(store.getState().camera.zoom).toBe(1);
+
+ controller.setZoomPercent(100_000);
+ expect(store.getState().camera.zoom).toBe(10);
+ controller.setZoomPercent(0.001);
+ expect(store.getState().camera.zoom).toBe(0.05);
+ });
+
+ it('fits the current drawing inside the viewport', () => {
+ const page = PageRecord.create('Page', 'page');
+ const shape = ShapeRecord.createRect(
+ page.id,
+ 100,
+ 200,
+ { w: 400, h: 200, fill: '#fff', stroke: '#000', radius: 0 },
+ 'shape'
+ );
+ const store = new Store();
+ store.setState((state) => ({
+ ...state,
+ doc: {
+ ...state.doc,
+ pages: { [page.id]: { ...page, shapeIds: [shape.id] } },
+ shapes: { [shape.id]: shape }
+ },
+ ui: { ...state.ui, currentPageId: page.id }
+ }));
+ const controller = new CameraController(store, () => viewport);
+
+ controller.fitAll();
+
+ expect(store.getState().camera).toEqual({ x: 300, y: 300, zoom: 1.8 });
+ });
+});
diff --git a/packages/ui/src/lib/editor/canvas/controllers/camera-controller.ts b/packages/ui/src/lib/editor/canvas/controllers/camera-controller.ts
new file mode 100644
index 0000000..dff4b4e
--- /dev/null
+++ b/packages/ui/src/lib/editor/canvas/controllers/camera-controller.ts
@@ -0,0 +1,195 @@
+import {
+ Camera,
+ getSelectedShapes,
+ getShapesOnCurrentPage,
+ shapeBounds,
+ type Action,
+ type Box2,
+ type Store,
+ type Vec2,
+ type Viewport
+} from '@inkfinite/core';
+
+const MIN_ZOOM = 0.05;
+const MAX_ZOOM = 10;
+const ZOOM_STEP = 1.2;
+const FIT_MARGIN = 80;
+
+/** Coordinates every camera interaction exposed by the editor UI. */
+export class CameraController {
+ constructor(
+ private readonly store: Store,
+ private readonly getViewport: () => Viewport
+ ) {}
+
+ /** Returns the current zoom as a rounded percentage. */
+ getZoomPercent(): number {
+ const percent = this.store.getState().camera.zoom * 100;
+ return Number.isFinite(percent) ? Math.round(percent) : 100;
+ }
+
+ /** Zooms one step toward the viewport center. */
+ zoomIn(): void {
+ this.zoomAt(ZOOM_STEP, this.viewportCenter());
+ }
+
+ /** Zooms one step away from the viewport center. */
+ zoomOut(): void {
+ this.zoomAt(1 / ZOOM_STEP, this.viewportCenter());
+ }
+
+ /** Sets an exact zoom while preserving the current viewport center. */
+ setZoomPercent(percent: number): void {
+ const currentZoom = this.store.getState().camera.zoom;
+ if (!Number.isFinite(currentZoom) || currentZoom <= 0) {
+ this.store.setState((state) => ({
+ ...state,
+ camera: Camera.create(state.camera.x, state.camera.y, 1)
+ }));
+ return;
+ }
+ const targetZoom = this.clampZoom(percent / 100);
+ this.zoomAt(targetZoom / currentZoom, this.viewportCenter());
+ }
+
+ /** Restores the origin at 100% zoom. */
+ reset(): void {
+ this.store.setState((state) => ({ ...state, camera: Camera.reset() }));
+ }
+
+ /** Frames every shape on the current page, or resets an empty page. */
+ fitAll(): void {
+ const shapes = getShapesOnCurrentPage(this.store.getState());
+ const bounds = this.getCombinedBounds(shapes);
+ if (bounds) {
+ this.fitBounds(bounds);
+ } else {
+ this.reset();
+ }
+ }
+
+ /** Frames the selection, falling back to every shape on the page. */
+ fitSelection(): void {
+ const bounds = this.getCombinedBounds(getSelectedShapes(this.store.getState()));
+ if (bounds) {
+ this.fitBounds(bounds);
+ } else {
+ this.fitAll();
+ }
+ }
+
+ /** Handles trackpad pan, modified wheel zoom, and camera keyboard shortcuts. */
+ handleAction(action: Action): boolean {
+ if (action.type === 'wheel') {
+ if (action.modifiers.ctrl || action.modifiers.meta) {
+ const factor = Math.exp(-action.deltaY * 0.0015);
+ this.zoomAt(factor, action.screen);
+ } else {
+ const horizontalDelta =
+ action.modifiers.shift && action.deltaX === 0 ? action.deltaY : action.deltaX;
+ const verticalDelta =
+ action.modifiers.shift && action.deltaX === 0 ? 0 : action.deltaY;
+ this.panByWheel(horizontalDelta, verticalDelta);
+ }
+ return true;
+ }
+
+ if (action.type !== 'key-down' || action.repeat || action.modifiers.alt) {
+ return false;
+ }
+
+ if (action.key === '+' || action.key === '=') {
+ this.zoomIn();
+ return true;
+ }
+ if (action.key === '-' || action.key === '_') {
+ this.zoomOut();
+ return true;
+ }
+ if (action.key === '0') {
+ this.setZoomPercent(100);
+ return true;
+ }
+ if (action.modifiers.shift && (action.key === '1' || action.code === 'Digit1')) {
+ this.fitAll();
+ return true;
+ }
+ if (action.modifiers.shift && (action.key === '2' || action.code === 'Digit2')) {
+ this.fitSelection();
+ return true;
+ }
+
+ return false;
+ }
+
+ private panByWheel(deltaX: number, deltaY: number): void {
+ if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) return;
+ this.store.setState((state) => ({
+ ...state,
+ camera: Camera.pan(state.camera, { x: -deltaX, y: -deltaY })
+ }));
+ }
+
+ private zoomAt(factor: number, anchor: Vec2): void {
+ if (!Number.isFinite(factor) || factor <= 0) return;
+ const viewport = this.getViewport();
+ this.store.setState((state) => {
+ const currentZoom =
+ Number.isFinite(state.camera.zoom) && state.camera.zoom > 0
+ ? state.camera.zoom
+ : 1;
+ const camera =
+ currentZoom === state.camera.zoom
+ ? state.camera
+ : { ...state.camera, zoom: currentZoom };
+ const targetZoom = this.clampZoom(currentZoom * factor);
+ return {
+ ...state,
+ camera: Camera.zoomAt(camera, targetZoom / currentZoom, anchor, viewport)
+ };
+ });
+ }
+
+ private fitBounds(bounds: Box2): void {
+ const viewport = this.getViewport();
+ const width = Math.max(bounds.max.x - bounds.min.x, 1);
+ const height = Math.max(bounds.max.y - bounds.min.y, 1);
+ const availableWidth = Math.max(viewport.width - FIT_MARGIN, 1);
+ const availableHeight = Math.max(viewport.height - FIT_MARGIN, 1);
+ const zoom = this.clampZoom(Math.min(availableWidth / width, availableHeight / height));
+ this.store.setState((state) => ({
+ ...state,
+ camera: {
+ x: (bounds.min.x + bounds.max.x) / 2,
+ y: (bounds.min.y + bounds.max.y) / 2,
+ zoom
+ }
+ }));
+ }
+
+ private getCombinedBounds(shapes: ReturnType): Box2 | null {
+ return shapes.reduce((combined, shape) => {
+ const bounds = shapeBounds(shape);
+ if (!combined) return bounds;
+ return {
+ min: {
+ x: Math.min(combined.min.x, bounds.min.x),
+ y: Math.min(combined.min.y, bounds.min.y)
+ },
+ max: {
+ x: Math.max(combined.max.x, bounds.max.x),
+ y: Math.max(combined.max.y, bounds.max.y)
+ }
+ };
+ }, null);
+ }
+
+ private viewportCenter(): Vec2 {
+ const viewport = this.getViewport();
+ return { x: viewport.width / 2, y: viewport.height / 2 };
+ }
+
+ private clampZoom(zoom: number): number {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+ }
+}
diff --git a/packages/ui/src/lib/editor/components/HistoryViewer.svelte b/packages/ui/src/lib/editor/components/HistoryViewer.svelte
index e609e83..25c2abb 100644
--- a/packages/ui/src/lib/editor/components/HistoryViewer.svelte
+++ b/packages/ui/src/lib/editor/components/HistoryViewer.svelte
@@ -102,13 +102,17 @@
}
.history-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--ink-space-4);
padding: var(--ink-space-5);
border-bottom: 1px solid color-mix(in srgb, var(--ink-border) 55%, transparent);
background: var(--ink-surface-raised);
}
.history-header h2 {
- margin: 0 0 var(--ink-space-4);
+ margin: 0;
color: var(--ink-heading);
font-family: var(--ink-font-display);
font-size: var(--ink-type-lg);
@@ -118,6 +122,7 @@
.history-actions {
display: flex;
+ flex-shrink: 0;
gap: 8px;
}
@@ -199,16 +204,14 @@
align-items: center;
gap: var(--ink-space-3);
padding: var(--ink-space-3) var(--ink-space-4);
- border-left: 3px solid var(--ink-accent);
+ border: 1px solid var(--ink-accent);
border-radius: var(--ink-radius-panel-small);
- background: var(--ink-surface-raised);
- box-shadow:
- 0 0 0 1px color-mix(in srgb, var(--ink-border) 35%, transparent),
- 0 2px 5px color-mix(in srgb, var(--ink-shadow-color) 16%, transparent);
+ background: var(--ink-canvas);
+ box-shadow: 0 2px 5px color-mix(in srgb, var(--ink-shadow-color) 16%, transparent);
}
.history-entry.redo {
- border-left-color: var(--ink-warning);
+ border-color: var(--ink-warning);
opacity: 0.76;
}
diff --git a/packages/ui/src/lib/editor/components/LayerPanel.svelte b/packages/ui/src/lib/editor/components/LayerPanel.svelte
index 066524c..68d635e 100644
--- a/packages/ui/src/lib/editor/components/LayerPanel.svelte
+++ b/packages/ui/src/lib/editor/components/LayerPanel.svelte
@@ -162,8 +162,8 @@