feat(phase-13): implement layer masks foundation for non-destructive editing

Add comprehensive layer mask system with core functionality for non-destructive editing.

Features:
- LayerMask interface with canvas, enabled, and inverted properties
- Layer interface updated with mask property
- Full mask management in layer store:
  * addMask() - Creates white mask (fully visible)
  * removeMask() - Removes mask from layer
  * toggleMask() - Enable/disable mask
  * invertMask() - Invert mask values
  * applyMask() - Apply mask to layer and remove
- Duplicate layer now clones masks
- Grayscale mask system (white=reveal, black=hide)

Changes:
- Updated types/layer.ts with LayerMask interface
- Modified Layer interface to include mask property
- Updated store/layer-store.ts:
  * Added mask property initialization to createLayer
  * Added 5 new mask management functions
  * Updated duplicateLayer to clone masks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 19:57:49 +01:00
parent 841d6ca0a5
commit 4ddb8fe0e3
2 changed files with 128 additions and 0 deletions

View File

@@ -30,6 +30,16 @@ interface LayerStore {
getActiveLayer: () => Layer | undefined;
/** Clear all layers */
clearLayers: () => void;
/** Add mask to layer */
addMask: (id: string) => void;
/** Remove mask from layer */
removeMask: (id: string) => void;
/** Enable/disable mask */
toggleMask: (id: string) => void;
/** Invert mask */
invertMask: (id: string) => void;
/** Apply mask (merge and remove) */
applyMask: (id: string) => void;
}
export const useLayerStore = create<LayerStore>((set, get) => ({
@@ -51,6 +61,7 @@ export const useLayerStore = create<LayerStore>((set, get) => ({
height: params.height,
x: params.x ?? 0,
y: params.y ?? 0,
mask: null,
createdAt: now,
updatedAt: now,
};
@@ -134,6 +145,22 @@ export const useLayerStore = create<LayerStore>((set, get) => ({
newLayer.canvas = canvas;
}
// Clone mask if exists
if (layer.mask?.canvas) {
const maskCanvas = document.createElement('canvas');
maskCanvas.width = layer.width;
maskCanvas.height = layer.height;
const maskCtx = maskCanvas.getContext('2d');
if (maskCtx) {
maskCtx.drawImage(layer.mask.canvas, 0, 0);
}
newLayer.mask = {
canvas: maskCanvas,
enabled: layer.mask.enabled,
inverted: layer.mask.inverted,
};
}
set((state) => ({
layers: [...state.layers, newLayer],
activeLayerId: newLayer.id,
@@ -236,4 +263,91 @@ export const useLayerStore = create<LayerStore>((set, get) => ({
clearLayers: () => {
set({ layers: [], activeLayerId: null });
},
addMask: (id) => {
const layer = get().getLayer(id);
if (!layer || layer.mask) return;
// Create mask canvas (white = fully visible)
const maskCanvas = document.createElement('canvas');
maskCanvas.width = layer.width;
maskCanvas.height = layer.height;
const ctx = maskCanvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
}
get().updateLayer(id, {
mask: {
canvas: maskCanvas,
enabled: true,
inverted: false,
},
});
},
removeMask: (id) => {
get().updateLayer(id, { mask: null });
},
toggleMask: (id) => {
const layer = get().getLayer(id);
if (!layer?.mask) return;
get().updateLayer(id, {
mask: {
...layer.mask,
enabled: !layer.mask.enabled,
},
});
},
invertMask: (id) => {
const layer = get().getLayer(id);
if (!layer?.mask?.canvas) return;
const maskCtx = layer.mask.canvas.getContext('2d');
if (!maskCtx) return;
// Invert the mask canvas
const imageData = maskCtx.getImageData(0, 0, layer.mask.canvas.width, layer.mask.canvas.height);
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i] = 255 - imageData.data[i]; // R
imageData.data[i + 1] = 255 - imageData.data[i + 1]; // G
imageData.data[i + 2] = 255 - imageData.data[i + 2]; // B
}
maskCtx.putImageData(imageData, 0, 0);
get().updateLayer(id, {
mask: {
...layer.mask,
inverted: !layer.mask.inverted,
},
});
},
applyMask: (id) => {
const layer = get().getLayer(id);
if (!layer?.canvas || !layer.mask?.canvas) return;
const ctx = layer.canvas.getContext('2d');
const maskCtx = layer.mask.canvas.getContext('2d');
if (!ctx || !maskCtx) return;
// Get layer and mask data
const layerData = ctx.getImageData(0, 0, layer.width, layer.height);
const maskData = maskCtx.getImageData(0, 0, layer.width, layer.height);
// Apply mask to layer alpha channel
for (let i = 0; i < layerData.data.length; i += 4) {
const maskValue = maskData.data[i]; // Use red channel of mask
layerData.data[i + 3] = (layerData.data[i + 3] * maskValue) / 255;
}
ctx.putImageData(layerData, 0, 0);
// Remove mask
get().removeMask(id);
},
}));

View File

@@ -19,6 +19,18 @@ export type BlendMode =
| 'color'
| 'luminosity';
/**
* Layer mask data
*/
export interface LayerMask {
/** Canvas element containing the mask's grayscale data */
canvas: HTMLCanvasElement | null;
/** Whether the mask is currently enabled */
enabled: boolean;
/** Whether to invert the mask (white=hide, black=reveal) */
inverted: boolean;
}
/**
* Layer interface representing a single layer in the canvas
*/
@@ -45,6 +57,8 @@ export interface Layer {
/** Position offset */
x: number;
y: number;
/** Layer mask for non-destructive editing */
mask: LayerMask | null;
/** Timestamp of creation */
createdAt: number;
/** Timestamp of last modification */