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
+92 -4
View File
@@ -2,7 +2,7 @@
import React from "react";
import { useParams } from "next/navigation";
import { MousePointer2, Hand, ScanFace, Type, ImagePlus } from "lucide-react";
import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react";
import { useEditorStore } from "../../../store/editor-store";
import { useUiStore, isDarkTheme } from "../../../store/ui-store";
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
@@ -12,6 +12,8 @@ import { EditorMobileSection } from "../../../components/editor/editor-mobile-se
import { EditorSidebar } from "../../../components/editor/editor-sidebar";
import { ToolRail } from "../../../components/editor/tool-rail";
import { exportProjectAsPng } from "../../../lib/export-png";
import { floodFillDataUrl } from "../../../lib/flood-fill";
import { commitStroke, type BrushStroke } from "../../../lib/brush-painter";
import { createEditorToolControllers } from "../../../lib/editor-tool-controller";
import { useFaceDetection } from "../../../hooks/use-face-detection";
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
@@ -29,6 +31,8 @@ const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
pointer: MousePointer2,
hand: Hand,
face: ScanFace,
fill: PaintBucket,
brush: Brush,
"add-text": Type,
"import-image": ImagePlus,
};
@@ -64,8 +68,13 @@ export default function EditorPage() {
removeSelectedLayer,
addLayerByType,
moveSelectedLayerOrder,
addCanvasSizedLayer,
importImageFromFile,
setImageLayerFaceBlur,
updateImageLayerSource,
setLayerEffect,
removeLayerEffect,
setLayerVisible,
setEffectEnabled,
setCanvasSize,
setTool,
undo,
@@ -83,11 +92,17 @@ export default function EditorPage() {
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
const [fillColor, setFillColor] = React.useState("#ff0000");
const [fillTolerance, setFillTolerance] = React.useState(32);
const [brushColor, setBrushColor] = React.useState("#000000");
const [brushSize, setBrushSize] = React.useState(20);
const [brushOpacity, setBrushOpacity] = React.useState(1);
const [brushHardness, setBrushHardness] = React.useState(0.8);
const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
const selectedImageLayer = React.useMemo(() => {
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) {
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) {
return null;
}
return {
@@ -126,7 +141,8 @@ export default function EditorPage() {
selectedLayer,
faceDetectionsLayerId,
faceDetections,
setImageLayerFaceBlur,
setLayerEffect,
removeLayerEffect,
});
React.useEffect(() => {
@@ -174,6 +190,8 @@ export default function EditorPage() {
useEditorShortcuts({
onSave: handleSave,
onUndo: undo,
onRedo: redo,
onCopy: copySelectedLayer,
onCut: cutSelectedLayer,
onPaste: pasteLayer,
@@ -204,6 +222,55 @@ export default function EditorPage() {
setCanvasSize(width, height);
}
const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => {
const layer = project.layers.find((l) => l.id === layerId);
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
const layerWidth = layer.width ?? Math.round(200 * layer.scale);
const layerHeight = layer.height ?? Math.round(150 * layer.scale);
// x/y are in layer CSS-pixel space; scale to image pixel space
const img = new Image();
const uri = layer.sourceUri;
const color = fillColor;
const tolerance = fillTolerance;
img.onload = () => {
const scaleX = img.naturalWidth / layerWidth;
const scaleY = img.naturalHeight / layerHeight;
const pixelX = x * scaleX;
const pixelY = y * scaleY;
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => {
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
}).catch(() => {});
};
img.src = uri;
}, [project.layers, fillColor, fillTolerance, updateImageLayerSource]);
const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => {
const layer = project.layers.find((l) => l.id === layerId);
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
try {
const nextUri = await commitStroke(layer.sourceUri, stroke);
updateImageLayerSource(layerId, nextUri);
} catch {}
}, [project.layers, updateImageLayerSource]);
const handleCreateFillLayer = React.useCallback(() => {
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = fillColor;
ctx.fillRect(0, 0, cw, ch);
addCanvasSizedLayer(canvas.toDataURL("image/png"), "Fill Layer");
}, [cw, ch, fillColor, addCanvasSizedLayer]);
const handleAddLayer = React.useCallback(() => {
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
addCanvasSizedLayer(canvas.toDataURL("image/png"), "Layer");
}, [cw, ch, addCanvasSizedLayer]);
const toolControllers = React.useMemo(
() =>
createEditorToolControllers({
@@ -213,6 +280,8 @@ export default function EditorPage() {
pointer: t("editor.toolPointer"),
pan: t("editor.toolPan"),
face: t("editor.toolFace"),
fill: t("editor.toolFill"),
brush: t("editor.toolBrush"),
text: t("editor.toolText"),
image: t("editor.toolImage"),
},
@@ -295,11 +364,28 @@ export default function EditorPage() {
onCopy={copySelectedLayer}
onCut={cutSelectedLayer}
onPaste={pasteLayer}
onFillLayer={handleFillLayer}
brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }}
onBrushCommit={handleBrushCommit}
/>
<EditorSidebar
isDark={isDark}
tool={tool}
fillColor={fillColor}
fillTolerance={fillTolerance}
onSetFillColor={setFillColor}
onSetFillTolerance={setFillTolerance}
onCreateFillLayer={handleCreateFillLayer}
brushColor={brushColor}
brushSize={brushSize}
brushOpacity={brushOpacity}
brushHardness={brushHardness}
onSetBrushColor={setBrushColor}
onSetBrushSize={setBrushSize}
onSetBrushOpacity={setBrushOpacity}
onSetBrushHardness={setBrushHardness}
onAddLayer={handleAddLayer}
layers={project.layers}
selectedLayerId={selectedLayerId}
selectedLayer={selectedLayer}
@@ -314,6 +400,8 @@ export default function EditorPage() {
censorColor={censorColor}
selectedFaceIndices={selectedFaceIndices}
onSelectLayer={selectLayer}
onSetLayerVisible={setLayerVisible}
onSetEffectEnabled={(layerId, kind, enabled) => setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)}
onMoveLayerOrder={moveSelectedLayerOrder}
onRemoveSelectedLayer={removeSelectedLayer}
onUndo={undo}