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:
syntaxbullet
2026-07-03 20:49:29 +02:00
parent daaa2a6667
commit 4ad0bb8b2c
33 changed files with 1889 additions and 351 deletions

View File

@@ -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,