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:
@@ -30,6 +30,16 @@ interface LayerStore {
|
|||||||
getActiveLayer: () => Layer | undefined;
|
getActiveLayer: () => Layer | undefined;
|
||||||
/** Clear all layers */
|
/** Clear all layers */
|
||||||
clearLayers: () => void;
|
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) => ({
|
export const useLayerStore = create<LayerStore>((set, get) => ({
|
||||||
@@ -51,6 +61,7 @@ export const useLayerStore = create<LayerStore>((set, get) => ({
|
|||||||
height: params.height,
|
height: params.height,
|
||||||
x: params.x ?? 0,
|
x: params.x ?? 0,
|
||||||
y: params.y ?? 0,
|
y: params.y ?? 0,
|
||||||
|
mask: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -134,6 +145,22 @@ export const useLayerStore = create<LayerStore>((set, get) => ({
|
|||||||
newLayer.canvas = canvas;
|
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) => ({
|
set((state) => ({
|
||||||
layers: [...state.layers, newLayer],
|
layers: [...state.layers, newLayer],
|
||||||
activeLayerId: newLayer.id,
|
activeLayerId: newLayer.id,
|
||||||
@@ -236,4 +263,91 @@ export const useLayerStore = create<LayerStore>((set, get) => ({
|
|||||||
clearLayers: () => {
|
clearLayers: () => {
|
||||||
set({ layers: [], activeLayerId: null });
|
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);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ export type BlendMode =
|
|||||||
| 'color'
|
| 'color'
|
||||||
| 'luminosity';
|
| '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
|
* Layer interface representing a single layer in the canvas
|
||||||
*/
|
*/
|
||||||
@@ -45,6 +57,8 @@ export interface Layer {
|
|||||||
/** Position offset */
|
/** Position offset */
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
/** Layer mask for non-destructive editing */
|
||||||
|
mask: LayerMask | null;
|
||||||
/** Timestamp of creation */
|
/** Timestamp of creation */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Timestamp of last modification */
|
/** Timestamp of last modification */
|
||||||
|
|||||||
Reference in New Issue
Block a user