2026-05-10 03:06:54 +07:00
|
|
|
import React from "react";
|
|
|
|
|
import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types";
|
|
|
|
|
import { FacePanel } from "./face-panel";
|
|
|
|
|
import { HistoryPanel } from "./history-panel";
|
|
|
|
|
import { LayersPanel } from "./layers-panel";
|
|
|
|
|
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
|
|
|
|
|
|
|
|
|
|
type EditorSidebarProps = {
|
|
|
|
|
isDark: boolean;
|
2026-05-21 00:02:12 +07:00
|
|
|
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;
|
2026-05-10 03:06:54 +07:00
|
|
|
layers: Layer[];
|
|
|
|
|
selectedLayerId: string | null;
|
|
|
|
|
selectedLayer: Layer | null;
|
|
|
|
|
history: { past: Project[]; future: Project[] };
|
|
|
|
|
canUndo: boolean;
|
|
|
|
|
canRedo: boolean;
|
|
|
|
|
faceDetections: FaceDetectionOverlay[];
|
|
|
|
|
facePreviews: FacePreview[];
|
|
|
|
|
faceStatus: "idle" | "detecting" | "unsupported";
|
|
|
|
|
blurMethod: FaceBlurMethod;
|
|
|
|
|
blurAmount: number;
|
|
|
|
|
censorColor: string;
|
|
|
|
|
selectedFaceIndices: number[];
|
|
|
|
|
onSelectLayer: (layerId: string | null) => void;
|
2026-05-21 00:02:12 +07:00
|
|
|
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
|
|
|
|
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union
|
2026-05-10 03:06:54 +07:00
|
|
|
onMoveLayerOrder: (direction: "up" | "down") => void;
|
|
|
|
|
onRemoveSelectedLayer: () => void;
|
|
|
|
|
onUndo: () => void;
|
|
|
|
|
onRedo: () => void;
|
|
|
|
|
onJumpToPast: (idx: number) => void;
|
|
|
|
|
onJumpToFuture: (idx: number) => void;
|
|
|
|
|
onSetBlurMethod: (method: FaceBlurMethod) => void;
|
|
|
|
|
onSetBlurAmount: (amount: number) => void;
|
|
|
|
|
onSetCensorColor: (color: string) => void;
|
|
|
|
|
onToggleFaceIndex: (index: number) => void;
|
|
|
|
|
onBlur: (indices: number[]) => void;
|
|
|
|
|
onClearBlur: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function EditorSidebar({
|
|
|
|
|
isDark,
|
|
|
|
|
tool,
|
2026-05-21 00:02:12 +07:00
|
|
|
fillColor,
|
|
|
|
|
fillTolerance,
|
|
|
|
|
onSetFillColor,
|
|
|
|
|
onSetFillTolerance,
|
|
|
|
|
onCreateFillLayer,
|
|
|
|
|
brushColor,
|
|
|
|
|
brushSize,
|
|
|
|
|
brushOpacity,
|
|
|
|
|
brushHardness,
|
|
|
|
|
onSetBrushColor,
|
|
|
|
|
onSetBrushSize,
|
|
|
|
|
onSetBrushOpacity,
|
|
|
|
|
onSetBrushHardness,
|
|
|
|
|
onAddLayer,
|
2026-05-10 03:06:54 +07:00
|
|
|
layers,
|
|
|
|
|
selectedLayerId,
|
|
|
|
|
selectedLayer,
|
|
|
|
|
history,
|
|
|
|
|
canUndo,
|
|
|
|
|
canRedo,
|
|
|
|
|
faceDetections,
|
|
|
|
|
facePreviews,
|
|
|
|
|
faceStatus,
|
|
|
|
|
blurMethod,
|
|
|
|
|
blurAmount,
|
|
|
|
|
censorColor,
|
|
|
|
|
selectedFaceIndices,
|
|
|
|
|
onSelectLayer,
|
2026-05-21 00:02:12 +07:00
|
|
|
onSetLayerVisible,
|
|
|
|
|
onSetEffectEnabled,
|
2026-05-10 03:06:54 +07:00
|
|
|
onMoveLayerOrder,
|
|
|
|
|
onRemoveSelectedLayer,
|
|
|
|
|
onUndo,
|
|
|
|
|
onRedo,
|
|
|
|
|
onJumpToPast,
|
|
|
|
|
onJumpToFuture,
|
|
|
|
|
onSetBlurMethod,
|
|
|
|
|
onSetBlurAmount,
|
|
|
|
|
onSetCensorColor,
|
|
|
|
|
onToggleFaceIndex,
|
|
|
|
|
onBlur,
|
|
|
|
|
onClearBlur,
|
|
|
|
|
}: EditorSidebarProps) {
|
|
|
|
|
return (
|
|
|
|
|
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<LayersPanel
|
|
|
|
|
layers={layers}
|
|
|
|
|
selectedLayerId={selectedLayerId}
|
|
|
|
|
isDark={isDark}
|
|
|
|
|
onSelectLayer={(layerId) => onSelectLayer(layerId)}
|
2026-05-21 00:02:12 +07:00
|
|
|
onSetLayerVisible={onSetLayerVisible}
|
|
|
|
|
onSetEffectEnabled={onSetEffectEnabled}
|
2026-05-10 03:06:54 +07:00
|
|
|
onMoveLayerOrder={onMoveLayerOrder}
|
|
|
|
|
onRemoveSelectedLayer={onRemoveSelectedLayer}
|
2026-05-21 00:02:12 +07:00
|
|
|
onAddLayer={onAddLayer}
|
2026-05-10 03:06:54 +07:00
|
|
|
/>
|
|
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
{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}
|
|
|
|
|
|
2026-05-10 03:06:54 +07:00
|
|
|
{tool === "face" ? (
|
|
|
|
|
<FacePanel
|
|
|
|
|
isDark={isDark}
|
|
|
|
|
selectedLayer={selectedLayer}
|
|
|
|
|
faceDetections={faceDetections}
|
|
|
|
|
facePreviews={facePreviews}
|
|
|
|
|
faceStatus={faceStatus}
|
|
|
|
|
blurMethod={blurMethod}
|
|
|
|
|
blurAmount={blurAmount}
|
|
|
|
|
censorColor={censorColor}
|
|
|
|
|
selectedFaceIndices={selectedFaceIndices}
|
2026-05-21 00:02:12 +07:00
|
|
|
hasActiveBlur={Boolean(selectedLayer && selectedLayer.effects.some((e) => e.kind === "face-blur"))}
|
2026-05-10 03:06:54 +07:00
|
|
|
onSetBlurMethod={onSetBlurMethod}
|
|
|
|
|
onSetBlurAmount={onSetBlurAmount}
|
|
|
|
|
onSetCensorColor={onSetCensorColor}
|
|
|
|
|
onToggleFaceIndex={onToggleFaceIndex}
|
|
|
|
|
onBlur={onBlur}
|
|
|
|
|
onClearBlur={onClearBlur}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<HistoryPanel
|
|
|
|
|
history={history}
|
|
|
|
|
isDark={isDark}
|
|
|
|
|
canUndo={canUndo}
|
|
|
|
|
canRedo={canRedo}
|
|
|
|
|
onUndo={onUndo}
|
|
|
|
|
onRedo={onRedo}
|
|
|
|
|
onJumpToPast={onJumpToPast}
|
|
|
|
|
onJumpToFuture={onJumpToFuture}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
}
|