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
+109 -32
View File
@@ -2,69 +2,146 @@
import type { Layer } from "@pien-studio/types";
import Image from "next/image";
import { Eye, EyeOff } from "lucide-react";
import { useTranslations } from "../../hooks/use-translations";
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
const EFFECT_LABELS: Record<string, string> = {
"face-blur": "Face Blur",
};
type Props = {
layers: Layer[];
selectedLayerId: string | null;
isDark: boolean;
onSelectLayer: (layerId: string) => void;
onSetLayerVisible: (layerId: string, visible: boolean) => void;
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void;
onMoveLayerOrder: (direction: "up" | "down") => void;
onRemoveSelectedLayer: () => void;
onAddLayer?: () => void;
};
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onMoveLayerOrder, onRemoveSelectedLayer }: Props) {
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onSetLayerVisible, onSetEffectEnabled, onMoveLayerOrder, onRemoveSelectedLayer, onAddLayer }: Props) {
const { t } = useTranslations();
return (
<div className={panelClass(isDark)}>
<div className="flex items-center justify-between">
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
{layers.length}
</span>
<div className="flex items-center gap-1.5">
{onAddLayer ? (
<button
type="button"
onClick={onAddLayer}
title={t("editor.newLayer")}
className={`rounded border px-1.5 py-0.5 text-[10px] font-semibold ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}
>
+ New
</button>
) : null}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
{layers.length}
</span>
</div>
</div>
<div className={`mt-3 max-h-[320px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
<div className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
{layers.slice().reverse().map((layer, idx) => {
const isSelected = layer.id === selectedLayerId;
const isImageLayer = layer.type === "image" || layer.type === "sticker";
const isRaster = layer.type === "raster" || layer.type === "sticker";
const hasEffects = layer.effects.length > 0;
const isHidden = layer.visible === false;
return (
<button
key={layer.id}
type="button"
onClick={() => onSelectLayer(layer.id)}
className={`group flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition ${
<div key={layer.id}>
<div className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
isSelected
? "bg-[var(--color-accent-strong)] text-white"
: isDark
? "text-[#d7dae0] hover:bg-white/10"
: "text-[#1f2430] hover:bg-black/5"
}`}
>
<div className="flex items-center gap-2">
<span className={`w-6 text-[10px] font-semibold ${isSelected ? "text-white/90" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>{idx + 1}</span>
<div className={`h-9 w-9 shrink-0 overflow-hidden rounded border ${isSelected ? "border-white/60 bg-white/10" : isDark ? "border-white/15 bg-[#1f2126]" : "border-black/10 bg-[#eef1f6]"}`}>
{isImageLayer && layer.sourceUri ? (
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={36} height={36} unoptimized className="h-full w-full object-cover" draggable={false} />
) : (
<div className={`flex h-full w-full items-center justify-center text-[9px] font-semibold uppercase tracking-wide ${isSelected ? "text-white/90" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
{layer.type}
</div>
)}
</div>
<div>
<p className="font-semibold">{layer.name ?? layer.type}</p>
<p className={`${isSelected ? "text-white/80" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>{layer.type}</p>
</div>
} ${isHidden ? "opacity-40" : ""}`}>
{/* Thumbnail */}
<button
type="button"
onClick={() => onSelectLayer(layer.id)}
className="shrink-0"
>
<div className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}>
{isRaster && layer.sourceUri ? (
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={40} height={40} unoptimized className="h-full w-full object-cover" draggable={false} />
) : (
<div className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
{layer.type}
</div>
)}
</div>
</button>
{/* Info */}
<button type="button" onClick={() => onSelectLayer(layer.id)} className="min-w-0 flex-1 text-left">
<p className="truncate font-semibold leading-tight">{layer.name ?? layer.type}</p>
<p className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>
{layer.type}
{layer.opacity < 1 ? ` · ${Math.round(layer.opacity * 100)}%` : ""}
</p>
</button>
{/* Visibility toggle */}
<button
type="button"
title={isHidden ? "Show layer" : "Hide layer"}
onClick={(e) => { e.stopPropagation(); onSetLayerVisible(layer.id, !isHidden); }}
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
>
{isHidden
? <EyeOff className="h-3.5 w-3.5" />
: <Eye className="h-3.5 w-3.5" />}
</button>
{/* Layer index */}
<span className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
{layers.length - idx}
</span>
</div>
<span className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : isDark ? "bg-[#3d424c]" : "bg-[#d4d8e0]"}`} />
</button>
{/* Effects chips */}
{hasEffects ? (
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
{layer.effects.map((effect) => {
const isDisabled = effect.enabled === false;
return (
<button
key={effect.kind}
type="button"
title={isDisabled ? "Enable effect" : "Disable effect"}
onClick={() => onSetEffectEnabled(layer.id, effect.kind, isDisabled)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
isDisabled
? isDark ? "bg-white/10 text-[#6b7280] line-through" : "bg-black/5 text-[#9ca3af] line-through"
: isSelected
? "bg-white/20 text-white"
: isDark
? "bg-[var(--color-accent-strong)]/20 text-[var(--color-accent-strong)]"
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
}`}
>
{isDisabled ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
{EFFECT_LABELS[effect.kind] ?? effect.kind}
</button>
);
})}
</div>
) : null}
</div>
);
})}
{layers.length === 0 ? <div className={`px-2 py-6 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.noLayersYet")}</div> : null}
{layers.length === 0 ? (
<div className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
{t("editor.noLayersYet")}
</div>
) : null}
</div>
<div className="mt-3 flex gap-2">
<div className="mt-2 flex gap-1.5">
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
<button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button>
<button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button>