From 0e59b2251cde557163bcf26856bb2690d458e7bb Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 19 Jul 2026 17:23:36 -0500 Subject: [PATCH] feat: add mouse/trackpad camera controls --- apps/web/src/lib/tests/input.test.ts | 28 +- packages/core/src/actions.ts | 368 ++++----- packages/core/tests/actions.test.ts | 726 +++++++++--------- packages/input-dom/src/index.ts | 13 +- .../ui/src/lib/components/BrushPopover.svelte | 9 +- .../ui/src/lib/editor/canvas/Canvas.svelte | 3 + .../editor/canvas/NavigationControls.svelte | 113 +++ .../canvas/NavigationControls.svelte.test.ts | 24 + .../lib/editor/canvas/canvas-store.svelte.ts | 21 + .../controllers/camera-controller.test.ts | 81 ++ .../canvas/controllers/camera-controller.ts | 195 +++++ .../editor/components/HistoryViewer.svelte | 17 +- .../lib/editor/components/LayerPanel.svelte | 6 +- .../components/LayerPanel.svelte.test.ts | 3 + .../lib/editor/components/StatusBar.svelte | 23 +- .../src/lib/editor/components/Toolbar.svelte | 142 ++-- .../editor/components/Toolbar.svelte.test.ts | 22 + packages/ui/src/lib/editor/constants.ts | 9 +- 18 files changed, 1137 insertions(+), 666 deletions(-) create mode 100644 packages/ui/src/lib/editor/canvas/NavigationControls.svelte create mode 100644 packages/ui/src/lib/editor/canvas/NavigationControls.svelte.test.ts create mode 100644 packages/ui/src/lib/editor/canvas/controllers/camera-controller.test.ts create mode 100644 packages/ui/src/lib/editor/canvas/controllers/camera-controller.ts 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 @@