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

@@ -48,5 +48,12 @@ function recordHistory(currentState: AppState, nextState: AppState): AppState {
}
function snapshot(state: AppState): HistorySnapshot {
return { document: state.document, editor: state.editor };
return {
document: state.document,
editor: {
...state.editor,
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
}

View File

@@ -1,15 +1,18 @@
import { describe, expect, test } from "bun:test";
import type { Layer } from "@core/layer";
import { createInitialAppState } from "@editor/initial-state";
import {
documentAddArtboardCommand,
documentAddAssetCommand,
documentAddGroupLayerCommand,
documentAddImageLayerCommand,
documentAddLayerMaskCommand,
documentAddRasterLayerCommand,
documentGroupLayersCommand,
documentMoveLayerCommand,
documentRemoveArtboardCommand,
documentRemoveLayerCommand,
documentRemoveLayerMaskCommand,
documentRenameArtboardCommand,
documentRenameLayerCommand,
documentSetArtboardBoundsCommand,
@@ -201,6 +204,53 @@ describe("document commands", () => {
expect(masked.document.artboards[0]?.layers[2]?.clippingMask).toEqual({ maskLayerId: "mask" });
});
test("adds editable raster masks directly before the target layer", () => {
const state = documentWithLayers([raster("target", "Target")]);
const masked = documentAddLayerMaskCommand.execute(
{ state },
{
layerId: "target",
asset: maskAsset(),
maskLayer: raster("mask", "Target Mask", "mask-asset"),
},
);
expect(masked.document.assets).toContainEqual(maskAsset());
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]);
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(masked.editor.tools.activeTool).toBe("brush");
});
test("removes attached layer masks and exits mask editing", () => {
const state = documentAddLayerMaskCommand.execute(
{ state: documentWithLayers([raster("target", "Target")]) },
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
);
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" });
expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]);
expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(unmasked.editor.maskEdit).toBeUndefined();
});
test("cleans mask references when deleting targets or mask layers", () => {
const state = documentAddLayerMaskCommand.execute(
{ state: documentWithLayers([raster("target", "Target")]) },
{ layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") },
);
const removedTarget = documentRemoveLayerCommand.execute({ state }, { layerId: "target" });
const removedMask = documentRemoveLayerCommand.execute({ state }, { layerId: "mask" });
expect(removedTarget.document.artboards[0]?.layers).toEqual([]);
expect(removedMask.document.artboards[0]?.layers[0]?.id).toBe("target");
expect(removedMask.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(removedMask.editor.maskEdit).toBeUndefined();
});
test("sets artboard visibility and lock state", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
@@ -236,7 +286,7 @@ describe("document commands", () => {
});
});
function documentWithLayers(layers: ReturnType<typeof group>[]) {
function documentWithLayers(layers: Layer[]) {
return {
...createInitialAppState("Test"),
document: {
@@ -262,3 +312,20 @@ function group(id: string, name: string) {
children: [],
};
}
function raster(id: string, name: string, assetId = `${id}-asset`) {
return {
id,
type: "raster" as const,
name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
};
}
function maskAsset() {
return { id: "mask-asset", name: "Target Mask", mimeType: "image/svg+xml", source: "mask", intrinsicSize: { w: 100, h: 100 } };
}

View File

@@ -107,6 +107,16 @@ export type DocumentSetLayerClippingMaskPayload = {
maskLayerId?: LayerId;
};
export type DocumentAddLayerMaskPayload = {
layerId: LayerId;
asset: Asset;
maskLayer: RasterLayer;
};
export type DocumentRemoveLayerMaskPayload = {
layerId: LayerId;
};
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
id: commandIds.documentAddArtboard,
name: "Add artboard",
@@ -153,13 +163,19 @@ export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayloa
name: "Remove artboard",
execute({ state }, payload) {
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
const document = {
...state.document,
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
};
return {
...state,
document: {
...state.document,
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
document,
editor: {
...state.editor,
selection: removedSelectedArtboard ? { layerIds: [] } : state.editor.selection,
maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined,
},
editor: removedSelectedArtboard ? { ...state.editor, selection: { layerIds: [] } } : state.editor,
};
},
};
@@ -280,11 +296,19 @@ export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
const removed = removeLayerFromDocument(state.document, payload.layerId);
if (!removed.layer) return state;
if (payload.toParentGroupId && !findGroup(removed.document, payload.toParentGroupId)) return state;
const maskLayerId = removed.layer.clippingMask?.maskLayerId;
const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined;
const documentAfterRemoval = removedMask?.document ?? removed.document;
if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state;
const documentWithMask = removedMask?.layer
? insertLayer(documentAfterRemoval, payload.toArtboardId, payload.toParentGroupId, removedMask.layer, payload.toIndex)
: documentAfterRemoval;
return {
...state,
document: insertLayer(removed.document, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex),
document: insertLayer(documentWithMask, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex + (removedMask?.layer ? 1 : 0)),
};
},
};
@@ -293,12 +317,14 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
id: commandIds.documentGroupLayers,
name: "Group layers",
execute({ state }, payload) {
const uniqueIds = [...new Set(payload.layerIds)];
if (uniqueIds.length === 0) return state;
const requestedIds = [...new Set(payload.layerIds)];
if (requestedIds.length === 0) return state;
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
if (!artboard) return state;
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])];
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
if (selected.length === 0) return state;
@@ -369,12 +395,14 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
if (payload.maskLayerId === payload.layerId) return state;
if (!payload.maskLayerId) {
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
return {
...state,
document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
const { clippingMask: _clippingMask, ...rest } = layer;
return rest;
}),
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
editor: {
...state.editor,
maskEdit: previousMaskId && isMaskEditFor(state.editor.maskEdit, payload.layerId, previousMaskId) ? undefined : state.editor.maskEdit,
},
};
}
@@ -402,6 +430,91 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
},
};
export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> = {
id: commandIds.documentAddLayerMask,
name: "Add layer mask",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId);
if (!targetLocation || targetLocation.layer.type === "group") return state;
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId;
if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state;
if (existingMaskLocation) {
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId },
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
},
},
};
}
}
if (payload.maskLayer.id === payload.layerId) return state;
if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state;
if (findLayerLocation(state.document, payload.maskLayer.id)) return state;
const maskLayer: RasterLayer = {
...payload.maskLayer,
visible: true,
locked: false,
opacity: 1,
clippingMask: undefined,
};
const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] };
const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index);
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } }));
return {
...state,
document,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: maskLayer.id },
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
},
},
};
},
};
export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayload> = {
id: commandIds.documentRemoveLayerMask,
name: "Remove layer mask",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId);
const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId;
if (!targetLocation || !maskLayerId) {
return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state;
}
const unmasked = mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer));
const document = removeUnreferencedMaskLayer(unmasked, maskLayerId);
return {
...state,
document,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: isMaskEditFor(state.editor.maskEdit, payload.layerId, maskLayerId) ? undefined : state.editor.maskEdit,
},
};
},
};
export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
id: commandIds.documentRemoveLayer,
name: "Remove layer",
@@ -409,10 +522,23 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
const removed = removeLayerFromDocument(state.document, payload.layerId);
if (!removed.layer) return state;
const removedLayerIds = collectLayerIds(removed.layer);
const removedMaskLayerIds = collectClippingMaskIds([removed.layer]);
const cleanedReferences = removeMissingMaskReferences(removed.document);
const document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
const selection = {
...state.editor.selection,
layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)),
};
return {
...state,
document: removed.document,
editor: { ...state.editor, selection: { ...state.editor.selection, layerIds: state.editor.selection.layerIds.filter((id) => id !== payload.layerId) } },
document,
editor: {
...state.editor,
selection,
maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined,
},
};
},
};
@@ -437,6 +563,8 @@ export const documentCommands = [
documentSetLayerLockedCommand,
documentRenameLayerCommand,
documentSetLayerClippingMaskCommand,
documentAddLayerMaskCommand,
documentRemoveLayerMaskCommand,
] satisfies Command<unknown>[];
type LayerLocation = {
@@ -590,3 +718,81 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi
}
return undefined;
}
function removeLayerMaskReference(layer: Layer): Layer {
const next = { ...layer };
delete next.clippingMask;
return next;
}
function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
if (isMaskLayerReferenced(document, maskLayerId)) return document;
return removeLayerFromDocument(document, maskLayerId).document;
}
function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): boolean {
return collectClippingMaskIds(document.artboards.flatMap((artboard) => artboard.layers)).has(maskLayerId);
}
function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
const existingLayerIds = collectDocumentLayerIds(document);
return mapAllLayersInDocument(document, (layer) => {
if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.maskLayerId)) return layer;
return removeLayerMaskReference(layer);
});
}
function isMaskEditFor(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId;
}
function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, document: ImageDocument) {
if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
return Boolean(target?.clippingMask?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
}
function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
const ids = new Set<LayerId>();
for (const artboard of document.artboards) collectLayerIdsFromTree(artboard.layers, ids);
return ids;
}
function collectLayerIds(layer: Layer, ids = new Set<LayerId>()): Set<LayerId> {
ids.add(layer.id);
if (layer.type === "group") collectLayerIdsFromTree(layer.children, ids);
return ids;
}
function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) collectLayerIds(layer, ids);
return ids;
}
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
}
return ids;
}
function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
const layerIdSet = new Set(layerIds);
return layers.flatMap((layer) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.maskLayerId] : []));
}
function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapAllLayersInTree(artboard.layers, mapLayer) })),
};
}
function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) => Layer): Layer[] {
return layers.map((layer) => {
const mapped = layer.type === "group" ? { ...layer, children: mapAllLayersInTree(layer.children, mapLayer) } : layer;
return mapLayer(mapped);
});
}

View File

@@ -18,12 +18,18 @@ export const commandIds = {
documentSetLayerLocked: "document.setLayerLocked",
documentRenameLayer: "document.renameLayer",
documentSetLayerClippingMask: "document.setLayerClippingMask",
documentAddLayerMask: "document.addLayerMask",
documentRemoveLayerMask: "document.removeLayerMask",
selectionSet: "selection.set",
selectionClear: "selection.clear",
selectionAddLayer: "selection.addLayer",
toolSetActive: "tool.setActive",
toolSetBrushSettings: "tool.setBrushSettings",
toolSetMaskEditLayer: "tool.setMaskEditLayer",
toolSetBrushPreview: "tool.setBrushPreview",
toolSetBrushStrokePreview: "tool.setBrushStrokePreview",
toolSetMaskViewMode: "tool.setMaskViewMode",
toolEnterMaskEdit: "tool.enterMaskEdit",
toolExitMaskEdit: "tool.exitMaskEdit",
toolEnterTemporaryPan: "tool.enterTemporaryPan",
toolExitTemporaryPan: "tool.exitTemporaryPan",
transformBegin: "transform.begin",

View File

@@ -4,12 +4,14 @@ export {
documentAddAssetCommand,
documentAddGroupLayerCommand,
documentAddImageLayerCommand,
documentAddLayerMaskCommand,
documentAddRasterLayerCommand,
documentCommands,
documentGroupLayersCommand,
documentMoveLayerCommand,
documentRemoveArtboardCommand,
documentRemoveLayerCommand,
documentRemoveLayerMaskCommand,
documentRenameArtboardCommand,
documentRenameLayerCommand,
documentSetArtboardBoundsCommand,
@@ -26,11 +28,13 @@ export type {
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,
DocumentGroupLayersPayload,
DocumentMoveLayerPayload,
DocumentRemoveArtboardPayload,
DocumentRemoveLayerPayload,
DocumentRemoveLayerMaskPayload,
DocumentRenameArtboardPayload,
DocumentRenameLayerPayload,
DocumentSetArtboardBoundsPayload,
@@ -50,10 +54,10 @@ export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand } from "./tool";
export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
export type { ToolSetActivePayload, ToolSetBrushSettingsPayload, ToolSetMaskEditLayerPayload } from "./tool";
export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
export {
viewportCommands,
viewportPanCommand,

View File

@@ -1,14 +1,16 @@
import { commandIds } from "./ids";
import type { commandIds } from "./ids";
import type {
DocumentAddArtboardPayload,
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,
DocumentGroupLayersPayload,
DocumentMoveLayerPayload,
DocumentRemoveArtboardPayload,
DocumentRemoveLayerPayload,
DocumentRemoveLayerMaskPayload,
DocumentRenameArtboardPayload,
DocumentRenameLayerPayload,
DocumentSetArtboardBoundsPayload,
@@ -21,7 +23,7 @@ import type {
DocumentUngroupLayerPayload,
} from "./document";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolSetActivePayload, ToolSetBrushSettingsPayload, ToolSetMaskEditLayerPayload } from "./tool";
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
import type {
ViewportFitArtboardPayload,
@@ -43,6 +45,8 @@ export type CommandPayloads = {
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
@@ -56,7 +60,11 @@ export type CommandPayloads = {
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
[commandIds.toolSetActive]: ToolSetActivePayload;
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
[commandIds.toolSetMaskEditLayer]: ToolSetMaskEditLayerPayload;
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
[commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload;
[commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload;
[commandIds.toolEnterMaskEdit]: ToolEnterMaskEditPayload;
[commandIds.toolExitMaskEdit]: void;
[commandIds.toolEnterTemporaryPan]: void;
[commandIds.toolExitTemporaryPan]: void;
[commandIds.transformBegin]: TransformBeginPayload;

View File

@@ -15,14 +15,19 @@ export const selectionSetCommand: Command<SelectionSetPayload> = {
id: commandIds.selectionSet,
name: "Set selection",
execute({ state }, payload) {
const selection = {
artboardId: payload.artboardId,
layerIds: [...payload.layerIds],
};
return {
...state,
editor: {
...state.editor,
selection: {
artboardId: payload.artboardId,
layerIds: [...payload.layerIds],
},
selection,
maskEdit: selection.layerIds.length === 1 && selection.layerIds[0] === state.editor.maskEdit?.targetLayerId ? state.editor.maskEdit : undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},
@@ -37,6 +42,9 @@ export const selectionClearCommand: Command = {
editor: {
...state.editor,
selection: { layerIds: [] },
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},
@@ -56,6 +64,9 @@ export const selectionAddLayerCommand: Command<SelectionAddLayerPayload> = {
...state.editor.selection,
layerIds: [...state.editor.selection.layerIds, payload.layerId],
},
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},

View File

@@ -1,11 +1,13 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand } from "./tool";
import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand } from "./tool";
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
describe("tool commands", () => {
test("sets active tool", () => {
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" });
expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" }, brush: { color: "#111827", size: 8, hardness: 100 } });
expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" }, brush: defaultBrush });
});
test("sets brush settings", () => {
@@ -14,10 +16,39 @@ describe("tool commands", () => {
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
});
test("sets mask edit layer", () => {
const next = toolSetMaskEditLayerCommand.execute({ state: createInitialAppState("Test") }, { layerId: "mask-1" });
test("sets and clears brush preview", () => {
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
expect(next.editor.maskEditLayerId).toBe("mask-1");
expect(showing.editor.brushPreview).toEqual({ position: { x: 10, y: 20 } });
expect(cleared.editor.brushPreview).toBeUndefined();
});
test("sets and clears brush stroke preview", () => {
const showing = toolSetBrushStrokePreviewCommand.execute({ state: createInitialAppState("Test") }, { layerId: "layer", assetId: "asset", source: "preview" });
const cleared = toolSetBrushStrokePreviewCommand.execute({ state: showing }, undefined);
expect(showing.editor.brushStrokePreview).toEqual({ layerId: "layer", assetId: "asset", source: "preview" });
expect(cleared.editor.brushStrokePreview).toBeUndefined();
});
test("enters and exits mask edit", () => {
const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" });
const exited = toolExitMaskEditCommand.execute({ state: editing }, undefined);
expect(editing.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(editing.editor.selection).toEqual({ artboardId: "a1", layerIds: ["target"] });
expect(editing.editor.tools.activeTool).toBe("brush");
expect(exited.editor.maskEdit).toBeUndefined();
});
test("sets mask view mode while editing a mask", () => {
const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" });
const alpha = toolSetMaskViewModeCommand.execute({ state: editing }, { mode: "alpha" });
const ignored = toolSetMaskViewModeCommand.execute({ state: createInitialAppState("Test") }, { mode: "blackWhite" });
expect(alpha.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask", viewMode: "alpha" });
expect(ignored.editor.maskEdit).toBeUndefined();
});
test("enters and exits temporary pan", () => {
@@ -25,7 +56,43 @@ describe("tool commands", () => {
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: { color: "#111827", size: 8, hardness: 100 } });
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush });
expect(restored.editor.tools).toEqual(initial.editor.tools);
});
});
function stateWithMask() {
return {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
artboards: [
{
id: "a1",
name: "Artboard 1",
bounds: { x: 0, y: 0, w: 320, h: 240 },
backgroundColor: "transparent" as const,
visible: true,
locked: false,
layers: [
raster("mask", "Mask"),
{ ...raster("target", "Target"), clippingMask: { maskLayerId: "mask" } },
],
},
],
},
};
}
function raster(id: string, name: string) {
return {
id,
type: "raster" as const,
name,
visible: true,
locked: false,
opacity: 1,
assetId: `${id}-asset`,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
};
}

View File

@@ -1,4 +1,8 @@
import type { LayerId } from "@core/id";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { Layer } from "@core/layer";
import type { MaskViewMode } from "@editor/state";
import type { BrushSettings, ToolId } from "@editor/tools";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -9,8 +13,23 @@ export type ToolSetActivePayload = {
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
export type ToolSetMaskEditLayerPayload = {
layerId?: LayerId;
export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined;
export type ToolSetBrushStrokePreviewPayload =
| {
layerId: LayerId;
assetId: AssetId;
source: string;
}
| undefined;
export type ToolSetMaskViewModePayload = {
mode: MaskViewMode;
};
export type ToolEnterMaskEditPayload = {
targetLayerId: LayerId;
maskLayerId: LayerId;
};
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
@@ -26,6 +45,8 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
activeTool: payload.tool,
interactionMode: { type: "tool", tool: payload.tool },
},
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},
@@ -52,15 +73,107 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
},
};
export const toolSetMaskEditLayerCommand: Command<ToolSetMaskEditLayerPayload> = {
id: commandIds.toolSetMaskEditLayer,
name: "Set mask edit layer",
export const toolSetBrushPreviewCommand: Command<ToolSetBrushPreviewPayload> = {
id: commandIds.toolSetBrushPreview,
name: "Set brush preview",
execute({ state }, payload) {
if (!payload) {
if (!state.editor.brushPreview) return state;
return { ...state, editor: { ...state.editor, brushPreview: undefined } };
}
const currentPosition = state.editor.brushPreview?.position;
if (currentPosition && currentPosition.x === payload.position.x && currentPosition.y === payload.position.y) return state;
return {
...state,
editor: {
...state.editor,
maskEditLayerId: payload.layerId,
brushPreview: { position: { ...payload.position } },
},
};
},
};
export const toolSetBrushStrokePreviewCommand: Command<ToolSetBrushStrokePreviewPayload> = {
id: commandIds.toolSetBrushStrokePreview,
name: "Set brush stroke preview",
execute({ state }, payload) {
if (!payload) {
if (!state.editor.brushStrokePreview) return state;
return { ...state, editor: { ...state.editor, brushStrokePreview: undefined } };
}
const currentPreview = state.editor.brushStrokePreview;
if (currentPreview?.layerId === payload.layerId && currentPreview.assetId === payload.assetId && currentPreview.source === payload.source) return state;
return {
...state,
editor: {
...state.editor,
brushStrokePreview: { layerId: payload.layerId, assetId: payload.assetId, source: payload.source },
},
};
},
};
export const toolSetMaskViewModeCommand: Command<ToolSetMaskViewModePayload> = {
id: commandIds.toolSetMaskViewMode,
name: "Set mask view mode",
execute({ state }, payload) {
if (!state.editor.maskEdit || state.editor.maskEdit.viewMode === payload.mode) return state;
return {
...state,
editor: {
...state.editor,
maskEdit: { ...state.editor.maskEdit, viewMode: payload.mode },
},
};
},
};
export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
id: commandIds.toolEnterMaskEdit,
name: "Enter mask edit",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.targetLayerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state;
if (targetLocation.layer.clippingMask?.maskLayerId !== payload.maskLayerId) return state;
if (maskLocation.layer.type === "group") return state;
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.targetLayerId] },
maskEdit: { targetLayerId: payload.targetLayerId, maskLayerId: payload.maskLayerId },
brushPreview: undefined,
brushStrokePreview: undefined,
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
},
},
};
},
};
export const toolExitMaskEditCommand: Command = {
id: commandIds.toolExitMaskEdit,
name: "Exit mask edit",
execute({ state }) {
if (!state.editor.maskEdit) return state;
return {
...state,
editor: {
...state.editor,
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
},
};
},
@@ -106,9 +219,43 @@ export const toolExitTemporaryPanCommand: Command = {
},
};
export const toolCommands = [toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command<unknown>[];
export const toolCommands = [
toolSetActiveCommand,
toolSetBrushSettingsCommand,
toolSetBrushPreviewCommand,
toolSetBrushStrokePreviewCommand,
toolSetMaskViewModeCommand,
toolEnterMaskEditCommand,
toolExitMaskEditCommand,
toolEnterTemporaryPanCommand,
toolExitTemporaryPanCommand,
] satisfies Command<unknown>[];
function clampNumber(value: number, min: number, max: number) {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, value));
}
type LayerLocation = {
artboardId: ArtboardId;
layer: Layer;
};
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
for (const artboard of document.artboards) {
const layer = findLayerInTree(artboard.layers, layerId);
if (layer) return { artboardId: artboard.id, 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;
}