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
+110 -2
View File
@@ -7,7 +7,21 @@ import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-det
type EditorSidebarProps = {
isDark: boolean;
tool: "pointer" | "hand" | "face";
tool: string;
fillColor?: string;
fillTolerance?: number;
onSetFillColor?: (color: string) => void;
onSetFillTolerance?: (tolerance: number) => void;
onCreateFillLayer?: () => void;
brushColor?: string;
brushSize?: number;
brushOpacity?: number;
brushHardness?: number;
onSetBrushColor?: (color: string) => void;
onSetBrushSize?: (size: number) => void;
onSetBrushOpacity?: (opacity: number) => void;
onSetBrushHardness?: (hardness: number) => void;
onAddLayer?: () => void;
layers: Layer[];
selectedLayerId: string | null;
selectedLayer: Layer | null;
@@ -22,6 +36,8 @@ type EditorSidebarProps = {
censorColor: string;
selectedFaceIndices: number[];
onSelectLayer: (layerId: string | null) => void;
onSetLayerVisible: (layerId: string, visible: boolean) => void;
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union
onMoveLayerOrder: (direction: "up" | "down") => void;
onRemoveSelectedLayer: () => void;
onUndo: () => void;
@@ -39,6 +55,20 @@ type EditorSidebarProps = {
export function EditorSidebar({
isDark,
tool,
fillColor,
fillTolerance,
onSetFillColor,
onSetFillTolerance,
onCreateFillLayer,
brushColor,
brushSize,
brushOpacity,
brushHardness,
onSetBrushColor,
onSetBrushSize,
onSetBrushOpacity,
onSetBrushHardness,
onAddLayer,
layers,
selectedLayerId,
selectedLayer,
@@ -53,6 +83,8 @@ export function EditorSidebar({
censorColor,
selectedFaceIndices,
onSelectLayer,
onSetLayerVisible,
onSetEffectEnabled,
onMoveLayerOrder,
onRemoveSelectedLayer,
onUndo,
@@ -74,10 +106,86 @@ export function EditorSidebar({
selectedLayerId={selectedLayerId}
isDark={isDark}
onSelectLayer={(layerId) => onSelectLayer(layerId)}
onSetLayerVisible={onSetLayerVisible}
onSetEffectEnabled={onSetEffectEnabled}
onMoveLayerOrder={onMoveLayerOrder}
onRemoveSelectedLayer={onRemoveSelectedLayer}
onAddLayer={onAddLayer}
/>
{tool === "fill" && onSetFillColor && onSetFillTolerance ? (
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
Fill
</p>
<div className="flex items-center gap-2 mb-3">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
<input
type="color"
value={fillColor ?? "#ff0000"}
onChange={(e) => onSetFillColor(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
/>
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{fillColor ?? "#ff0000"}</span>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Tolerance</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{fillTolerance ?? 32}</span>
</div>
<input
type="range"
min={0}
max={255}
value={fillTolerance ?? 32}
onChange={(e) => onSetFillTolerance(Number(e.target.value))}
className="w-full accent-[var(--color-accent-strong)]"
/>
</div>
</div>
) : null}
{tool === "brush" && onSetBrushColor && onSetBrushSize && onSetBrushOpacity && onSetBrushHardness ? (
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
Brush
</p>
<div className="flex items-center gap-2 mb-3">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
<input
type="color"
value={brushColor ?? "#000000"}
onChange={(e) => onSetBrushColor(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
/>
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{brushColor ?? "#000000"}</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Size</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{brushSize ?? 20}px</span>
</div>
<input type="range" min={1} max={200} value={brushSize ?? 20} onChange={(e) => onSetBrushSize(Number(e.target.value))} className="w-full accent-[var(--color-accent-strong)]" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Opacity</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushOpacity ?? 1) * 100)}%</span>
</div>
<input type="range" min={0} max={100} value={Math.round((brushOpacity ?? 1) * 100)} onChange={(e) => onSetBrushOpacity(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Hardness</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushHardness ?? 0.8) * 100)}%</span>
</div>
<input type="range" min={0} max={100} value={Math.round((brushHardness ?? 0.8) * 100)} onChange={(e) => onSetBrushHardness(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
</div>
</div>
</div>
) : null}
{tool === "face" ? (
<FacePanel
isDark={isDark}
@@ -89,7 +197,7 @@ export function EditorSidebar({
blurAmount={blurAmount}
censorColor={censorColor}
selectedFaceIndices={selectedFaceIndices}
hasActiveBlur={Boolean(selectedLayer && selectedLayer.type === "image" && selectedLayer.faceBlur)}
hasActiveBlur={Boolean(selectedLayer && selectedLayer.effects.some((e) => e.kind === "face-blur"))}
onSetBlurMethod={onSetBlurMethod}
onSetBlurAmount={onSetBlurAmount}
onSetCensorColor={onSetCensorColor}