From fedb7bc2e348252af23a1a735993a8e7e50da5f4 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:22:24 +0800 Subject: [PATCH] feat: add layers panel with blend modes, effects, and drag reorder Implements the layer management panel for the image editor (Features 16, 40): - Blend mode dropdown (16 modes) and opacity slider for active layer - Layer list with visibility/lock toggles, thumbnails, and inline rename - Pointer-based drag reorder (not HTML5 drag API) - Right-click context menu: Duplicate, Merge Down, Flatten All, Delete - Layer effects section: Drop Shadow, Inner Shadow, Outer Glow, Stroke - Each effect with enable/disable toggle and full parameter controls - Wired into EditorRightPanel as the "Layers" tab content --- .../components/editor/editor-right-panel.tsx | 5 +- .../components/editor/panels/layers-panel.tsx | 1009 +++++++++++++++++ 2 files changed, 1012 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/editor/panels/layers-panel.tsx diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx index db63ace7..827d094f 100644 --- a/apps/web/src/components/editor/editor-right-panel.tsx +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -2,6 +2,7 @@ import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/stores/editor-store"; +import { LayersPanel } from "./panels/layers-panel"; const TABS = [ { id: "layers" as const, label: "Layers" }, @@ -57,8 +58,8 @@ export function EditorRightPanel() {
- {/* Tab content rendered by agents: layers-panel, adjustments-panel, history-panel */} -
+ {activeTab === "layers" && } + {activeTab !== "layers" &&
}
{/* Color panel always visible at bottom (Agent 5) */}
diff --git a/apps/web/src/components/editor/panels/layers-panel.tsx b/apps/web/src/components/editor/panels/layers-panel.tsx new file mode 100644 index 00000000..4d89b154 --- /dev/null +++ b/apps/web/src/components/editor/panels/layers-panel.tsx @@ -0,0 +1,1009 @@ +// 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" + 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]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setEditing(true); + }, []); + + 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" + /> + + {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} +
+ ); +}