feat(masks): add mask editing mode
This commit is contained in:
@@ -23,6 +23,7 @@ export const commandIds = {
|
||||
selectionAddLayer: "selection.addLayer",
|
||||
toolSetActive: "tool.setActive",
|
||||
toolSetBrushSettings: "tool.setBrushSettings",
|
||||
toolSetMaskEditLayer: "tool.setMaskEditLayer",
|
||||
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||
transformBegin: "transform.begin",
|
||||
|
||||
@@ -50,10 +50,10 @@ export type { CommandRegistry } from "./registry";
|
||||
export { createCommandRegistry } from "./registry";
|
||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } 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 type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
export type { ToolSetActivePayload, ToolSetBrushSettingsPayload } from "./tool";
|
||||
export type { ToolSetActivePayload, ToolSetBrushSettingsPayload, ToolSetMaskEditLayerPayload } from "./tool";
|
||||
export {
|
||||
viewportCommands,
|
||||
viewportPanCommand,
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
DocumentUngroupLayerPayload,
|
||||
} from "./document";
|
||||
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 {
|
||||
ViewportFitArtboardPayload,
|
||||
@@ -56,6 +56,7 @@ export type CommandPayloads = {
|
||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||
[commandIds.toolSetMaskEditLayer]: ToolSetMaskEditLayerPayload;
|
||||
[commandIds.toolEnterTemporaryPan]: void;
|
||||
[commandIds.toolExitTemporaryPan]: void;
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand } from "./tool";
|
||||
import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetMaskEditLayerCommand } from "./tool";
|
||||
|
||||
describe("tool commands", () => {
|
||||
test("sets active tool", () => {
|
||||
@@ -14,6 +14,12 @@ describe("tool commands", () => {
|
||||
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", () => {
|
||||
const initial = createInitialAppState("Test");
|
||||
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LayerId } from "@core/id";
|
||||
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
@@ -8,6 +9,10 @@ export type ToolSetActivePayload = {
|
||||
|
||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||
|
||||
export type ToolSetMaskEditLayerPayload = {
|
||||
layerId?: LayerId;
|
||||
};
|
||||
|
||||
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||
id: commandIds.toolSetActive,
|
||||
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 = {
|
||||
id: commandIds.toolEnterTemporaryPan,
|
||||
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) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
|
||||
@@ -13,6 +13,7 @@ export const initialEditorState: EditorState = {
|
||||
},
|
||||
tools: initialToolState,
|
||||
transformSession: undefined,
|
||||
maskEditLayerId: undefined,
|
||||
};
|
||||
|
||||
export function createInitialAppState(name = "Untitled"): AppState {
|
||||
|
||||
@@ -21,6 +21,7 @@ export type EditorState = {
|
||||
selection: SelectionState;
|
||||
tools: ToolState;
|
||||
transformSession?: TransformSession;
|
||||
maskEditLayerId?: LayerId;
|
||||
};
|
||||
|
||||
export type HistorySnapshot = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ScreenRect, WebGlRendererContext } from "./types";
|
||||
export type ImageTextureRenderer = {
|
||||
syncAssets(assets: readonly Asset[]): void;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect): boolean;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
@@ -24,12 +25,19 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
|
||||
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 texCoordBuffer = gl.createBuffer();
|
||||
const maskTexCoordBuffer = gl.createBuffer();
|
||||
const textures = new Map<string, TextureEntry>();
|
||||
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.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.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(texCoordLocation);
|
||||
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -75,12 +84,57 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
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() {
|
||||
disposed = true;
|
||||
for (const entry of textures.values()) disposeEntry(gl, entry);
|
||||
gl.deleteBuffer(positionBuffer);
|
||||
gl.deleteBuffer(texCoordBuffer);
|
||||
gl.deleteBuffer(maskTexCoordBuffer);
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteProgram(maskedProgram);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -185,6 +239,19 @@ function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) {
|
||||
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) {
|
||||
const x1 = (rect.x / canvas.width) * 2 - 1;
|
||||
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
|
||||
@@ -237,6 +304,56 @@ function createProgram(gl: WebGL2RenderingContext) {
|
||||
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) {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) throw new Error("Failed to create shader");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
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 { clearScreenRect } from "./clear-rect";
|
||||
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 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) {
|
||||
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);
|
||||
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(
|
||||
context: WebGlRendererContext,
|
||||
document: ImageDocument,
|
||||
viewport: ViewportState,
|
||||
editor: EditorState,
|
||||
layer: Layer,
|
||||
imageTextureRenderer: ImageTextureRenderer,
|
||||
clipRect: ScreenRect,
|
||||
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 (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;
|
||||
}
|
||||
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
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 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;
|
||||
|
||||
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||
@@ -67,6 +73,25 @@ function resolveLayerClipRect(
|
||||
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>()) {
|
||||
for (const layer of layers) {
|
||||
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport);
|
||||
}
|
||||
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);
|
||||
renderTransformControls(rendererContext, frame.document, frame.editor);
|
||||
|
||||
@@ -77,7 +77,14 @@ export function App({ app }: AppProps) {
|
||||
dispatch={app.store.dispatch}
|
||||
/>
|
||||
</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">
|
||||
<BottomControlsIsland
|
||||
viewport={state.editor.viewport}
|
||||
|
||||
@@ -12,12 +12,13 @@ import { downloadArtboardPng } from "./exportArtboardPng";
|
||||
export type LayersSheetProps = {
|
||||
document: ImageDocument;
|
||||
selection: SelectionState;
|
||||
maskEditLayerId?: string;
|
||||
open: boolean;
|
||||
dispatch: AppStore["dispatch"];
|
||||
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 selectedLayer = findLayerInfoInDocument(document, selection.layerIds[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}
|
||||
setEditingTitle={setEditingTitle}
|
||||
selectedMaskLayer={selectedLayer}
|
||||
maskEditLayerId={maskEditLayerId}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
))
|
||||
@@ -159,6 +161,7 @@ function LayerRow({
|
||||
editingTitle,
|
||||
setEditingTitle,
|
||||
selectedMaskLayer,
|
||||
maskEditLayerId,
|
||||
dispatch,
|
||||
}: {
|
||||
document: ImageDocument;
|
||||
@@ -170,6 +173,7 @@ function LayerRow({
|
||||
editingTitle: EditingTitle | undefined;
|
||||
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||
selectedMaskLayer?: LayerInfo;
|
||||
maskEditLayerId?: string;
|
||||
dispatch: AppStore["dispatch"];
|
||||
}) {
|
||||
const selected = selectedLayerIds.includes(layer.id);
|
||||
@@ -178,6 +182,7 @@ function LayerRow({
|
||||
const maskIndent = maskLayer ? 24 : 0;
|
||||
const canSetMask =
|
||||
Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId);
|
||||
const editingMask = maskEditLayerId === layer.clippingMask?.maskLayerId;
|
||||
return (
|
||||
<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 }}>
|
||||
<span className="h-px w-5 bg-sky-200/25" />
|
||||
<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>
|
||||
) : null}
|
||||
{layer.type === "group"
|
||||
@@ -259,6 +271,7 @@ function LayerRow({
|
||||
editingTitle={editingTitle}
|
||||
setEditingTitle={setEditingTitle}
|
||||
selectedMaskLayer={selectedMaskLayer}
|
||||
maskEditLayerId={maskEditLayerId}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -15,7 +15,7 @@ export type BrushSession = {
|
||||
|
||||
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;
|
||||
const layerId = editor.selection.layerIds[0];
|
||||
const layerId = editor.maskEditLayerId ?? editor.selection.layerIds[0];
|
||||
if (!layerId) return undefined;
|
||||
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||
if (!layer || layer.locked || !layer.visible) return undefined;
|
||||
@@ -43,7 +43,7 @@ export async function updateBrushSession(options: {
|
||||
height: 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),
|
||||
color: options.color,
|
||||
color: state.editor.maskEditLayerId ? "#ffffff" : options.color,
|
||||
size: options.size,
|
||||
hardness: options.hardness,
|
||||
mode: options.session.mode,
|
||||
|
||||
Reference in New Issue
Block a user