Add pan tool toolbar
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "bun-react-template",
|
"name": "bun-react-template",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"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=="],
|
"@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-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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -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 =
|
export type InteractionMode =
|
||||||
| { type: "tool"; tool: ToolId }
|
| { type: "tool"; tool: ToolId }
|
||||||
@@ -13,3 +15,7 @@ export const initialToolState: ToolState = {
|
|||||||
activeTool: "select",
|
activeTool: "select",
|
||||||
interactionMode: { type: "tool", tool: "select" },
|
interactionMode: { type: "tool", tool: "select" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
||||||
|
return interactionMode.type === "temporary-pan" || (interactionMode.type === "tool" && interactionMode.tool === "pan");
|
||||||
|
}
|
||||||
|
|||||||
80
input/viewport-pan-store.test.ts
Normal file
80
input/viewport-pan-store.test.ts
Normal file
@@ -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<typeof createAppStore>) {
|
||||||
|
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>): PointerInputEvent {
|
||||||
|
return {
|
||||||
|
pointerId: 1,
|
||||||
|
pointerType: "mouse",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
buttons: 0,
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
12
view/App.tsx
12
view/App.tsx
@@ -1,5 +1,7 @@
|
|||||||
import type { ImageStudioApp } from "@app/app";
|
import type { ImageStudioApp } from "@app/app";
|
||||||
import { CanvasViewport } from "./CanvasViewport";
|
import { CanvasViewport } from "./CanvasViewport";
|
||||||
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
|
import { labelForTool } from "./toolLabels";
|
||||||
import { useAppState } from "./useAppState";
|
import { useAppState } from "./useAppState";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -16,9 +18,17 @@ export function App({ app }: AppProps) {
|
|||||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
|
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
|
||||||
<h1 className="text-sm font-medium">Image Studio</h1>
|
<h1 className="text-sm font-medium">Image Studio</h1>
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
{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}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||||
|
<ToolOverlay
|
||||||
|
activeTool={state.editor.tools.activeTool}
|
||||||
|
interactionMode={state.editor.tools.interactionMode}
|
||||||
|
dispatch={app.store.dispatch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<CanvasViewport store={app.store} />
|
<CanvasViewport store={app.store} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
57
view/ToolOverlay.tsx
Normal file
57
view/ToolOverlay.tsx
Normal file
@@ -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 (
|
||||||
|
<nav
|
||||||
|
aria-label="Tools"
|
||||||
|
className="pointer-events-auto flex flex-col gap-1 rounded-lg border border-white/10 bg-black/70 p-1 text-white shadow-xl backdrop-blur"
|
||||||
|
>
|
||||||
|
{availableToolIds.map((tool) => {
|
||||||
|
const active = isToolHighlighted(tool, activeTool, interactionMode);
|
||||||
|
const Icon = iconForTool(tool);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tool}
|
||||||
|
type="button"
|
||||||
|
aria-label={labelForTool(tool)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={labelForTool(tool)}
|
||||||
|
className={buttonClass(active)}
|
||||||
|
onClick={() => dispatch("tool.setActive", { tool })}
|
||||||
|
>
|
||||||
|
<Icon size={22} weight={active ? "fill" : "regular"} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"}`;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { InteractionMode } from "@editor/tools";
|
import type { InteractionMode } from "@editor/tools";
|
||||||
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import type { CanvasInputState } from "./useCanvasInput";
|
import type { CanvasInputState } from "./useCanvasInput";
|
||||||
|
|
||||||
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
|
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
|
||||||
if (input.isPanning) return "cursor-grabbing";
|
if (input.isPanning) return "cursor-grabbing";
|
||||||
if (interactionMode.type === "temporary-pan") return "cursor-grab";
|
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
||||||
return "cursor-default";
|
return "cursor-default";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, type RefObject } from "react";
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||||
import {
|
import {
|
||||||
createViewportPanInputController,
|
createViewportPanInputController,
|
||||||
@@ -35,7 +36,7 @@ export function useCanvasInput(
|
|||||||
globalPointerConsumer: options.globalPointerConsumer,
|
globalPointerConsumer: options.globalPointerConsumer,
|
||||||
dispatch: store.dispatch,
|
dispatch: store.dispatch,
|
||||||
getCurrentZoom: () => store.getState().editor.viewport.zoom,
|
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) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|||||||
5
view/toolLabels.ts
Normal file
5
view/toolLabels.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ToolId } from "@editor/tools";
|
||||||
|
|
||||||
|
export function labelForTool(tool: ToolId): string {
|
||||||
|
return tool === "pan" ? "Pan" : "Select";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user