Implements comprehensive quality-of-life improvements for professional editing experience: **1. Status Bar Component** (`components/editor/status-bar.tsx`): - Real-time canvas dimensions display (width × height) - Live zoom percentage indicator - Dynamic cursor position tracking in canvas coordinates - FPS counter for performance monitoring - Memory usage display (when browser supports performance.memory) - Icons for each metric (Maximize2, ZoomIn, MousePointer, Activity, HardDrive) - Fixed bottom position with clean UI - Updates at 60 FPS for smooth cursor tracking - Memory updates every 2 seconds to reduce overhead **2. Mini-Map / Navigator** (`components/canvas/mini-map.tsx`): - Live thumbnail preview of entire canvas - Renders all visible layers with proper stacking order - Checkerboard background for transparency visualization - Interactive viewport indicator (blue rectangle with semi-transparent fill) - Click or drag to pan viewport to different canvas areas - Collapsible with expand/minimize toggle button - Maintains canvas aspect ratio (max 200px) - Positioned in bottom-right corner as floating overlay - Zoom percentage display at bottom - Smart scaling for optimal thumbnail size - Cursor changes to pointer/grabbing during interaction **3. Keyboard Shortcuts Help Panel** (`components/editor/shortcuts-help-panel.tsx`): - Comprehensive list of 40+ keyboard shortcuts - 7 categories: File, Edit, View, Tools, Layers, Transform, Adjustments, Help - Real-time search filtering (searches action, category, keys, description) - Beautiful kbd element styling for shortcut keys - Modal overlay with backdrop blur - Opens with `?` or `F1` keys - Closes with `Esc` key or backdrop click - Fully responsive with scrollable content - Organized sections with category headers - Shows key combinations with proper separators (+) - Optional descriptions for special shortcuts (e.g., "Hold to pan") - Footer with helpful hints **Integration Changes:** **Canvas Component** (`canvas-with-tools.tsx`): - Added `onCursorMove` prop callback for cursor position reporting - Modified `handlePointerMove` to report canvas coordinates - Created `handlePointerLeave` to clear cursor when leaving canvas - Integrated MiniMap component as overlay **Editor Layout** (`editor-layout.tsx`): - Added cursor position state management - Integrated StatusBar at bottom of layout - Added ShortcutsHelpPanel with state management - Keyboard event handlers for `?` and `F1` to open shortcuts - Cursor position passed down to CanvasWithTools and up to StatusBar **Features:** - Non-intrusive overlays that don't block canvas interaction - All components optimized for performance - Responsive design adapts to different screen sizes - Professional appearance matching app theme - Smooth animations and transitions - Real-time updates without lag **User Experience Improvements:** - Quick access to all shortcuts via `?` or `F1` - Always-visible status information in bottom bar - Easy canvas navigation with mini-map - Performance monitoring at a glance - Professional editor feel with polished UI All features tested and working smoothly with no performance impact. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
|
import { useCanvasStore, useLayerStore } from '@/store';
|
|
import { cn } from '@/lib/utils';
|
|
import { Maximize2, Minimize2 } from 'lucide-react';
|
|
|
|
const MINIMAP_MAX_SIZE = 200; // Maximum width/height in pixels
|
|
|
|
export function MiniMap() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
const { width: canvasWidth, height: canvasHeight, zoom, offsetX, offsetY, setZoom, setPanOffset } = useCanvasStore();
|
|
const { layers } = useLayerStore();
|
|
|
|
// Calculate minimap dimensions maintaining aspect ratio
|
|
const aspectRatio = canvasWidth / canvasHeight;
|
|
let minimapWidth = MINIMAP_MAX_SIZE;
|
|
let minimapHeight = MINIMAP_MAX_SIZE;
|
|
|
|
if (aspectRatio > 1) {
|
|
// Landscape
|
|
minimapHeight = MINIMAP_MAX_SIZE / aspectRatio;
|
|
} else {
|
|
// Portrait
|
|
minimapWidth = MINIMAP_MAX_SIZE * aspectRatio;
|
|
}
|
|
|
|
const scale = minimapWidth / canvasWidth;
|
|
|
|
// Render minimap
|
|
useEffect(() => {
|
|
if (!canvasRef.current || !isExpanded) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Set canvas size
|
|
canvas.width = minimapWidth;
|
|
canvas.height = minimapHeight;
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, minimapWidth, minimapHeight);
|
|
|
|
// Draw checkerboard background
|
|
const checkSize = 4;
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, minimapWidth, minimapHeight);
|
|
ctx.fillStyle = '#e0e0e0';
|
|
for (let y = 0; y < minimapHeight; y += checkSize) {
|
|
for (let x = 0; x < minimapWidth; x += checkSize) {
|
|
if ((x / checkSize + y / checkSize) % 2 === 0) {
|
|
ctx.fillRect(x, y, checkSize, checkSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw all visible layers (scaled down)
|
|
ctx.save();
|
|
ctx.scale(scale, scale);
|
|
|
|
layers
|
|
.filter((layer) => layer.visible)
|
|
.sort((a, b) => a.order - b.order) // Bottom to top
|
|
.forEach((layer) => {
|
|
ctx.globalAlpha = layer.opacity;
|
|
ctx.drawImage(layer.canvas, layer.x, layer.y);
|
|
});
|
|
|
|
ctx.restore();
|
|
|
|
// Draw viewport indicator
|
|
const viewportWidth = window.innerWidth - 344; // Approximate viewport width
|
|
const viewportHeight = window.innerHeight - 138; // Approximate viewport height
|
|
|
|
const visibleCanvasWidth = viewportWidth / zoom;
|
|
const visibleCanvasHeight = viewportHeight / zoom;
|
|
|
|
const viewportX = (-offsetX / zoom) * scale;
|
|
const viewportY = (-offsetY / zoom) * scale;
|
|
const viewportW = visibleCanvasWidth * scale;
|
|
const viewportH = visibleCanvasHeight * scale;
|
|
|
|
// Draw viewport rectangle
|
|
ctx.strokeStyle = '#3b82f6'; // Primary blue
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(viewportX, viewportY, viewportW, viewportH);
|
|
|
|
// Draw semi-transparent fill
|
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.1)';
|
|
ctx.fillRect(viewportX, viewportY, viewportW, viewportH);
|
|
}, [layers, zoom, offsetX, offsetY, minimapWidth, minimapHeight, scale, isExpanded]);
|
|
|
|
// Handle click/drag to change viewport
|
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
if (!canvasRef.current) return;
|
|
setIsDragging(true);
|
|
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
// Convert minimap coordinates to canvas coordinates
|
|
const canvasX = x / scale;
|
|
const canvasY = y / scale;
|
|
|
|
// Center the viewport on the clicked point
|
|
const newOffsetX = -canvasX * zoom + (window.innerWidth - 344) / 2;
|
|
const newOffsetY = -canvasY * zoom + (window.innerHeight - 138) / 2;
|
|
|
|
setPanOffset(newOffsetX, newOffsetY);
|
|
}, [scale, zoom, setPanOffset]);
|
|
|
|
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
|
if (!isDragging || !canvasRef.current) return;
|
|
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
const canvasX = x / scale;
|
|
const canvasY = y / scale;
|
|
|
|
const newOffsetX = -canvasX * zoom + (window.innerWidth - 344) / 2;
|
|
const newOffsetY = -canvasY * zoom + (window.innerHeight - 138) / 2;
|
|
|
|
setPanOffset(newOffsetX, newOffsetY);
|
|
}, [isDragging, scale, zoom, setPanOffset]);
|
|
|
|
const handlePointerUp = useCallback(() => {
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
if (!isExpanded) {
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute bottom-4 right-4 z-10 bg-card border border-border rounded-md shadow-lg"
|
|
>
|
|
<button
|
|
onClick={() => setIsExpanded(true)}
|
|
className="p-2 hover:bg-accent rounded-md transition-colors"
|
|
title="Show Navigator"
|
|
>
|
|
<Maximize2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute bottom-4 right-4 z-10 bg-card border border-border rounded-md shadow-lg p-2 flex flex-col gap-2"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-muted-foreground">Navigator</span>
|
|
<button
|
|
onClick={() => setIsExpanded(false)}
|
|
className="p-1 hover:bg-accent rounded transition-colors"
|
|
title="Hide Navigator"
|
|
>
|
|
<Minimize2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mini-map canvas */}
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={cn(
|
|
'border border-border rounded cursor-pointer',
|
|
isDragging && 'cursor-grabbing'
|
|
)}
|
|
style={{
|
|
width: `${minimapWidth}px`,
|
|
height: `${minimapHeight}px`,
|
|
}}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerUp}
|
|
/>
|
|
|
|
{/* Zoom indicator */}
|
|
<div className="text-xs text-center text-muted-foreground font-mono">
|
|
{Math.round(zoom * 100)}%
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|