From 166310ace06dda56a97369bb9c019cc911cd968f Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 16:08:08 +0200 Subject: [PATCH] feat(input): drag artboard transform controls --- input/index.ts | 2 + input/transform-controls.test.ts | 66 ++++++++++++++++++++ input/transform-controls.ts | 102 +++++++++++++++++++++++++++++++ view/canvas/useCanvasInput.ts | 32 +++++++++- 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 input/transform-controls.test.ts create mode 100644 input/transform-controls.ts diff --git a/input/index.ts b/input/index.ts index 153340b..17069ba 100644 --- a/input/index.ts +++ b/input/index.ts @@ -6,6 +6,8 @@ export { export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard"; export { handleArtboardSelection } from "./selection"; +export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls"; +export type { TransformControlsInputController } from "./transform-controls"; export type { ViewportPanInputController } from "./viewport-pan"; export { createViewportPanInputController } from "./viewport-pan"; export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer"; diff --git a/input/transform-controls.test.ts b/input/transform-controls.test.ts new file mode 100644 index 0000000..006740d --- /dev/null +++ b/input/transform-controls.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { commandIds } from "@commands/ids"; +import { createInitialAppState } from "@editor/initial-state"; +import type { PointerInputEvent } from "./pointer"; +import { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls"; + +const ignoredState = undefined as never; + +describe("transform controls input", () => { + test("hit tests artboard handles and body", () => { + const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }; + const bounds = { x: -50, y: -50, w: 100, h: 100 }; + + expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, viewport)).toBe("nw"); + expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, viewport)).toBe("body"); + expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined(); + }); + + test("dispatches transform lifecycle for selected artboard", () => { + let state = { + ...createInitialAppState("Test"), + document: { + ...createInitialAppState("Test").document, + artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }], + }, + editor: { + ...createInitialAppState("Test").editor, + viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, + selection: { artboardId: "a1", layerIds: [] }, + }, + }; + const dispatched: unknown[] = []; + const controller = createTransformControlsInputController({ + getDocument: () => state.document, + getEditor: () => state.editor, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload as never } }; + return ignoredState; + }, + }); + + expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(true); + expect(controller.pointerMove(pointerEvent({ position: { x: 110, y: 120 }, buttons: 1 }))).toBe(true); + expect(controller.pointerUp(pointerEvent({ position: { x: 110, y: 120 }, buttons: 0 }))).toBe(true); + expect(dispatched.map((event) => (event as { commandId: string }).commandId)).toEqual([ + commandIds.transformBegin, + commandIds.transformUpdate, + commandIds.transformEnd, + ]); + }); +}); + +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/input/transform-controls.ts b/input/transform-controls.ts new file mode 100644 index 0000000..75f0371 --- /dev/null +++ b/input/transform-controls.ts @@ -0,0 +1,102 @@ +import { commandIds } from "@commands/ids"; +import type { Dispatch } from "@commands/dispatcher"; +import type { ImageDocument } from "@core/document"; +import type { Rect, Vec2D } from "@core/geometry"; +import type { EditorState } from "@editor/state"; +import type { TransformHandle } from "@editor/transform"; +import type { PointerInputEvent } from "./pointer"; + +export type TransformControlsInputController = { + pointerDown(event: PointerInputEvent): boolean; + pointerMove(event: PointerInputEvent): boolean; + pointerUp(event: PointerInputEvent): boolean; +}; + +export function createTransformControlsInputController(options: { + getDocument: () => ImageDocument; + getEditor: () => EditorState; + dispatch: Dispatch; +}): TransformControlsInputController { + return { + pointerDown(event) { + if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false; + + const editor = options.getEditor(); + const artboardId = editor.selection.artboardId; + if (!artboardId || editor.tools.activeTool !== "select") return false; + + const artboard = options.getDocument().artboards.find((candidate) => candidate.id === artboardId); + if (!artboard) return false; + + const handle = hitTestArtboardTransformHandle(event.position, artboard.bounds, editor.viewport); + if (!handle) return false; + + options.dispatch(commandIds.transformBegin, { + target: { type: "artboard", id: artboard.id }, + handle, + point: viewportPointToDocumentPoint(event.position, editor.viewport), + initialBounds: artboard.bounds, + }); + return true; + }, + pointerMove(event) { + const editor = options.getEditor(); + if (!editor.transformSession) return false; + + options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport) }); + return true; + }, + pointerUp() { + if (!options.getEditor().transformSession) return false; + + options.dispatch(commandIds.transformEnd, undefined); + return true; + }, + }; +} + +export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: EditorState["viewport"]): TransformHandle | undefined { + const rect = documentRectToViewportRect(bounds, viewport); + const handles = transformHandleRects(rect); + const handle = handles.find((candidate) => pointInRect(position, candidate.rect)); + if (handle) return handle.handle; + if (pointInRect(position, rect)) return "body"; + return undefined; +} + +function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect }[] { + const size = 12; + const half = size / 2; + const points: { handle: TransformHandle; point: Vec2D }[] = [ + { handle: "nw", point: { x: rect.x, y: rect.y } }, + { handle: "n", point: { x: rect.x + rect.w / 2, y: rect.y } }, + { handle: "ne", point: { x: rect.x + rect.w, y: rect.y } }, + { handle: "e", point: { x: rect.x + rect.w, y: rect.y + rect.h / 2 } }, + { handle: "se", point: { x: rect.x + rect.w, y: rect.y + rect.h } }, + { handle: "s", point: { x: rect.x + rect.w / 2, y: rect.y + rect.h } }, + { handle: "sw", point: { x: rect.x, y: rect.y + rect.h } }, + { handle: "w", point: { x: rect.x, y: rect.y + rect.h / 2 } }, + ]; + + return points.map(({ handle, point }) => ({ handle, rect: { x: point.x - half, y: point.y - half, w: size, h: size } })); +} + +function viewportPointToDocumentPoint(point: Vec2D, viewport: EditorState["viewport"]): Vec2D { + return { + x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom, + y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom, + }; +} + +function documentRectToViewportRect(rect: Rect, viewport: EditorState["viewport"]): Rect { + return { + x: viewport.size.w / 2 + (rect.x - viewport.center.x) * viewport.zoom, + y: viewport.size.h / 2 + (rect.y - viewport.center.y) * viewport.zoom, + w: rect.w * viewport.zoom, + h: rect.h * viewport.zoom, + }; +} + +function pointInRect(point: Vec2D, rect: Rect) { + return point.x >= rect.x && point.x <= rect.x + rect.w && point.y >= rect.y && point.y <= rect.y + rect.h; +} diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index 2151a23..82cc575 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -3,6 +3,7 @@ import type { AppStore } from "@editor/store"; import { isPanInteractionMode } from "@editor/tools"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; import { + createTransformControlsInputController, createViewportPanInputController, handleArtboardSelection, handleViewportWheel, @@ -32,6 +33,12 @@ export function useCanvasInput( const canvas = canvasRef.current; if (!canvas) return; + const transformHandler = createTransformControlsInputController({ + getDocument: () => store.getState().document, + getEditor: () => store.getState().editor, + dispatch: store.dispatch, + }); + const panHandler = createViewportPanInputController({ globalKeyConsumer: options.globalKeybindConsumer, globalPointerConsumer: options.globalPointerConsumer, @@ -52,6 +59,13 @@ export function useCanvasInput( const handlePointerDown = (event: PointerEvent) => { const inputEvent = pointerInputEventFromPointerEvent(event); + const transformed = transformHandler.pointerDown(inputEvent); + if (transformed) { + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + return; + } + const consumed = panHandler.pointerDown(inputEvent); if (consumed) { canvas.setPointerCapture(event.pointerId); @@ -71,12 +85,26 @@ export function useCanvasInput( }; const handlePointerMove = (event: PointerEvent) => { - const consumed = panHandler.pointerMove(pointerInputEventFromPointerEvent(event)); + const inputEvent = pointerInputEventFromPointerEvent(event); + const transformed = transformHandler.pointerMove(inputEvent); + if (transformed) { + event.preventDefault(); + return; + } + + const consumed = panHandler.pointerMove(inputEvent); if (consumed) event.preventDefault(); }; const handlePointerUp = (event: PointerEvent) => { - const consumed = panHandler.pointerUp(pointerInputEventFromPointerEvent(event)); + const inputEvent = pointerInputEventFromPointerEvent(event); + const transformed = transformHandler.pointerUp(inputEvent); + if (transformed) { + event.preventDefault(); + return; + } + + const consumed = panHandler.pointerUp(inputEvent); if (!consumed) return; setIsPanning(false);