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:
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { ToastProvider } from '@/components/providers/toast-provider';
|
import { ToastProvider } from '@/components/providers/toast-provider';
|
||||||
import { ContextMenu } from '@/components/ui/context-menu';
|
import { ContextMenu } from '@/components/ui/context-menu';
|
||||||
|
import { LoadingOverlay } from '@/components/ui/loading-overlay';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Paint UI - Browser Image Editor',
|
title: 'Paint UI - Browser Image Editor',
|
||||||
@@ -38,6 +39,7 @@ export default function RootLayout({
|
|||||||
{children}
|
{children}
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<ContextMenu />
|
<ContextMenu />
|
||||||
|
<LoadingOverlay />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
|
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
|
||||||
import { useHistoryStore } from '@/store/history-store';
|
import { useHistoryStore } from '@/store/history-store';
|
||||||
import { useSelectionStore } from '@/store/selection-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 { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
|
||||||
import { renderText } from '@/lib/text-utils';
|
import { renderText } from '@/lib/text-utils';
|
||||||
import { DrawCommand } from '@/core/commands';
|
import { DrawCommand } from '@/core/commands';
|
||||||
import {
|
import { getTool, preloadCommonTools } from '@/lib/tool-loader';
|
||||||
PencilTool,
|
import type { BaseTool } from '@/tools';
|
||||||
BrushTool,
|
|
||||||
EraserTool,
|
|
||||||
FillTool,
|
|
||||||
EyedropperTool,
|
|
||||||
RectangularSelectionTool,
|
|
||||||
EllipticalSelectionTool,
|
|
||||||
LassoSelectionTool,
|
|
||||||
MagicWandTool,
|
|
||||||
MoveTool,
|
|
||||||
FreeTransformTool,
|
|
||||||
ShapeTool,
|
|
||||||
TextTool,
|
|
||||||
type BaseTool,
|
|
||||||
} from '@/tools';
|
|
||||||
import type { PointerState } from '@/types';
|
import type { PointerState } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { OnCanvasTextEditor } from './on-canvas-text-editor';
|
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() {
|
export function CanvasWithTools() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const drawCommandRef = useRef<DrawCommand | null>(null);
|
const drawCommandRef = useRef<DrawCommand | null>(null);
|
||||||
|
const toolsCache = useRef<Record<string, BaseTool>>({});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
width,
|
width,
|
||||||
@@ -83,6 +52,52 @@ export function CanvasWithTools() {
|
|||||||
pressure: 1,
|
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
|
// Render canvas
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -212,7 +227,9 @@ export function CanvasWithTools() {
|
|||||||
// Transform tools
|
// Transform tools
|
||||||
const transformTools = ['move', 'transform'];
|
const transformTools = ['move', 'transform'];
|
||||||
if (e.button === 0 && !e.shiftKey && transformTools.includes(activeTool)) {
|
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 = {
|
const newPointer: PointerState = {
|
||||||
isDown: true,
|
isDown: true,
|
||||||
x: canvasPos.x,
|
x: canvasPos.x,
|
||||||
@@ -244,7 +261,10 @@ export function CanvasWithTools() {
|
|||||||
pressure: e.pressure || 1,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +274,8 @@ export function CanvasWithTools() {
|
|||||||
const activeLayer = getActiveLayer();
|
const activeLayer = getActiveLayer();
|
||||||
if (!activeLayer || !activeLayer.canvas) return;
|
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 = {
|
const newPointer: PointerState = {
|
||||||
isDown: true,
|
isDown: true,
|
||||||
x: canvasPos.x,
|
x: canvasPos.x,
|
||||||
@@ -289,13 +310,16 @@ export function CanvasWithTools() {
|
|||||||
|
|
||||||
setPointer(newPointer);
|
setPointer(newPointer);
|
||||||
|
|
||||||
|
const tool = toolsCache.current[activeTool];
|
||||||
|
if (!tool) return; // Tool not loaded yet
|
||||||
|
|
||||||
// Create draw command for history
|
// 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
|
// Call tool's onPointerDown
|
||||||
const ctx = activeLayer.canvas.getContext('2d');
|
const ctx = activeLayer.canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
tools[activeTool].onPointerDown(newPointer, ctx, settings);
|
tool.onPointerDown(newPointer, ctx, settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -330,9 +354,12 @@ export function CanvasWithTools() {
|
|||||||
|
|
||||||
setPointer(newPointer);
|
setPointer(newPointer);
|
||||||
|
|
||||||
|
const tool = toolsCache.current[activeTool];
|
||||||
|
if (!tool) return; // Tool not loaded yet
|
||||||
|
|
||||||
const ctx = activeLayer.canvas.getContext('2d');
|
const ctx = activeLayer.canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
tools[activeTool].onPointerMove(newPointer, ctx, settings);
|
tool.onPointerMove(newPointer, ctx, settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -348,9 +375,12 @@ export function CanvasWithTools() {
|
|||||||
const activeLayer = getActiveLayer();
|
const activeLayer = getActiveLayer();
|
||||||
if (!activeLayer || !activeLayer.canvas) return;
|
if (!activeLayer || !activeLayer.canvas) return;
|
||||||
|
|
||||||
|
const tool = toolsCache.current[activeTool];
|
||||||
|
if (!tool) return; // Tool not loaded yet
|
||||||
|
|
||||||
const ctx = activeLayer.canvas.getContext('2d');
|
const ctx = activeLayer.canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
tools[activeTool].onPointerUp(pointer, ctx, settings);
|
tool.onPointerUp(pointer, ctx, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture after state and add to history
|
// Capture after state and add to history
|
||||||
@@ -369,7 +399,7 @@ export function CanvasWithTools() {
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative h-full w-full overflow-hidden bg-canvas-bg',
|
'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}
|
onWheel={handleWheel}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const tools: { type: ToolType; icon: React.ReactNode; label: string }[] = [
|
const tools: { type: ToolType; icon: React.ReactNode; label: string; shortcut: string }[] = [
|
||||||
{ type: 'pencil', icon: <Pencil className="h-5 w-5" />, label: 'Pencil' },
|
{ type: 'pencil', icon: <Pencil className="h-5 w-5" />, label: 'Pencil', shortcut: '1' },
|
||||||
{ type: 'brush', icon: <Paintbrush className="h-5 w-5" />, label: 'Brush' },
|
{ type: 'brush', icon: <Paintbrush className="h-5 w-5" />, label: 'Brush', shortcut: '2' },
|
||||||
{ type: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser' },
|
{ type: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser', shortcut: '3' },
|
||||||
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill' },
|
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill', shortcut: '4' },
|
||||||
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper' },
|
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper', shortcut: '5' },
|
||||||
{ type: 'text', icon: <Type className="h-5 w-5" />, label: 'Text' },
|
{ type: 'text', icon: <Type className="h-5 w-5" />, label: 'Text', shortcut: '6' },
|
||||||
{ type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select' },
|
{ type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select', shortcut: '7' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ToolPalette() {
|
export function ToolPalette() {
|
||||||
@@ -49,9 +49,9 @@ export function ToolPalette() {
|
|||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'hover:bg-accent text-muted-foreground hover:text-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}
|
aria-pressed={activeTool === tool.type}
|
||||||
title={tool.label}
|
title={`${tool.label} (${tool.shortcut})`}
|
||||||
>
|
>
|
||||||
{tool.icon}
|
{tool.icon}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
29
components/ui/loading-overlay.tsx
Normal file
29
components/ui/loading-overlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useCanvasStore, useLayerStore } from '@/store';
|
import { useCanvasStore, useLayerStore } from '@/store';
|
||||||
import { useHistoryStore } from '@/store/history-store';
|
import { useHistoryStore } from '@/store/history-store';
|
||||||
|
import { useLoadingStore } from '@/store/loading-store';
|
||||||
import {
|
import {
|
||||||
openImageFile,
|
openImageFile,
|
||||||
exportCanvasAsImage,
|
exportCanvasAsImage,
|
||||||
@@ -18,6 +19,7 @@ export function useFileOperations() {
|
|||||||
const { width, height, setDimensions } = useCanvasStore();
|
const { width, height, setDimensions } = useCanvasStore();
|
||||||
const { layers, createLayer, clearLayers } = useLayerStore();
|
const { layers, createLayer, clearLayers } = useLayerStore();
|
||||||
const { clearHistory } = useHistoryStore();
|
const { clearHistory } = useHistoryStore();
|
||||||
|
const { setLoading } = useLoadingStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new image
|
* Create new image
|
||||||
@@ -43,6 +45,7 @@ export function useFileOperations() {
|
|||||||
*/
|
*/
|
||||||
const openImage = useCallback(
|
const openImage = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
|
setLoading(true, `Opening ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
const img = await openImageFile(file);
|
const img = await openImageFile(file);
|
||||||
|
|
||||||
@@ -69,9 +72,11 @@ export function useFileOperations() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open image:', error);
|
console.error('Failed to open image:', error);
|
||||||
toast.error('Failed to open image file');
|
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(
|
const openProject = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
|
setLoading(true, `Opening project ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
const projectData = await loadProject(file);
|
const projectData = await loadProject(file);
|
||||||
|
|
||||||
@@ -114,9 +120,11 @@ export function useFileOperations() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open project:', error);
|
console.error('Failed to open project:', error);
|
||||||
toast.error('Failed to open project file');
|
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(
|
const exportImage = useCallback(
|
||||||
async (format: 'png' | 'jpeg' | 'webp', quality: number, filename: string) => {
|
async (format: 'png' | 'jpeg' | 'webp', quality: number, filename: string) => {
|
||||||
|
setLoading(true, `Exporting ${filename}.${format}...`);
|
||||||
try {
|
try {
|
||||||
// Create temporary canvas with all layers
|
// Create temporary canvas with all layers
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
@@ -155,9 +164,11 @@ export function useFileOperations() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to export image:', error);
|
console.error('Failed to export image:', error);
|
||||||
toast.error('Failed to export image');
|
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(
|
const saveProject = useCallback(
|
||||||
async (filename: string) => {
|
async (filename: string) => {
|
||||||
|
setLoading(true, `Saving project ${filename}.json...`);
|
||||||
try {
|
try {
|
||||||
await exportProject(layers, width, height, filename);
|
await exportProject(layers, width, height, filename);
|
||||||
toast.success(`Saved project ${filename}.json`);
|
toast.success(`Saved project ${filename}.json`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save project:', error);
|
console.error('Failed to save project:', error);
|
||||||
toast.error('Failed to save project');
|
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 { useEffect } from 'react';
|
||||||
import { useHistoryStore } from '@/store/history-store';
|
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
|
* Keyboard shortcuts configuration
|
||||||
@@ -14,52 +17,34 @@ interface KeyboardShortcut {
|
|||||||
description: string;
|
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
|
* Hook to manage keyboard shortcuts
|
||||||
*/
|
*/
|
||||||
export function useKeyboardShortcuts() {
|
export function useKeyboardShortcuts() {
|
||||||
const { undo, redo, canUndo, canRedo } = useHistoryStore();
|
const { undo, redo, canUndo, canRedo } = useHistoryStore();
|
||||||
|
const { setActiveTool } = useToolStore();
|
||||||
|
const { layers, activeLayerId, setActiveLayer } = useLayerStore();
|
||||||
|
const { isOnCanvasEditorActive } = useTextStore();
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
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;
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (
|
||||||
|
isOnCanvasEditorActive ||
|
||||||
target.tagName === 'INPUT' ||
|
target.tagName === 'INPUT' ||
|
||||||
target.tagName === 'TEXTAREA' ||
|
target.tagName === 'TEXTAREA' ||
|
||||||
target.isContentEditable
|
target.isContentEditable
|
||||||
@@ -67,21 +52,70 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const shortcut of shortcuts) {
|
// Tool selection shortcuts: 1-7
|
||||||
const ctrlMatch = shortcut.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
if (TOOL_SHORTCUTS[e.key]) {
|
||||||
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
|
e.preventDefault();
|
||||||
const altMatch = shortcut.alt ? e.altKey : !e.altKey;
|
setActiveTool(TOOL_SHORTCUTS[e.key]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
// Layer navigation: Arrow Up/Down
|
||||||
e.key.toLowerCase() === shortcut.key.toLowerCase() &&
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
ctrlMatch &&
|
e.preventDefault();
|
||||||
shiftMatch &&
|
|
||||||
altMatch
|
// Sort layers by order (highest first, same as UI)
|
||||||
) {
|
const sortedLayers = [...layers].sort((a, b) => b.order - a.order);
|
||||||
e.preventDefault();
|
const currentIndex = sortedLayers.findIndex((l) => l.id === activeLayerId);
|
||||||
shortcut.handler();
|
|
||||||
break;
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [undo, redo, canUndo, canRedo]);
|
}, [
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
setActiveTool,
|
||||||
|
layers,
|
||||||
|
activeLayerId,
|
||||||
|
setActiveLayer,
|
||||||
|
isOnCanvasEditorActive,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
152
lib/tool-loader.ts
Normal file
152
lib/tool-loader.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { BaseTool } from '@/tools';
|
||||||
|
import type { ToolType } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool loader cache
|
||||||
|
*/
|
||||||
|
const toolCache = new Map<string, BaseTool>();
|
||||||
|
const toolLoadingPromises = new Map<string, Promise<BaseTool>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically import and instantiate a tool
|
||||||
|
*/
|
||||||
|
async function loadTool(toolType: ToolType): Promise<BaseTool> {
|
||||||
|
// 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<BaseTool> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
19
store/loading-store.ts
Normal file
19
store/loading-store.ts
Normal file
@@ -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<LoadingState>((set) => ({
|
||||||
|
isLoading: false,
|
||||||
|
loadingMessage: null,
|
||||||
|
|
||||||
|
setLoading: (loading: boolean, message?: string) => {
|
||||||
|
set({
|
||||||
|
isLoading: loading,
|
||||||
|
loadingMessage: message || null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user