From c946fb4c937d18edea133249793fc4f914466525 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 17:32:46 +0200 Subject: [PATCH] feat(tools): add crop tool --- commands/tool.test.ts | 4 ++-- editor/tools.ts | 2 +- input/transform-controls.test.ts | 28 ++++++++++++++++++++++++++++ input/transform-controls.ts | 5 +++-- view/ToolOverlay.tsx | 4 +++- view/canvas/cursor.ts | 1 + view/canvas/useCanvasInput.ts | 3 ++- view/toolLabels.ts | 9 ++++++++- 8 files changed, 48 insertions(+), 8 deletions(-) diff --git a/commands/tool.test.ts b/commands/tool.test.ts index efb13e4..d9e14b5 100644 --- a/commands/tool.test.ts +++ b/commands/tool.test.ts @@ -4,8 +4,8 @@ import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiv describe("tool commands", () => { test("sets active tool", () => { - const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "pan" }); - expect(next.editor.tools).toEqual({ activeTool: "pan", interactionMode: { type: "tool", tool: "pan" } }); + const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" }); + expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } }); }); test("enters and exits temporary pan", () => { diff --git a/editor/tools.ts b/editor/tools.ts index 49774d0..a8407ec 100644 --- a/editor/tools.ts +++ b/editor/tools.ts @@ -1,4 +1,4 @@ -export const availableToolIds = ["select", "pan"] as const; +export const availableToolIds = ["select", "crop", "pan"] as const; export type ToolId = (typeof availableToolIds)[number]; diff --git a/input/transform-controls.test.ts b/input/transform-controls.test.ts index 281d7fc..cf3eaf5 100644 --- a/input/transform-controls.test.ts +++ b/input/transform-controls.test.ts @@ -44,6 +44,34 @@ describe("transform controls input", () => { expect(dispatched).toEqual([]); }); + test("crop tool resizes from handles but does not move body", () => { + const state = { + ...createInitialAppState("Test"), + document: { + ...createInitialAppState("Test").document, + artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], + }, + editor: { + ...createInitialAppState("Test").editor, + viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, + selection: { artboardId: "a1", layerIds: [] }, + tools: { activeTool: "crop" as const, interactionMode: { type: "tool" as const, tool: "crop" as const } }, + }, + }; + const dispatched: unknown[] = []; + const controller = createTransformControlsInputController({ + getDocument: () => state.document, + getEditor: () => state.editor, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(false); + expect(controller.pointerDown(pointerEvent({ position: { x: 150, y: 150 }, buttons: 1 }))).toBe(true); + }); + test("dispatches transform lifecycle for selected artboard", () => { let state = { ...createInitialAppState("Test"), diff --git a/input/transform-controls.ts b/input/transform-controls.ts index 5ad3371..3449df0 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -25,7 +25,8 @@ export function createTransformControlsInputController(options: { if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false; const editor = options.getEditor(); - if (editor.tools.activeTool !== "select" || isPanInteractionMode(editor.tools.interactionMode)) return false; + const cropToolActive = editor.tools.activeTool === "crop"; + if ((editor.tools.activeTool !== "select" && !cropToolActive) || isPanInteractionMode(editor.tools.interactionMode)) return false; const document = options.getDocument(); const target = selectedTransformTarget(document, editor.selection); @@ -35,7 +36,7 @@ export function createTransformControlsInputController(options: { if (!bounds) return false; const handle = hitTestArtboardTransformHandle(event.position, bounds, editor.viewport); - if (!handle) return false; + if (!handle || (cropToolActive && handle === "body")) return false; options.dispatch(commandIds.transformBegin, { target, diff --git a/view/ToolOverlay.tsx b/view/ToolOverlay.tsx index 2e44434..9caeb76 100644 --- a/view/ToolOverlay.tsx +++ b/view/ToolOverlay.tsx @@ -1,4 +1,4 @@ -import { Cursor, Hand } from "@phosphor-icons/react"; +import { Crop, Cursor, Hand } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { AppStore } from "@editor/store"; import type { InteractionMode, ToolId } from "@editor/tools"; @@ -41,6 +41,8 @@ export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverl function iconForTool(tool: ToolId) { switch (tool) { + case "crop": + return Crop; case "pan": return Hand; case "select": diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 50944cc..82c7b4e 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -5,5 +5,6 @@ import type { CanvasInputState } from "./useCanvasInput"; export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) { if (input.isPanning) return "cursor-grabbing"; if (isPanInteractionMode(interactionMode)) return "cursor-grab"; + if (interactionMode.type === "tool" && interactionMode.tool === "crop") return "cursor-crosshair"; return "cursor-default"; } diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index 82cc575..f3ab340 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -75,7 +75,8 @@ export function useCanvasInput( } const state = store.getState(); - const selected = state.editor.tools.activeTool === "select" && handleArtboardSelection({ + const selectionToolActive = state.editor.tools.activeTool === "select" || state.editor.tools.activeTool === "crop"; + const selected = selectionToolActive && handleArtboardSelection({ event: inputEvent, document: state.document, viewport: state.editor.viewport, diff --git a/view/toolLabels.ts b/view/toolLabels.ts index 29b9b52..fcfaabe 100644 --- a/view/toolLabels.ts +++ b/view/toolLabels.ts @@ -1,5 +1,12 @@ import type { ToolId } from "@editor/tools"; export function labelForTool(tool: ToolId): string { - return tool === "pan" ? "Pan" : "Select"; + switch (tool) { + case "crop": + return "Crop"; + case "pan": + return "Pan"; + case "select": + return "Select"; + } }