feat(ui/perf): implement loading states, keyboard navigation, and lazy-loaded tools
Add comprehensive UX and performance improvements: **Loading States & Feedback:** - Add loading overlay with spinner and custom messages - Integrate loading states into all file operations (open, save, export) - Create loading-store.ts for centralized loading state management **Keyboard Navigation:** - Expand keyboard shortcuts to include tool selection (1-7) - Add layer navigation with Arrow Up/Down - Add layer operations (Ctrl+D duplicate, Delete/Backspace remove) - Display keyboard shortcuts in tool tooltips - Enhanced keyboard shortcut system with proper key conflict handling **Performance - Code Splitting:** - Implement dynamic tool loader with lazy loading - Tools load on-demand when first selected - Preload common tools (pencil, brush, eraser) for instant access - Add tool caching to prevent redundant loads - Reduces initial bundle size and improves startup time **Integration:** - Add LoadingOverlay to app layout - Update canvas-with-tools to use lazy-loaded tool instances - Add keyboard shortcut hints to tool palette UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCanvasStore, useLayerStore } from '@/store';
|
||||
import { useHistoryStore } from '@/store/history-store';
|
||||
import { useLoadingStore } from '@/store/loading-store';
|
||||
import {
|
||||
openImageFile,
|
||||
exportCanvasAsImage,
|
||||
@@ -18,6 +19,7 @@ export function useFileOperations() {
|
||||
const { width, height, setDimensions } = useCanvasStore();
|
||||
const { layers, createLayer, clearLayers } = useLayerStore();
|
||||
const { clearHistory } = useHistoryStore();
|
||||
const { setLoading } = useLoadingStore();
|
||||
|
||||
/**
|
||||
* Create new image
|
||||
@@ -43,6 +45,7 @@ export function useFileOperations() {
|
||||
*/
|
||||
const openImage = useCallback(
|
||||
async (file: File) => {
|
||||
setLoading(true, `Opening ${file.name}...`);
|
||||
try {
|
||||
const img = await openImageFile(file);
|
||||
|
||||
@@ -69,9 +72,11 @@ export function useFileOperations() {
|
||||
} catch (error) {
|
||||
console.error('Failed to open image:', error);
|
||||
toast.error('Failed to open image file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[clearLayers, clearHistory, setDimensions, createLayer]
|
||||
[clearLayers, clearHistory, setDimensions, createLayer, setLoading]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -79,6 +84,7 @@ export function useFileOperations() {
|
||||
*/
|
||||
const openProject = useCallback(
|
||||
async (file: File) => {
|
||||
setLoading(true, `Opening project ${file.name}...`);
|
||||
try {
|
||||
const projectData = await loadProject(file);
|
||||
|
||||
@@ -114,9 +120,11 @@ export function useFileOperations() {
|
||||
} catch (error) {
|
||||
console.error('Failed to open project:', error);
|
||||
toast.error('Failed to open project file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[clearLayers, clearHistory, setDimensions, createLayer]
|
||||
[clearLayers, clearHistory, setDimensions, createLayer, setLoading]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -124,6 +132,7 @@ export function useFileOperations() {
|
||||
*/
|
||||
const exportImage = useCallback(
|
||||
async (format: 'png' | 'jpeg' | 'webp', quality: number, filename: string) => {
|
||||
setLoading(true, `Exporting ${filename}.${format}...`);
|
||||
try {
|
||||
// Create temporary canvas with all layers
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
@@ -155,9 +164,11 @@ export function useFileOperations() {
|
||||
} catch (error) {
|
||||
console.error('Failed to export image:', error);
|
||||
toast.error('Failed to export image');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[layers, width, height]
|
||||
[layers, width, height, setLoading]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -165,15 +176,18 @@ export function useFileOperations() {
|
||||
*/
|
||||
const saveProject = useCallback(
|
||||
async (filename: string) => {
|
||||
setLoading(true, `Saving project ${filename}.json...`);
|
||||
try {
|
||||
await exportProject(layers, width, height, filename);
|
||||
toast.success(`Saved project ${filename}.json`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save project:', error);
|
||||
toast.error('Failed to save project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[layers, width, height]
|
||||
[layers, width, height, setLoading]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useHistoryStore } from '@/store/history-store';
|
||||
import { useToolStore, useLayerStore, useTextStore } from '@/store';
|
||||
import { duplicateLayerWithHistory, deleteLayerWithHistory } from '@/lib/layer-operations';
|
||||
import type { ToolType } from '@/types';
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts configuration
|
||||
@@ -14,52 +17,34 @@ interface KeyboardShortcut {
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool shortcuts mapping
|
||||
*/
|
||||
const TOOL_SHORTCUTS: Record<string, ToolType> = {
|
||||
'1': 'pencil',
|
||||
'2': 'brush',
|
||||
'3': 'eraser',
|
||||
'4': 'fill',
|
||||
'5': 'eyedropper',
|
||||
'6': 'text',
|
||||
'7': 'select',
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage keyboard shortcuts
|
||||
*/
|
||||
export function useKeyboardShortcuts() {
|
||||
const { undo, redo, canUndo, canRedo } = useHistoryStore();
|
||||
const { setActiveTool } = useToolStore();
|
||||
const { layers, activeLayerId, setActiveLayer } = useLayerStore();
|
||||
const { isOnCanvasEditorActive } = useTextStore();
|
||||
|
||||
useEffect(() => {
|
||||
const shortcuts: KeyboardShortcut[] = [
|
||||
{
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
shift: false,
|
||||
handler: () => {
|
||||
if (canUndo()) {
|
||||
undo();
|
||||
}
|
||||
},
|
||||
description: 'Undo',
|
||||
},
|
||||
{
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
handler: () => {
|
||||
if (canRedo()) {
|
||||
redo();
|
||||
}
|
||||
},
|
||||
description: 'Redo',
|
||||
},
|
||||
{
|
||||
key: 'y',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
if (canRedo()) {
|
||||
redo();
|
||||
}
|
||||
},
|
||||
description: 'Redo (alternative)',
|
||||
},
|
||||
];
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check if we're in an input field
|
||||
// Check if we're in an input field or text editor is active
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
isOnCanvasEditorActive ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
@@ -67,21 +52,70 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const ctrlMatch = shortcut.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||||
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
|
||||
const altMatch = shortcut.alt ? e.altKey : !e.altKey;
|
||||
// Tool selection shortcuts: 1-7
|
||||
if (TOOL_SHORTCUTS[e.key]) {
|
||||
e.preventDefault();
|
||||
setActiveTool(TOOL_SHORTCUTS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key.toLowerCase() === shortcut.key.toLowerCase() &&
|
||||
ctrlMatch &&
|
||||
shiftMatch &&
|
||||
altMatch
|
||||
) {
|
||||
e.preventDefault();
|
||||
shortcut.handler();
|
||||
break;
|
||||
// Layer navigation: Arrow Up/Down
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
|
||||
// Sort layers by order (highest first, same as UI)
|
||||
const sortedLayers = [...layers].sort((a, b) => b.order - a.order);
|
||||
const currentIndex = sortedLayers.findIndex((l) => l.id === activeLayerId);
|
||||
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
if (e.key === 'ArrowUp' && currentIndex > 0) {
|
||||
setActiveLayer(sortedLayers[currentIndex - 1].id);
|
||||
} else if (e.key === 'ArrowDown' && currentIndex < sortedLayers.length - 1) {
|
||||
setActiveLayer(sortedLayers[currentIndex + 1].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete layer: Delete or Backspace (without modifier keys)
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (activeLayerId && layers.length > 1) {
|
||||
if (confirm('Delete this layer?')) {
|
||||
deleteLayerWithHistory(activeLayerId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Duplicate layer: Ctrl+D
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'd' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (activeLayerId) {
|
||||
duplicateLayerWithHistory(activeLayerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo: Ctrl+Z (but not Ctrl+Shift+Z)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo()) {
|
||||
undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Redo: Ctrl+Shift+Z or Ctrl+Y
|
||||
if (
|
||||
((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) ||
|
||||
((e.ctrlKey || e.metaKey) && e.key === 'y')
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (canRedo()) {
|
||||
redo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,7 +124,17 @@ export function useKeyboardShortcuts() {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [undo, redo, canUndo, canRedo]);
|
||||
}, [
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
setActiveTool,
|
||||
layers,
|
||||
activeLayerId,
|
||||
setActiveLayer,
|
||||
isOnCanvasEditorActive,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user