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

@@ -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);
});
}