mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: layer effects, bucket fill, simple brush
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user