From 2e18f4345370e6fb8dd2a1eaa678e09637ec1e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 21 Nov 2025 16:08:24 +0100 Subject: [PATCH] feat(ui/perf): implement loading states, keyboard navigation, and lazy-loaded tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/layout.tsx | 2 + components/canvas/canvas-with-tools.tsx | 116 +++++++++++------- components/tools/tool-palette.tsx | 20 ++-- components/ui/loading-overlay.tsx | 29 +++++ hooks/use-file-operations.ts | 22 +++- hooks/use-keyboard-shortcuts.ts | 144 ++++++++++++++-------- lib/tool-loader.ts | 152 ++++++++++++++++++++++++ store/loading-store.ts | 19 +++ 8 files changed, 397 insertions(+), 107 deletions(-) create mode 100644 components/ui/loading-overlay.tsx create mode 100644 lib/tool-loader.ts create mode 100644 store/loading-store.ts diff --git a/app/layout.tsx b/app/layout.tsx index 8d73368..6feb4b2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import './globals.css'; import { ToastProvider } from '@/components/providers/toast-provider'; import { ContextMenu } from '@/components/ui/context-menu'; +import { LoadingOverlay } from '@/components/ui/loading-overlay'; export const metadata: Metadata = { title: 'Paint UI - Browser Image Editor', @@ -38,6 +39,7 @@ export default function RootLayout({ {children} + ); diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index 74f3b96..2d94db2 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -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 = { - 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(null); const containerRef = useRef(null); const drawCommandRef = useRef(null); + const toolsCache = useRef>({}); 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 => { + 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} diff --git a/components/tools/tool-palette.tsx b/components/tools/tool-palette.tsx index b658fe3..3f9aa52 100644 --- a/components/tools/tool-palette.tsx +++ b/components/tools/tool-palette.tsx @@ -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: , label: 'Pencil' }, - { type: 'brush', icon: , label: 'Brush' }, - { type: 'eraser', icon: , label: 'Eraser' }, - { type: 'fill', icon: , label: 'Fill' }, - { type: 'eyedropper', icon: , label: 'Eyedropper' }, - { type: 'text', icon: , label: 'Text' }, - { type: 'select', icon: , label: 'Select' }, +const tools: { type: ToolType; icon: React.ReactNode; label: string; shortcut: string }[] = [ + { type: 'pencil', icon: , label: 'Pencil', shortcut: '1' }, + { type: 'brush', icon: , label: 'Brush', shortcut: '2' }, + { type: 'eraser', icon: , label: 'Eraser', shortcut: '3' }, + { type: 'fill', icon: , label: 'Fill', shortcut: '4' }, + { type: 'eyedropper', icon: , label: 'Eyedropper', shortcut: '5' }, + { type: 'text', icon: , label: 'Text', shortcut: '6' }, + { type: 'select', icon: , 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} diff --git a/components/ui/loading-overlay.tsx b/components/ui/loading-overlay.tsx new file mode 100644 index 0000000..72b43b9 --- /dev/null +++ b/components/ui/loading-overlay.tsx @@ -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 ( +
+
+
+
+ ); +} diff --git a/hooks/use-file-operations.ts b/hooks/use-file-operations.ts index cf1b2f8..86da653 100644 --- a/hooks/use-file-operations.ts +++ b/hooks/use-file-operations.ts @@ -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] ); /** diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 1f9520b..93bebfc 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -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 = { + '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, + ]); } /** diff --git a/lib/tool-loader.ts b/lib/tool-loader.ts new file mode 100644 index 0000000..857207d --- /dev/null +++ b/lib/tool-loader.ts @@ -0,0 +1,152 @@ +import type { BaseTool } from '@/tools'; +import type { ToolType } from '@/types'; + +/** + * Tool loader cache + */ +const toolCache = new Map(); +const toolLoadingPromises = new Map>(); + +/** + * Dynamically import and instantiate a tool + */ +async function loadTool(toolType: ToolType): Promise { + // Check cache first + if (toolCache.has(toolType)) { + return toolCache.get(toolType)!; + } + + // Check if already loading + if (toolLoadingPromises.has(toolType)) { + return toolLoadingPromises.get(toolType)!; + } + + // Start loading + const loadPromise = (async () => { + let tool: BaseTool; + + switch (toolType) { + case 'pencil': { + const { PencilTool } = await import('@/tools/pencil-tool'); + tool = new PencilTool(); + break; + } + case 'brush': { + const { BrushTool } = await import('@/tools/brush-tool'); + tool = new BrushTool(); + break; + } + case 'eraser': { + const { EraserTool } = await import('@/tools/eraser-tool'); + tool = new EraserTool(); + break; + } + case 'fill': { + const { FillTool } = await import('@/tools/fill-tool'); + tool = new FillTool(); + break; + } + case 'eyedropper': { + const { EyedropperTool } = await import('@/tools/eyedropper-tool'); + tool = new EyedropperTool(); + break; + } + case 'select': + case 'rectangular-select': { + const { RectangularSelectionTool } = await import('@/tools/rectangular-selection-tool'); + tool = new RectangularSelectionTool(); + break; + } + case 'elliptical-select': { + const { EllipticalSelectionTool } = await import('@/tools/elliptical-selection-tool'); + tool = new EllipticalSelectionTool(); + break; + } + case 'lasso-select': { + const { LassoSelectionTool } = await import('@/tools/lasso-selection-tool'); + tool = new LassoSelectionTool(); + break; + } + case 'magic-wand': { + const { MagicWandTool } = await import('@/tools/magic-wand-tool'); + tool = new MagicWandTool(); + break; + } + case 'move': { + const { MoveTool } = await import('@/tools/move-tool'); + tool = new MoveTool(); + break; + } + case 'transform': { + const { FreeTransformTool } = await import('@/tools/free-transform-tool'); + tool = new FreeTransformTool(); + break; + } + case 'shape': { + const { ShapeTool } = await import('@/tools/shape-tool'); + tool = new ShapeTool(); + break; + } + case 'text': { + const { TextTool } = await import('@/tools/text-tool'); + tool = new TextTool(); + break; + } + default: { + // Fallback to pencil tool + const { PencilTool } = await import('@/tools/pencil-tool'); + tool = new PencilTool(); + } + } + + // Cache the tool + toolCache.set(toolType, tool); + toolLoadingPromises.delete(toolType); + + return tool; + })(); + + toolLoadingPromises.set(toolType, loadPromise); + return loadPromise; +} + +/** + * Get a tool instance (loads it if not cached) + */ +export async function getTool(toolType: ToolType): Promise { + return loadTool(toolType); +} + +/** + * Preload a tool (for performance optimization) + */ +export function preloadTool(toolType: ToolType): void { + loadTool(toolType).catch((error) => { + console.error(`Failed to preload tool ${toolType}:`, error); + }); +} + +/** + * Preload commonly used tools + */ +export function preloadCommonTools(): void { + // Preload the most commonly used tools + preloadTool('pencil'); + preloadTool('brush'); + preloadTool('eraser'); +} + +/** + * Check if a tool is loaded + */ +export function isToolLoaded(toolType: ToolType): boolean { + return toolCache.has(toolType); +} + +/** + * Clear tool cache (for testing) + */ +export function clearToolCache(): void { + toolCache.clear(); + toolLoadingPromises.clear(); +} diff --git a/store/loading-store.ts b/store/loading-store.ts new file mode 100644 index 0000000..d84bf7a --- /dev/null +++ b/store/loading-store.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +interface LoadingState { + isLoading: boolean; + loadingMessage: string | null; + setLoading: (loading: boolean, message?: string) => void; +} + +export const useLoadingStore = create((set) => ({ + isLoading: false, + loadingMessage: null, + + setLoading: (loading: boolean, message?: string) => { + set({ + isLoading: loading, + loadingMessage: message || null, + }); + }, +}));