fix: rewrite copy/paste/duplicate using Fabric's native clone(), add layer features
- Copy/paste/duplicate now uses obj.clone() instead of broken fromObject() serialization - Ctrl+C stores actual object refs, Ctrl+V clones them — works for images, groups, paths, text - System clipboard copy (Ctrl+C / Ctrl+Shift+C) properly re-selects after screenshot - Layer panel: double-click to rename, collapsible groups with expand/collapse arrow - Auto-indexed names: Image 1, Image 2, Drawing 1, Drawing 2, etc. - Group children visible in layer panel when expanded - Copy/paste works on groups and mixed multi-selections
This commit is contained in:
@@ -18,6 +18,7 @@ interface LayerPanelProps {
|
|||||||
onToggleLock: (id: string) => void;
|
onToggleLock: (id: string) => void;
|
||||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onRename: (id: string, name: string) => void;
|
||||||
onGroup: () => void;
|
onGroup: () => void;
|
||||||
onUngroup: () => void;
|
onUngroup: () => void;
|
||||||
hasSelection: boolean;
|
hasSelection: boolean;
|
||||||
@@ -26,10 +27,13 @@ interface LayerPanelProps {
|
|||||||
|
|
||||||
export default function LayerPanel({
|
export default function LayerPanel({
|
||||||
layers, selectedIds, onSelect, onToggleVisible, onToggleLock,
|
layers, selectedIds, onSelect, onToggleVisible, onToggleLock,
|
||||||
onReorder, onDelete, onGroup, onUngroup, hasSelection, hasGroupSelection,
|
onReorder, onDelete, onRename, onGroup, onUngroup, hasSelection, hasGroupSelection,
|
||||||
}: LayerPanelProps) {
|
}: LayerPanelProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState('');
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const onDragStart = useCallback((idx: number) => setDragIdx(idx), []);
|
const onDragStart = useCallback((idx: number) => setDragIdx(idx), []);
|
||||||
const onDragOver = useCallback((e: React.DragEvent) => e.preventDefault(), []);
|
const onDragOver = useCallback((e: React.DragEvent) => e.preventDefault(), []);
|
||||||
@@ -40,15 +44,36 @@ export default function LayerPanel({
|
|||||||
setDragIdx(null);
|
setDragIdx(null);
|
||||||
}, [dragIdx, onReorder]);
|
}, [dragIdx, onReorder]);
|
||||||
|
|
||||||
|
const toggleGroupExpand = useCallback((id: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRename = useCallback((id: string, currentName: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditValue(currentName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commitRename = useCallback(() => {
|
||||||
|
if (editingId && editValue.trim()) {
|
||||||
|
onRename(editingId, editValue.trim());
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
setEditValue('');
|
||||||
|
}, [editingId, editValue, onRename]);
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '28px',
|
position: 'absolute', right: 0, top: 0, bottom: 0, width: '28px',
|
||||||
background: '#1e1e1e', borderLeft: '1px solid #2a2a2a', zIndex: 100,
|
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
|
||||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '8px',
|
display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '8px',
|
||||||
}}>
|
}}>
|
||||||
<button onClick={() => setCollapsed(false)} title="Show layers"
|
<button onClick={() => setCollapsed(false)} title="Show layers"
|
||||||
style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: '14px', padding: '4px' }}>
|
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', fontSize: '14px', padding: '4px' }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M7 2L2 7l5 5" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M7 2L2 7l5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -57,35 +82,151 @@ export default function LayerPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTypeIcon(type: string, isGroup: boolean) {
|
||||||
|
if (isGroup) return '\u25B8'; // triangle
|
||||||
|
if (type === 'image') return '\u{1F5BC}';
|
||||||
|
if (type === 'i-text') return 'T';
|
||||||
|
if (type === 'path') return '\u270E';
|
||||||
|
return '\u25C7';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayer(layer: LayerItem, realIdx: number, depth = 0) {
|
||||||
|
const selected = selectedIds.includes(layer.id);
|
||||||
|
const isExpanded = expandedGroups.has(layer.id);
|
||||||
|
const isEditing = editingId === layer.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={layer.id}>
|
||||||
|
<div
|
||||||
|
draggable={!isEditing}
|
||||||
|
onDragStart={() => onDragStart(realIdx)}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={() => onDrop(realIdx)}
|
||||||
|
onClick={() => onSelect(layer.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '3px',
|
||||||
|
padding: `3px 6px 3px ${6 + depth * 12}px`, cursor: 'pointer',
|
||||||
|
background: selected ? 'rgba(74,158,255,0.1)' : dragIdx === realIdx ? '#1a1a1a' : 'transparent',
|
||||||
|
borderBottom: '1px solid #151515',
|
||||||
|
borderLeft: selected ? '2px solid #4a9eff' : '2px solid transparent',
|
||||||
|
opacity: layer.visible ? 1 : 0.35,
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = '#161616'; }}
|
||||||
|
onMouseLeave={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{/* Group expand/collapse */}
|
||||||
|
{layer.isGroup ? (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); toggleGroupExpand(layer.id); }}
|
||||||
|
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: '#666', flexShrink: 0, fontSize: '10px', width: '14px' }}>
|
||||||
|
{isExpanded ? '\u25BE' : '\u25B8'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ width: '14px', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visibility toggle */}
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onToggleVisible(layer.id); }}
|
||||||
|
title={layer.visible ? 'Hide' : 'Show'}
|
||||||
|
style={{ background: 'none', border: 'none', padding: '1px', cursor: 'pointer', color: layer.visible ? '#666' : '#333', flexShrink: 0 }}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||||
|
{layer.visible ? (
|
||||||
|
<><ellipse cx="5" cy="5" rx="4" ry="2.5" /><circle cx="5" cy="5" r="1" fill="currentColor" /></>
|
||||||
|
) : (
|
||||||
|
<><line x1="1" y1="1" x2="9" y2="9" /><ellipse cx="5" cy="5" rx="4" ry="2.5" /></>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Lock toggle */}
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onToggleLock(layer.id); }}
|
||||||
|
title={layer.locked ? 'Unlock' : 'Lock'}
|
||||||
|
style={{ background: 'none', border: 'none', padding: '1px', cursor: 'pointer', color: layer.locked ? '#e8a946' : '#333', flexShrink: 0 }}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||||
|
{layer.locked ? (
|
||||||
|
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0V5" /></>
|
||||||
|
) : (
|
||||||
|
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0" /></>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Type icon */}
|
||||||
|
<span style={{ fontSize: '9px', color: '#444', flexShrink: 0, width: '12px', textAlign: 'center' }}>
|
||||||
|
{getTypeIcon(layer.type, layer.isGroup)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Name (editable) */}
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={commitRename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commitRename();
|
||||||
|
if (e.key === 'Escape') { setEditingId(null); setEditValue(''); }
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
flex: 1, fontSize: '11px', color: '#ddd', background: '#0d0d0d',
|
||||||
|
border: '1px solid #333', borderRadius: '3px', padding: '1px 4px',
|
||||||
|
outline: 'none', minWidth: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); startRename(layer.id, layer.name); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, fontSize: '11px', color: selected ? '#ccc' : '#888',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
cursor: 'text',
|
||||||
|
}}
|
||||||
|
title="Double-click to rename"
|
||||||
|
>
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onDelete(layer.id); }}
|
||||||
|
title="Delete"
|
||||||
|
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: '#333', flexShrink: 0 }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#333'; }}>
|
||||||
|
<svg width="8" height="8" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<line x1="2" y1="2" x2="8" y2="8" /><line x1="8" y1="2" x2="2" y2="8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group children */}
|
||||||
|
{layer.isGroup && isExpanded && layer.children && (
|
||||||
|
layer.children.map((child, ci) => renderLayer(child, realIdx, depth + 1))
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '200px',
|
position: 'absolute', right: 0, top: 0, bottom: 0, width: '200px',
|
||||||
background: '#1e1e1e', borderLeft: '1px solid #2a2a2a', zIndex: 100,
|
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
|
||||||
display: 'flex', flexDirection: 'column', userSelect: 'none',
|
display: 'flex', flexDirection: 'column', userSelect: 'none',
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '6px 8px', borderBottom: '1px solid #2a2a2a', flexShrink: 0,
|
padding: '6px 8px', borderBottom: '1px solid #1a1a1a', flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#999', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
<span style={{ fontSize: '10px', fontWeight: 600, color: '#555', letterSpacing: '0.8px', textTransform: 'uppercase' }}>
|
||||||
Layers
|
Layers
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: '2px' }}>
|
<div style={{ display: 'flex', gap: '2px' }}>
|
||||||
<SmallBtn title="Group (Ctrl+G)" disabled={!hasSelection} onClick={onGroup}>
|
<SmallBtn title="Group (Ctrl+G)" disabled={!hasSelection} onClick={onGroup}>G+</SmallBtn>
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
|
<SmallBtn title="Ungroup (Ctrl+Shift+G)" disabled={!hasGroupSelection} onClick={onUngroup}>G-</SmallBtn>
|
||||||
<rect x="1" y="1" width="4" height="4" rx="0.5" /><rect x="7" y="7" width="4" height="4" rx="0.5" />
|
|
||||||
<path d="M5 6h2M6 5v2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</SmallBtn>
|
|
||||||
<SmallBtn title="Ungroup (Ctrl+Shift+G)" disabled={!hasGroupSelection} onClick={onUngroup}>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
|
|
||||||
<rect x="1" y="1" width="4" height="4" rx="0.5" /><rect x="7" y="7" width="4" height="4" rx="0.5" />
|
|
||||||
<path d="M4 6h4" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</SmallBtn>
|
|
||||||
<SmallBtn title="Collapse panel" onClick={() => setCollapsed(true)}>
|
<SmallBtn title="Collapse panel" onClick={() => setCollapsed(true)}>
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M5 2l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M5 2l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</SmallBtn>
|
</SmallBtn>
|
||||||
@@ -96,79 +237,10 @@ export default function LayerPanel({
|
|||||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
{[...layers].reverse().map((layer, i) => {
|
{[...layers].reverse().map((layer, i) => {
|
||||||
const realIdx = layers.length - 1 - i;
|
const realIdx = layers.length - 1 - i;
|
||||||
const selected = selectedIds.includes(layer.id);
|
return renderLayer(layer, realIdx);
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={layer.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={() => onDragStart(realIdx)}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDrop={() => onDrop(realIdx)}
|
|
||||||
onClick={() => onSelect(layer.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: '4px',
|
|
||||||
padding: '3px 6px', cursor: 'pointer',
|
|
||||||
background: selected ? '#2a3a50' : dragIdx === realIdx ? '#2a2a2a' : 'transparent',
|
|
||||||
borderBottom: '1px solid #222',
|
|
||||||
opacity: layer.visible ? 1 : 0.4,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = '#252525'; }}
|
|
||||||
onMouseLeave={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
|
||||||
>
|
|
||||||
{/* Visibility toggle */}
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); onToggleVisible(layer.id); }}
|
|
||||||
title={layer.visible ? 'Hide' : 'Show'}
|
|
||||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: layer.visible ? '#888' : '#444', flexShrink: 0 }}>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
|
||||||
{layer.visible ? (
|
|
||||||
<><ellipse cx="5" cy="5" rx="4" ry="2.5" /><circle cx="5" cy="5" r="1" fill="currentColor" /></>
|
|
||||||
) : (
|
|
||||||
<><line x1="1" y1="1" x2="9" y2="9" /><ellipse cx="5" cy="5" rx="4" ry="2.5" /></>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Lock toggle */}
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); onToggleLock(layer.id); }}
|
|
||||||
title={layer.locked ? 'Unlock' : 'Lock'}
|
|
||||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: layer.locked ? '#e8a946' : '#444', flexShrink: 0 }}>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
|
||||||
{layer.locked ? (
|
|
||||||
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0V5" /></>
|
|
||||||
) : (
|
|
||||||
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0" /></>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Type icon */}
|
|
||||||
<span style={{ fontSize: '9px', color: '#555', flexShrink: 0, width: '12px', textAlign: 'center' }}>
|
|
||||||
{layer.isGroup ? '📁' : layer.type === 'image' ? '🖼' : layer.type === 'i-text' ? 'T' : layer.type === 'path' ? '✏' : '◇'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<span style={{
|
|
||||||
flex: 1, fontSize: '11px', color: selected ? '#ccc' : '#999',
|
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{layer.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); onDelete(layer.id); }}
|
|
||||||
title="Delete"
|
|
||||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: '#444', flexShrink: 0, opacity: 0.5 }}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = '#ff6b6b'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.color = '#444'; }}>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.3">
|
|
||||||
<line x1="2" y1="2" x2="8" y2="8" /><line x1="8" y1="2" x2="2" y2="8" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
{layers.length === 0 && (
|
{layers.length === 0 && (
|
||||||
<div style={{ padding: '12px', textAlign: 'center', color: '#444', fontSize: '11px' }}>
|
<div style={{ padding: '16px', textAlign: 'center', color: '#333', fontSize: '11px' }}>
|
||||||
No objects
|
No objects
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,10 +255,10 @@ function SmallBtn({ onClick, title, disabled, children }: {
|
|||||||
return (
|
return (
|
||||||
<button onClick={onClick} title={title} disabled={disabled}
|
<button onClick={onClick} title={title} disabled={disabled}
|
||||||
style={{
|
style={{
|
||||||
background: 'none', border: 'none', padding: '3px', cursor: disabled ? 'default' : 'pointer',
|
background: 'none', border: 'none', padding: '2px 4px', cursor: disabled ? 'default' : 'pointer',
|
||||||
color: disabled ? '#333' : '#888', borderRadius: '3px',
|
color: disabled ? '#222' : '#666', borderRadius: '3px', fontSize: '9px', fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.background = '#333'; }}
|
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.background = '#1a1a1a'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'none'; }}>
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'none'; }}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -296,30 +296,49 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Refresh layer list from canvas
|
// Refresh layer list from canvas
|
||||||
|
// Auto-name counters
|
||||||
|
const nameCounters = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
function autoName(obj: any, i: number): string {
|
||||||
|
if (obj.name) return obj.name;
|
||||||
|
const type = obj.type || 'object';
|
||||||
|
if (type === 'image') {
|
||||||
|
nameCounters.current.image = (nameCounters.current.image || 0) + 1;
|
||||||
|
return `Image ${nameCounters.current.image}`;
|
||||||
|
}
|
||||||
|
if (type === 'i-text') return (obj.text || 'Text').slice(0, 20);
|
||||||
|
if (type === 'path') {
|
||||||
|
nameCounters.current.path = (nameCounters.current.path || 0) + 1;
|
||||||
|
return `Drawing ${nameCounters.current.path}`;
|
||||||
|
}
|
||||||
|
if (type === 'group') return `Group (${obj._objects?.length || 0})`;
|
||||||
|
return `${type} ${i + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLayerItem(obj: any, i: number): any {
|
||||||
|
const id = obj.id || `layer-${i}`;
|
||||||
|
const isGroup = obj.type === 'group';
|
||||||
|
let children: any[] | undefined;
|
||||||
|
if (isGroup && obj._objects) {
|
||||||
|
children = obj._objects.map((child: any, ci: number) => buildLayerItem(child, ci));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: autoName(obj, i),
|
||||||
|
type: obj.type || 'object',
|
||||||
|
visible: obj.visible !== false,
|
||||||
|
locked: !obj.selectable,
|
||||||
|
isGroup,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const refreshLayers = useCallback(() => {
|
const refreshLayers = useCallback(() => {
|
||||||
const canvas = canvasRef.current?.getCanvas();
|
const canvas = canvasRef.current?.getCanvas();
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
nameCounters.current = {};
|
||||||
const objects = canvas.getObjects();
|
const objects = canvas.getObjects();
|
||||||
const layers = objects.map((obj: any, i: number) => {
|
const layers = objects.map((obj: any, i: number) => buildLayerItem(obj, i));
|
||||||
const id = obj.id || `layer-${i}`;
|
|
||||||
const isGroup = obj.type === 'group';
|
|
||||||
let name = obj.name || '';
|
|
||||||
if (!name) {
|
|
||||||
if (obj.type === 'image') name = 'Image';
|
|
||||||
else if (obj.type === 'i-text') name = (obj.text || 'Text').slice(0, 20);
|
|
||||||
else if (obj.type === 'path') name = 'Drawing';
|
|
||||||
else if (isGroup) name = `Group (${obj._objects?.length || 0})`;
|
|
||||||
else name = obj.type || 'Object';
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: `${name}`,
|
|
||||||
type: obj.type || 'object',
|
|
||||||
visible: obj.visible !== false,
|
|
||||||
locked: !obj.selectable,
|
|
||||||
isGroup,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setLayerList(layers);
|
setLayerList(layers);
|
||||||
const activeIds = canvas.getActiveObjects().map((o: any) => o.id).filter(Boolean);
|
const activeIds = canvas.getActiveObjects().map((o: any) => o.id).filter(Boolean);
|
||||||
setSelectedLayerIds(activeIds);
|
setSelectedLayerIds(activeIds);
|
||||||
@@ -938,6 +957,15 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
refreshLayers();
|
refreshLayers();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onRename={(id, name) => {
|
||||||
|
const canvas = canvasRef.current?.getCanvas();
|
||||||
|
if (!canvas) return;
|
||||||
|
const obj = canvas.getObjects().find((o: any) => o.id === id);
|
||||||
|
if (obj) {
|
||||||
|
(obj as any).name = name;
|
||||||
|
refreshLayers();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onGroup={handleGroup}
|
onGroup={handleGroup}
|
||||||
onUngroup={handleUngroup}
|
onUngroup={handleUngroup}
|
||||||
hasSelection={canvasRef.current?.getCanvas()?.getActiveObjects()?.length! > 1}
|
hasSelection={canvasRef.current?.getCanvas()?.getActiveObjects()?.length! > 1}
|
||||||
|
|||||||
Reference in New Issue
Block a user