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:
@@ -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 {
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { InteractionMode } from "@editor/tools";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
import type { CanvasInputState } from "./useCanvasInput";
|
||||
|
||||
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
|
||||
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false) {
|
||||
if (input.isPanning) return "cursor-grabbing";
|
||||
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
||||
if (interactionMode.type === "tool" && (interactionMode.tool === "crop" || interactionMode.tool === "brush" || interactionMode.tool === "eraser")) return "cursor-crosshair";
|
||||
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
|
||||
if (interactionMode.type === "tool" && interactionMode.tool === "crop") return "cursor-crosshair";
|
||||
return "cursor-default";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
pointerInputEventFromPointerEvent,
|
||||
wheelInputEventFromWheelEvent,
|
||||
} from "@input/index";
|
||||
import { beginBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
||||
import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
|
||||
|
||||
export type CanvasInputOptions = {
|
||||
globalKeybindConsumer: GlobalKeybindConsumer;
|
||||
@@ -50,9 +51,31 @@ export function useCanvasInput(
|
||||
isPanMode: () => isPanInteractionMode(store.getState().editor.tools.interactionMode),
|
||||
});
|
||||
|
||||
const clearBrushPreview = () => {
|
||||
if (store.getState().editor.brushPreview) store.dispatch(commandIds.toolSetBrushPreview, undefined);
|
||||
};
|
||||
|
||||
const updateBrushPreview = (position: { x: number; y: number }) => {
|
||||
if (!pointInsideCanvas(position, canvas)) {
|
||||
clearBrushPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
if (!canPreviewBrush(state.document, state.editor)) {
|
||||
clearBrushPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch(commandIds.toolSetBrushPreview, { position: viewportPointToDocumentPoint(position, state.editor.viewport) });
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event));
|
||||
if (consumed) event.preventDefault();
|
||||
if (consumed) {
|
||||
clearBrushPreview();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
@@ -64,6 +87,7 @@ export function useCanvasInput(
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
const transformed = transformHandler.pointerDown(inputEvent);
|
||||
if (transformed) {
|
||||
clearBrushPreview();
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -71,6 +95,7 @@ export function useCanvasInput(
|
||||
|
||||
const consumed = panHandler.pointerDown(inputEvent);
|
||||
if (consumed) {
|
||||
clearBrushPreview();
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
setIsPanning(true);
|
||||
event.preventDefault();
|
||||
@@ -78,10 +103,12 @@ export function useCanvasInput(
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
|
||||
const brush = (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
|
||||
? beginBrushSession(state.document, state.editor, viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport))
|
||||
? beginBrushSession(state.document, state.editor, documentPoint)
|
||||
: undefined;
|
||||
if (brush) {
|
||||
store.dispatch(commandIds.toolSetBrushPreview, { position: documentPoint });
|
||||
brushSessionId.current += 1;
|
||||
brushSession.current = brush;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
@@ -104,43 +131,53 @@ export function useCanvasInput(
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
if (brushSession.current) {
|
||||
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
|
||||
const session = brushSession.current;
|
||||
brushSessionId.current += 1;
|
||||
brushSession.current = undefined;
|
||||
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSessionId = brushSessionId.current;
|
||||
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
|
||||
const settings = store.getState().editor.tools.brush;
|
||||
void updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness }).then((nextSession) => {
|
||||
if (brushSessionId.current === activeSessionId) brushSession.current = nextSession;
|
||||
});
|
||||
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness });
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const transformed = transformHandler.pointerMove(inputEvent);
|
||||
if (transformed) {
|
||||
clearBrushPreview();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const consumed = panHandler.pointerMove(inputEvent);
|
||||
if (consumed) event.preventDefault();
|
||||
if (consumed) {
|
||||
clearBrushPreview();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
updateBrushPreview(inputEvent.position);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
const inputEvent = pointerInputEventFromPointerEvent(event);
|
||||
if (brushSession.current) {
|
||||
const session = brushSession.current;
|
||||
brushSessionId.current += 1;
|
||||
brushSession.current = undefined;
|
||||
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const transformed = transformHandler.pointerUp(inputEvent);
|
||||
if (transformed) {
|
||||
clearBrushPreview();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -149,9 +186,14 @@ export function useCanvasInput(
|
||||
if (!consumed) return;
|
||||
|
||||
setIsPanning(false);
|
||||
clearBrushPreview();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (!brushSession.current) clearBrushPreview();
|
||||
};
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
const consumed = handleViewportWheel({
|
||||
event: wheelInputEventFromWheelEvent(event),
|
||||
@@ -169,6 +211,7 @@ export function useCanvasInput(
|
||||
canvas.addEventListener("pointermove", handlePointerMove);
|
||||
canvas.addEventListener("pointerup", handlePointerUp);
|
||||
canvas.addEventListener("pointercancel", handlePointerUp);
|
||||
canvas.addEventListener("pointerleave", handlePointerLeave);
|
||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
@@ -178,6 +221,7 @@ export function useCanvasInput(
|
||||
canvas.removeEventListener("pointermove", handlePointerMove);
|
||||
canvas.removeEventListener("pointerup", handlePointerUp);
|
||||
canvas.removeEventListener("pointercancel", handlePointerUp);
|
||||
canvas.removeEventListener("pointerleave", handlePointerLeave);
|
||||
canvas.removeEventListener("wheel", handleWheel);
|
||||
};
|
||||
}, [canvasRef, options, store]);
|
||||
@@ -185,6 +229,10 @@ export function useCanvasInput(
|
||||
return { isPanning };
|
||||
}
|
||||
|
||||
function pointInsideCanvas(point: { x: number; y: number }, canvas: HTMLCanvasElement) {
|
||||
return point.x >= 0 && point.y >= 0 && point.x <= canvas.width && point.y <= canvas.height;
|
||||
}
|
||||
|
||||
function viewportPointToDocumentPoint(point: { x: number; y: number }, viewport: { center: { x: number; y: number }; size: { w: number; h: number }; zoom: number }) {
|
||||
return {
|
||||
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
|
||||
|
||||
Reference in New Issue
Block a user