diff --git a/bun.lock b/bun.lock index b3a743a..5cfe9fb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "bun-react-template", "dependencies": { + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-slot": "^1.2.3", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", @@ -84,6 +85,8 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], diff --git a/editor/tools.ts b/editor/tools.ts index f2e38fd..49774d0 100644 --- a/editor/tools.ts +++ b/editor/tools.ts @@ -1,4 +1,6 @@ -export type ToolId = "select" | "pan"; +export const availableToolIds = ["select", "pan"] as const; + +export type ToolId = (typeof availableToolIds)[number]; export type InteractionMode = | { type: "tool"; tool: ToolId } @@ -13,3 +15,7 @@ export const initialToolState: ToolState = { activeTool: "select", interactionMode: { type: "tool", tool: "select" }, }; + +export function isPanInteractionMode(interactionMode: InteractionMode): boolean { + return interactionMode.type === "temporary-pan" || (interactionMode.type === "tool" && interactionMode.tool === "pan"); +} diff --git a/input/viewport-pan-store.test.ts b/input/viewport-pan-store.test.ts new file mode 100644 index 0000000..93c3508 --- /dev/null +++ b/input/viewport-pan-store.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { toolCommands } from "@commands/tool"; +import { viewportCommands } from "@commands/viewport"; +import { createCommandRegistry } from "@commands/registry"; +import { createInitialAppState } from "@editor/initial-state"; +import { createAppStore } from "@editor/store"; +import type { PointerInputEvent } from "./pointer"; +import { createViewportPanInputController } from "./viewport-pan"; + +const registry = createCommandRegistry([...toolCommands, ...viewportCommands]); + +describe("viewport pan store integration", () => { + test("space key input updates actual store tool mode", () => { + const store = createAppStore(createInitialAppState("Test"), registry); + const controller = createController(store); + + expect(controller.keyDown(keyEvent("Space"))).toBe(true); + expect(store.getState().editor.tools.interactionMode).toEqual({ type: "temporary-pan", previousTool: "select" }); + + expect(controller.keyUp(keyEvent("Space"))).toBe(true); + expect(store.getState().editor.tools.interactionMode).toEqual({ type: "tool", tool: "select" }); + }); + + test("left-drag only pans while temporary pan is active", () => { + const store = createAppStore(createInitialAppState("Test"), registry); + const controller = createController(store); + + expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(false); + expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(false); + expect(store.getState().editor.viewport.center).toEqual({ x: 0, y: 0 }); + + store.dispatch("tool.enterTemporaryPan", undefined); + + expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true); + expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 10, y: 0 } }))).toBe(true); + expect(store.getState().editor.viewport.center).toEqual({ x: -10, y: 0 }); + }); + + test("left-drag pans while pan tool is active", () => { + const store = createAppStore(createInitialAppState("Test"), registry); + const controller = createController(store); + + store.dispatch("tool.setActive", { tool: "pan" }); + + expect(controller.pointerDown(pointerEvent({ buttons: 1, position: { x: 0, y: 0 } }))).toBe(true); + expect(controller.pointerMove(pointerEvent({ buttons: 1, position: { x: 4, y: -2 } }))).toBe(true); + expect(store.getState().editor.viewport.center).toEqual({ x: -4, y: 2 }); + }); +}); + +function createController(store: ReturnType) { + return createViewportPanInputController({ + globalKeyConsumer: () => false, + globalPointerConsumer: () => false, + getCurrentZoom: () => store.getState().editor.viewport.zoom, + isPanMode: () => { + const mode = store.getState().editor.tools.interactionMode; + return mode.type === "temporary-pan" || (mode.type === "tool" && mode.tool === "pan"); + }, + dispatch: store.dispatch, + }); +} + +function keyEvent(code: string) { + return { key: code === "Space" ? " " : code, code, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }; +} + +function pointerEvent(overrides: Partial): PointerInputEvent { + return { + pointerId: 1, + pointerType: "mouse", + position: { x: 0, y: 0 }, + buttons: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides, + }; +} diff --git a/package.json b/package.json index 73f2d58..43385d8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "bun test" }, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-slot": "^1.2.3", "bun-plugin-tailwind": "^0.1.2", "class-variance-authority": "^0.7.1", diff --git a/view/App.tsx b/view/App.tsx index c8de23c..9b0b651 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -1,5 +1,7 @@ import type { ImageStudioApp } from "@app/app"; import { CanvasViewport } from "./CanvasViewport"; +import { ToolOverlay } from "./ToolOverlay"; +import { labelForTool } from "./toolLabels"; import { useAppState } from "./useAppState"; import "./index.css"; @@ -16,9 +18,17 @@ export function App({ app }: AppProps) {

Image Studio

- {state.document.name} · {zoomPercent}% · {state.editor.viewport.size.w}×{state.editor.viewport.size.h} + {state.document.name} · {labelForTool(state.editor.tools.activeTool)} · {zoomPercent}% · {state.editor.viewport.size.w}× + {state.editor.viewport.size.h}
+
+ +
); diff --git a/view/ToolOverlay.tsx b/view/ToolOverlay.tsx new file mode 100644 index 0000000..0086044 --- /dev/null +++ b/view/ToolOverlay.tsx @@ -0,0 +1,57 @@ +import { Cursor, Hand } from "@phosphor-icons/react"; +import type { AppStore } from "@editor/store"; +import type { InteractionMode, ToolId } from "@editor/tools"; +import { availableToolIds } from "@editor/tools"; +import { labelForTool } from "./toolLabels"; + +export type ToolOverlayProps = { + activeTool: ToolId; + interactionMode: InteractionMode; + dispatch: AppStore["dispatch"]; +}; + +export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverlayProps) { + return ( + + ); +} + +function iconForTool(tool: ToolId) { + switch (tool) { + case "pan": + return Hand; + case "select": + return Cursor; + } +} + +function isToolHighlighted(tool: ToolId, activeTool: ToolId, interactionMode: InteractionMode) { + if (interactionMode.type === "temporary-pan") return tool === "pan"; + return activeTool === tool; +} + +function buttonClass(active: boolean) { + return `grid size-9 place-items-center rounded-md transition focus:outline-none focus-visible:outline-none ${active ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`; +} diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 311d92a..50944cc 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -1,8 +1,9 @@ import type { InteractionMode } from "@editor/tools"; +import { isPanInteractionMode } from "@editor/tools"; import type { CanvasInputState } from "./useCanvasInput"; export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) { if (input.isPanning) return "cursor-grabbing"; - if (interactionMode.type === "temporary-pan") return "cursor-grab"; + if (isPanInteractionMode(interactionMode)) return "cursor-grab"; return "cursor-default"; } diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index 80b03d5..27a7770 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -1,5 +1,6 @@ import { useEffect, useState, type RefObject } from "react"; import type { AppStore } from "@editor/store"; +import { isPanInteractionMode } from "@editor/tools"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; import { createViewportPanInputController, @@ -35,7 +36,7 @@ export function useCanvasInput( globalPointerConsumer: options.globalPointerConsumer, dispatch: store.dispatch, getCurrentZoom: () => store.getState().editor.viewport.zoom, - isPanMode: () => store.getState().editor.tools.interactionMode.type === "temporary-pan", + isPanMode: () => isPanInteractionMode(store.getState().editor.tools.interactionMode), }); const handleKeyDown = (event: KeyboardEvent) => { diff --git a/view/toolLabels.ts b/view/toolLabels.ts new file mode 100644 index 0000000..29b9b52 --- /dev/null +++ b/view/toolLabels.ts @@ -0,0 +1,5 @@ +import type { ToolId } from "@editor/tools"; + +export function labelForTool(tool: ToolId): string { + return tool === "pan" ? "Pan" : "Select"; +}