From 28fcbdceda07e24277f01bb4da21da5ca039c9ce Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 16:04:43 +0200 Subject: [PATCH] feat(input): support artboard selection --- input/index.ts | 1 + input/selection.test.ts | 69 +++++++++++++++++++++++++++++++++++ input/selection.ts | 35 ++++++++++++++++++ view/canvas/useCanvasInput.ts | 22 ++++++++--- 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 input/selection.test.ts create mode 100644 input/selection.ts diff --git a/input/index.ts b/input/index.ts index 11f16cc..153340b 100644 --- a/input/index.ts +++ b/input/index.ts @@ -5,6 +5,7 @@ export { } from "./dom"; export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard"; +export { handleArtboardSelection } from "./selection"; export type { ViewportPanInputController } from "./viewport-pan"; export { createViewportPanInputController } from "./viewport-pan"; export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer"; diff --git a/input/selection.test.ts b/input/selection.test.ts new file mode 100644 index 0000000..10b5dc7 --- /dev/null +++ b/input/selection.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { commandIds } from "@commands/ids"; +import { createInitialAppState } from "@editor/initial-state"; +import { handleArtboardSelection } from "./selection"; +import type { PointerInputEvent } from "./pointer"; + +const ignoredState = undefined as never; + +describe("selection input", () => { + test("selects artboard hit by left click", () => { + const 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 } }, + }, + }; + const dispatched: unknown[] = []; + + const consumed = handleArtboardSelection({ + event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }), + document: state.document, + viewport: state.editor.viewport, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(consumed).toBe(true); + expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: [] } }]); + }); + + test("clears selection when clicking outside artboards", () => { + const state = createInitialAppState("Test"); + const dispatched: unknown[] = []; + + const consumed = handleArtboardSelection({ + event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }), + document: state.document, + viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(consumed).toBe(true); + expect(dispatched).toEqual([{ commandId: commandIds.selectionClear, payload: undefined }]); + }); +}); + +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/selection.ts b/input/selection.ts new file mode 100644 index 0000000..d516ee9 --- /dev/null +++ b/input/selection.ts @@ -0,0 +1,35 @@ +import { commandIds } from "@commands/ids"; +import type { Dispatch } from "@commands/dispatcher"; +import type { ImageDocument } from "@core/document"; +import type { ViewportState } from "@editor/state"; +import type { PointerInputEvent } from "./pointer"; + +export function handleArtboardSelection(options: { + event: PointerInputEvent; + document: ImageDocument; + viewport: ViewportState; + dispatch: Dispatch; +}): boolean { + if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false; + + const point = viewportPointToDocumentPoint(options.event.position, options.viewport); + const artboard = [...options.document.artboards].reverse().find((candidate) => { + const bounds = candidate.bounds; + return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h; + }); + + if (!artboard) { + options.dispatch(commandIds.selectionClear, undefined); + return true; + } + + options.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [] }); + return true; +} + +function viewportPointToDocumentPoint(point: PointerInputEvent["position"], viewport: ViewportState) { + 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, + }; +} diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index 27a7770..2151a23 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -4,6 +4,7 @@ import { isPanInteractionMode } from "@editor/tools"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; import { createViewportPanInputController, + handleArtboardSelection, handleViewportWheel, keybindEventFromKeyboardEvent, pointerInputEventFromPointerEvent, @@ -50,12 +51,23 @@ export function useCanvasInput( }; const handlePointerDown = (event: PointerEvent) => { - const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event)); - if (!consumed) return; + const inputEvent = pointerInputEventFromPointerEvent(event); + const consumed = panHandler.pointerDown(inputEvent); + if (consumed) { + canvas.setPointerCapture(event.pointerId); + setIsPanning(true); + event.preventDefault(); + return; + } - canvas.setPointerCapture(event.pointerId); - setIsPanning(true); - event.preventDefault(); + const state = store.getState(); + const selected = state.editor.tools.activeTool === "select" && handleArtboardSelection({ + event: inputEvent, + document: state.document, + viewport: state.editor.viewport, + dispatch: store.dispatch, + }); + if (selected) event.preventDefault(); }; const handlePointerMove = (event: PointerEvent) => {