feat(masks): add mask editing mode

This commit is contained in:
syntaxbullet
2026-07-03 18:07:04 +02:00
parent 4a21b28ed0
commit 15f3a934e0
13 changed files with 211 additions and 20 deletions

View File

@@ -23,6 +23,7 @@ export const commandIds = {
selectionAddLayer: "selection.addLayer", selectionAddLayer: "selection.addLayer",
toolSetActive: "tool.setActive", toolSetActive: "tool.setActive",
toolSetBrushSettings: "tool.setBrushSettings", toolSetBrushSettings: "tool.setBrushSettings",
toolSetMaskEditLayer: "tool.setMaskEditLayer",
toolEnterTemporaryPan: "tool.enterTemporaryPan", toolEnterTemporaryPan: "tool.enterTemporaryPan",
toolExitTemporaryPan: "tool.exitTemporaryPan", toolExitTemporaryPan: "tool.exitTemporaryPan",
transformBegin: "transform.begin", transformBegin: "transform.begin",

View File

@@ -50,10 +50,10 @@ export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry"; export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand } from "./tool"; export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform"; export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
export type { ToolSetActivePayload, ToolSetBrushSettingsPayload } from "./tool"; export type { ToolSetActivePayload, ToolSetBrushSettingsPayload, ToolSetMaskEditLayerPayload } from "./tool";
export { export {
viewportCommands, viewportCommands,
viewportPanCommand, viewportPanCommand,

View File

@@ -21,7 +21,7 @@ import type {
DocumentUngroupLayerPayload, DocumentUngroupLayerPayload,
} from "./document"; } from "./document";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolSetActivePayload, ToolSetBrushSettingsPayload } from "./tool"; import type { ToolSetActivePayload, ToolSetBrushSettingsPayload, ToolSetMaskEditLayerPayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
import type { import type {
ViewportFitArtboardPayload, ViewportFitArtboardPayload,
@@ -56,6 +56,7 @@ export type CommandPayloads = {
[commandIds.selectionAddLayer]: SelectionAddLayerPayload; [commandIds.selectionAddLayer]: SelectionAddLayerPayload;
[commandIds.toolSetActive]: ToolSetActivePayload; [commandIds.toolSetActive]: ToolSetActivePayload;
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload; [commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
[commandIds.toolSetMaskEditLayer]: ToolSetMaskEditLayerPayload;
[commandIds.toolEnterTemporaryPan]: void; [commandIds.toolEnterTemporaryPan]: void;
[commandIds.toolExitTemporaryPan]: void; [commandIds.toolExitTemporaryPan]: void;
[commandIds.transformBegin]: TransformBeginPayload; [commandIds.transformBegin]: TransformBeginPayload;

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state"; import { createInitialAppState } from "@editor/initial-state";
import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand } from "./tool"; import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand } from "./tool";
describe("tool commands", () => { describe("tool commands", () => {
test("sets active tool", () => { test("sets active tool", () => {
@@ -14,6 +14,12 @@ describe("tool commands", () => {
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 }); 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" });
expect(next.editor.maskEditLayerId).toBe("mask-1");
});
test("enters and exits temporary pan", () => { test("enters and exits temporary pan", () => {
const initial = createInitialAppState("Test"); const initial = createInitialAppState("Test");
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined); const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);

View File

@@ -1,3 +1,4 @@
import type { LayerId } from "@core/id";
import type { BrushSettings, ToolId } from "@editor/tools"; import type { BrushSettings, ToolId } from "@editor/tools";
import type { Command } from "./command"; import type { Command } from "./command";
import { commandIds } from "./ids"; import { commandIds } from "./ids";
@@ -8,6 +9,10 @@ export type ToolSetActivePayload = {
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>; export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
export type ToolSetMaskEditLayerPayload = {
layerId?: LayerId;
};
export const toolSetActiveCommand: Command<ToolSetActivePayload> = { export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
id: commandIds.toolSetActive, id: commandIds.toolSetActive,
name: "Set active tool", name: "Set active tool",
@@ -47,6 +52,20 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
}, },
}; };
export const toolSetMaskEditLayerCommand: Command<ToolSetMaskEditLayerPayload> = {
id: commandIds.toolSetMaskEditLayer,
name: "Set mask edit layer",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
maskEditLayerId: payload.layerId,
},
};
},
};
export const toolEnterTemporaryPanCommand: Command = { export const toolEnterTemporaryPanCommand: Command = {
id: commandIds.toolEnterTemporaryPan, id: commandIds.toolEnterTemporaryPan,
name: "Enter temporary pan", name: "Enter temporary pan",
@@ -87,7 +106,7 @@ export const toolExitTemporaryPanCommand: Command = {
}, },
}; };
export const toolCommands = [toolSetActiveCommand, toolSetBrushSettingsCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command<unknown>[]; export const toolCommands = [toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command<unknown>[];
function clampNumber(value: number, min: number, max: number) { function clampNumber(value: number, min: number, max: number) {
if (!Number.isFinite(value)) return min; if (!Number.isFinite(value)) return min;

View File

@@ -13,6 +13,7 @@ export const initialEditorState: EditorState = {
}, },
tools: initialToolState, tools: initialToolState,
transformSession: undefined, transformSession: undefined,
maskEditLayerId: undefined,
}; };
export function createInitialAppState(name = "Untitled"): AppState { export function createInitialAppState(name = "Untitled"): AppState {

View File

@@ -21,6 +21,7 @@ export type EditorState = {
selection: SelectionState; selection: SelectionState;
tools: ToolState; tools: ToolState;
transformSession?: TransformSession; transformSession?: TransformSession;
maskEditLayerId?: LayerId;
}; };
export type HistorySnapshot = { export type HistorySnapshot = {

View File

@@ -4,6 +4,7 @@ import type { ScreenRect, WebGlRendererContext } from "./types";
export type ImageTextureRenderer = { export type ImageTextureRenderer = {
syncAssets(assets: readonly Asset[]): void; syncAssets(assets: readonly Asset[]): void;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean; render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect): boolean;
dispose(): void; dispose(): void;
}; };
@@ -24,12 +25,19 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
const positionLocation = gl.getAttribLocation(program, "a_position"); const positionLocation = gl.getAttribLocation(program, "a_position");
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord"); const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
const samplerLocation = gl.getUniformLocation(program, "u_image"); const samplerLocation = gl.getUniformLocation(program, "u_image");
const maskedProgram = createMaskedProgram(gl);
const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position");
const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord");
const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord");
const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image");
const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask");
const positionBuffer = gl.createBuffer(); const positionBuffer = gl.createBuffer();
const texCoordBuffer = gl.createBuffer(); const texCoordBuffer = gl.createBuffer();
const maskTexCoordBuffer = gl.createBuffer();
const textures = new Map<string, TextureEntry>(); const textures = new Map<string, TextureEntry>();
let disposed = false; let disposed = false;
if (!positionBuffer || !texCoordBuffer || !samplerLocation) throw new Error("Failed to create image texture renderer"); if (!positionBuffer || !texCoordBuffer || !maskTexCoordBuffer || !samplerLocation || !maskedSamplerLocation || !maskedMaskSamplerLocation) throw new Error("Failed to create image texture renderer");
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW);
@@ -68,6 +76,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(texCoordLocation); gl.enableVertexAttribArray(texCoordLocation);
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
@@ -75,12 +84,57 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND); gl.disable(gl.BLEND);
return true; return true;
}, },
renderMasked(asset, rect, maskAsset, maskRect, clipRect) {
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
const maskEntry = getTextureEntry(context, textures, maskAsset, invalidate, () => disposed);
const texture = renderableTexture(entry);
const maskTexture = renderableTexture(maskEntry);
if (!texture || !maskTexture) return false;
gl.enable(gl.SCISSOR_TEST);
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(maskedProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(maskedSamplerLocation, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
gl.uniform1i(maskedMaskSamplerLocation, 1);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedPositionLocation);
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedTexCoordLocation);
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.disable(gl.BLEND);
return true;
},
dispose() { dispose() {
disposed = true; disposed = true;
for (const entry of textures.values()) disposeEntry(gl, entry); for (const entry of textures.values()) disposeEntry(gl, entry);
gl.deleteBuffer(positionBuffer); gl.deleteBuffer(positionBuffer);
gl.deleteBuffer(texCoordBuffer); gl.deleteBuffer(texCoordBuffer);
gl.deleteBuffer(maskTexCoordBuffer);
gl.deleteProgram(program); gl.deleteProgram(program);
gl.deleteProgram(maskedProgram);
}, },
}; };
} }
@@ -185,6 +239,19 @@ function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) {
return texture; return texture;
} }
function fullTexCoords() {
return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]);
}
function texCoordsForRect(drawRect: ScreenRect, sourceRect: ScreenRect) {
const x1 = (drawRect.x - sourceRect.x) / sourceRect.w;
const x2 = (drawRect.x + drawRect.w - sourceRect.x) / sourceRect.w;
const y1 = (drawRect.y - sourceRect.y) / sourceRect.h;
const y2 = (drawRect.y + drawRect.h - sourceRect.y) / sourceRect.h;
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
}
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) { function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
const x1 = (rect.x / canvas.width) * 2 - 1; const x1 = (rect.x / canvas.width) * 2 - 1;
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1; const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
@@ -237,6 +304,56 @@ function createProgram(gl: WebGL2RenderingContext) {
return program; return program;
} }
function createMaskedProgram(gl: WebGL2RenderingContext) {
const vertexShader = compileShader(
gl,
gl.VERTEX_SHADER,
`#version 300 es
in vec2 a_position;
in vec2 a_texCoord;
in vec2 a_maskTexCoord;
out vec2 v_texCoord;
out vec2 v_maskTexCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
v_maskTexCoord = a_maskTexCoord;
}`,
);
const fragmentShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
`#version 300 es
precision mediump float;
uniform sampler2D u_image;
uniform sampler2D u_mask;
in vec2 v_texCoord;
in vec2 v_maskTexCoord;
out vec4 outColor;
void main() {
vec4 color = texture(u_image, v_texCoord);
float maskAlpha = texture(u_mask, v_maskTexCoord).a;
outColor = vec4(color.rgb, color.a * maskAlpha);
}`,
);
const program = gl.createProgram();
if (!program) throw new Error("Failed to create masked image shader program");
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(program) ?? "Unknown masked program link error";
gl.deleteProgram(program);
throw new Error(message);
}
return program;
}
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) { function compileShader(gl: WebGL2RenderingContext, type: number, source: string) {
const shader = gl.createShader(type); const shader = gl.createShader(type);
if (!shader) throw new Error("Failed to create shader"); if (!shader) throw new Error("Failed to create shader");

View File

@@ -1,6 +1,6 @@
import type { ImageDocument } from "@core/document"; import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer"; import type { Layer } from "@core/layer";
import type { ViewportState } from "@editor/state"; import type { EditorState, ViewportState } from "@editor/state";
import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { resolveTransformTargetBounds } from "@editor/transform-targets";
import { clearScreenRect } from "./clear-rect"; import { clearScreenRect } from "./clear-rect";
import type { ImageTextureRenderer } from "./image-textures"; import type { ImageTextureRenderer } from "./image-textures";
@@ -10,39 +10,45 @@ import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) { export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
for (const artboard of document.artboards) { for (const artboard of document.artboards) {
if (!artboard.visible) continue; if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport); const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = collectMaskLayerIds(artboard.layers); const maskLayerIds = collectMaskLayerIds(artboard.layers);
for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect, maskLayerIds); for (const layer of artboard.layers) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds);
} }
} }
function renderLayer( function renderLayer(
context: WebGlRendererContext, context: WebGlRendererContext,
document: ImageDocument, document: ImageDocument,
viewport: ViewportState, editor: EditorState,
layer: Layer, layer: Layer,
imageTextureRenderer: ImageTextureRenderer, imageTextureRenderer: ImageTextureRenderer,
clipRect: ScreenRect, clipRect: ScreenRect,
maskLayerIds: ReadonlySet<string>, maskLayerIds: ReadonlySet<string>,
) { ) {
if (!layer.visible || maskLayerIds.has(layer.id)) return; const editingMaskLayer = editor.maskEditLayerId === layer.id;
if (!layer.visible || (maskLayerIds.has(layer.id) && !editingMaskLayer)) return;
const effectiveClipRect = resolveLayerClipRect(context, document, viewport, layer, clipRect); const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect);
if (!effectiveClipRect) return; if (!effectiveClipRect) return;
if (layer.type === "group") { if (layer.type === "group") {
for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer, effectiveClipRect, maskLayerIds); for (const child of layer.children) renderLayer(context, document, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds);
return; return;
} }
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
if (!bounds) return; if (!bounds) return;
const rect = documentRectToScreenRect(context.canvas, bounds, viewport); const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const maskLayer = !editingMaskLayer && layer.clippingMask ? findLayer(document, layer.clippingMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
const maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined;
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) return;
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return; if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return;
const fallbackRect = intersectScreenRects(rect, effectiveClipRect); const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
@@ -67,6 +73,25 @@ function resolveLayerClipRect(
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport)); return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
} }
function findLayer(document: ImageDocument, layerId: string): Layer | undefined {
for (const artboard of document.artboards) {
const layer = findLayerInTree(artboard.layers, layerId);
if (layer) return layer;
}
return undefined;
}
function findLayerInTree(layers: readonly Layer[], layerId: string): 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;
}
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) { function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
for (const layer of layers) { for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);

View File

@@ -59,7 +59,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport); if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport);
} }
imageTextureRenderer.syncAssets(frame.document.assets); imageTextureRenderer.syncAssets(frame.document.assets);
renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer); renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
renderSelectionOverlay(rendererContext, frame.document, frame.editor); renderSelectionOverlay(rendererContext, frame.document, frame.editor);
renderTransformControls(rendererContext, frame.document, frame.editor); renderTransformControls(rendererContext, frame.document, frame.editor);

View File

@@ -77,7 +77,14 @@ export function App({ app }: AppProps) {
dispatch={app.store.dispatch} dispatch={app.store.dispatch}
/> />
</div> </div>
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} dispatch={app.store.dispatch} onClose={() => setLayersOpen(false)} /> <LayersSheet
document={state.document}
selection={state.editor.selection}
maskEditLayerId={state.editor.maskEditLayerId}
open={layersOpen}
dispatch={app.store.dispatch}
onClose={() => setLayersOpen(false)}
/>
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center"> <div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland <BottomControlsIsland
viewport={state.editor.viewport} viewport={state.editor.viewport}

View File

@@ -12,12 +12,13 @@ import { downloadArtboardPng } from "./exportArtboardPng";
export type LayersSheetProps = { export type LayersSheetProps = {
document: ImageDocument; document: ImageDocument;
selection: SelectionState; selection: SelectionState;
maskEditLayerId?: string;
open: boolean; open: boolean;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
onClose: () => void; onClose: () => void;
}; };
export function LayersSheet({ document, selection, open, dispatch, onClose }: LayersSheetProps) { export function LayersSheet({ document, selection, maskEditLayerId, open, dispatch, onClose }: LayersSheetProps) {
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id; const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]); const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0); const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
@@ -137,6 +138,7 @@ export function LayersSheet({ document, selection, open, dispatch, onClose }: La
editingTitle={editingTitle} editingTitle={editingTitle}
setEditingTitle={setEditingTitle} setEditingTitle={setEditingTitle}
selectedMaskLayer={selectedLayer} selectedMaskLayer={selectedLayer}
maskEditLayerId={maskEditLayerId}
dispatch={dispatch} dispatch={dispatch}
/> />
)) ))
@@ -159,6 +161,7 @@ function LayerRow({
editingTitle, editingTitle,
setEditingTitle, setEditingTitle,
selectedMaskLayer, selectedMaskLayer,
maskEditLayerId,
dispatch, dispatch,
}: { }: {
document: ImageDocument; document: ImageDocument;
@@ -170,6 +173,7 @@ function LayerRow({
editingTitle: EditingTitle | undefined; editingTitle: EditingTitle | undefined;
setEditingTitle: (editingTitle: EditingTitle | undefined) => void; setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
selectedMaskLayer?: LayerInfo; selectedMaskLayer?: LayerInfo;
maskEditLayerId?: string;
dispatch: AppStore["dispatch"]; dispatch: AppStore["dispatch"];
}) { }) {
const selected = selectedLayerIds.includes(layer.id); const selected = selectedLayerIds.includes(layer.id);
@@ -178,6 +182,7 @@ function LayerRow({
const maskIndent = maskLayer ? 24 : 0; const maskIndent = maskLayer ? 24 : 0;
const canSetMask = const canSetMask =
Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId); Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId);
const editingMask = maskEditLayerId === layer.clippingMask?.maskLayerId;
return ( return (
<div> <div>
<div <div
@@ -244,6 +249,13 @@ function LayerRow({
<div className="mt-1 flex items-center gap-2 text-xs text-sky-100/60" style={{ paddingLeft: 52 + depth * 16 + maskIndent }}> <div className="mt-1 flex items-center gap-2 text-xs text-sky-100/60" style={{ paddingLeft: 52 + depth * 16 + maskIndent }}>
<span className="h-px w-5 bg-sky-200/25" /> <span className="h-px w-5 bg-sky-200/25" />
<span>masked by {maskLayer.name}</span> <span>masked by {maskLayer.name}</span>
<button
type="button"
className={`rounded-full px-2 py-0.5 transition ${editingMask ? "bg-sky-300 text-black" : "bg-sky-400/10 text-sky-100/75 hover:bg-sky-400/20 hover:text-sky-50"}`}
onClick={() => dispatch(commandIds.toolSetMaskEditLayer, { layerId: editingMask ? undefined : layer.clippingMask?.maskLayerId })}
>
{editingMask ? "Editing mask" : "Edit mask"}
</button>
</div> </div>
) : null} ) : null}
{layer.type === "group" {layer.type === "group"
@@ -259,6 +271,7 @@ function LayerRow({
editingTitle={editingTitle} editingTitle={editingTitle}
setEditingTitle={setEditingTitle} setEditingTitle={setEditingTitle}
selectedMaskLayer={selectedMaskLayer} selectedMaskLayer={selectedMaskLayer}
maskEditLayerId={maskEditLayerId}
dispatch={dispatch} dispatch={dispatch}
/> />
)) ))

View File

@@ -15,7 +15,7 @@ export type BrushSession = {
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined { export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const layerId = editor.selection.layerIds[0]; const layerId = editor.maskEditLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined; if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || !layer.visible) return undefined; if (!layer || layer.locked || !layer.visible) return undefined;
@@ -43,7 +43,7 @@ export async function updateBrushSession(options: {
height: asset.intrinsicSize.h, height: asset.intrinsicSize.h,
from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h), to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
color: options.color, color: state.editor.maskEditLayerId ? "#ffffff" : options.color,
size: options.size, size: options.size,
hardness: options.hardness, hardness: options.hardness,
mode: options.session.mode, mode: options.session.mode,