feat: enhance layer masking functionality and brush controls
- Refactor LayersSheet component to support mask editing state and improve layer visibility handling. - Introduce functions to collect mask layer IDs and count display layers excluding masks. - Update BrushControls to include mask view mode options and a Done button for exiting mask editing. - Modify brush session handling to support brush previews when editing masks. - Implement a new BrushPreviewRenderer for rendering brush strokes with visual feedback. - Add document geometry utilities for transforming points and resolving layer bounds. - Ensure proper cleanup of brush preview on pointer leave and other interactions.
This commit is contained in:
107
input/document-geometry.ts
Normal file
107
input/document-geometry.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect, Size, Vec2D } from "@core/geometry";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
|
||||
export type InputViewportState = {
|
||||
center: Vec2D;
|
||||
zoom: number;
|
||||
rotation?: number;
|
||||
size: Size;
|
||||
};
|
||||
|
||||
export type InputSelectionState = {
|
||||
artboardId?: ArtboardId;
|
||||
layerIds: LayerId[];
|
||||
};
|
||||
|
||||
export type InputTransformTarget =
|
||||
| { type: "artboard"; id: ArtboardId }
|
||||
| { type: "layer"; id: LayerId };
|
||||
|
||||
export function selectedTransformTarget(_document: ImageDocument, selection: InputSelectionState): InputTransformTarget | undefined {
|
||||
if (selection.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] };
|
||||
if (selection.artboardId) return { type: "artboard", id: selection.artboardId };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveTransformTargetBounds(document: ImageDocument, target: InputTransformTarget): Rect | undefined {
|
||||
switch (target.type) {
|
||||
case "artboard":
|
||||
return document.artboards.find((artboard) => artboard.id === target.id)?.bounds;
|
||||
case "layer": {
|
||||
const layer = findLayer(document, target.id);
|
||||
return layer ? resolveLayerBounds(document, layer) : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function viewportPointToDocumentPoint(point: Vec2D, viewport: InputViewportState): 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function documentRectToViewportRect(rect: Rect, viewport: InputViewportState): 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 findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const layer = findLayerInTree(artboard.layers, layerId);
|
||||
if (layer) return layer;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const child = findLayerInTree(layer.children, layerId);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undefined {
|
||||
switch (layer.type) {
|
||||
case "group":
|
||||
return unionRects(layer.children.flatMap((child) => {
|
||||
const bounds = resolveLayerBounds(document, child);
|
||||
return bounds ? [bounds] : [];
|
||||
}));
|
||||
case "image":
|
||||
case "raster": {
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return undefined;
|
||||
|
||||
return {
|
||||
x: layer.transform.position.x,
|
||||
y: layer.transform.position.y,
|
||||
w: asset.intrinsicSize.w * layer.transform.scale.x,
|
||||
h: asset.intrinsicSize.h * layer.transform.scale.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unionRects(rects: Rect[]): Rect | undefined {
|
||||
if (rects.length === 0) return undefined;
|
||||
|
||||
const minX = Math.min(...rects.map((rect) => rect.x));
|
||||
const minY = Math.min(...rects.map((rect) => rect.y));
|
||||
const maxX = Math.max(...rects.map((rect) => rect.x + rect.w));
|
||||
const maxY = Math.max(...rects.map((rect) => rect.y + rect.h));
|
||||
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { Dispatch } from "@commands/dispatcher";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import type { KeybindEvent } from "./keyboard";
|
||||
|
||||
export type LayerInfo = {
|
||||
@@ -17,7 +16,12 @@ export type LayerDropTarget = {
|
||||
layer: Layer;
|
||||
};
|
||||
|
||||
export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: SelectionState; dispatch: Dispatch }): boolean {
|
||||
export type DeleteSelectionState = {
|
||||
artboardId?: ArtboardId;
|
||||
layerIds: LayerId[];
|
||||
};
|
||||
|
||||
export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: DeleteSelectionState; dispatch: Dispatch }): boolean {
|
||||
if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false;
|
||||
if (options.event.key !== "Backspace" && options.event.key !== "Delete") return false;
|
||||
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import { handleArtboardSelection } from "./selection";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
const ignoredState = undefined as never;
|
||||
const viewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } };
|
||||
|
||||
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", visible: true, locked: false, layers: [] }],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||
},
|
||||
};
|
||||
const document = createDocument({
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||
});
|
||||
const dispatched: unknown[] = [];
|
||||
|
||||
const consumed = handleArtboardSelection({
|
||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||
document: state.document,
|
||||
viewport: state.editor.viewport,
|
||||
document,
|
||||
viewport,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
@@ -36,45 +29,37 @@ describe("selection input", () => {
|
||||
});
|
||||
|
||||
test("selects topmost image layer before artboard", () => {
|
||||
const state = {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
|
||||
artboards: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Artboard",
|
||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [
|
||||
{
|
||||
id: "l1",
|
||||
type: "image",
|
||||
name: "Image",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: "asset-1",
|
||||
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||
},
|
||||
};
|
||||
const document = createDocument({
|
||||
assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }],
|
||||
artboards: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Artboard",
|
||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [
|
||||
{
|
||||
id: "l1",
|
||||
type: "image",
|
||||
name: "Image",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: "asset-1",
|
||||
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const dispatched: unknown[] = [];
|
||||
|
||||
const consumed = handleArtboardSelection({
|
||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||
document: state.document,
|
||||
viewport: state.editor.viewport,
|
||||
document,
|
||||
viewport,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
@@ -85,14 +70,70 @@ describe("selection input", () => {
|
||||
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["l1"] } }]);
|
||||
});
|
||||
|
||||
test("clears selection when clicking outside artboards", () => {
|
||||
const state = createInitialAppState("Test");
|
||||
test("ignores mask layers during canvas hit testing", () => {
|
||||
const document = createDocument({
|
||||
assets: [
|
||||
{ id: "target-asset", name: "Target", mimeType: "image/png", source: "target", intrinsicSize: { w: 50, h: 50 } },
|
||||
{ id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 50, h: 50 } },
|
||||
],
|
||||
artboards: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Artboard",
|
||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [
|
||||
{
|
||||
id: "target",
|
||||
type: "image",
|
||||
name: "Target",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: "target-asset",
|
||||
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
clippingMask: { maskLayerId: "mask" },
|
||||
},
|
||||
{
|
||||
id: "mask",
|
||||
type: "raster",
|
||||
name: "Mask",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId: "mask-asset",
|
||||
transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
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 } },
|
||||
document,
|
||||
viewport,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
},
|
||||
});
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: ["target"] } }]);
|
||||
});
|
||||
|
||||
test("clears selection when clicking outside artboards", () => {
|
||||
const document = createDocument();
|
||||
const dispatched: unknown[] = [];
|
||||
|
||||
const consumed = handleArtboardSelection({
|
||||
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
|
||||
document,
|
||||
viewport,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
@@ -104,6 +145,17 @@ describe("selection input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createDocument(overrides: Partial<ImageDocument> = {}): ImageDocument {
|
||||
return {
|
||||
id: "d1",
|
||||
name: "Test",
|
||||
version: 1,
|
||||
assets: [],
|
||||
artboards: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
|
||||
return {
|
||||
pointerId: 1,
|
||||
|
||||
@@ -2,14 +2,13 @@ import { commandIds } from "@commands/ids";
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
export function handleArtboardSelection(options: {
|
||||
event: PointerInputEvent;
|
||||
document: ImageDocument;
|
||||
viewport: ViewportState;
|
||||
viewport: InputViewportState;
|
||||
dispatch: Dispatch;
|
||||
}): boolean {
|
||||
if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false;
|
||||
@@ -37,20 +36,21 @@ export function handleArtboardSelection(options: {
|
||||
}
|
||||
|
||||
function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) {
|
||||
const maskLayerIds = collectMaskLayerIds(document.artboards.flatMap((artboard) => artboard.layers));
|
||||
for (const artboard of [...document.artboards].reverse()) {
|
||||
if (!artboard.visible || artboard.locked) continue;
|
||||
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point);
|
||||
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point, maskLayerIds);
|
||||
if (layerId) return { artboardId: artboard.id, layerId };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined {
|
||||
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }, maskLayerIds: ReadonlySet<string>): string | undefined {
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible || layer.locked) continue;
|
||||
if (!layer.visible || layer.locked || maskLayerIds.has(layer.id)) continue;
|
||||
if (layer.type === "group") {
|
||||
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point);
|
||||
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point, maskLayerIds);
|
||||
if (childId) return childId;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,11 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
|
||||
for (const layer of layers) {
|
||||
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
import { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
||||
import { createTransformControlsInputController, hitTestArtboardTransformHandle, type TransformControlsEditorState } from "./transform-controls";
|
||||
|
||||
const ignoredState = undefined as never;
|
||||
const defaultViewport = { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } };
|
||||
const defaultTools = {
|
||||
activeTool: "select" as const,
|
||||
interactionMode: { type: "tool" as const, tool: "select" as const },
|
||||
};
|
||||
|
||||
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();
|
||||
expect(hitTestArtboardTransformHandle({ x: 50, y: 50 }, bounds, defaultViewport)).toBe("nw");
|
||||
expect(hitTestArtboardTransformHandle({ x: 100, y: 100 }, bounds, defaultViewport)).toBe("body");
|
||||
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, defaultViewport)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not transform while temporary pan is active", () => {
|
||||
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: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const }, brush: { color: "#111827", size: 8, hardness: 100 } },
|
||||
},
|
||||
};
|
||||
const state = createState({
|
||||
selection: { artboardId: "a1", layerIds: [] },
|
||||
tools: { activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" } },
|
||||
});
|
||||
const dispatched: unknown[] = [];
|
||||
const controller = createTransformControlsInputController({
|
||||
getDocument: () => state.document,
|
||||
@@ -45,19 +40,10 @@ describe("transform controls input", () => {
|
||||
});
|
||||
|
||||
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 }, brush: { color: "#111827", size: 8, hardness: 100 } },
|
||||
},
|
||||
};
|
||||
const state = createState({
|
||||
selection: { artboardId: "a1", layerIds: [] },
|
||||
tools: { activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } },
|
||||
});
|
||||
const dispatched: unknown[] = [];
|
||||
const controller = createTransformControlsInputController({
|
||||
getDocument: () => state.document,
|
||||
@@ -73,25 +59,14 @@ describe("transform controls input", () => {
|
||||
});
|
||||
|
||||
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", 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: [] },
|
||||
},
|
||||
};
|
||||
let state = createState({ 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 } };
|
||||
if (commandId === commandIds.transformBegin) state = { ...state, editor: { ...state.editor, transformSession: payload } };
|
||||
return ignoredState;
|
||||
},
|
||||
});
|
||||
@@ -108,6 +83,24 @@ describe("transform controls input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createState(editorOverrides: Partial<TransformControlsEditorState> = {}): { document: ImageDocument; editor: TransformControlsEditorState } {
|
||||
return {
|
||||
document: {
|
||||
id: "d1",
|
||||
name: "Test",
|
||||
version: 1,
|
||||
assets: [],
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||
},
|
||||
editor: {
|
||||
viewport: defaultViewport,
|
||||
selection: { layerIds: [] },
|
||||
tools: defaultTools,
|
||||
...editorOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
|
||||
return {
|
||||
pointerId: 1,
|
||||
|
||||
@@ -2,13 +2,36 @@ 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 { isPanInteractionMode } from "@editor/tools";
|
||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||
import type { TransformHandle } from "@editor/transform";
|
||||
import {
|
||||
documentRectToViewportRect,
|
||||
resolveTransformTargetBounds,
|
||||
selectedTransformTarget,
|
||||
viewportPointToDocumentPoint,
|
||||
type InputSelectionState,
|
||||
type InputTransformTarget,
|
||||
type InputViewportState,
|
||||
} from "./document-geometry";
|
||||
import { findLayerInfoInDocument } from "./layers-panel";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
|
||||
|
||||
type InputToolId = "select" | "crop" | "brush" | "eraser" | "pan";
|
||||
|
||||
type InputInteractionMode =
|
||||
| { type: "tool"; tool: InputToolId }
|
||||
| { type: "temporary-pan"; previousTool: InputToolId };
|
||||
|
||||
export type TransformControlsEditorState = {
|
||||
viewport: InputViewportState;
|
||||
selection: InputSelectionState;
|
||||
tools: {
|
||||
activeTool: InputToolId;
|
||||
interactionMode: InputInteractionMode;
|
||||
};
|
||||
transformSession?: unknown;
|
||||
};
|
||||
|
||||
export type TransformControlsInputController = {
|
||||
pointerDown(event: PointerInputEvent): boolean;
|
||||
pointerMove(event: PointerInputEvent): boolean;
|
||||
@@ -17,7 +40,7 @@ export type TransformControlsInputController = {
|
||||
|
||||
export function createTransformControlsInputController(options: {
|
||||
getDocument: () => ImageDocument;
|
||||
getEditor: () => EditorState;
|
||||
getEditor: () => TransformControlsEditorState;
|
||||
dispatch: Dispatch;
|
||||
}): TransformControlsInputController {
|
||||
return {
|
||||
@@ -62,7 +85,7 @@ export function createTransformControlsInputController(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: EditorState["viewport"]): TransformHandle | undefined {
|
||||
export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, viewport: InputViewportState): TransformHandle | undefined {
|
||||
const rect = documentRectToViewportRect(bounds, viewport);
|
||||
const handles = transformHandleRects(rect);
|
||||
const handle = handles.find((candidate) => pointInRect(position, candidate.rect));
|
||||
@@ -71,7 +94,7 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTransformTargetLocked(document: ImageDocument, target: { type: "artboard" | "layer"; id: string }) {
|
||||
function isTransformTargetLocked(document: ImageDocument, target: InputTransformTarget) {
|
||||
if (target.type === "artboard") {
|
||||
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
|
||||
return !artboard || !artboard.visible || artboard.locked;
|
||||
@@ -97,20 +120,8 @@ function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect
|
||||
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 isPanInteractionMode(interactionMode: InputInteractionMode): boolean {
|
||||
return interactionMode.type === "temporary-pan" || (interactionMode.type === "tool" && interactionMode.tool === "pan");
|
||||
}
|
||||
|
||||
function pointInRect(point: Vec2D, rect: Rect) {
|
||||
|
||||
@@ -1,61 +1,101 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import { commandIds } from "@commands/ids";
|
||||
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]);
|
||||
const ignoredState = undefined as never;
|
||||
|
||||
describe("viewport pan store integration", () => {
|
||||
test("space key input updates actual store tool mode", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||
test("space key input updates actual tool mode", () => {
|
||||
const store = createInputStore();
|
||||
const controller = createController(store);
|
||||
|
||||
expect(controller.keyDown(keyEvent("Space"))).toBe(true);
|
||||
expect(store.getState().editor.tools.interactionMode).toEqual({ type: "temporary-pan", previousTool: "select" });
|
||||
expect(store.state.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" });
|
||||
expect(store.state.tools.interactionMode).toEqual({ type: "tool", tool: "select" });
|
||||
});
|
||||
|
||||
test("left-drag only pans while temporary pan is active", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||
const store = createInputStore();
|
||||
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 });
|
||||
expect(store.state.viewport.center).toEqual({ x: 0, y: 0 });
|
||||
|
||||
store.dispatch(commandIds.toolEnterTemporaryPan, 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 });
|
||||
expect(store.state.viewport.center).toEqual({ x: -10, y: 0 });
|
||||
});
|
||||
|
||||
test("left-drag pans while pan tool is active", () => {
|
||||
const store = createAppStore(createInitialAppState("Test"), registry);
|
||||
const store = createInputStore();
|
||||
const controller = createController(store);
|
||||
|
||||
store.dispatch(commandIds.toolSetActive, { 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 });
|
||||
expect(store.state.viewport.center).toEqual({ x: -4, y: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
function createController(store: ReturnType<typeof createAppStore>) {
|
||||
type InputToolId = "select" | "crop" | "brush" | "eraser" | "pan";
|
||||
|
||||
type InputStore = ReturnType<typeof createInputStore>;
|
||||
|
||||
function createInputStore() {
|
||||
const store = {
|
||||
state: {
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1 },
|
||||
tools: {
|
||||
activeTool: "select" as InputToolId,
|
||||
interactionMode: { type: "tool" as const, tool: "select" as InputToolId },
|
||||
},
|
||||
},
|
||||
dispatch: ((commandId, payload) => {
|
||||
switch (commandId) {
|
||||
case commandIds.toolSetActive:
|
||||
store.state.tools.activeTool = payload.tool;
|
||||
store.state.tools.interactionMode = { type: "tool", tool: payload.tool };
|
||||
break;
|
||||
case commandIds.toolEnterTemporaryPan:
|
||||
store.state.tools.interactionMode = { type: "temporary-pan", previousTool: store.state.tools.activeTool };
|
||||
break;
|
||||
case commandIds.toolExitTemporaryPan: {
|
||||
const mode = store.state.tools.interactionMode;
|
||||
if (mode.type === "temporary-pan") {
|
||||
store.state.tools.activeTool = mode.previousTool;
|
||||
store.state.tools.interactionMode = { type: "tool", tool: mode.previousTool };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case commandIds.viewportPan:
|
||||
store.state.viewport.center = {
|
||||
x: store.state.viewport.center.x + payload.delta.x,
|
||||
y: store.state.viewport.center.y + payload.delta.y,
|
||||
};
|
||||
break;
|
||||
}
|
||||
return ignoredState;
|
||||
}) as Dispatch,
|
||||
};
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function createController(store: InputStore) {
|
||||
return createViewportPanInputController({
|
||||
globalKeyConsumer: () => false,
|
||||
globalPointerConsumer: () => false,
|
||||
getCurrentZoom: () => store.getState().editor.viewport.zoom,
|
||||
getCurrentZoom: () => store.state.viewport.zoom,
|
||||
isPanMode: () => {
|
||||
const mode = store.getState().editor.tools.interactionMode;
|
||||
const mode = store.state.tools.interactionMode;
|
||||
return mode.type === "temporary-pan" || (mode.type === "tool" && mode.tool === "pan");
|
||||
},
|
||||
dispatch: store.dispatch,
|
||||
|
||||
Reference in New Issue
Block a user