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
+3 -1
View File
@@ -26,7 +26,7 @@ describe("CanvasRenderer", () => {
it("keeps the rotation handle interactive", () => {
const layer: Layer = {
id: "layer-1",
type: "image",
type: "raster",
sourceUri: "data:image/png;base64,test",
x: 0,
y: 0,
@@ -35,6 +35,8 @@ describe("CanvasRenderer", () => {
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
};
const onRotateLayer = vi.fn();
+89 -37
View File
@@ -8,10 +8,13 @@ import {
CANVAS_ROTATE_HANDLE_BASE_SIZE,
} from "../lib/editor-constants";
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
import { renderImageWithFaceBlur } from "../lib/face-blur-renderer";
import { renderLayerWithEffects } from "../lib/effects/registry";
import { getToolUiDefinition } from "../lib/tools/registry";
import { getToolDefinition } from "@pien-studio/editor-core";
import { useCanvasInteractions } from "../hooks/use-canvas-interactions";
import { useTranslations } from "../hooks/use-translations";
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
import { createStroke, paintSegment, type BrushStroke, type BrushOptions } from "../lib/brush-painter";
import type { Layer, LayerEffect } from "@pien-studio/types";
interface CanvasRendererProps {
layers: Layer[];
@@ -29,7 +32,10 @@ interface CanvasRendererProps {
onInteractionEnd?: () => void;
onContextMenu?: (x: number, y: number) => void;
isDark: boolean;
tool?: "pointer" | "hand" | "face";
tool?: string;
onFillLayer?: (layerId: string, x: number, y: number) => void;
brushOptions?: BrushOptions;
onBrushCommit?: (layerId: string, stroke: BrushStroke) => void;
faceDetections?: {
x: number;
y: number;
@@ -40,49 +46,57 @@ interface CanvasRendererProps {
faceOverlayLayerId?: string | null;
faceBlurPreview?: {
layerId: string;
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number }[];
effects: LayerEffect[];
} | null;
}
function BlurredImageLayer({
function BrushOverlayCanvas({ stroke, width, height }: { stroke: HTMLCanvasElement; width: number; height: number }) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
React.useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const ctx = el.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, el.width, el.height);
ctx.drawImage(stroke, 0, 0, el.width, el.height);
});
return (
<canvas
ref={canvasRef}
width={Math.max(1, Math.round(width))}
height={Math.max(1, Math.round(height))}
className="pointer-events-none absolute inset-0 h-full w-full rounded"
/>
);
}
function EffectImageLayer({
layer,
width,
height,
faceBlurOverride,
effectsOverride,
}: {
layer: Layer;
width: number;
height: number;
faceBlurOverride?: {
method: FaceBlurMethod;
amount: number;
regions: {
x: number;
y: number;
width: number;
height: number;
censorColor?: string;
}[];
censorColor?: string;
} | null;
effectsOverride?: LayerEffect[] | null;
}) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const imageRef = React.useRef<HTMLImageElement | null>(null);
const activeEffects = effectsOverride ?? layer.effects;
const draw = React.useCallback(() => {
const canvas = canvasRef.current;
const image = imageRef.current;
if (!canvas || !image) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const cw = canvas.width;
const ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
const blur = faceBlurOverride ?? layer.faceBlur;
renderImageWithFaceBlur(ctx, image, blur, cw, ch);
}, [faceBlurOverride, layer.faceBlur]);
ctx.clearRect(0, 0, canvas.width, canvas.height);
renderLayerWithEffects(ctx, image, activeEffects, canvas.width, canvas.height);
}, [activeEffects]);
React.useEffect(() => {
if (!layer.sourceUri) return;
@@ -129,6 +143,9 @@ export function CanvasRenderer({
onInteractionStart,
onInteractionEnd,
onContextMenu,
onFillLayer,
brushOptions,
onBrushCommit,
isDark,
tool = "pointer",
faceDetections = [],
@@ -136,6 +153,36 @@ export function CanvasRenderer({
faceBlurPreview = null,
}: CanvasRendererProps) {
const { t } = useTranslations();
const brushStrokeRef = React.useRef<BrushStroke | null>(null);
const brushLastPosRef = React.useRef<{ x: number; y: number } | null>(null);
const [brushOverlay, setBrushOverlay] = React.useState<{ layerId: string; canvas: HTMLCanvasElement } | null>(null);
const handleBrushStrokeStart = React.useCallback((layerId: string, x: number, y: number, layerWidth: number, layerHeight: number): void => {
if (!brushOptions) return;
const stroke = createStroke(layerWidth, layerHeight);
brushStrokeRef.current = stroke;
brushLastPosRef.current = { x, y };
paintSegment(stroke, x, y, x, y, brushOptions);
setBrushOverlay({ layerId, canvas: stroke.canvas });
}, [brushOptions]);
const handleBrushStrokeMove = React.useCallback((_layerId: string, x: number, y: number) => {
if (!brushStrokeRef.current || !brushLastPosRef.current || !brushOptions) return;
const { x: lx, y: ly } = brushLastPosRef.current;
paintSegment(brushStrokeRef.current, lx, ly, x, y, brushOptions);
brushLastPosRef.current = { x, y };
setBrushOverlay((prev) => prev ? { ...prev } : prev);
}, [brushOptions]);
const handleBrushStrokeEnd = React.useCallback((layerId: string) => {
const stroke = brushStrokeRef.current;
brushStrokeRef.current = null;
brushLastPosRef.current = null;
setBrushOverlay(null);
if (stroke && onBrushCommit) onBrushCommit(layerId, stroke);
}, [onBrushCommit]);
const {
containerRef,
viewport,
@@ -164,6 +211,10 @@ export function CanvasRenderer({
onInteractionStart,
onInteractionEnd,
onContextMenu,
onFillLayer,
onBrushStrokeStart: handleBrushStrokeStart,
onBrushStrokeMove: handleBrushStrokeMove,
onBrushStrokeEnd: handleBrushStrokeEnd,
});
const faceLabelOverlays = React.useMemo(() => {
@@ -194,10 +245,10 @@ export function CanvasRenderer({
height: "100%",
touchAction: "none",
userSelect: "none",
cursor: tool === "hand" || isSpacePan ? "grab" : "default",
cursor: isSpacePan ? "grab" : (getToolUiDefinition(tool)?.cursor ?? "default"),
}}
onPointerDown={(e) => {
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
if (getToolDefinition(tool)?.interactionMode === "select" && e.button === 0) onSelectLayer(null);
onContainerPointerDown(e);
}}
onMouseDown={(e) => e.preventDefault()}
@@ -232,7 +283,7 @@ export function CanvasRenderer({
>
{layers.map((layer, idx) => {
const isSelected = layer.id === selectedLayerId;
const isImage = layer.type === "image";
const isImage = layer.type === "raster";
const layerWidth =
layer.width ??
(isImage ? Math.round(200 * layer.scale) : undefined);
@@ -256,7 +307,8 @@ export function CanvasRenderer({
height: layerHeight,
transform: `rotate(${layer.rotation}deg)`,
opacity: layer.opacity,
cursor: "move",
display: layer.visible === false ? "none" : undefined,
cursor: getToolUiDefinition(tool)?.cursor ?? "move",
border: isSelected
? "2px solid var(--color-accent-strong)"
: "1px dashed transparent",
@@ -270,17 +322,14 @@ export function CanvasRenderer({
onClick={() => onSelectLayer(layer.id)}
>
{isImage && layer.sourceUri ? (
(faceBlurPreview &&
faceBlurPreview.layerId === layer.id &&
faceBlurPreview.regions.length > 0) ||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
<BlurredImageLayer
layer.effects.length > 0 || (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
<EffectImageLayer
layer={layer}
width={layerWidth ?? 1}
height={layerHeight ?? 1}
faceBlurOverride={
effectsOverride={
faceBlurPreview && faceBlurPreview.layerId === layer.id
? faceBlurPreview
? faceBlurPreview.effects
: null
}
/>
@@ -308,6 +357,9 @@ export function CanvasRenderer({
{layer.type}
</div>
)}
{brushOverlay && brushOverlay.layerId === layer.id ? (
<BrushOverlayCanvas stroke={brushOverlay.canvas} width={layerWidth ?? 1} height={layerHeight ?? 1} />
) : null}
{tool === "face" && faceOverlayLayerId === layer.id
? faceDetections.map((face, index) => (
<div
@@ -1,8 +1,9 @@
import React from "react";
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
import type { Layer, LayerEffect } from "@pien-studio/types";
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
import { CanvasRenderer } from "../canvas-renderer";
import { CanvasContextMenu } from "./canvas-context-menu";
import type { BrushOptions, BrushStroke } from "../../lib/brush-painter";
type EditorCanvasStageProps = {
isDark: boolean;
@@ -10,15 +11,13 @@ type EditorCanvasStageProps = {
canvasWidth: number;
canvasHeight: number;
selectedLayerId: string | null;
tool: "pointer" | "hand" | "face";
tool: string;
onFillLayer?: (layerId: string, x: number, y: number) => void;
brushOptions?: BrushOptions;
onBrushCommit?: (layerId: string, stroke: BrushStroke) => void;
faceDetections: FaceDetectionOverlay[];
faceOverlayLayerId: string | null;
faceBlurPreview: {
layerId: string;
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number }[];
} | null;
faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null;
contextMenu: { x: number; y: number } | null;
labels: { copy: string; cut: string; paste: string };
onSelectLayer: (id: string | null) => void;
@@ -64,6 +63,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
onCopy,
onCut,
onPaste,
onFillLayer,
brushOptions,
onBrushCommit,
} = props;
return (
@@ -93,6 +95,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
onInteractionStart={onInteractionStart}
onInteractionEnd={onInteractionEnd}
onContextMenu={onContextMenu}
onFillLayer={onFillLayer}
brushOptions={brushOptions}
onBrushCommit={onBrushCommit}
isDark={isDark}
tool={tool}
faceDetections={faceDetections}
@@ -17,7 +17,7 @@ describe("EditorMobileSection", () => {
layers: [],
selectedLayerId: null,
tool: "face",
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f" }],
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f", sourceWidth: 100, sourceHeight: 100 }],
faceOverlayLayerId: null,
faceStatus: "idle",
faceBlurPreview: null,
@@ -1,6 +1,6 @@
import React from "react";
import { CanvasRenderer } from "../canvas-renderer";
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
import type { Layer, LayerEffect } from "@pien-studio/types";
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
type EditorMobileSectionProps = {
@@ -9,16 +9,11 @@ type EditorMobileSectionProps = {
canvasHeight: number;
layers: Layer[];
selectedLayerId: string | null;
tool: "pointer" | "hand" | "face";
tool: string;
faceDetections: FaceDetectionOverlay[];
faceOverlayLayerId: string | null;
faceStatus: "idle" | "detecting" | "unsupported";
faceBlurPreview: {
layerId: string;
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number }[];
} | null;
faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null;
labels: {
resize: string;
faceMlFailedShort: string;
+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}
+1 -1
View File
@@ -57,7 +57,7 @@ export function FacePanel({
? t("editor.faceMlFailed")
: faceStatus === "detecting"
? t("editor.detectingFaces")
: selectedLayer?.type !== "image"
: selectedLayer?.type !== "raster"
? t("editor.selectImageLayer")
: faceDetections.length === 0
? t("editor.noFacesFound")
+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>