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;
+6 -1
View File
@@ -32,8 +32,13 @@ export function useEditorBindings() {
removeSelectedLayer: s.removeSelectedLayer,
addLayerByType: s.addLayerByType,
moveSelectedLayerOrder: s.moveSelectedLayerOrder,
addCanvasSizedLayer: s.addCanvasSizedLayer,
importImageFromFile: s.importImageFromFile,
setImageLayerFaceBlur: s.setImageLayerFaceBlur,
updateImageLayerSource: s.updateImageLayerSource,
setLayerEffect: s.setLayerEffect,
removeLayerEffect: s.removeLayerEffect,
setLayerVisible: s.setLayerVisible,
setEffectEnabled: s.setEffectEnabled,
setCanvasSize: s.setCanvasSize,
setTool: s.setTool,
undo: s.undo,
+43 -11
View File
@@ -2,14 +2,20 @@ import React from "react";
type UseEditorShortcutsOptions = {
onSave: () => void;
onUndo: () => void;
onRedo: () => void;
onCopy: () => void;
onCut: () => void;
onPaste: () => void;
onPaste: (e?: ClipboardEvent) => void;
onDelete: () => void;
};
function isEditableTarget(e: Event) {
return e.target instanceof HTMLElement && /^(input|textarea|select)$/i.test(e.target.tagName);
}
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
const { onSave, onCopy, onCut, onPaste, onDelete } = options;
const { onSave, onUndo, onRedo, onCopy, onCut, onPaste, onDelete } = options;
React.useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
@@ -19,31 +25,57 @@ export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
onSave();
return;
}
if (e.key.toLowerCase() === "c") {
if (e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault();
onCopy();
onUndo();
return;
}
if (e.key.toLowerCase() === "x") {
if (e.key.toLowerCase() === "z" && e.shiftKey) {
e.preventDefault();
onCut();
onRedo();
return;
}
if (e.key.toLowerCase() === "v") {
if (e.key.toLowerCase() === "y") {
e.preventDefault();
onPaste();
onRedo();
return;
}
}
if (e.key === "Delete" || e.key === "Backspace") {
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
if (isEditableTarget(e)) return;
e.preventDefault();
onDelete();
}
}
function handleCopy(e: ClipboardEvent) {
if (isEditableTarget(e)) return;
e.preventDefault();
onCopy();
}
function handleCut(e: ClipboardEvent) {
if (isEditableTarget(e)) return;
e.preventDefault();
onCut();
}
function handlePaste(e: ClipboardEvent) {
if (isEditableTarget(e)) return;
e.preventDefault();
onPaste(e);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onCopy, onCut, onDelete, onPaste, onSave]);
window.addEventListener("copy", handleCopy);
window.addEventListener("cut", handleCut);
window.addEventListener("paste", handlePaste);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("copy", handleCopy);
window.removeEventListener("cut", handleCut);
window.removeEventListener("paste", handlePaste);
};
}, [onCopy, onCut, onDelete, onPaste, onRedo, onSave, onUndo]);
}
+29 -14
View File
@@ -1,25 +1,28 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Layer } from "@pien-studio/types";
import type { Layer, LayerEffect } from "@pien-studio/types";
import { useFaceBlurWorkflow } from "./use-face-blur-workflow";
function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
return {
id: "layer-1",
type: "image",
type: "raster",
sourceUri: "data:image/png;base64,abc",
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
...overrides,
};
}
describe("useFaceBlurWorkflow", () => {
it("selects all detected faces by default when no existing face blur", async () => {
const setImageLayerFaceBlur = vi.fn();
const setLayerEffect = vi.fn();
const removeLayerEffect = vi.fn();
const selectedLayer = makeImageLayer();
const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
@@ -31,28 +34,37 @@ describe("useFaceBlurWorkflow", () => {
selectedLayer,
faceDetectionsLayerId: "layer-1",
faceDetections,
setImageLayerFaceBlur,
setLayerEffect,
removeLayerEffect,
}),
);
await waitFor(() => {
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
expect(result.current.faceBlurPreview?.regions).toHaveLength(2);
const faceBlurEffect = result.current.faceBlurPreview?.effects.find((e) => e.kind === "face-blur");
expect(faceBlurEffect?.kind === "face-blur" ? faceBlurEffect.regions : undefined).toHaveLength(2);
});
});
it("keeps selection empty when selected image already has blur", async () => {
const setImageLayerFaceBlur = vi.fn();
const selectedLayer = makeImageLayer({
faceBlur: { method: "gaussian", amount: 14, regions: [{ x: 0, y: 0, width: 4, height: 4 }] },
});
const setLayerEffect = vi.fn();
const removeLayerEffect = vi.fn();
const faceBlurEffect: LayerEffect = {
kind: "face-blur",
enabled: true,
method: "gaussian",
amount: 14,
regions: [{ x: 0, y: 0, width: 4, height: 4 }],
};
const selectedLayer = makeImageLayer({ effects: [faceBlurEffect] });
const { result } = renderHook(() =>
useFaceBlurWorkflow({
selectedLayer,
faceDetectionsLayerId: "layer-1",
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }],
setImageLayerFaceBlur,
setLayerEffect,
removeLayerEffect,
}),
);
@@ -63,7 +75,8 @@ describe("useFaceBlurWorkflow", () => {
});
it("toggles face selection and applies blur to selected regions", async () => {
const setImageLayerFaceBlur = vi.fn();
const setLayerEffect = vi.fn();
const removeLayerEffect = vi.fn();
const selectedLayer = makeImageLayer();
const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
@@ -75,7 +88,8 @@ describe("useFaceBlurWorkflow", () => {
selectedLayer,
faceDetectionsLayerId: "layer-1",
faceDetections,
setImageLayerFaceBlur,
setLayerEffect,
removeLayerEffect,
}),
);
@@ -93,10 +107,11 @@ describe("useFaceBlurWorkflow", () => {
result.current.blurFaces(result.current.selectedFaceIndices);
});
expect(setImageLayerFaceBlur).toHaveBeenCalledTimes(1);
expect(setImageLayerFaceBlur).toHaveBeenCalledWith(
expect(setLayerEffect).toHaveBeenCalledTimes(1);
expect(setLayerEffect).toHaveBeenCalledWith(
"layer-1",
expect.objectContaining({
kind: "face-blur",
method: "gaussian",
amount: 14,
regions: expect.arrayContaining([
+31 -22
View File
@@ -1,34 +1,37 @@
import React from "react";
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
import type { FaceBlurMethod, Layer, LayerEffect } from "@pien-studio/types";
import type { FaceDetectionOverlay } from "./use-face-detection";
type FaceBlurPreview = {
layerId: string;
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number; sourceWidth: number; sourceHeight: number; censorColor?: string }[];
censorColor?: string;
effects: LayerEffect[];
};
type UseFaceBlurWorkflowOptions = {
selectedLayer: Layer | null;
faceDetectionsLayerId: string | null;
faceDetections: FaceDetectionOverlay[];
setImageLayerFaceBlur: (layerId: string, faceBlur: Layer["faceBlur"] | undefined) => void;
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
};
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options;
const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options;
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
const [blurAmount, setBlurAmount] = React.useState(14);
const [censorColor, setCensorColor] = React.useState("#111111");
const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null);
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "image" && faceDetectionsLayerId === selectedLayer.id);
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
const faceBlurEffect = React.useMemo(
() => selectedLayer?.effects.find((e): e is Extract<LayerEffect, { kind: "face-blur" }> => e.kind === "face-blur") ?? null,
[selectedLayer?.effects],
);
const buildBlurRegions = React.useCallback(
(indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "image") return [];
if (!selectedLayer || selectedLayer.type !== "raster") return [];
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return [];
const indexSet = new Set(indices);
return faceDetections
@@ -57,17 +60,19 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
}, []);
const clearBlur = React.useCallback(() => {
if (!selectedLayer || selectedLayer.type !== "image") return;
setImageLayerFaceBlur(selectedLayer.id, undefined);
if (!selectedLayer) return;
removeLayerEffect(selectedLayer.id, "face-blur");
setFaceBlurPreview(null);
}, [selectedLayer, setImageLayerFaceBlur]);
}, [selectedLayer, removeLayerEffect]);
const blurFaces = React.useCallback(
(indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) return;
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return;
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
const regions = buildBlurRegions(indices);
setImageLayerFaceBlur(selectedLayer.id, {
setLayerEffect(selectedLayer.id, {
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions,
@@ -76,7 +81,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setSelectedFaceIndices([]);
setFaceBlurPreview(null);
},
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur],
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setLayerEffect],
);
React.useEffect(() => {
@@ -85,7 +90,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setFaceBlurPreview(null);
return;
}
if (selectedLayer?.faceBlur) {
if (faceBlurEffect) {
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
return;
}
@@ -94,10 +99,10 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
return next;
});
}, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]);
}, [faceDetections, hasDetectableSelection, faceBlurEffect]);
React.useEffect(() => {
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") {
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
setFaceBlurPreview(null);
return;
}
@@ -109,10 +114,14 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setFaceBlurPreview({
layerId: selectedLayer.id,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
effects: [{
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
}],
});
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);