feat: implement recent snaps feature with import/export functionality and UI enhancements
This commit is contained in:
+75
-2
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import type Konva from 'konva';
|
||||
import Canvas from './components/Canvas';
|
||||
import TopBar from './components/TopBar';
|
||||
@@ -7,10 +7,12 @@ import Inspector from './components/Inspector';
|
||||
import LayersPanel from './components/LayersPanel';
|
||||
import FontLoader from './components/FontLoader';
|
||||
import { useCanvasStore } from './store/canvasStore';
|
||||
import { useRecentSnapsStore } from './store/recentSnapsStore';
|
||||
|
||||
function App() {
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const {
|
||||
snap,
|
||||
deleteElement,
|
||||
duplicateElement,
|
||||
selectedElementId,
|
||||
@@ -22,13 +24,84 @@ function App() {
|
||||
showGrid,
|
||||
setTool,
|
||||
selectElement,
|
||||
newSnap,
|
||||
importSnap,
|
||||
exportSnap,
|
||||
saveToHistory,
|
||||
} = useCanvasStore();
|
||||
|
||||
const { addRecentSnap } = useRecentSnapsStore();
|
||||
|
||||
// Handle file import
|
||||
const handleImportFile = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// Save current snap to recent before importing
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
saveToHistory();
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const json = evt.target?.result as string;
|
||||
importSnap(json);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}, [snap, addRecentSnap, saveToHistory, importSnap]);
|
||||
|
||||
// Handle file export
|
||||
const handleExportFile = useCallback(() => {
|
||||
const json = exportSnap();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = `${snap.meta.title || 'canvas'}.json`;
|
||||
link.href = url;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addRecentSnap(snap);
|
||||
}, [exportSnap, snap, addRecentSnap]);
|
||||
|
||||
// Handle new snap
|
||||
const handleNewSnap = useCallback(() => {
|
||||
if (confirm('Create a new canvas? Unsaved changes will be lost.')) {
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
newSnap({ title: 'Untitled', aspect: '16:9', width: 1920, height: 1080 });
|
||||
}
|
||||
}, [snap, addRecentSnap, newSnap]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isMeta = e.metaKey || e.ctrlKey;
|
||||
|
||||
// New snap (⌘N)
|
||||
if (isMeta && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
handleNewSnap();
|
||||
}
|
||||
|
||||
// Open file (⌘O)
|
||||
if (isMeta && e.key === 'o') {
|
||||
e.preventDefault();
|
||||
handleImportFile();
|
||||
}
|
||||
|
||||
// Save/Export (⌘S)
|
||||
if (isMeta && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleExportFile();
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
if (selectedElementId && !isInputFocused()) {
|
||||
@@ -103,7 +176,7 @@ function App() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedElementId, deleteElement, duplicateElement, undo, redo, zoom, setZoom, showGrid, setShowGrid, setTool, selectElement]);
|
||||
}, [selectedElementId, deleteElement, duplicateElement, undo, redo, zoom, setZoom, showGrid, setShowGrid, setTool, selectElement, handleNewSnap, handleImportFile, handleExportFile]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-neutral-900 text-white">
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import React, { useRef, useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import { Stage, Layer, Rect, Line, Text } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore';
|
||||
@@ -112,8 +112,8 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Grid overlay
|
||||
const renderGrid = () => {
|
||||
// Grid overlay - memoized for performance
|
||||
const gridLines = useMemo(() => {
|
||||
if (!showGrid) return null;
|
||||
const gridSize = 50;
|
||||
const lines = [];
|
||||
@@ -143,10 +143,10 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
}
|
||||
|
||||
return <>{lines}</>;
|
||||
};
|
||||
}, [showGrid, width, height]);
|
||||
|
||||
// Brand strip
|
||||
const renderBrandStrip = () => {
|
||||
// Brand strip - memoized
|
||||
const brandStripElement = useMemo(() => {
|
||||
const brandStrip = background.brandStrip;
|
||||
if (!brandStrip?.enabled) return null;
|
||||
|
||||
@@ -178,9 +178,9 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}, [background.brandStrip, width, height]);
|
||||
|
||||
const stagePosition = getStagePosition();
|
||||
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -200,8 +200,8 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
>
|
||||
<Layer>
|
||||
{renderBackground()}
|
||||
{renderBrandStrip()}
|
||||
{renderGrid()}
|
||||
{brandStripElement}
|
||||
{gridLines}
|
||||
|
||||
{snap.elements.map((element) => {
|
||||
if (!element.visible) return null;
|
||||
@@ -247,4 +247,4 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Canvas;
|
||||
export default memo(Canvas);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import type { CodeElement, TextElement, ArrowElement } from '../types';
|
||||
import BackgroundPanel from './inspector/BackgroundPanel';
|
||||
@@ -16,7 +16,34 @@ const Inspector: React.FC = () => {
|
||||
moveElementDown,
|
||||
} = useCanvasStore();
|
||||
|
||||
const selectedElement = snap.elements.find(el => el.id === selectedElementId);
|
||||
const selectedElement = useMemo(
|
||||
() => snap.elements.find(el => el.id === selectedElementId),
|
||||
[snap.elements, selectedElementId]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
deleteElement(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, deleteElement]);
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
duplicateElement(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, duplicateElement]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
moveElementUp(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, moveElementUp]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
if (selectedElement) {
|
||||
moveElementDown(selectedElement.id);
|
||||
}
|
||||
}, [selectedElement, moveElementDown]);
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-[#09090b] border-l border-white/5 overflow-y-auto h-full">
|
||||
@@ -30,7 +57,7 @@ const Inspector: React.FC = () => {
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => duplicateElement(selectedElement.id)}
|
||||
onClick={handleDuplicate}
|
||||
className="p-1.5 hover:bg-white/10 rounded-md text-neutral-400 hover:text-white transition-colors"
|
||||
title="Duplicate (⌘D)"
|
||||
>
|
||||
@@ -40,7 +67,7 @@ const Inspector: React.FC = () => {
|
||||
</button>
|
||||
<div className="w-px h-3 bg-white/10 mx-1" />
|
||||
<button
|
||||
onClick={() => deleteElement(selectedElement.id)}
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 hover:bg-red-500/10 hover:text-red-500 rounded-md text-neutral-400 transition-colors"
|
||||
title="Delete (⌫)"
|
||||
>
|
||||
@@ -54,7 +81,7 @@ const Inspector: React.FC = () => {
|
||||
{/* Layer Controls */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => moveElementDown(selectedElement.id)}
|
||||
onClick={handleMoveDown}
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-xs font-medium text-neutral-400 hover:text-white transition-colors border border-white/5"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -63,7 +90,7 @@ const Inspector: React.FC = () => {
|
||||
Send Backward
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveElementUp(selectedElement.id)}
|
||||
onClick={handleMoveUp}
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-xs font-medium text-neutral-400 hover:text-white transition-colors border border-white/5"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -98,4 +125,4 @@ const Inspector: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Inspector;
|
||||
export default memo(Inspector);
|
||||
|
||||
+133
-86
@@ -1,20 +1,21 @@
|
||||
import React from 'react';
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import type { CanvasElement } from '../types';
|
||||
|
||||
const LayersPanel: React.FC = () => {
|
||||
const {
|
||||
snap,
|
||||
selectedElementId,
|
||||
selectElement,
|
||||
updateElement,
|
||||
moveElementUp,
|
||||
moveElementDown,
|
||||
deleteElement,
|
||||
} = useCanvasStore();
|
||||
|
||||
const elements = [...snap.elements].reverse(); // Show top layers first
|
||||
|
||||
// Memoized layer item component for performance
|
||||
const LayerItem = memo(({
|
||||
element,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onToggleLock,
|
||||
onToggleVisibility
|
||||
}: {
|
||||
element: CanvasElement;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleLock: () => void;
|
||||
onToggleVisibility: () => void;
|
||||
}) => {
|
||||
const getElementIcon = (type: CanvasElement['type']) => {
|
||||
switch (type) {
|
||||
case 'code':
|
||||
@@ -49,6 +50,113 @@ const LayersPanel: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'bg-blue-600/20 border border-blue-500/50'
|
||||
: 'hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Element type icon */}
|
||||
<div className={`shrink-0 ${isSelected ? 'text-blue-400' : 'text-neutral-400'}`}>
|
||||
{getElementIcon(element.type)}
|
||||
</div>
|
||||
|
||||
{/* Element name */}
|
||||
<span className={`flex-1 text-sm truncate ${
|
||||
isSelected ? 'text-white' : 'text-neutral-300'
|
||||
} ${!element.visible ? 'opacity-50' : ''}`}>
|
||||
{getElementLabel(element)}
|
||||
</span>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* Lock toggle */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleLock();
|
||||
}}
|
||||
className={`p-1 rounded hover:bg-white/10 transition-colors ${
|
||||
element.locked ? 'text-yellow-400' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
title={element.locked ? 'Unlock' : 'Lock'}
|
||||
>
|
||||
{element.locked ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility();
|
||||
}}
|
||||
className={`p-1 rounded hover:bg-white/10 transition-colors ${
|
||||
element.visible ? 'text-neutral-500 hover:text-neutral-300' : 'text-red-400'
|
||||
}`}
|
||||
title={element.visible ? 'Hide' : 'Show'}
|
||||
>
|
||||
{element.visible ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LayerItem.displayName = 'LayerItem';
|
||||
|
||||
const LayersPanel: React.FC = () => {
|
||||
const {
|
||||
snap,
|
||||
selectedElementId,
|
||||
selectElement,
|
||||
updateElement,
|
||||
moveElementUp,
|
||||
moveElementDown,
|
||||
deleteElement,
|
||||
} = useCanvasStore();
|
||||
|
||||
const elements = useMemo(() => [...snap.elements].reverse(), [snap.elements]); // Show top layers first
|
||||
|
||||
const handleToggleLock = useCallback((id: string, locked: boolean) => {
|
||||
updateElement(id, { locked: !locked });
|
||||
}, [updateElement]);
|
||||
|
||||
const handleToggleVisibility = useCallback((id: string, visible: boolean) => {
|
||||
updateElement(id, { visible: !visible });
|
||||
}, [updateElement]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
if (selectedElementId) moveElementUp(selectedElementId);
|
||||
}, [selectedElementId, moveElementUp]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
if (selectedElementId) moveElementDown(selectedElementId);
|
||||
}, [selectedElementId, moveElementDown]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (selectedElementId) deleteElement(selectedElementId);
|
||||
}, [selectedElementId, deleteElement]);
|
||||
|
||||
return (
|
||||
<div className="w-56 bg-[#09090b] border-r border-white/5 flex flex-col h-full">
|
||||
<div className="p-4 border-b border-white/5">
|
||||
@@ -65,75 +173,14 @@ const LayersPanel: React.FC = () => {
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{elements.map((element) => (
|
||||
<div
|
||||
<LayerItem
|
||||
key={element.id}
|
||||
onClick={() => selectElement(element.id)}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
|
||||
selectedElementId === element.id
|
||||
? 'bg-blue-600/20 border border-blue-500/50'
|
||||
: 'hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Element type icon */}
|
||||
<div className={`shrink-0 ${selectedElementId === element.id ? 'text-blue-400' : 'text-neutral-400'}`}>
|
||||
{getElementIcon(element.type)}
|
||||
</div>
|
||||
|
||||
{/* Element name */}
|
||||
<span className={`flex-1 text-sm truncate ${
|
||||
selectedElementId === element.id ? 'text-white' : 'text-neutral-300'
|
||||
} ${!element.visible ? 'opacity-50' : ''}`}>
|
||||
{getElementLabel(element)}
|
||||
</span>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* Lock toggle */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateElement(element.id, { locked: !element.locked });
|
||||
}}
|
||||
className={`p-1 rounded hover:bg-white/10 transition-colors ${
|
||||
element.locked ? 'text-yellow-400' : 'text-neutral-500 hover:text-neutral-300'
|
||||
}`}
|
||||
title={element.locked ? 'Unlock' : 'Lock'}
|
||||
>
|
||||
{element.locked ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateElement(element.id, { visible: !element.visible });
|
||||
}}
|
||||
className={`p-1 rounded hover:bg-white/10 transition-colors ${
|
||||
element.visible ? 'text-neutral-500 hover:text-neutral-300' : 'text-red-400'
|
||||
}`}
|
||||
title={element.visible ? 'Hide' : 'Show'}
|
||||
>
|
||||
{element.visible ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
element={element}
|
||||
isSelected={selectedElementId === element.id}
|
||||
onSelect={() => selectElement(element.id)}
|
||||
onToggleLock={() => handleToggleLock(element.id, element.locked)}
|
||||
onToggleVisibility={() => handleToggleVisibility(element.id, element.visible)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -145,7 +192,7 @@ const LayersPanel: React.FC = () => {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => moveElementDown(selectedElementId)}
|
||||
onClick={handleMoveDown}
|
||||
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
|
||||
title="Move Down (Back)"
|
||||
>
|
||||
@@ -154,7 +201,7 @@ const LayersPanel: React.FC = () => {
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveElementUp(selectedElementId)}
|
||||
onClick={handleMoveUp}
|
||||
className="p-1.5 rounded-md hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
|
||||
title="Move Up (Front)"
|
||||
>
|
||||
@@ -164,7 +211,7 @@ const LayersPanel: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteElement(selectedElementId)}
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 text-neutral-400 hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
@@ -179,4 +226,4 @@ const LayersPanel: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LayersPanel;
|
||||
export default memo(LayersPanel);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useRecentSnapsStore, formatRelativeTime, type RecentSnapEntry } from '../store/recentSnapsStore';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
|
||||
interface RecentSnapsDropdownProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
const RecentSnapItem = memo(({
|
||||
entry,
|
||||
onOpen,
|
||||
onDelete
|
||||
}: {
|
||||
entry: RecentSnapEntry;
|
||||
onOpen: (snap: RecentSnapEntry) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onOpen(entry)}
|
||||
>
|
||||
{/* Thumbnail Preview */}
|
||||
<div
|
||||
className="w-12 h-9 rounded bg-neutral-800 border border-white/10 flex items-center justify-center overflow-hidden flex-shrink-0"
|
||||
style={{
|
||||
background: entry.snap.background.type === 'gradient'
|
||||
? `linear-gradient(${entry.snap.background.gradient.angle}deg, ${entry.snap.background.gradient.from}, ${entry.snap.background.gradient.to})`
|
||||
: entry.snap.background.solid.color
|
||||
}}
|
||||
>
|
||||
<span className="text-[8px] text-white/40">{entry.snap.elements.length} el</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-neutral-200 truncate font-medium">{entry.title}</div>
|
||||
<div className="text-xs text-neutral-500">{formatRelativeTime(entry.savedAt)}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isHovered && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(entry.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-500/20 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
RecentSnapItem.displayName = 'RecentSnapItem';
|
||||
|
||||
const RecentSnapsDropdown: React.FC<RecentSnapsDropdownProps> = ({ isOpen, onClose, anchorRef }) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { recentSnaps, removeRecentSnap, clearRecentSnaps } = useRecentSnapsStore();
|
||||
const { setSnap, saveToHistory } = useCanvasStore();
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
anchorRef.current &&
|
||||
!anchorRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose, anchorRef]);
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleOpenSnap = useCallback((entry: RecentSnapEntry) => {
|
||||
saveToHistory();
|
||||
setSnap(entry.snap);
|
||||
onClose();
|
||||
}, [setSnap, saveToHistory, onClose]);
|
||||
|
||||
const handleDeleteSnap = useCallback((id: string) => {
|
||||
removeRecentSnap(id);
|
||||
}, [removeRecentSnap]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute left-0 top-full mt-2 w-72 bg-[#0a0a0c] border border-white/10 rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
>
|
||||
<div className="p-3 border-b border-white/5 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500 uppercase tracking-wider">Recent Projects</span>
|
||||
{recentSnaps.length > 0 && (
|
||||
<button
|
||||
onClick={clearRecentSnaps}
|
||||
className="text-xs text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{recentSnaps.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-white/5 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500">No recent projects</p>
|
||||
<p className="text-xs text-neutral-600 mt-1">Projects you save will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{recentSnaps.map((entry) => (
|
||||
<RecentSnapItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onOpen={handleOpenSnap}
|
||||
onDelete={handleDeleteSnap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RecentSnapsDropdown);
|
||||
+60
-13
@@ -1,13 +1,18 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useCallback, memo } from 'react';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import { useRecentSnapsStore } from '../store/recentSnapsStore';
|
||||
import { ASPECT_RATIOS } from '../types';
|
||||
import type Konva from 'konva';
|
||||
import RecentSnapsDropdown from './RecentSnapsDropdown';
|
||||
|
||||
interface TopBarProps {
|
||||
stageRef: React.RefObject<Konva.Stage | null>;
|
||||
}
|
||||
|
||||
const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
const [showRecentSnaps, setShowRecentSnaps] = useState(false);
|
||||
const recentButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const {
|
||||
snap,
|
||||
updateMeta,
|
||||
@@ -17,15 +22,22 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
undo,
|
||||
redo,
|
||||
history,
|
||||
saveToHistory,
|
||||
} = useCanvasStore();
|
||||
|
||||
const { addRecentSnap } = useRecentSnapsStore();
|
||||
|
||||
const handleNewSnap = () => {
|
||||
const handleNewSnap = useCallback(() => {
|
||||
if (confirm('Create a new canvas? Unsaved changes will be lost.')) {
|
||||
// Save current snap to recent before creating new
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
newSnap({ title: 'Untitled', aspect: '16:9', width: 1920, height: 1080 });
|
||||
}
|
||||
};
|
||||
}, [snap, addRecentSnap, newSnap]);
|
||||
|
||||
const handleAspectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const handleAspectChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const ratio = ASPECT_RATIOS.find(r => r.name === e.target.value);
|
||||
if (ratio) {
|
||||
updateMeta({
|
||||
@@ -34,9 +46,9 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
height: ratio.height,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [updateMeta]);
|
||||
|
||||
const handleExportImage = async (format: 'png' | 'jpeg', scale: number = 2) => {
|
||||
const handleExportImage = useCallback(async (format: 'png' | 'jpeg', scale: number = 2) => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
@@ -64,9 +76,9 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
link.download = `${snap.meta.title || 'canvas'}.${format}`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
};
|
||||
}, [stageRef, snap.meta]);
|
||||
|
||||
const handleExportJSON = () => {
|
||||
const handleExportJSON = useCallback(() => {
|
||||
const json = exportSnap();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -75,15 +87,23 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
link.href = url;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Save to recent snaps when exporting
|
||||
addRecentSnap(snap);
|
||||
}, [exportSnap, snap, addRecentSnap]);
|
||||
|
||||
const handleImportJSON = () => {
|
||||
const handleImportJSON = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// Save current snap to recent before importing
|
||||
if (snap.elements.length > 0) {
|
||||
addRecentSnap(snap);
|
||||
}
|
||||
saveToHistory();
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const json = e.target?.result as string;
|
||||
@@ -93,7 +113,11 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
}, [snap, addRecentSnap, saveToHistory, importSnap]);
|
||||
|
||||
const toggleRecentSnaps = useCallback(() => {
|
||||
setShowRecentSnaps((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-16 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-40 fixed top-0 w-full">
|
||||
@@ -123,12 +147,35 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
<button
|
||||
onClick={handleImportJSON}
|
||||
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-all active:scale-95"
|
||||
title="Open"
|
||||
title="Open File (⌘O)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Recent Snaps Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={recentButtonRef}
|
||||
onClick={toggleRecentSnaps}
|
||||
className={`p-2 rounded-lg transition-all active:scale-95 ${
|
||||
showRecentSnaps
|
||||
? 'text-white bg-white/10'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
title="Recent Projects"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<RecentSnapsDropdown
|
||||
isOpen={showRecentSnaps}
|
||||
onClose={() => setShowRecentSnaps(false)}
|
||||
anchorRef={recentButtonRef as React.RefObject<HTMLElement>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -225,4 +272,4 @@ const TopBar: React.FC<TopBarProps> = ({ stageRef }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
export default memo(TopBar);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Snap } from '../types';
|
||||
|
||||
export interface RecentSnapEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
thumbnail?: string;
|
||||
savedAt: number;
|
||||
snap: Snap;
|
||||
}
|
||||
|
||||
interface RecentSnapsState {
|
||||
recentSnaps: RecentSnapEntry[];
|
||||
maxRecent: number;
|
||||
|
||||
// Actions
|
||||
addRecentSnap: (snap: Snap, thumbnail?: string) => void;
|
||||
removeRecentSnap: (id: string) => void;
|
||||
clearRecentSnaps: () => void;
|
||||
getRecentSnaps: () => RecentSnapEntry[];
|
||||
}
|
||||
|
||||
export const useRecentSnapsStore = create<RecentSnapsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
recentSnaps: [],
|
||||
maxRecent: 10,
|
||||
|
||||
addRecentSnap: (snap: Snap, thumbnail?: string) => {
|
||||
const id = `snap_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const entry: RecentSnapEntry = {
|
||||
id,
|
||||
title: snap.meta.title || 'Untitled',
|
||||
thumbnail,
|
||||
savedAt: Date.now(),
|
||||
snap: JSON.parse(JSON.stringify(snap)), // Deep clone
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
// Remove duplicates with same title and similar content
|
||||
const filtered = state.recentSnaps.filter(
|
||||
(s) => s.title !== snap.meta.title
|
||||
);
|
||||
|
||||
// Add new entry at the beginning
|
||||
const updated = [entry, ...filtered];
|
||||
|
||||
// Keep only maxRecent entries
|
||||
return {
|
||||
recentSnaps: updated.slice(0, state.maxRecent),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeRecentSnap: (id: string) => {
|
||||
set((state) => ({
|
||||
recentSnaps: state.recentSnaps.filter((s) => s.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearRecentSnaps: () => {
|
||||
set({ recentSnaps: [] });
|
||||
},
|
||||
|
||||
getRecentSnaps: () => {
|
||||
return get().recentSnaps;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'code-canvas-recent-snaps',
|
||||
// Only persist essential data to keep storage size reasonable
|
||||
partialize: (state) => ({
|
||||
recentSnaps: state.recentSnaps.map((entry) => ({
|
||||
...entry,
|
||||
thumbnail: undefined, // Don't persist thumbnails to save space
|
||||
})),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper to format relative time
|
||||
export const formatRelativeTime = (timestamp: number): string => {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
||||
}
|
||||
return 'Just now';
|
||||
};
|
||||
Reference in New Issue
Block a user