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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user