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:
syntaxbullet
2026-07-03 20:49:29 +02:00
parent daaa2a6667
commit 4ad0bb8b2c
33 changed files with 1889 additions and 351 deletions

View File

@@ -9,48 +9,88 @@ import type { AppStore } from "@editor/store";
export type BrushSession = {
layerId: string;
assetId: string;
previousPoint: Vec2D;
mode: "brush" | "eraser";
source?: string;
pending?: Promise<void>;
cancelled?: boolean;
};
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.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;
return { layerId, previousPoint: point, mode: editor.tools.activeTool };
const layer = resolveBrushTargetLayer(document, editor);
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
}
export async function updateBrushSession(options: {
export function canPreviewBrush(document: ImageDocument, editor: EditorState): boolean {
return Boolean(resolveBrushTargetLayer(document, editor));
}
function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const editingMask = Boolean(editor.maskEdit);
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
return layer;
}
export function updateBrushSession(options: {
store: AppStore;
session: BrushSession;
point: Vec2D;
color: string;
size: number;
hardness: number;
}): Promise<BrushSession> {
}): BrushSession {
const state = options.store.getState();
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
if (!layer) return { ...options.session, previousPoint: options.point };
if (!layer) return options.session;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return { ...options.session, previousPoint: options.point };
if (!asset) return options.session;
const source = await drawStroke({
source: asset.source,
width: asset.intrinsicSize.w,
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: state.editor.maskEditLayerId ? "#ffffff" : options.color,
size: options.size,
hardness: options.hardness,
mode: options.session.mode,
});
const from = options.session.previousPoint;
const to = options.point;
options.session.previousPoint = to;
options.session.pending = (options.session.pending ?? Promise.resolve())
.then(async () => {
if (options.session.cancelled) return;
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: asset.id, source });
return { ...options.session, previousPoint: options.point };
const source = await drawStroke({
source: options.session.source ?? asset.source,
width: asset.intrinsicSize.w,
height: asset.intrinsicSize.h,
from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
color: state.editor.maskEdit ? "#ffffff" : options.color,
size: options.size,
hardness: options.hardness,
mode: options.session.mode,
});
if (options.session.cancelled) return;
options.session.source = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
})
.catch(() => undefined);
return options.session;
}
export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) {
await options.session.pending;
if (options.session.cancelled) return;
if (options.session.source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source: options.session.source });
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
}
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {