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:
2025-11-21 16:08:24 +01:00
parent 108dfb5cec
commit 2e18f43453
8 changed files with 397 additions and 107 deletions

View File

@@ -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]
);
/**

View File

@@ -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,
]);
}
/**