mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Remove editor from AppLayout so it owns the full viewport with no sidebar/padding - Add back-arrow in menu bar for navigation, restyle menu bar to match native app feel - Slim left toolbar from 48px to 36px with smaller icons and subtler dividers - Fix options bar overlapping canvas via shrink-0 and overflow-hidden - Fix opacity/blend controls clipping in right panel with min-w-0 on flex children - Add global range slider CSS for consistent thin-track + round-thumb styling - Make meme template routes public so img tags can load without Bearer auth - Show pipeline export buttons in default compact view, not just expanded hover - Queue individual AI feature installs instead of erroring when one is active
1033 lines
30 KiB
TypeScript
1033 lines
30 KiB
TypeScript
// 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<ObjectEffects["dropShadow"]> = {
|
|
enabled: true,
|
|
color: "#000000",
|
|
opacity: 75,
|
|
angle: 135,
|
|
distance: 5,
|
|
blur: 10,
|
|
spread: 0,
|
|
};
|
|
|
|
const DEFAULT_INNER_SHADOW: NonNullable<ObjectEffects["innerShadow"]> = {
|
|
enabled: true,
|
|
color: "#000000",
|
|
opacity: 75,
|
|
angle: 135,
|
|
distance: 5,
|
|
blur: 10,
|
|
};
|
|
|
|
const DEFAULT_OUTER_GLOW: NonNullable<ObjectEffects["outerGlow"]> = {
|
|
enabled: true,
|
|
color: "#ffffff",
|
|
opacity: 75,
|
|
blur: 10,
|
|
spread: 0,
|
|
};
|
|
|
|
const DEFAULT_STROKE: NonNullable<ObjectEffects["stroke"]> = {
|
|
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<string, unknown>) => {
|
|
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 (
|
|
<div className="flex flex-col h-full" data-testid="layers-panel">
|
|
{/* Top row: blend mode + opacity for active layer */}
|
|
<div className="space-y-2 pb-2 border-b border-border">
|
|
<BlendModeSelect
|
|
value={activeLayer?.blendMode ?? "source-over"}
|
|
onChange={(mode) => {
|
|
if (activeLayerId) updateLayer(activeLayerId, { blendMode: mode });
|
|
}}
|
|
/>
|
|
<OpacitySlider
|
|
value={activeLayer?.opacity ?? 1}
|
|
onChange={(opacity) => {
|
|
if (activeLayerId) updateLayer(activeLayerId, { opacity });
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Layer list */}
|
|
<div className="flex-1 overflow-y-auto py-1 min-h-0" role="listbox" aria-label="Layers">
|
|
{displayLayers.map((layer) => {
|
|
const realIndex = layers.findIndex((l) => l.id === layer.id);
|
|
return (
|
|
<LayerRow
|
|
key={layer.id}
|
|
layer={layer}
|
|
realIndex={realIndex}
|
|
totalLayers={layers.length}
|
|
isActive={layer.id === activeLayerId}
|
|
onSelect={() => 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)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Layer effects section (only when an object is selected) */}
|
|
{selectedObject && (
|
|
<div className="border-t border-border pt-1">
|
|
<LayerEffectsSection effects={objectEffects} onChange={handleEffectChange} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom action bar */}
|
|
<div className="flex items-center gap-1 pt-2 border-t border-border">
|
|
<button
|
|
type="button"
|
|
onClick={addLayer}
|
|
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
title="New Layer (Ctrl+Shift+N)"
|
|
aria-label="Add layer"
|
|
data-testid="add-layer-btn"
|
|
>
|
|
<Plus size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeLayerId && layers.length > 1) {
|
|
removeLayer(activeLayerId);
|
|
}
|
|
}}
|
|
disabled={layers.length <= 1}
|
|
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
title="Delete Layer"
|
|
aria-label="Delete layer"
|
|
data-testid="delete-layer-btn"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Context menu */}
|
|
{contextMenu && (
|
|
<LayerContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
layerId={contextMenu.layerId}
|
|
layerCount={layers.length}
|
|
layerIndex={layers.findIndex((l) => l.id === contextMenu.layerId)}
|
|
onDuplicate={() => {
|
|
duplicateLayer(contextMenu.layerId);
|
|
closeContextMenu();
|
|
}}
|
|
onMergeDown={() => {
|
|
mergeDown(contextMenu.layerId);
|
|
closeContextMenu();
|
|
}}
|
|
onFlattenAll={() => {
|
|
flattenAll();
|
|
closeContextMenu();
|
|
}}
|
|
onDelete={() => {
|
|
removeLayer(contextMenu.layerId);
|
|
closeContextMenu();
|
|
}}
|
|
onClose={closeContextMenu}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BlendModeSelect
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode: string) => void }) {
|
|
return (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<label htmlFor="blend-mode-select" className="text-[10px] text-muted-foreground shrink-0">
|
|
Blend
|
|
</label>
|
|
<select
|
|
id="blend-mode-select"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="flex-1 min-w-0 h-7 text-xs bg-muted border border-border rounded px-1.5 text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
data-testid="blend-mode-select"
|
|
>
|
|
{BLEND_MODES.map((mode) => (
|
|
<option key={mode.value} value={mode.value}>
|
|
{mode.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpacitySlider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function OpacitySlider({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
|
const percent = Math.round(value * 100);
|
|
return (
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<label htmlFor="layer-opacity-slider" className="text-[10px] text-muted-foreground shrink-0">
|
|
Opacity
|
|
</label>
|
|
<input
|
|
id="layer-opacity-slider"
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
value={percent}
|
|
onChange={(e) => onChange(Number(e.target.value) / 100)}
|
|
className="flex-1 min-w-0"
|
|
data-testid="layer-opacity-slider"
|
|
/>
|
|
<span className="text-[10px] font-mono text-foreground tabular-nums shrink-0">
|
|
{percent}%
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<HTMLInputElement>(null);
|
|
const rowRef = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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 (
|
|
<div
|
|
ref={rowRef}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none group",
|
|
"hover:bg-muted/50 transition-colors",
|
|
isActive && "bg-primary/10 border-l-2 border-primary",
|
|
!isActive && "border-l-2 border-transparent",
|
|
)}
|
|
role="option"
|
|
aria-selected={isActive}
|
|
onPointerDown={handlePointerDown}
|
|
onContextMenu={onContextMenu}
|
|
data-testid={`layer-row-${layer.id}`}
|
|
data-layer-id={layer.id}
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
>
|
|
{/* Visibility toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleVisibility();
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
title={layer.visible ? "Hide layer" : "Show layer"}
|
|
aria-label={layer.visible ? "Hide layer" : "Show layer"}
|
|
>
|
|
{layer.visible ? <Eye size={14} /> : <EyeOff size={14} />}
|
|
</button>
|
|
|
|
{/* Lock toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleLock();
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
title={layer.locked ? "Unlock layer" : "Lock layer"}
|
|
aria-label={layer.locked ? "Unlock layer" : "Lock layer"}
|
|
>
|
|
{layer.locked ? <Lock size={14} /> : <Unlock size={14} />}
|
|
</button>
|
|
|
|
{/* Thumbnail */}
|
|
<div className="w-8 h-8 shrink-0 rounded border border-border bg-muted overflow-hidden flex items-center justify-center">
|
|
{layer.thumbnail ? (
|
|
<img
|
|
src={layer.thumbnail}
|
|
alt={`${layer.name} thumbnail`}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<Layers size={12} className="text-muted-foreground/50" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div className="flex-1 min-w-0">
|
|
{editing ? (
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => 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}`}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"block text-xs truncate text-left bg-transparent border-0 p-0 w-full cursor-pointer",
|
|
isActive ? "text-foreground font-medium" : "text-muted-foreground",
|
|
)}
|
|
onClick={handleNameClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
data-testid={`layer-name-${layer.id}`}
|
|
>
|
|
{layer.name}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<HTMLDivElement>(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 (
|
|
<div
|
|
ref={menuRef}
|
|
className="fixed z-50 min-w-[140px] bg-card border border-border rounded-lg shadow-lg py-1"
|
|
style={{ left: pos.x, top: pos.y }}
|
|
data-testid="layer-context-menu"
|
|
>
|
|
{items.map((item) => (
|
|
<button
|
|
key={item.label}
|
|
type="button"
|
|
disabled={item.disabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
item.action();
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left transition-colors",
|
|
item.disabled
|
|
? "text-muted-foreground/40 cursor-not-allowed"
|
|
: "text-foreground hover:bg-muted",
|
|
item.label === "Delete" && !item.disabled && "text-red-500",
|
|
)}
|
|
>
|
|
<item.icon size={14} />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LayerEffectsSection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function LayerEffectsSection({
|
|
effects,
|
|
onChange,
|
|
}: {
|
|
effects: ObjectEffects | undefined;
|
|
onChange: (key: keyof ObjectEffects, updates: Record<string, unknown>) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-0.5 max-h-[200px] overflow-y-auto">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70 px-1">
|
|
Layer Effects
|
|
</p>
|
|
|
|
<EffectToggleSection
|
|
label="Drop Shadow"
|
|
enabled={effects?.dropShadow?.enabled ?? false}
|
|
onToggle={(enabled) =>
|
|
onChange("dropShadow", {
|
|
...DEFAULT_DROP_SHADOW,
|
|
...effects?.dropShadow,
|
|
enabled,
|
|
})
|
|
}
|
|
>
|
|
{effects?.dropShadow?.enabled && (
|
|
<ShadowControls
|
|
values={effects.dropShadow}
|
|
onChange={(updates) => onChange("dropShadow", updates)}
|
|
includeSpread
|
|
/>
|
|
)}
|
|
</EffectToggleSection>
|
|
|
|
<EffectToggleSection
|
|
label="Inner Shadow"
|
|
enabled={effects?.innerShadow?.enabled ?? false}
|
|
onToggle={(enabled) =>
|
|
onChange("innerShadow", {
|
|
...DEFAULT_INNER_SHADOW,
|
|
...effects?.innerShadow,
|
|
enabled,
|
|
})
|
|
}
|
|
>
|
|
{effects?.innerShadow?.enabled && (
|
|
<ShadowControls
|
|
values={effects.innerShadow}
|
|
onChange={(updates) => onChange("innerShadow", updates)}
|
|
includeSpread={false}
|
|
/>
|
|
)}
|
|
</EffectToggleSection>
|
|
|
|
<EffectToggleSection
|
|
label="Outer Glow"
|
|
enabled={effects?.outerGlow?.enabled ?? false}
|
|
onToggle={(enabled) =>
|
|
onChange("outerGlow", {
|
|
...DEFAULT_OUTER_GLOW,
|
|
...effects?.outerGlow,
|
|
enabled,
|
|
})
|
|
}
|
|
>
|
|
{effects?.outerGlow?.enabled && (
|
|
<GlowControls
|
|
values={effects.outerGlow}
|
|
onChange={(updates) => onChange("outerGlow", updates)}
|
|
/>
|
|
)}
|
|
</EffectToggleSection>
|
|
|
|
<EffectToggleSection
|
|
label="Stroke"
|
|
enabled={effects?.stroke?.enabled ?? false}
|
|
onToggle={(enabled) =>
|
|
onChange("stroke", {
|
|
...DEFAULT_STROKE,
|
|
...effects?.stroke,
|
|
enabled,
|
|
})
|
|
}
|
|
>
|
|
{effects?.stroke?.enabled && (
|
|
<StrokeControls
|
|
values={effects.stroke}
|
|
onChange={(updates) => onChange("stroke", updates)}
|
|
/>
|
|
)}
|
|
</EffectToggleSection>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EffectToggleSection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function EffectToggleSection({
|
|
label,
|
|
enabled,
|
|
onToggle,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
enabled: boolean;
|
|
onToggle: (enabled: boolean) => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div className="border-b border-border/50 last:border-0">
|
|
<div className="flex items-center gap-1.5 px-1 py-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
|
|
>
|
|
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
</button>
|
|
<label className="flex items-center gap-1.5 flex-1 cursor-pointer text-xs text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={(e) => onToggle(e.target.checked)}
|
|
className="rounded border-border"
|
|
/>
|
|
{label}
|
|
</label>
|
|
</div>
|
|
{expanded && <div className="px-2 pb-2 space-y-1.5">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, unknown>) => void;
|
|
includeSpread: boolean;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<EffectColorInput
|
|
label="Color"
|
|
value={values.color}
|
|
onChange={(color) => onChange({ color })}
|
|
/>
|
|
<EffectSlider
|
|
label="Opacity"
|
|
value={values.opacity}
|
|
min={0}
|
|
max={100}
|
|
suffix="%"
|
|
onChange={(opacity) => onChange({ opacity })}
|
|
/>
|
|
<EffectSlider
|
|
label="Angle"
|
|
value={values.angle}
|
|
min={0}
|
|
max={360}
|
|
suffix="deg"
|
|
onChange={(angle) => onChange({ angle })}
|
|
/>
|
|
<EffectSlider
|
|
label="Distance"
|
|
value={values.distance}
|
|
min={0}
|
|
max={100}
|
|
suffix="px"
|
|
onChange={(distance) => onChange({ distance })}
|
|
/>
|
|
<EffectSlider
|
|
label="Blur"
|
|
value={values.blur}
|
|
min={0}
|
|
max={100}
|
|
suffix="px"
|
|
onChange={(blur) => onChange({ blur })}
|
|
/>
|
|
{includeSpread && values.spread !== undefined && (
|
|
<EffectSlider
|
|
label="Spread"
|
|
value={values.spread}
|
|
min={0}
|
|
max={100}
|
|
suffix="px"
|
|
onChange={(spread) => onChange({ spread })}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GlowControls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function GlowControls({
|
|
values,
|
|
onChange,
|
|
}: {
|
|
values: { color: string; opacity: number; blur: number; spread: number };
|
|
onChange: (updates: Record<string, unknown>) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<EffectColorInput
|
|
label="Color"
|
|
value={values.color}
|
|
onChange={(color) => onChange({ color })}
|
|
/>
|
|
<EffectSlider
|
|
label="Opacity"
|
|
value={values.opacity}
|
|
min={0}
|
|
max={100}
|
|
suffix="%"
|
|
onChange={(opacity) => onChange({ opacity })}
|
|
/>
|
|
<EffectSlider
|
|
label="Blur"
|
|
value={values.blur}
|
|
min={0}
|
|
max={100}
|
|
suffix="px"
|
|
onChange={(blur) => onChange({ blur })}
|
|
/>
|
|
<EffectSlider
|
|
label="Spread"
|
|
value={values.spread}
|
|
min={0}
|
|
max={100}
|
|
suffix="px"
|
|
onChange={(spread) => onChange({ spread })}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// StrokeControls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function StrokeControls({
|
|
values,
|
|
onChange,
|
|
}: {
|
|
values: {
|
|
color: string;
|
|
width: number;
|
|
position: "inside" | "center" | "outside";
|
|
};
|
|
onChange: (updates: Record<string, unknown>) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<EffectColorInput
|
|
label="Color"
|
|
value={values.color}
|
|
onChange={(color) => onChange({ color })}
|
|
/>
|
|
<EffectSlider
|
|
label="Width"
|
|
value={values.width}
|
|
min={1}
|
|
max={20}
|
|
suffix="px"
|
|
onChange={(width) => onChange({ width })}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-muted-foreground shrink-0 w-12">Position</span>
|
|
<select
|
|
value={values.position}
|
|
onChange={(e) =>
|
|
onChange({
|
|
position: e.target.value as "inside" | "center" | "outside",
|
|
})
|
|
}
|
|
className="flex-1 h-6 text-[10px] bg-muted border border-border rounded px-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
>
|
|
<option value="inside">Inside</option>
|
|
<option value="center">Center</option>
|
|
<option value="outside">Outside</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="text-[10px] text-muted-foreground shrink-0 w-12">{label}</span>
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
value={value}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
className="flex-1 min-w-0"
|
|
/>
|
|
<span className="text-[10px] font-mono text-foreground tabular-nums w-11 text-right shrink-0">
|
|
{value}
|
|
{suffix}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EffectColorInput({
|
|
label,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[10px] text-muted-foreground shrink-0 w-12">{label}</span>
|
|
<input
|
|
type="color"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-6 h-6 rounded border border-border cursor-pointer bg-transparent p-0"
|
|
/>
|
|
<span className="text-[10px] font-mono text-muted-foreground">{value}</span>
|
|
</div>
|
|
);
|
|
}
|