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,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
import { useHistoryStore } from '@/store/history-store';
import { useSelectionStore } from '@/store/selection-store';
@@ -9,48 +9,17 @@ import { drawMarchingAnts } from '@/lib/selection-utils';
import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
import { renderText } from '@/lib/text-utils';
import { DrawCommand } from '@/core/commands';
import {
PencilTool,
BrushTool,
EraserTool,
FillTool,
EyedropperTool,
RectangularSelectionTool,
EllipticalSelectionTool,
LassoSelectionTool,
MagicWandTool,
MoveTool,
FreeTransformTool,
ShapeTool,
TextTool,
type BaseTool,
} from '@/tools';
import { getTool, preloadCommonTools } from '@/lib/tool-loader';
import type { BaseTool } from '@/tools';
import type { PointerState } from '@/types';
import { cn } from '@/lib/utils';
import { OnCanvasTextEditor } from './on-canvas-text-editor';
// Tool instances
const tools: Record<string, BaseTool> = {
pencil: new PencilTool(),
brush: new BrushTool(),
eraser: new EraserTool(),
fill: new FillTool(),
eyedropper: new EyedropperTool(),
select: new RectangularSelectionTool(),
'rectangular-select': new RectangularSelectionTool(),
'elliptical-select': new EllipticalSelectionTool(),
'lasso-select': new LassoSelectionTool(),
'magic-wand': new MagicWandTool(),
move: new MoveTool(),
transform: new FreeTransformTool(),
shape: new ShapeTool(),
text: new TextTool(),
};
export function CanvasWithTools() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const drawCommandRef = useRef<DrawCommand | null>(null);
const toolsCache = useRef<Record<string, BaseTool>>({});
const {
width,
@@ -83,6 +52,52 @@ export function CanvasWithTools() {
pressure: 1,
});
// Helper to get tool (lazy load if not in cache)
const getToolInstance = useCallback(async (toolKey: string): Promise<BaseTool | null> => {
if (toolsCache.current[toolKey]) {
return toolsCache.current[toolKey];
}
try {
const tool = await getTool(toolKey as any);
toolsCache.current[toolKey] = tool;
return tool;
} catch (error) {
console.error(`Failed to load tool ${toolKey}:`, error);
return null;
}
}, []);
// Preload common tools on mount
useEffect(() => {
preloadCommonTools();
// Preload tools into cache
const initTools = async () => {
const commonTools = ['pencil', 'brush', 'eraser'];
for (const toolKey of commonTools) {
await getToolInstance(toolKey);
}
};
initTools();
}, [getToolInstance]);
// Eagerly load active tool when it changes
useEffect(() => {
const loadActiveTool = async () => {
// Load the active tool
await getToolInstance(activeTool);
// For selection tools, also load the specific selection type
if (activeTool === 'select') {
await getToolInstance(`${selectionType}-select`);
}
};
loadActiveTool();
}, [activeTool, selectionType, getToolInstance]);
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
@@ -212,7 +227,9 @@ export function CanvasWithTools() {
// Transform tools
const transformTools = ['move', 'transform'];
if (e.button === 0 && !e.shiftKey && transformTools.includes(activeTool)) {
const tool = tools[activeTool];
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
@@ -244,7 +261,10 @@ export function CanvasWithTools() {
pressure: e.pressure || 1,
};
tools.text.onPointerDown(newPointer, {} as any, settings);
const textTool = toolsCache.current['text'];
if (textTool) {
textTool.onPointerDown(newPointer, {} as any, settings);
}
return;
}
@@ -254,7 +274,8 @@ export function CanvasWithTools() {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const tool = tools[`${selectionType}-select`] || tools['select'];
const tool = toolsCache.current[`${selectionType}-select`] || toolsCache.current['select'];
if (!tool) return; // Tool not loaded yet
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
@@ -289,13 +310,16 @@ export function CanvasWithTools() {
setPointer(newPointer);
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
// Create draw command for history
drawCommandRef.current = new DrawCommand(activeLayer.id, tools[activeTool].name);
drawCommandRef.current = new DrawCommand(activeLayer.id, tool.name);
// Call tool's onPointerDown
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tools[activeTool].onPointerDown(newPointer, ctx, settings);
tool.onPointerDown(newPointer, ctx, settings);
}
}
};
@@ -330,9 +354,12 @@ export function CanvasWithTools() {
setPointer(newPointer);
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tools[activeTool].onPointerMove(newPointer, ctx, settings);
tool.onPointerMove(newPointer, ctx, settings);
}
}
};
@@ -348,9 +375,12 @@ export function CanvasWithTools() {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const tool = toolsCache.current[activeTool];
if (!tool) return; // Tool not loaded yet
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tools[activeTool].onPointerUp(pointer, ctx, settings);
tool.onPointerUp(pointer, ctx, settings);
}
// Capture after state and add to history
@@ -369,7 +399,7 @@ export function CanvasWithTools() {
ref={containerRef}
className={cn(
'relative h-full w-full overflow-hidden bg-canvas-bg',
isPanning ? 'cursor-grabbing' : `cursor-${tools[activeTool]?.getCursor(settings) || 'default'}`
isPanning ? 'cursor-grabbing' : `cursor-${toolsCache.current[activeTool]?.getCursor(settings) || 'default'}`
)}
onWheel={handleWheel}
onPointerDown={handlePointerDown}

View File

@@ -13,14 +13,14 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
const tools: { type: ToolType; icon: React.ReactNode; label: string }[] = [
{ type: 'pencil', icon: <Pencil className="h-5 w-5" />, label: 'Pencil' },
{ type: 'brush', icon: <Paintbrush className="h-5 w-5" />, label: 'Brush' },
{ type: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser' },
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill' },
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper' },
{ type: 'text', icon: <Type className="h-5 w-5" />, label: 'Text' },
{ type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select' },
const tools: { type: ToolType; icon: React.ReactNode; label: string; shortcut: string }[] = [
{ type: 'pencil', icon: <Pencil className="h-5 w-5" />, label: 'Pencil', shortcut: '1' },
{ type: 'brush', icon: <Paintbrush className="h-5 w-5" />, label: 'Brush', shortcut: '2' },
{ type: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser', shortcut: '3' },
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill', shortcut: '4' },
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper', shortcut: '5' },
{ type: 'text', icon: <Type className="h-5 w-5" />, label: 'Text', shortcut: '6' },
{ type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select', shortcut: '7' },
];
export function ToolPalette() {
@@ -49,9 +49,9 @@ export function ToolPalette() {
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
)}
aria-label={tool.label}
aria-label={`${tool.label} (${tool.shortcut})`}
aria-pressed={activeTool === tool.type}
title={tool.label}
title={`${tool.label} (${tool.shortcut})`}
>
{tool.icon}
</button>

View File

@@ -0,0 +1,29 @@
'use client';
import { useLoadingStore } from '@/store/loading-store';
import { Loader2 } from 'lucide-react';
export function LoadingOverlay() {
const { isLoading, loadingMessage } = useLoadingStore();
if (!isLoading) return null;
return (
<div
className="fixed inset-0 z-[10000] bg-background/80 backdrop-blur-sm flex items-center justify-center animate-fadeIn"
role="dialog"
aria-busy="true"
aria-label="Loading"
>
<div className="bg-card border border-border rounded-lg p-6 shadow-2xl flex flex-col items-center gap-4 min-w-[300px]">
<Loader2 className="h-12 w-12 text-primary animate-spin" aria-hidden="true" />
{loadingMessage && (
<p className="text-sm font-medium text-foreground text-center">
{loadingMessage}
</p>
)}
<p className="text-xs text-muted-foreground">Please wait...</p>
</div>
</div>
);
}