diff --git a/src/App.tsx b/src/App.tsx index 7d54885..a65ece0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 (
diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index a92f366..de9e823 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -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 = ({ 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 = ({ 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 = ({ stageRef }) => { )} ); - }; + }, [background.brandStrip, width, height]); - const stagePosition = getStagePosition(); + const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]); return (
= ({ stageRef }) => { > {renderBackground()} - {renderBrandStrip()} - {renderGrid()} + {brandStripElement} + {gridLines} {snap.elements.map((element) => { if (!element.visible) return null; @@ -247,4 +247,4 @@ const Canvas: React.FC = ({ stageRef }) => { ); }; -export default Canvas; +export default memo(Canvas); diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 9a4fdd5..807baa0 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -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 (
@@ -30,7 +57,7 @@ const Inspector: React.FC = () => {
); }; -export default Inspector; +export default memo(Inspector); diff --git a/src/components/LayersPanel.tsx b/src/components/LayersPanel.tsx index 25387b4..e94d490 100644 --- a/src/components/LayersPanel.tsx +++ b/src/components/LayersPanel.tsx @@ -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 ( +
+ {/* Element type icon */} +
+ {getElementIcon(element.type)} +
+ + {/* Element name */} + + {getElementLabel(element)} + + + {/* Action buttons */} +
+ {/* Lock toggle */} + + + {/* Visibility toggle */} + +
+
+ ); +}); + +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 (
@@ -65,75 +173,14 @@ const LayersPanel: React.FC = () => { ) : (
{elements.map((element) => ( -
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 */} -
- {getElementIcon(element.type)} -
- - {/* Element name */} - - {getElementLabel(element)} - - - {/* Action buttons */} -
- {/* Lock toggle */} - - - {/* Visibility toggle */} - -
-
+ element={element} + isSelected={selectedElementId === element.id} + onSelect={() => selectElement(element.id)} + onToggleLock={() => handleToggleLock(element.id, element.locked)} + onToggleVisibility={() => handleToggleVisibility(element.id, element.visible)} + /> ))}
)} @@ -145,7 +192,7 @@ const LayersPanel: React.FC = () => {
+ )} +
+ ); +}); + +RecentSnapItem.displayName = 'RecentSnapItem'; + +const RecentSnapsDropdown: React.FC = ({ isOpen, onClose, anchorRef }) => { + const dropdownRef = useRef(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 ( +
+
+ Recent Projects + {recentSnaps.length > 0 && ( + + )} +
+ +
+ {recentSnaps.length === 0 ? ( +
+
+ + + +
+

No recent projects

+

Projects you save will appear here

+
+ ) : ( +
+ {recentSnaps.map((entry) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default memo(RecentSnapsDropdown); diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 9978c4c..6426f72 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -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; } const TopBar: React.FC = ({ stageRef }) => { + const [showRecentSnaps, setShowRecentSnaps] = useState(false); + const recentButtonRef = useRef(null); + const { snap, updateMeta, @@ -17,15 +22,22 @@ const TopBar: React.FC = ({ 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) => { + const handleAspectChange = useCallback((e: React.ChangeEvent) => { const ratio = ASPECT_RATIOS.find(r => r.name === e.target.value); if (ratio) { updateMeta({ @@ -34,9 +46,9 @@ const TopBar: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ stageRef }) => { } }; input.click(); - }; + }, [snap, addRecentSnap, saveToHistory, importSnap]); + + const toggleRecentSnaps = useCallback(() => { + setShowRecentSnaps((prev) => !prev); + }, []); return (
@@ -123,12 +147,35 @@ const TopBar: React.FC = ({ stageRef }) => { + + {/* Recent Snaps Button */} +
+ + setShowRecentSnaps(false)} + anchorRef={recentButtonRef as React.RefObject} + /> +
@@ -225,4 +272,4 @@ const TopBar: React.FC = ({ stageRef }) => { ); }; -export default TopBar; +export default memo(TopBar); diff --git a/src/store/recentSnapsStore.ts b/src/store/recentSnapsStore.ts new file mode 100644 index 0000000..509f504 --- /dev/null +++ b/src/store/recentSnapsStore.ts @@ -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()( + 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'; +};