feat(input): drag artboard transform controls
This commit is contained in:
@@ -6,6 +6,8 @@ export {
|
|||||||
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
||||||
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
||||||
export { handleArtboardSelection } from "./selection";
|
export { handleArtboardSelection } from "./selection";
|
||||||
|
export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
||||||
|
export type { TransformControlsInputController } from "./transform-controls";
|
||||||
export type { ViewportPanInputController } from "./viewport-pan";
|
export type { ViewportPanInputController } from "./viewport-pan";
|
||||||
export { createViewportPanInputController } from "./viewport-pan";
|
export { createViewportPanInputController } from "./viewport-pan";
|
||||||
export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer";
|
export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer";
|
||||||
|
|||||||
66
input/transform-controls.test.ts
Normal file
66
input/transform-controls.test.ts
Normal file
@@ -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>): PointerInputEvent {
|
||||||
|
return {
|
||||||
|
pointerId: 1,
|
||||||
|
pointerType: "mouse",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
buttons: 0,
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
102
input/transform-controls.ts
Normal file
102
input/transform-controls.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { AppStore } from "@editor/store";
|
|||||||
import { isPanInteractionMode } from "@editor/tools";
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||||
import {
|
import {
|
||||||
|
createTransformControlsInputController,
|
||||||
createViewportPanInputController,
|
createViewportPanInputController,
|
||||||
handleArtboardSelection,
|
handleArtboardSelection,
|
||||||
handleViewportWheel,
|
handleViewportWheel,
|
||||||
@@ -32,6 +33,12 @@ export function useCanvasInput(
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const transformHandler = createTransformControlsInputController({
|
||||||
|
getDocument: () => store.getState().document,
|
||||||
|
getEditor: () => store.getState().editor,
|
||||||
|
dispatch: store.dispatch,
|
||||||
|
});
|
||||||
|
|
||||||
const panHandler = createViewportPanInputController({
|
const panHandler = createViewportPanInputController({
|
||||||
globalKeyConsumer: options.globalKeybindConsumer,
|
globalKeyConsumer: options.globalKeybindConsumer,
|
||||||
globalPointerConsumer: options.globalPointerConsumer,
|
globalPointerConsumer: options.globalPointerConsumer,
|
||||||
@@ -52,6 +59,13 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||||
|
const transformed = transformHandler.pointerDown(inputEvent);
|
||||||
|
if (transformed) {
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const consumed = panHandler.pointerDown(inputEvent);
|
const consumed = panHandler.pointerDown(inputEvent);
|
||||||
if (consumed) {
|
if (consumed) {
|
||||||
canvas.setPointerCapture(event.pointerId);
|
canvas.setPointerCapture(event.pointerId);
|
||||||
@@ -71,12 +85,26 @@ export function useCanvasInput(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
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();
|
if (consumed) event.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (event: PointerEvent) => {
|
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;
|
if (!consumed) return;
|
||||||
|
|
||||||
setIsPanning(false);
|
setIsPanning(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user