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:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
163
commands/tool.ts
163
commands/tool.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user