// apps/web/src/components/editor/panels/layers-panel.tsx import { ChevronDown, ChevronRight, Copy, Eye, EyeOff, Layers, Lock, MergeIcon, Plus, Trash2, Unlock, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; import type { EditorLayer, ObjectEffects } from "@/types/editor"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const BLEND_MODES = [ { value: "source-over", label: "Normal" }, { value: "multiply", label: "Multiply" }, { value: "screen", label: "Screen" }, { value: "overlay", label: "Overlay" }, { value: "darken", label: "Darken" }, { value: "lighten", label: "Lighten" }, { value: "color-dodge", label: "Color Dodge" }, { value: "color-burn", label: "Color Burn" }, { value: "hard-light", label: "Hard Light" }, { value: "soft-light", label: "Soft Light" }, { value: "difference", label: "Difference" }, { value: "exclusion", label: "Exclusion" }, { value: "hue", label: "Hue" }, { value: "saturation", label: "Saturation" }, { value: "color", label: "Color" }, { value: "luminosity", label: "Luminosity" }, ] as const; const DEFAULT_DROP_SHADOW: NonNullable = { enabled: true, color: "#000000", opacity: 75, angle: 135, distance: 5, blur: 10, spread: 0, }; const DEFAULT_INNER_SHADOW: NonNullable = { enabled: true, color: "#000000", opacity: 75, angle: 135, distance: 5, blur: 10, }; const DEFAULT_OUTER_GLOW: NonNullable = { enabled: true, color: "#ffffff", opacity: 75, blur: 10, spread: 0, }; const DEFAULT_STROKE: NonNullable = { enabled: true, color: "#000000", width: 2, position: "outside", }; // --------------------------------------------------------------------------- // LayersPanel // --------------------------------------------------------------------------- export function LayersPanel() { const layers = useEditorStore((s) => s.layers); const activeLayerId = useEditorStore((s) => s.activeLayerId); const objects = useEditorStore((s) => s.objects); const addLayer = useEditorStore((s) => s.addLayer); const removeLayer = useEditorStore((s) => s.removeLayer); const duplicateLayer = useEditorStore((s) => s.duplicateLayer); const setActiveLayer = useEditorStore((s) => s.setActiveLayer); const updateLayer = useEditorStore((s) => s.updateLayer); const reorderLayers = useEditorStore((s) => s.reorderLayers); const mergeDown = useEditorStore((s) => s.mergeDown); const flattenAll = useEditorStore((s) => s.flattenAll); const activeLayer = layers.find((l) => l.id === activeLayerId); // Context menu const [contextMenu, setContextMenu] = useState<{ x: number; y: number; layerId: string; } | null>(null); const closeContextMenu = useCallback(() => setContextMenu(null), []); useEffect(() => { if (contextMenu) { const handler = () => closeContextMenu(); document.addEventListener("click", handler); document.addEventListener("contextmenu", handler); return () => { document.removeEventListener("click", handler); document.removeEventListener("contextmenu", handler); }; } }, [contextMenu, closeContextMenu]); const handleContextMenu = useCallback((e: React.MouseEvent, layerId: string) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, layerId }); }, []); // Active layer effects from objects const activeLayerObjects = objects.filter((o) => o.layerId === activeLayerId); const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); const updateObject = useEditorStore((s) => s.updateObject); // Get the first selected object on the active layer for effects editing const selectedObject = activeLayerObjects.find((o) => selectedObjectIds.includes(o.id)); const objectEffects = selectedObject?.effects; const handleEffectChange = useCallback( (effectKey: keyof ObjectEffects, updates: Record) => { if (!selectedObject) return; const currentEffects = selectedObject.effects || {}; const currentEffect = currentEffects[effectKey] || {}; updateObject(selectedObject.id, { effects: { ...currentEffects, [effectKey]: { ...currentEffect, ...updates }, }, } as never); }, [selectedObject, updateObject], ); // Displayed layers: newest (highest index) first const displayLayers = [...layers].reverse(); return (
{/* Top row: blend mode + opacity for active layer */}
{ if (activeLayerId) updateLayer(activeLayerId, { blendMode: mode }); }} /> { if (activeLayerId) updateLayer(activeLayerId, { opacity }); }} />
{/* Layer list */}
{displayLayers.map((layer) => { const realIndex = layers.findIndex((l) => l.id === layer.id); return ( setActiveLayer(layer.id)} onToggleVisibility={() => updateLayer(layer.id, { visible: !layer.visible })} onToggleLock={() => updateLayer(layer.id, { locked: !layer.locked })} onRename={(name) => updateLayer(layer.id, { name })} onReorder={reorderLayers} onContextMenu={(e) => handleContextMenu(e, layer.id)} /> ); })}
{/* Layer effects section (only when an object is selected) */} {selectedObject && (
)} {/* Bottom action bar */}
{/* Context menu */} {contextMenu && ( l.id === contextMenu.layerId)} onDuplicate={() => { duplicateLayer(contextMenu.layerId); closeContextMenu(); }} onMergeDown={() => { mergeDown(contextMenu.layerId); closeContextMenu(); }} onFlattenAll={() => { flattenAll(); closeContextMenu(); }} onDelete={() => { removeLayer(contextMenu.layerId); closeContextMenu(); }} onClose={closeContextMenu} /> )}
); } // --------------------------------------------------------------------------- // BlendModeSelect // --------------------------------------------------------------------------- function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode: string) => void }) { return (
); } // --------------------------------------------------------------------------- // OpacitySlider // --------------------------------------------------------------------------- function OpacitySlider({ value, onChange }: { value: number; onChange: (v: number) => void }) { const percent = Math.round(value * 100); return (
onChange(Number(e.target.value) / 100)} className="flex-1 min-w-0" data-testid="layer-opacity-slider" /> {percent}%
); } // --------------------------------------------------------------------------- // LayerRow // --------------------------------------------------------------------------- interface LayerRowProps { layer: EditorLayer; realIndex: number; totalLayers: number; isActive: boolean; onSelect: () => void; onToggleVisibility: () => void; onToggleLock: () => void; onRename: (name: string) => void; onReorder: (from: number, to: number) => void; onContextMenu: (e: React.MouseEvent) => void; } function LayerRow({ layer, realIndex, totalLayers, isActive, onSelect, onToggleVisibility, onToggleLock, onRename, onReorder, onContextMenu, }: LayerRowProps) { const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(layer.name); const inputRef = useRef(null); const rowRef = useRef(null); // Drag reorder state const dragState = useRef<{ startY: number; startIndex: number; currentIndex: number; dragging: boolean; } | null>(null); useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [editing]); const commitRename = useCallback(() => { const trimmed = editName.trim(); if (trimmed && trimmed !== layer.name) { onRename(trimmed); } else { setEditName(layer.name); } setEditing(false); }, [editName, layer.name, onRename]); // Timer-based single-click vs double-click differentiation for the name button const clickTimerRef = useRef | null>(null); const handleNameClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (clickTimerRef.current) { // Second click within threshold: enter rename mode clearTimeout(clickTimerRef.current); clickTimerRef.current = null; setEditing(true); } else { // First click: start timer; if no second click, select the layer clickTimerRef.current = setTimeout(() => { clickTimerRef.current = null; onSelect(); }, 250); } }, [onSelect], ); const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); // Handled by the timer-based approach in handleNameClick }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { commitRename(); } else if (e.key === "Escape") { setEditName(layer.name); setEditing(false); } }, [commitRename, layer.name], ); // Pointer-based drag reorder const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (editing || e.button !== 0) return; onSelect(); const rowEl = rowRef.current; if (!rowEl) return; const startY = e.clientY; dragState.current = { startY, startIndex: realIndex, currentIndex: realIndex, dragging: false, }; const handlePointerMove = (ev: PointerEvent) => { if (!dragState.current) return; const dy = ev.clientY - dragState.current.startY; if (!dragState.current.dragging && Math.abs(dy) < 4) return; dragState.current.dragging = true; rowEl.style.opacity = "0.6"; // Compute target index based on vertical movement // Since display is reversed, moving down visually means moving to a lower real index const rowHeight = 44; const displayOffset = Math.round(dy / rowHeight); // Display is reversed: visual down = lower real index const newRealIndex = Math.max(0, Math.min(totalLayers - 1, realIndex - displayOffset)); dragState.current.currentIndex = newRealIndex; }; const handlePointerUp = () => { document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerUp); rowEl.style.opacity = ""; if ( dragState.current?.dragging && dragState.current.currentIndex !== dragState.current.startIndex ) { onReorder(dragState.current.startIndex, dragState.current.currentIndex); } dragState.current = null; }; document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); }, [editing, realIndex, totalLayers, onSelect, onReorder], ); return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(); } }} > {/* Visibility toggle */} {/* Lock toggle */} {/* Thumbnail */}
{layer.thumbnail ? ( {`${layer.name} ) : ( )}
{/* Name */}
{editing ? ( setEditName(e.target.value)} onBlur={commitRename} onKeyDown={handleKeyDown} onPointerDown={(e) => e.stopPropagation()} className="w-full text-xs bg-muted border border-border rounded px-1 py-0.5 text-foreground outline-none focus:ring-1 focus:ring-primary" data-testid={`layer-name-input-${layer.id}`} /> ) : ( )}
); } // --------------------------------------------------------------------------- // LayerContextMenu // --------------------------------------------------------------------------- interface LayerContextMenuProps { x: number; y: number; layerId: string; layerCount: number; layerIndex: number; onDuplicate: () => void; onMergeDown: () => void; onFlattenAll: () => void; onDelete: () => void; onClose: () => void; } function LayerContextMenu({ x, y, layerCount, layerIndex, onDuplicate, onMergeDown, onFlattenAll, onDelete, }: LayerContextMenuProps) { const menuRef = useRef(null); // Adjust position to stay within viewport const [pos, setPos] = useState({ x, y }); useEffect(() => { if (menuRef.current) { const rect = menuRef.current.getBoundingClientRect(); const nx = x + rect.width > window.innerWidth ? x - rect.width : x; const ny = y + rect.height > window.innerHeight ? y - rect.height : y; setPos({ x: Math.max(0, nx), y: Math.max(0, ny) }); } }, [x, y]); const items = [ { label: "Duplicate", icon: Copy, action: onDuplicate, disabled: false, }, { label: "Merge Down", icon: MergeIcon, action: onMergeDown, disabled: layerIndex <= 0, }, { label: "Flatten All", icon: Layers, action: onFlattenAll, disabled: layerCount <= 1, }, { label: "Delete", icon: Trash2, action: onDelete, disabled: layerCount <= 1, }, ]; return (
{items.map((item) => ( ))}
); } // --------------------------------------------------------------------------- // LayerEffectsSection // --------------------------------------------------------------------------- function LayerEffectsSection({ effects, onChange, }: { effects: ObjectEffects | undefined; onChange: (key: keyof ObjectEffects, updates: Record) => void; }) { return (

Layer Effects

onChange("dropShadow", { ...DEFAULT_DROP_SHADOW, ...effects?.dropShadow, enabled, }) } > {effects?.dropShadow?.enabled && ( onChange("dropShadow", updates)} includeSpread /> )} onChange("innerShadow", { ...DEFAULT_INNER_SHADOW, ...effects?.innerShadow, enabled, }) } > {effects?.innerShadow?.enabled && ( onChange("innerShadow", updates)} includeSpread={false} /> )} onChange("outerGlow", { ...DEFAULT_OUTER_GLOW, ...effects?.outerGlow, enabled, }) } > {effects?.outerGlow?.enabled && ( onChange("outerGlow", updates)} /> )} onChange("stroke", { ...DEFAULT_STROKE, ...effects?.stroke, enabled, }) } > {effects?.stroke?.enabled && ( onChange("stroke", updates)} /> )}
); } // --------------------------------------------------------------------------- // EffectToggleSection // --------------------------------------------------------------------------- function EffectToggleSection({ label, enabled, onToggle, children, }: { label: string; enabled: boolean; onToggle: (enabled: boolean) => void; children: React.ReactNode; }) { const [expanded, setExpanded] = useState(false); return (
{expanded &&
{children}
}
); } // --------------------------------------------------------------------------- // ShadowControls (Drop Shadow + Inner Shadow) // --------------------------------------------------------------------------- function ShadowControls({ values, onChange, includeSpread, }: { values: { color: string; opacity: number; angle: number; distance: number; blur: number; spread?: number; }; onChange: (updates: Record) => void; includeSpread: boolean; }) { return (
onChange({ color })} /> onChange({ opacity })} /> onChange({ angle })} /> onChange({ distance })} /> onChange({ blur })} /> {includeSpread && values.spread !== undefined && ( onChange({ spread })} /> )}
); } // --------------------------------------------------------------------------- // GlowControls // --------------------------------------------------------------------------- function GlowControls({ values, onChange, }: { values: { color: string; opacity: number; blur: number; spread: number }; onChange: (updates: Record) => void; }) { return (
onChange({ color })} /> onChange({ opacity })} /> onChange({ blur })} /> onChange({ spread })} />
); } // --------------------------------------------------------------------------- // StrokeControls // --------------------------------------------------------------------------- function StrokeControls({ values, onChange, }: { values: { color: string; width: number; position: "inside" | "center" | "outside"; }; onChange: (updates: Record) => void; }) { return (
onChange({ color })} /> onChange({ width })} />
Position
); } // --------------------------------------------------------------------------- // Shared effect sub-components // --------------------------------------------------------------------------- function EffectSlider({ label, value, min, max, suffix, onChange, }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (v: number) => void; }) { return (
{label} onChange(Number(e.target.value))} className="flex-1 min-w-0" /> {value} {suffix}
); } function EffectColorInput({ label, value, onChange, }: { label: string; value: string; onChange: (v: string) => void; }) { return (
{label} onChange(e.target.value)} className="w-6 h-6 rounded border border-border cursor-pointer bg-transparent p-0" /> {value}
); }