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