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,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}
|
||||
|
||||
Reference in New Issue
Block a user