feat: layer effects, bucket fill, simple brush

This commit is contained in:
2026-05-21 00:02:12 +07:00
parent bcf4650c70
commit e7dd9420e7
47 changed files with 1675 additions and 1038 deletions
+61 -10
View File
@@ -1,5 +1,6 @@
import React from "react";
import type { Layer } from "@pien-studio/types";
import { getToolDefinition } from "@pien-studio/editor-core";
type Viewport = {
x: number;
@@ -10,7 +11,7 @@ type Viewport = {
type UseCanvasInteractionsOptions = {
canvasWidth: number;
canvasHeight: number;
tool: "pointer" | "hand" | "face";
tool: string;
onSelectLayer: (id: string | null) => void;
onMoveLayer: (id: string, x: number, y: number) => void;
onMoveLayerEnd?: (id: string, x: number, y: number) => void;
@@ -21,6 +22,10 @@ type UseCanvasInteractionsOptions = {
onInteractionStart?: () => void;
onInteractionEnd?: () => void;
onContextMenu?: (x: number, y: number) => void;
onFillLayer?: (layerId: string, x: number, y: number) => void;
onBrushStrokeStart?: (layerId: string, x: number, y: number, layerWidth: number, layerHeight: number) => void;
onBrushStrokeMove?: (layerId: string, x: number, y: number) => void;
onBrushStrokeEnd?: (layerId: string) => void;
};
const MIN_SCALE = 0.1;
@@ -43,6 +48,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onInteractionStart,
onInteractionEnd,
onContextMenu,
onFillLayer,
onBrushStrokeStart,
onBrushStrokeMove,
onBrushStrokeEnd,
} = options;
const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 });
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -86,6 +95,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
startRotation: number;
lastRotation: number;
} | null>(null);
const brushRef = React.useRef<{ id: string; lastX: number; lastY: number; rect: DOMRect } | null>(null);
const pinchRef = React.useRef<{
active: boolean;
initialPinchPx: number;
@@ -237,10 +248,18 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
return;
}
if (e.button !== 0) return;
if (tool !== "hand" && tool !== "face" && !isSpacePan) return;
e.currentTarget.setPointerCapture(e.pointerId);
isPanning.current = true;
lastPos.current = { x: e.clientX, y: e.clientY };
const toolDef = getToolDefinition(tool);
if (toolDef?.interactionMode !== "select" && !isSpacePan) {
e.currentTarget.setPointerCapture(e.pointerId);
isPanning.current = true;
lastPos.current = { x: e.clientX, y: e.clientY };
return;
}
if (isSpacePan) {
e.currentTarget.setPointerCapture(e.pointerId);
isPanning.current = true;
lastPos.current = { x: e.clientX, y: e.clientY };
}
}
function onContainerPointerMove(e: React.PointerEvent<HTMLDivElement>) {
@@ -251,7 +270,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy }));
return;
}
if (tool === "pointer" && rotateRef.current && onRotateLayer) {
if (brushRef.current && onBrushStrokeMove) {
const { rect } = brushRef.current;
const localX = (e.clientX - rect.left) / viewport.scale;
const localY = (e.clientY - rect.top) / viewport.scale;
brushRef.current.lastX = localX;
brushRef.current.lastY = localY;
onBrushStrokeMove(brushRef.current.id, localX, localY);
return;
}
const toolDef = getToolDefinition(tool);
if (toolDef?.allowsLayerRotate && rotateRef.current && onRotateLayer) {
const { centerX, centerY, startAngle, startRotation, id } = rotateRef.current;
const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
const delta = currentAngle - startAngle;
@@ -260,7 +289,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onRotateLayer(id, nextRotation);
return;
}
if (tool === "pointer" && resizeRef.current && onResizeLayer) {
if (toolDef?.allowsLayerResize && resizeRef.current && onResizeLayer) {
const dx = (e.clientX - resizeRef.current.startX) / viewport.scale;
const dy = (e.clientY - resizeRef.current.startY) / viewport.scale;
const corner = resizeRef.current.corner;
@@ -315,7 +344,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
});
return;
}
if (tool === "pointer" && dragRef.current) {
if (toolDef?.allowsLayerDrag && dragRef.current) {
const dx = (e.clientX - dragRef.current.startEventX) / viewport.scale;
const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale;
const nextX = dragRef.current.startLayerX + dx;
@@ -362,6 +391,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
const { id, lastRotation } = rotateRef.current;
if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation);
}
if (brushRef.current && onBrushStrokeEnd) {
onBrushStrokeEnd(brushRef.current.id);
brushRef.current = null;
}
dragRef.current = null;
resizeRef.current = null;
resizeMovePendingRef.current = null;
@@ -372,6 +405,24 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
if (isSpacePan) return;
e.stopPropagation();
const toolDef = getToolDefinition(tool);
if (toolDef?.interactionMode === "paint") {
const rect = e.currentTarget.getBoundingClientRect();
const localX = (e.clientX - rect.left) / viewport.scale;
const localY = (e.clientY - rect.top) / viewport.scale;
onSelectLayer(layer.id);
if (tool === "brush" && onBrushStrokeStart) {
e.currentTarget.setPointerCapture(e.pointerId);
brushRef.current = { id: layer.id, lastX: localX, lastY: localY, rect: e.currentTarget.getBoundingClientRect() };
const lw = layer.width ?? Math.round(200 * layer.scale);
const lh = layer.height ?? Math.round(150 * layer.scale);
onBrushStrokeStart(layer.id, localX, localY, lw, lh);
beginInteraction();
} else if (onFillLayer) {
onFillLayer(layer.id, localX, localY);
}
return;
}
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = {
id: layer.id,
@@ -387,7 +438,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
if (tool !== "pointer" || !onResizeLayer) return;
if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
rotateRef.current = null;
@@ -411,7 +462,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
if (tool !== "pointer" || !onRotateLayer) return;
if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
resizeRef.current = null;