diff --git a/src/editor/core/EditorCore.tsx b/src/editor/core/EditorCore.tsx index ba45182e..8623ace4 100644 --- a/src/editor/core/EditorCore.tsx +++ b/src/editor/core/EditorCore.tsx @@ -48,7 +48,7 @@ export interface DetailedValidationResult { // Components import { Editor3DCanvas } from './Editor3DCanvas'; -import { EditorPanels } from './EditorPanels'; +import { EditorPanels } from './panels'; import { EditorHeader, type MapListItem } from './EditorHeader'; import { EditorToolbar } from './EditorToolbar'; import { EditorContextMenu, buildContextMenuActions, type ContextMenuAction } from './EditorContextMenu'; diff --git a/src/editor/core/EditorPanels.tsx b/src/editor/core/EditorPanels.tsx deleted file mode 100644 index 4c2a1e04..00000000 --- a/src/editor/core/EditorPanels.tsx +++ /dev/null @@ -1,1984 +0,0 @@ -/** - * EditorPanels - Professional right sidebar - * - * Modern panel system with collapsible sections, card-based layout, - * and polished styling. Organized for efficient workflow. - */ - -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import type { - EditorConfig, - EditorState, - EditorObject, - EditorMapData, - ToolConfig, -} from '../config/EditorConfig'; -import type { DetailedValidationResult } from './EditorCore'; -import type { MapData } from '@/data/maps/MapTypes'; -import { AIGeneratePanel } from '../panels/AIGeneratePanel'; -import { - generateBorderDecorations, - clearBorderDecorations, - countBorderDecorations, - type BorderDecorationStyle, - type BorderDecorationSettings, - DEFAULT_BORDER_SETTINGS, -} from '../utils/borderDecorations'; - -export interface EditorPanelsProps { - config: EditorConfig; - state: EditorState; - visibility: { - labels: boolean; - grid: boolean; - categories: Record; - }; - onToolSelect: (toolId: string) => void; - onElevationSelect: (elevation: number) => void; - onFeatureSelect: (feature: string) => void; - onMaterialSelect: (materialId: number) => void; - onBrushSizeChange: (size: number) => void; - onPanelChange: (panelId: string) => void; - onBiomeChange: (biomeId: string) => void; - onObjectAdd: (obj: Omit) => string; - onObjectRemove: (id: string) => void; - onObjectPropertyUpdate: (id: string, key: string, value: unknown) => void; - onMetadataUpdate: (updates: Partial>) => void; - onValidate: () => void; - onAutoFix?: () => void; - validationResult?: DetailedValidationResult; - onToggleLabels: () => void; - onToggleGrid: () => void; - onToggleCategory: (category: string) => void; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onAIMapGenerated?: (mapData: MapData) => void; - onUpdateObjects?: (objects: EditorObject[]) => void; -} - -// Tool categories for paint panel -const TOOL_CATEGORIES = { - paint: { name: 'Paint', tools: ['brush', 'fill', 'eraser'] }, - shapes: { name: 'Shapes', tools: ['line', 'rect', 'ellipse', 'plateau', 'ramp'] }, - platform: { name: 'Platform', tools: ['platform_brush', 'platform_rect', 'platform_ramp'] }, - sculpt: { name: 'Sculpt', tools: ['raise', 'lower', 'smooth', 'noise'] }, -}; - -// Panel icons -const PANEL_ICONS: Record = { - ai: 'đŸĒ„', - paint: '🎨', - bases: '🏰', - objects: 'đŸ“Ļ', - decorations: 'đŸŒŋ', - settings: 'âš™ī¸', - validate: '✓', -}; - -// Collapsible section component with smooth animation -function Section({ - title, - icon, - children, - defaultOpen = true, - theme, - badge, -}: { - title: string; - icon?: string; - children: React.ReactNode; - defaultOpen?: boolean; - theme: EditorConfig['theme']; - badge?: string | number; -}) { - const [isOpen, setIsOpen] = useState(defaultOpen); - const contentRef = useRef(null); - const [contentHeight, setContentHeight] = useState('auto'); - - useEffect(() => { - if (contentRef.current) { - setContentHeight(contentRef.current.scrollHeight); - } - }, [children, isOpen]); - - return ( -
- -
-
- {children} -
-
-
- ); -} - -// Modern panel tab with animated indicator - compact design -function PanelTab({ - active, - onClick, - icon, - name, - theme, - hasContent, -}: { - active: boolean; - onClick: () => void; - icon?: string; - name: string; - theme: EditorConfig['theme']; - hasContent?: boolean; -}) { - return ( - - ); -} - -// Slider with visual feedback and premium animations -function Slider({ - label, - value, - min, - max, - onChange, - theme, - showValue = true, -}: { - label: string; - value: number; - min: number; - max: number; - onChange: (value: number) => void; - theme: EditorConfig['theme']; - showValue?: boolean; -}) { - const percentage = ((value - min) / (max - min)) * 100; - const [isDragging, setIsDragging] = useState(false); - - return ( -
-
- {label} - {showValue && ( - - {value} - - )} -
-
- {/* Track fill */} -
- {/* Thumb indicator */} -
- onChange(Number(e.target.value))} - onMouseDown={() => setIsDragging(true)} - onMouseUp={() => setIsDragging(false)} - onMouseLeave={() => setIsDragging(false)} - onTouchStart={() => setIsDragging(true)} - onTouchEnd={() => setIsDragging(false)} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> -
-
- ); -} - -// Tool grid with category support and premium animations -function ToolGrid({ - tools, - activeTool, - onSelect, - theme, - columns = 4, -}: { - tools: ToolConfig[]; - activeTool: string; - onSelect: (toolId: string) => void; - theme: EditorConfig['theme']; - columns?: number; -}) { - return ( -
- {tools.map((tool) => { - const isActive = activeTool === tool.id; - return ( - - ); - })} -
- ); -} - -// Elevation palette - compact color swatches with premium animations -function ElevationPalette({ - elevations, - selected, - onSelect, - theme, -}: { - elevations: EditorConfig['terrain']['elevations']; - selected: number; - onSelect: (id: number) => void; - theme: EditorConfig['theme']; -}) { - return ( -
-
- {elevations.map((elev) => { - const isSelected = selected === elev.id; - return ( - - ); - })} -
- {/* Selected elevation info with fade transition */} - {elevations.find((e) => e.id === selected) && ( -
-
e.id === selected)?.color }} - /> - - {elevations.find((e) => e.id === selected)?.name} - - {!elevations.find((e) => e.id === selected)?.walkable && ( - - Blocked - - )} -
- )} -
- ); -} - -// Feature buttons with premium animations -function FeatureGrid({ - features, - selected, - onSelect, - theme, -}: { - features: EditorConfig['terrain']['features']; - selected: string; - onSelect: (id: string) => void; - theme: EditorConfig['theme']; -}) { - return ( -
- {features.map((feature) => { - const isSelected = selected === feature.id; - return ( - - ); - })} -
- ); -} - -// Material selector with premium animations -function MaterialSelector({ - materials, - selected, - onSelect, - theme, -}: { - materials: EditorConfig['terrain']['materials']; - selected: number; - onSelect: (id: number) => void; - theme: EditorConfig['theme']; -}) { - if (!materials || materials.length === 0) return null; - - return ( -
- {materials.map((mat) => { - const isSelected = selected === mat.id; - return ( - - ); - })} -
- ); -} - -// Toggle switch component with premium animations -function ToggleSwitch({ - checked, - onChange, - label, - theme, -}: { - checked: boolean; - onChange: () => void; - label: string; - theme: EditorConfig['theme']; -}) { - return ( - - ); -} - -// Paint panel -function PaintPanel({ - config, - state, - onToolSelect, - onElevationSelect, - onFeatureSelect, - onMaterialSelect, - onBrushSizeChange, -}: { - config: EditorConfig; - state: EditorState; - onToolSelect: (toolId: string) => void; - onElevationSelect: (elevation: number) => void; - onFeatureSelect: (feature: string) => void; - onMaterialSelect: (materialId: number) => void; - onBrushSizeChange: (size: number) => void; -}) { - const theme = config.theme; - const activeTool = config.tools.find((t) => t.id === state.activeTool); - - // Group tools by category - const getToolsForCategory = (category: string): ToolConfig[] => { - const toolIds = TOOL_CATEGORIES[category as keyof typeof TOOL_CATEGORIES]?.tools || []; - return toolIds - .map((id) => config.tools.find((t) => t.id === id)) - .filter((t): t is ToolConfig => t !== undefined); - }; - - return ( -
- {/* All tools in categories */} -
-
- {Object.entries(TOOL_CATEGORIES).map(([catId, cat]) => { - const tools = getToolsForCategory(catId); - if (tools.length === 0) return null; - return ( -
-
- {cat.name} -
- -
- ); - })} -
-
- - {/* Brush size (contextual) */} - {activeTool?.hasBrushSize && ( -
- -
- )} - - {/* Elevation */} -
- -
- - {/* Features */} -
- -
- - {/* Materials */} - {config.terrain.materials && config.terrain.materials.length > 0 && ( -
- -
- )} -
- ); -} - -// Objects panel (generic for bases, objects, decorations) with premium animations -function ObjectsPanel({ - config, - state, - category, - onObjectAdd, - onObjectRemove, -}: { - config: EditorConfig; - state: EditorState; - category: string; - onObjectAdd: (obj: Omit) => string; - onObjectRemove: (id: string) => void; -}) { - const theme = config.theme; - const categoryObjects = config.objectTypes.filter((t) => t.category === category); - const placedObjects = state.mapData?.objects.filter((obj) => { - const objType = config.objectTypes.find((t) => t.id === obj.type); - return objType?.category === category; - }) || []; - - const handleAddObject = (typeId: string) => { - if (!state.mapData) return; - const objType = config.objectTypes.find((t) => t.id === typeId); - if (!objType) return; - - const defaultProperties: Record = {}; - if (objType.properties) { - for (const prop of objType.properties) { - if (prop.defaultValue !== undefined) { - defaultProperties[prop.key] = prop.defaultValue; - } - } - } - - onObjectAdd({ - type: typeId, - x: Math.floor(state.mapData.width / 2), - y: Math.floor(state.mapData.height / 2), - radius: objType.defaultRadius, - properties: defaultProperties, - }); - }; - - return ( -
- {/* Add new objects */} -
-
- {categoryObjects.map((objType) => ( - - ))} -
- {categoryObjects.length === 0 && ( -
- No {category} types defined -
- )} -
- - {/* Placed objects list */} -
0 ? placedObjects.length : undefined} - > - {placedObjects.length > 0 ? ( -
- {placedObjects.map((obj) => { - const objType = config.objectTypes.find((t) => t.id === obj.type); - if (!objType) return null; - const isSelected = state.selectedObjects.includes(obj.id); - - return ( -
- {objType.icon} -
-
- {objType.name} -
-
- ({Math.round(obj.x)}, {Math.round(obj.y)}) -
-
- -
- ); - })} -
- ) : ( -
- No {category} placed yet -
- )} -
-
- ); -} - -// Settings panel -function SettingsPanel({ - config, - state, - visibility, - onBiomeChange, - onMetadataUpdate, - onToggleLabels, - onToggleGrid, - onToggleCategory, - onUpdateObjects, -}: { - config: EditorConfig; - state: EditorState; - visibility: { labels: boolean; grid: boolean; categories: Record }; - onBiomeChange: (biomeId: string) => void; - onMetadataUpdate: (updates: Partial>) => void; - onToggleLabels: () => void; - onToggleGrid: () => void; - onToggleCategory: (category: string) => void; - onUpdateObjects?: (objects: EditorObject[]) => void; -}) { - const theme = config.theme; - - // Border decoration state - const [borderStyle, setBorderStyle] = useState('rocks'); - const [borderDensity, setBorderDensity] = useState(0.7); - const [isGenerating, setIsGenerating] = useState(false); - - if (!state.mapData) return null; - - const categories = Array.from(new Set(config.objectTypes.map((t) => t.category))); - const borderDecorationCount = countBorderDecorations(state.mapData.objects); - - const handleGenerateBorderDecorations = () => { - if (!state.mapData || !onUpdateObjects) return; - setIsGenerating(true); - - const settings: BorderDecorationSettings = { - ...DEFAULT_BORDER_SETTINGS, - style: borderStyle, - density: borderDensity, - }; - - const newObjects = generateBorderDecorations(state.mapData, settings); - onUpdateObjects(newObjects); - setIsGenerating(false); - }; - - const handleClearBorderDecorations = () => { - if (!state.mapData || !onUpdateObjects) return; - const newObjects = clearBorderDecorations(state.mapData.objects); - onUpdateObjects(newObjects); - }; - - return ( -
- {/* Map info */} -
-
-
- - onMetadataUpdate({ name: e.target.value })} - className="w-full mt-1 px-3 py-2 rounded-lg text-sm" - style={{ - backgroundColor: theme.surface, - border: `1px solid ${theme.border}`, - color: theme.text.primary, - }} - /> -
-
-
- - onMetadataUpdate({ width: Number(e.target.value) })} - className="w-full mt-1 px-3 py-2 rounded-lg text-sm font-mono" - style={{ - backgroundColor: theme.surface, - border: `1px solid ${theme.border}`, - color: theme.text.primary, - }} - /> -
-
- - onMetadataUpdate({ height: Number(e.target.value) })} - className="w-full mt-1 px-3 py-2 rounded-lg text-sm font-mono" - style={{ - backgroundColor: theme.surface, - border: `1px solid ${theme.border}`, - color: theme.text.primary, - }} - /> -
-
-
-
- - {/* Biome selection */} -
-
- {config.biomes.map((biome) => ( - - ))} -
-
- - {/* Visibility toggles */} -
-
- - -
-
- Categories -
- {categories.map((category) => ( - onToggleCategory(category)} - label={category.charAt(0).toUpperCase() + category.slice(1)} - theme={theme} - /> - ))} -
-
- - {/* Border Decorations */} - {onUpdateObjects && ( -
-
-
- -
- {(['rocks', 'crystals', 'trees', 'mixed', 'alien', 'dead_trees'] as BorderDecorationStyle[]).map((style) => ( - - ))} -
-
- -
-
- - - {Math.round(borderDensity * 100)}% - -
- setBorderDensity(parseFloat(e.target.value))} - className="w-full" - /> -
- -
- - {borderDecorationCount > 0 && ( - - )} -
- -
- Places decorative {borderStyle} around the map edges to create an imposing boundary wall. -
-
-
- )} -
- ); -} - -// Validation panel -function ValidatePanel({ - config, - validationResult, - onValidate, - onAutoFix, -}: { - config: EditorConfig; - validationResult?: DetailedValidationResult; - onValidate: () => void; - onAutoFix?: () => void; -}) { - const theme = config.theme; - const hasResult = validationResult?.timestamp !== undefined; - const isValidating = validationResult?.isValidating ?? false; - const isValid = validationResult?.valid ?? true; - const issues = validationResult?.issues ?? []; - const stats = validationResult?.stats; - - const errors = issues.filter(i => i.severity === 'error'); - const warnings = issues.filter(i => i.severity === 'warning'); - const hasFixes = issues.some(i => i.suggestedFix); - - return ( -
- {/* Validate button with premium animation */} - - - {/* Description */} -
- Checks that all bases are connected and expansions are reachable. -
- - {/* Results */} - {hasResult && !isValidating && ( -
- {/* Status banner */} -
- - {isValid ? '✓' : '✗'} - -
-
- {isValid ? 'Validation Passed' : 'Validation Failed'} -
-
- {errors.length} error{errors.length !== 1 ? 's' : ''}, {warnings.length} warning{warnings.length !== 1 ? 's' : ''} -
-
-
- - {/* Statistics */} - {stats && ( -
-
- {[ - { label: 'Nodes', value: stats.totalNodes }, - { label: 'Islands', value: stats.islandCount, warn: stats.islandCount > 1 }, - { label: 'Connected', value: stats.connectedPairs, success: true }, - { label: 'Blocked', value: stats.blockedPairs, error: stats.blockedPairs > 0 }, - ].map((stat) => ( -
-
- {stat.label} -
-
- {stat.value} -
-
- ))} -
-
- )} - - {/* Errors */} - {errors.length > 0 && ( -
-
- {errors.map((issue, idx) => ( -
-
- {issue.message} -
- {issue.affectedNodes && issue.affectedNodes.length > 0 && ( -
- Affected: {issue.affectedNodes.join(', ')} -
- )} - {issue.suggestedFix && ( -
- 💡 - {issue.suggestedFix.description} -
- )} -
- ))} -
-
- )} - - {/* Warnings */} - {warnings.length > 0 && ( -
-
- {warnings.map((issue, idx) => ( -
-
- {issue.message} -
- {issue.affectedNodes && issue.affectedNodes.length > 0 && ( -
- Affected: {issue.affectedNodes.join(', ')} -
- )} - {issue.suggestedFix && ( -
- 💡 - {issue.suggestedFix.description} -
- )} -
- ))} -
-
- )} - - {/* Success message */} - {!errors.length && !warnings.length && ( -
- All connectivity checks passed! -
- )} - - {/* Auto-fix button */} - {onAutoFix && hasFixes && ( - - )} -
- )} - - {/* Help section */} -
-
    -
  • - ✓ - All main bases can reach each other -
  • -
  • - ✓ - Natural expansions are accessible -
  • -
  • - ✓ - No important bases are isolated -
  • -
  • - ✓ - Ramps connect elevation differences -
  • -
-
-
- ); -} - -// Rotation dial component for intuitive rotation control -function RotationDial({ - value, - onChange, - theme, -}: { - value: number; - onChange: (value: number) => void; - theme: EditorConfig['theme']; -}) { - const dialRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); - - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - setIsDragging(true); - updateRotation(e); - }; - - const updateRotation = (e: React.MouseEvent | MouseEvent) => { - if (!dialRef.current) return; - const rect = dialRef.current.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const dx = e.clientX - centerX; - const dy = e.clientY - centerY; - let angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; - if (angle < 0) angle += 360; - onChange(Math.round(angle) % 360); - }; - - useEffect(() => { - if (!isDragging) return; - - const handleMouseMove = (e: MouseEvent) => updateRotation(e); - const handleMouseUp = () => setIsDragging(false); - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [isDragging]); - - // Quick snap buttons - const snapAngles = [0, 45, 90, 135, 180, 225, 270, 315]; - - return ( -
-
- - Rotation - - - {value}° - -
- -
- {/* Dial */} -
- {/* Inner circle */} -
- {/* Rotation indicator */} -
- {/* Center dot */} -
-
- - {/* Cardinal markers */} - {[0, 90, 180, 270].map((angle) => ( -
- ))} -
- - {/* Quick snap buttons */} -
- {snapAngles.map((angle) => ( - - ))} -
-
-
- ); -} - -// Scale control with visual feedback -function ScaleControl({ - value, - min, - max, - onChange, - theme, -}: { - value: number; - min: number; - max: number; - onChange: (value: number) => void; - theme: EditorConfig['theme']; -}) { - const percentage = ((value - min) / (max - min)) * 100; - const [isDragging, setIsDragging] = useState(false); - - // Quick scale presets - const presets = [0.5, 1.0, 1.5, 2.0]; - - return ( -
-
- - Scale - - - {value.toFixed(2)}x - -
- - {/* Slider */} -
-
-
- onChange(Number(e.target.value))} - onMouseDown={() => setIsDragging(true)} - onMouseUp={() => setIsDragging(false)} - onMouseLeave={() => setIsDragging(false)} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> -
- - {/* Preset buttons */} -
- {presets.map((preset) => ( - - ))} -
-
- ); -} - -// Selected panel - wrapper with empty state -function SelectedPanel({ - config, - state, - onPropertyUpdate, - onRemove, -}: { - config: EditorConfig; - state: EditorState; - onPropertyUpdate: (id: string, key: string, value: unknown) => void; - onRemove: (id: string) => void; -}) { - const theme = config.theme; - - if (state.selectedObjects.length === 0 || !state.mapData) { - return ( -
-
- ✎ -
-
- No Object Selected -
-
- Click on an object in the map to select it and edit its properties here -
-
- ); - } - - return ( - - ); -} - -// Selected object properties panel with enhanced UI -function SelectedObjectPanelContent({ - config, - state, - onPropertyUpdate, - onRemove, -}: { - config: EditorConfig; - state: EditorState; - onPropertyUpdate: (id: string, key: string, value: unknown) => void; - onRemove: (id: string) => void; -}) { - const theme = config.theme; - - if (state.selectedObjects.length === 0 || !state.mapData) return null; - - const selectedId = state.selectedObjects[0]; - const selectedObj = state.mapData.objects.find((o) => o.id === selectedId); - if (!selectedObj) return null; - - const objType = config.objectTypes.find((t) => t.id === selectedObj.type); - if (!objType) return null; - - const properties = objType.properties || []; - - // Separate rotation and scale from other properties - const rotationProp = properties.find((p) => p.key === 'rotation'); - const scaleProp = properties.find((p) => p.key === 'scale'); - const otherProps = properties.filter((p) => p.key !== 'rotation' && p.key !== 'scale'); - - const currentRotation = (selectedObj.properties?.rotation as number) ?? rotationProp?.defaultValue ?? 0; - const currentScale = (selectedObj.properties?.scale as number) ?? scaleProp?.defaultValue ?? 1; - - return ( -
- {/* Header */} -
-
-
- {objType.icon} -
-
-
- {objType.name} -
-
- x:{Math.round(selectedObj.x)} - y:{Math.round(selectedObj.y)} -
-
-
- -
- - {/* Transform Section */} - {(scaleProp || rotationProp) && ( -
-
- Transform -
- - {/* Scale control */} - {scaleProp && ( - onPropertyUpdate(selectedId, 'scale', v)} - theme={theme} - /> - )} - - {/* Rotation dial */} - {rotationProp && ( - onPropertyUpdate(selectedId, 'rotation', v)} - theme={theme} - /> - )} -
- )} - - {/* Other Properties */} - {otherProps.length > 0 && ( -
-
- Properties -
- - {otherProps.map((prop) => { - const currentValue = selectedObj.properties?.[prop.key] ?? prop.defaultValue; - - if (prop.type === 'number') { - return ( - onPropertyUpdate(selectedId, prop.key, v)} - theme={theme} - /> - ); - } - - if (prop.type === 'select' && prop.options) { - return ( -
- - -
- ); - } - - return null; - })} -
- )} - - {/* Multi-selection indicator */} - {state.selectedObjects.length > 1 && ( -
-
- +{state.selectedObjects.length - 1} -
- more selected -
- )} -
- ); -} - -// Keyboard shortcuts footer -function ShortcutsFooter({ theme }: { theme: EditorConfig['theme'] }) { - return ( -
-
- - - B - {' '} - Brush - - - - G - {' '} - Fill - - - - 0-5 - {' '} - Elev - - - - [ ] - {' '} - Size - -
-
- ); -} - -// Animated panel content wrapper -function AnimatedPanelContent({ - isActive, - children, -}: { - isActive: boolean; - children: React.ReactNode; -}) { - const [shouldRender, setShouldRender] = useState(isActive); - const [isAnimating, setIsAnimating] = useState(false); - - useEffect(() => { - if (isActive) { - setShouldRender(true); - // Small delay to trigger CSS transition - requestAnimationFrame(() => { - setIsAnimating(true); - }); - } else { - setIsAnimating(false); - // Wait for animation to complete before unmounting - const timer = setTimeout(() => setShouldRender(false), 200); - return () => clearTimeout(timer); - } - }, [isActive]); - - if (!shouldRender) return null; - - return ( -
- {children} -
- ); -} - -// Main panels component -export function EditorPanels({ - config, - state, - visibility, - onToolSelect, - onElevationSelect, - onFeatureSelect, - onMaterialSelect, - onBrushSizeChange, - onPanelChange, - onBiomeChange, - onObjectAdd, - onObjectRemove, - onObjectPropertyUpdate, - onMetadataUpdate, - onValidate, - onAutoFix, - validationResult, - onToggleLabels, - onToggleGrid, - onToggleCategory, - onMouseEnter, - onMouseLeave, - onAIMapGenerated, - onUpdateObjects, -}: EditorPanelsProps) { - const theme = config.theme; - - return ( -
- {/* Panel tabs */} -
- {config.panels.map((panel) => ( - onPanelChange(panel.id)} - icon={panel.icon} - name={panel.name} - theme={theme} - hasContent={panel.id === 'selected' ? state.selectedObjects.length > 0 : undefined} - /> - ))} -
- - {/* Panel content with animated transitions */} -
- - {onAIMapGenerated && ( - - )} - - - - - - - - - - - - - - - - - - - - - - -
- - {/* Keyboard shortcuts footer */} - -
- ); -} - -export default EditorPanels; diff --git a/src/editor/core/panels/EditorPanels.tsx b/src/editor/core/panels/EditorPanels.tsx new file mode 100644 index 00000000..ea44aabd --- /dev/null +++ b/src/editor/core/panels/EditorPanels.tsx @@ -0,0 +1,197 @@ +'use client'; + +import type { + EditorConfig, + EditorState, + EditorObject, + EditorMapData, +} from '../../config/EditorConfig'; +import type { DetailedValidationResult } from '../EditorCore'; +import type { MapData } from '@/data/maps/MapTypes'; +import { AIGeneratePanel } from '../../panels/AIGeneratePanel'; +import { PanelTab, AnimatedPanelContent, ShortcutsFooter } from './shared'; +import { PaintPanel } from './PaintPanel'; +import { ObjectsPanel } from './ObjectsPanel'; +import { SettingsPanel } from './SettingsPanel'; +import { ValidatePanel } from './ValidatePanel'; +import { SelectedPanel } from './SelectedPanel'; + +export interface EditorPanelsProps { + config: EditorConfig; + state: EditorState; + visibility: { + labels: boolean; + grid: boolean; + categories: Record; + }; + onToolSelect: (toolId: string) => void; + onElevationSelect: (elevation: number) => void; + onFeatureSelect: (feature: string) => void; + onMaterialSelect: (materialId: number) => void; + onBrushSizeChange: (size: number) => void; + onPanelChange: (panelId: string) => void; + onBiomeChange: (biomeId: string) => void; + onObjectAdd: (obj: Omit) => string; + onObjectRemove: (id: string) => void; + onObjectPropertyUpdate: (id: string, key: string, value: unknown) => void; + onMetadataUpdate: (updates: Partial>) => void; + onValidate: () => void; + onAutoFix?: () => void; + validationResult?: DetailedValidationResult; + onToggleLabels: () => void; + onToggleGrid: () => void; + onToggleCategory: (category: string) => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onAIMapGenerated?: (mapData: MapData) => void; + onUpdateObjects?: (objects: EditorObject[]) => void; +} + +export function EditorPanels({ + config, + state, + visibility, + onToolSelect, + onElevationSelect, + onFeatureSelect, + onMaterialSelect, + onBrushSizeChange, + onPanelChange, + onBiomeChange, + onObjectAdd, + onObjectRemove, + onObjectPropertyUpdate, + onMetadataUpdate, + onValidate, + onAutoFix, + validationResult, + onToggleLabels, + onToggleGrid, + onToggleCategory, + onMouseEnter, + onMouseLeave, + onAIMapGenerated, + onUpdateObjects, +}: EditorPanelsProps) { + const theme = config.theme; + + return ( +
+ {/* Panel tabs */} +
+ {config.panels.map((panel) => ( + onPanelChange(panel.id)} + icon={panel.icon} + name={panel.name} + theme={theme} + hasContent={panel.id === 'selected' ? state.selectedObjects.length > 0 : undefined} + /> + ))} +
+ + {/* Panel content with animated transitions */} +
+ + {onAIMapGenerated && ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* Keyboard shortcuts footer */} + +
+ ); +} + +export default EditorPanels; diff --git a/src/editor/core/panels/ObjectsPanel.tsx b/src/editor/core/panels/ObjectsPanel.tsx new file mode 100644 index 00000000..2d8e20a9 --- /dev/null +++ b/src/editor/core/panels/ObjectsPanel.tsx @@ -0,0 +1,137 @@ +'use client'; + +import type { EditorConfig, EditorState, EditorObject } from '../../config/EditorConfig'; +import { Section } from './shared'; + +export interface ObjectsPanelProps { + config: EditorConfig; + state: EditorState; + category: string; + onObjectAdd: (obj: Omit) => string; + onObjectRemove: (id: string) => void; +} + +export function ObjectsPanel({ + config, + state, + category, + onObjectAdd, + onObjectRemove, +}: ObjectsPanelProps) { + const theme = config.theme; + const categoryObjects = config.objectTypes.filter((t) => t.category === category); + const placedObjects = state.mapData?.objects.filter((obj) => { + const objType = config.objectTypes.find((t) => t.id === obj.type); + return objType?.category === category; + }) || []; + + const handleAddObject = (typeId: string) => { + if (!state.mapData) return; + const objType = config.objectTypes.find((t) => t.id === typeId); + if (!objType) return; + + const defaultProperties: Record = {}; + if (objType.properties) { + for (const prop of objType.properties) { + if (prop.defaultValue !== undefined) { + defaultProperties[prop.key] = prop.defaultValue; + } + } + } + + onObjectAdd({ + type: typeId, + x: Math.floor(state.mapData.width / 2), + y: Math.floor(state.mapData.height / 2), + radius: objType.defaultRadius, + properties: defaultProperties, + }); + }; + + return ( +
+ {/* Add new objects */} +
+
+ {categoryObjects.map((objType) => ( + + ))} +
+ {categoryObjects.length === 0 && ( +
+ No {category} types defined +
+ )} +
+ + {/* Placed objects list */} +
0 ? placedObjects.length : undefined} + > + {placedObjects.length > 0 ? ( +
+ {placedObjects.map((obj) => { + const objType = config.objectTypes.find((t) => t.id === obj.type); + if (!objType) return null; + const isSelected = state.selectedObjects.includes(obj.id); + + return ( +
+ {objType.icon} +
+
+ {objType.name} +
+
+ ({Math.round(obj.x)}, {Math.round(obj.y)}) +
+
+ +
+ ); + })} +
+ ) : ( +
+ No {category} placed yet +
+ )} +
+
+ ); +} diff --git a/src/editor/core/panels/PaintPanel.tsx b/src/editor/core/panels/PaintPanel.tsx new file mode 100644 index 00000000..06d2df14 --- /dev/null +++ b/src/editor/core/panels/PaintPanel.tsx @@ -0,0 +1,120 @@ +'use client'; + +import type { EditorConfig, EditorState, ToolConfig } from '../../config/EditorConfig'; +import { + Section, + Slider, + ToolGrid, + ElevationPalette, + FeatureGrid, + MaterialSelector, + TOOL_CATEGORIES, +} from './shared'; + +export interface PaintPanelProps { + config: EditorConfig; + state: EditorState; + onToolSelect: (toolId: string) => void; + onElevationSelect: (elevation: number) => void; + onFeatureSelect: (feature: string) => void; + onMaterialSelect: (materialId: number) => void; + onBrushSizeChange: (size: number) => void; +} + +export function PaintPanel({ + config, + state, + onToolSelect, + onElevationSelect, + onFeatureSelect, + onMaterialSelect, + onBrushSizeChange, +}: PaintPanelProps) { + const theme = config.theme; + const activeTool = config.tools.find((t) => t.id === state.activeTool); + + // Group tools by category + const getToolsForCategory = (category: string): ToolConfig[] => { + const toolIds = TOOL_CATEGORIES[category as keyof typeof TOOL_CATEGORIES]?.tools || []; + return toolIds + .map((id) => config.tools.find((t) => t.id === id)) + .filter((t): t is ToolConfig => t !== undefined); + }; + + return ( +
+ {/* All tools in categories */} +
+
+ {Object.entries(TOOL_CATEGORIES).map(([catId, cat]) => { + const tools = getToolsForCategory(catId); + if (tools.length === 0) return null; + return ( +
+
+ {cat.name} +
+ +
+ ); + })} +
+
+ + {/* Brush size (contextual) */} + {activeTool?.hasBrushSize && ( +
+ +
+ )} + + {/* Elevation */} +
+ +
+ + {/* Features */} +
+ +
+ + {/* Materials */} + {config.terrain.materials && config.terrain.materials.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/src/editor/core/panels/SelectedPanel.tsx b/src/editor/core/panels/SelectedPanel.tsx new file mode 100644 index 00000000..2f38336d --- /dev/null +++ b/src/editor/core/panels/SelectedPanel.tsx @@ -0,0 +1,196 @@ +'use client'; + +import type { EditorConfig, EditorState } from '../../config/EditorConfig'; +import { Slider, RotationDial, ScaleControl } from './shared'; + +export interface SelectedPanelProps { + config: EditorConfig; + state: EditorState; + onPropertyUpdate: (id: string, key: string, value: unknown) => void; + onRemove: (id: string) => void; +} + +export function SelectedPanel({ + config, + state, + onPropertyUpdate, + onRemove, +}: SelectedPanelProps) { + const theme = config.theme; + + if (state.selectedObjects.length === 0 || !state.mapData) { + return ( +
+
+ ✎ +
+
+ No Object Selected +
+
+ Click on an object in the map to select it and edit its properties here +
+
+ ); + } + + const selectedId = state.selectedObjects[0]; + const selectedObj = state.mapData.objects.find((o) => o.id === selectedId); + if (!selectedObj) return null; + + const objType = config.objectTypes.find((t) => t.id === selectedObj.type); + if (!objType) return null; + + const properties = objType.properties || []; + + // Separate rotation and scale from other properties + const rotationProp = properties.find((p) => p.key === 'rotation'); + const scaleProp = properties.find((p) => p.key === 'scale'); + const otherProps = properties.filter((p) => p.key !== 'rotation' && p.key !== 'scale'); + + const currentRotation = (selectedObj.properties?.rotation as number) ?? rotationProp?.defaultValue ?? 0; + const currentScale = (selectedObj.properties?.scale as number) ?? scaleProp?.defaultValue ?? 1; + + return ( +
+ {/* Header */} +
+
+
+ {objType.icon} +
+
+
+ {objType.name} +
+
+ x:{Math.round(selectedObj.x)} + y:{Math.round(selectedObj.y)} +
+
+
+ +
+ + {/* Transform Section */} + {(scaleProp || rotationProp) && ( +
+
+ Transform +
+ + {/* Scale control */} + {scaleProp && ( + onPropertyUpdate(selectedId, 'scale', v)} + theme={theme} + /> + )} + + {/* Rotation dial */} + {rotationProp && ( + onPropertyUpdate(selectedId, 'rotation', v)} + theme={theme} + /> + )} +
+ )} + + {/* Other Properties */} + {otherProps.length > 0 && ( +
+
+ Properties +
+ + {otherProps.map((prop) => { + const currentValue = selectedObj.properties?.[prop.key] ?? prop.defaultValue; + + if (prop.type === 'number') { + return ( + onPropertyUpdate(selectedId, prop.key, v)} + theme={theme} + /> + ); + } + + if (prop.type === 'select' && prop.options) { + return ( +
+ + +
+ ); + } + + return null; + })} +
+ )} + + {/* Multi-selection indicator */} + {state.selectedObjects.length > 1 && ( +
+
+ +{state.selectedObjects.length - 1} +
+ more selected +
+ )} +
+ ); +} diff --git a/src/editor/core/panels/SettingsPanel.tsx b/src/editor/core/panels/SettingsPanel.tsx new file mode 100644 index 00000000..9e035655 --- /dev/null +++ b/src/editor/core/panels/SettingsPanel.tsx @@ -0,0 +1,271 @@ +'use client'; + +import { useState } from 'react'; +import type { EditorConfig, EditorState, EditorMapData, EditorObject } from '../../config/EditorConfig'; +import { Section, ToggleSwitch } from './shared'; +import { + generateBorderDecorations, + clearBorderDecorations, + countBorderDecorations, + type BorderDecorationStyle, + type BorderDecorationSettings, + DEFAULT_BORDER_SETTINGS, +} from '../../utils/borderDecorations'; + +export interface SettingsPanelProps { + config: EditorConfig; + state: EditorState; + visibility: { labels: boolean; grid: boolean; categories: Record }; + onBiomeChange: (biomeId: string) => void; + onMetadataUpdate: (updates: Partial>) => void; + onToggleLabels: () => void; + onToggleGrid: () => void; + onToggleCategory: (category: string) => void; + onUpdateObjects?: (objects: EditorObject[]) => void; +} + +export function SettingsPanel({ + config, + state, + visibility, + onBiomeChange, + onMetadataUpdate, + onToggleLabels, + onToggleGrid, + onToggleCategory, + onUpdateObjects, +}: SettingsPanelProps) { + const theme = config.theme; + + // Border decoration state + const [borderStyle, setBorderStyle] = useState('rocks'); + const [borderDensity, setBorderDensity] = useState(0.7); + const [isGenerating, setIsGenerating] = useState(false); + + if (!state.mapData) return null; + + const categories = Array.from(new Set(config.objectTypes.map((t) => t.category))); + const borderDecorationCount = countBorderDecorations(state.mapData.objects); + + const handleGenerateBorderDecorations = () => { + if (!state.mapData || !onUpdateObjects) return; + setIsGenerating(true); + + const settings: BorderDecorationSettings = { + ...DEFAULT_BORDER_SETTINGS, + style: borderStyle, + density: borderDensity, + }; + + const newObjects = generateBorderDecorations(state.mapData, settings); + onUpdateObjects(newObjects); + setIsGenerating(false); + }; + + const handleClearBorderDecorations = () => { + if (!state.mapData || !onUpdateObjects) return; + const newObjects = clearBorderDecorations(state.mapData.objects); + onUpdateObjects(newObjects); + }; + + return ( +
+ {/* Map info */} +
+
+
+ + onMetadataUpdate({ name: e.target.value })} + className="w-full mt-1 px-3 py-2 rounded-lg text-sm" + style={{ + backgroundColor: theme.surface, + border: `1px solid ${theme.border}`, + color: theme.text.primary, + }} + /> +
+
+
+ + onMetadataUpdate({ width: Number(e.target.value) })} + className="w-full mt-1 px-3 py-2 rounded-lg text-sm font-mono" + style={{ + backgroundColor: theme.surface, + border: `1px solid ${theme.border}`, + color: theme.text.primary, + }} + /> +
+
+ + onMetadataUpdate({ height: Number(e.target.value) })} + className="w-full mt-1 px-3 py-2 rounded-lg text-sm font-mono" + style={{ + backgroundColor: theme.surface, + border: `1px solid ${theme.border}`, + color: theme.text.primary, + }} + /> +
+
+
+
+ + {/* Biome selection */} +
+
+ {config.biomes.map((biome) => ( + + ))} +
+
+ + {/* Visibility toggles */} +
+
+ + +
+
+ Categories +
+ {categories.map((category) => ( + onToggleCategory(category)} + label={category.charAt(0).toUpperCase() + category.slice(1)} + theme={theme} + /> + ))} +
+
+ + {/* Border Decorations */} + {onUpdateObjects && ( +
+
+
+ +
+ {(['rocks', 'crystals', 'trees', 'mixed', 'alien', 'dead_trees'] as BorderDecorationStyle[]).map((style) => ( + + ))} +
+
+ +
+
+ + + {Math.round(borderDensity * 100)}% + +
+ setBorderDensity(parseFloat(e.target.value))} + className="w-full" + /> +
+ +
+ + {borderDecorationCount > 0 && ( + + )} +
+ +
+ Places decorative {borderStyle} around the map edges to create an imposing boundary wall. +
+
+
+ )} +
+ ); +} diff --git a/src/editor/core/panels/ValidatePanel.tsx b/src/editor/core/panels/ValidatePanel.tsx new file mode 100644 index 00000000..0ff3731b --- /dev/null +++ b/src/editor/core/panels/ValidatePanel.tsx @@ -0,0 +1,254 @@ +'use client'; + +import type { EditorConfig } from '../../config/EditorConfig'; +import type { DetailedValidationResult } from '../EditorCore'; +import { Section } from './shared'; + +export interface ValidatePanelProps { + config: EditorConfig; + validationResult?: DetailedValidationResult; + onValidate: () => void; + onAutoFix?: () => void; +} + +export function ValidatePanel({ + config, + validationResult, + onValidate, + onAutoFix, +}: ValidatePanelProps) { + const theme = config.theme; + const hasResult = validationResult?.timestamp !== undefined; + const isValidating = validationResult?.isValidating ?? false; + const isValid = validationResult?.valid ?? true; + const issues = validationResult?.issues ?? []; + const stats = validationResult?.stats; + + const errors = issues.filter(i => i.severity === 'error'); + const warnings = issues.filter(i => i.severity === 'warning'); + const hasFixes = issues.some(i => i.suggestedFix); + + return ( +
+ {/* Validate button */} + + + {/* Description */} +
+ Checks that all bases are connected and expansions are reachable. +
+ + {/* Results */} + {hasResult && !isValidating && ( +
+ {/* Status banner */} +
+ + {isValid ? '✓' : '✗'} + +
+
+ {isValid ? 'Validation Passed' : 'Validation Failed'} +
+
+ {errors.length} error{errors.length !== 1 ? 's' : ''}, {warnings.length} warning{warnings.length !== 1 ? 's' : ''} +
+
+
+ + {/* Statistics */} + {stats && ( +
+
+ {[ + { label: 'Nodes', value: stats.totalNodes }, + { label: 'Islands', value: stats.islandCount, warn: stats.islandCount > 1 }, + { label: 'Connected', value: stats.connectedPairs, success: true }, + { label: 'Blocked', value: stats.blockedPairs, error: stats.blockedPairs > 0 }, + ].map((stat) => ( +
+
+ {stat.label} +
+
+ {stat.value} +
+
+ ))} +
+
+ )} + + {/* Errors */} + {errors.length > 0 && ( +
+
+ {errors.map((issue, idx) => ( +
+
+ {issue.message} +
+ {issue.affectedNodes && issue.affectedNodes.length > 0 && ( +
+ Affected: {issue.affectedNodes.join(', ')} +
+ )} + {issue.suggestedFix && ( +
+ 💡 + {issue.suggestedFix.description} +
+ )} +
+ ))} +
+
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+
+ {warnings.map((issue, idx) => ( +
+
+ {issue.message} +
+ {issue.affectedNodes && issue.affectedNodes.length > 0 && ( +
+ Affected: {issue.affectedNodes.join(', ')} +
+ )} + {issue.suggestedFix && ( +
+ 💡 + {issue.suggestedFix.description} +
+ )} +
+ ))} +
+
+ )} + + {/* Success message */} + {!errors.length && !warnings.length && ( +
+ All connectivity checks passed! +
+ )} + + {/* Auto-fix button */} + {onAutoFix && hasFixes && ( + + )} +
+ )} + + {/* Help section */} +
+
    +
  • + ✓ + All main bases can reach each other +
  • +
  • + ✓ + Natural expansions are accessible +
  • +
  • + ✓ + No important bases are isolated +
  • +
  • + ✓ + Ramps connect elevation differences +
  • +
+
+
+ ); +} diff --git a/src/editor/core/panels/index.ts b/src/editor/core/panels/index.ts new file mode 100644 index 00000000..99501cb0 --- /dev/null +++ b/src/editor/core/panels/index.ts @@ -0,0 +1,6 @@ +export { EditorPanels, type EditorPanelsProps } from './EditorPanels'; +export { PaintPanel, type PaintPanelProps } from './PaintPanel'; +export { ObjectsPanel, type ObjectsPanelProps } from './ObjectsPanel'; +export { SettingsPanel, type SettingsPanelProps } from './SettingsPanel'; +export { ValidatePanel, type ValidatePanelProps } from './ValidatePanel'; +export { SelectedPanel, type SelectedPanelProps } from './SelectedPanel'; diff --git a/src/editor/core/panels/shared.tsx b/src/editor/core/panels/shared.tsx new file mode 100644 index 00000000..993e9093 --- /dev/null +++ b/src/editor/core/panels/shared.tsx @@ -0,0 +1,825 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import type { EditorConfig, ToolConfig } from '../../config/EditorConfig'; + +// Panel icons +export const PANEL_ICONS: Record = { + ai: 'đŸĒ„', + paint: '🎨', + bases: '🏰', + objects: 'đŸ“Ļ', + decorations: 'đŸŒŋ', + settings: 'âš™ī¸', + validate: '✓', +}; + +// Tool categories for paint panel +export const TOOL_CATEGORIES = { + paint: { name: 'Paint', tools: ['brush', 'fill', 'eraser'] }, + shapes: { name: 'Shapes', tools: ['line', 'rect', 'ellipse', 'plateau', 'ramp'] }, + platform: { name: 'Platform', tools: ['platform_brush', 'platform_rect', 'platform_ramp'] }, + sculpt: { name: 'Sculpt', tools: ['raise', 'lower', 'smooth', 'noise'] }, +}; + +// Collapsible section component with smooth animation +export function Section({ + title, + icon, + children, + defaultOpen = true, + theme, + badge, +}: { + title: string; + icon?: string; + children: React.ReactNode; + defaultOpen?: boolean; + theme: EditorConfig['theme']; + badge?: string | number; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const contentRef = useRef(null); + const [contentHeight, setContentHeight] = useState('auto'); + + useEffect(() => { + if (contentRef.current) { + setContentHeight(contentRef.current.scrollHeight); + } + }, [children, isOpen]); + + return ( +
+ +
+
+ {children} +
+
+
+ ); +} + +// Modern panel tab with animated indicator +export function PanelTab({ + active, + onClick, + icon, + name, + theme, + hasContent, +}: { + active: boolean; + onClick: () => void; + icon?: string; + name: string; + theme: EditorConfig['theme']; + hasContent?: boolean; +}) { + return ( + + ); +} + +// Slider with visual feedback +export function Slider({ + label, + value, + min, + max, + onChange, + theme, + showValue = true, +}: { + label: string; + value: number; + min: number; + max: number; + onChange: (value: number) => void; + theme: EditorConfig['theme']; + showValue?: boolean; +}) { + const percentage = ((value - min) / (max - min)) * 100; + const [isDragging, setIsDragging] = useState(false); + + return ( +
+
+ {label} + {showValue && ( + + {value} + + )} +
+
+ {/* Track fill */} +
+ {/* Thumb indicator */} +
+ onChange(Number(e.target.value))} + onMouseDown={() => setIsDragging(true)} + onMouseUp={() => setIsDragging(false)} + onMouseLeave={() => setIsDragging(false)} + onTouchStart={() => setIsDragging(true)} + onTouchEnd={() => setIsDragging(false)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> +
+
+ ); +} + +// Toggle switch component +export function ToggleSwitch({ + checked, + onChange, + label, + theme, +}: { + checked: boolean; + onChange: () => void; + label: string; + theme: EditorConfig['theme']; +}) { + return ( + + ); +} + +// Tool grid with category support +export function ToolGrid({ + tools, + activeTool, + onSelect, + theme, + columns = 4, +}: { + tools: ToolConfig[]; + activeTool: string; + onSelect: (toolId: string) => void; + theme: EditorConfig['theme']; + columns?: number; +}) { + return ( +
+ {tools.map((tool) => { + const isActive = activeTool === tool.id; + return ( + + ); + })} +
+ ); +} + +// Elevation palette - compact color swatches +export function ElevationPalette({ + elevations, + selected, + onSelect, + theme, +}: { + elevations: EditorConfig['terrain']['elevations']; + selected: number; + onSelect: (id: number) => void; + theme: EditorConfig['theme']; +}) { + return ( +
+
+ {elevations.map((elev) => { + const isSelected = selected === elev.id; + return ( + + ); + })} +
+ {/* Selected elevation info with fade transition */} + {elevations.find((e) => e.id === selected) && ( +
+
e.id === selected)?.color }} + /> + + {elevations.find((e) => e.id === selected)?.name} + + {!elevations.find((e) => e.id === selected)?.walkable && ( + + Blocked + + )} +
+ )} +
+ ); +} + +// Feature buttons +export function FeatureGrid({ + features, + selected, + onSelect, + theme, +}: { + features: EditorConfig['terrain']['features']; + selected: string; + onSelect: (id: string) => void; + theme: EditorConfig['theme']; +}) { + return ( +
+ {features.map((feature) => { + const isSelected = selected === feature.id; + return ( + + ); + })} +
+ ); +} + +// Material selector +export function MaterialSelector({ + materials, + selected, + onSelect, + theme, +}: { + materials: EditorConfig['terrain']['materials']; + selected: number; + onSelect: (id: number) => void; + theme: EditorConfig['theme']; +}) { + if (!materials || materials.length === 0) return null; + + return ( +
+ {materials.map((mat) => { + const isSelected = selected === mat.id; + return ( + + ); + })} +
+ ); +} + +// Rotation dial component for intuitive rotation control +export function RotationDial({ + value, + onChange, + theme, +}: { + value: number; + onChange: (value: number) => void; + theme: EditorConfig['theme']; +}) { + const dialRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + updateRotation(e); + }; + + const updateRotation = (e: React.MouseEvent | MouseEvent) => { + if (!dialRef.current) return; + const rect = dialRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const dx = e.clientX - centerX; + const dy = e.clientY - centerY; + let angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; + if (angle < 0) angle += 360; + onChange(Math.round(angle) % 360); + }; + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => updateRotation(e); + const handleMouseUp = () => setIsDragging(false); + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging]); + + // Quick snap buttons + const snapAngles = [0, 45, 90, 135, 180, 225, 270, 315]; + + return ( +
+
+ + Rotation + + + {value}° + +
+ +
+ {/* Dial */} +
+ {/* Inner circle */} +
+ {/* Rotation indicator */} +
+ {/* Center dot */} +
+
+ + {/* Cardinal markers */} + {[0, 90, 180, 270].map((angle) => ( +
+ ))} +
+ + {/* Quick snap buttons */} +
+ {snapAngles.map((angle) => ( + + ))} +
+
+
+ ); +} + +// Scale control with visual feedback +export function ScaleControl({ + value, + min, + max, + onChange, + theme, +}: { + value: number; + min: number; + max: number; + onChange: (value: number) => void; + theme: EditorConfig['theme']; +}) { + const percentage = ((value - min) / (max - min)) * 100; + const [isDragging, setIsDragging] = useState(false); + + // Quick scale presets + const presets = [0.5, 1.0, 1.5, 2.0]; + + return ( +
+
+ + Scale + + + {value.toFixed(2)}x + +
+ + {/* Slider */} +
+
+
+ onChange(Number(e.target.value))} + onMouseDown={() => setIsDragging(true)} + onMouseUp={() => setIsDragging(false)} + onMouseLeave={() => setIsDragging(false)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> +
+ + {/* Preset buttons */} +
+ {presets.map((preset) => ( + + ))} +
+
+ ); +} + +// Animated panel content wrapper +export function AnimatedPanelContent({ + isActive, + children, +}: { + isActive: boolean; + children: React.ReactNode; +}) { + const [shouldRender, setShouldRender] = useState(isActive); + const [isAnimating, setIsAnimating] = useState(false); + + useEffect(() => { + if (isActive) { + setShouldRender(true); + // Small delay to trigger CSS transition + requestAnimationFrame(() => { + setIsAnimating(true); + }); + } else { + setIsAnimating(false); + // Wait for animation to complete before unmounting + const timer = setTimeout(() => setShouldRender(false), 200); + return () => clearTimeout(timer); + } + }, [isActive]); + + if (!shouldRender) return null; + + return ( +
+ {children} +
+ ); +} + +// Keyboard shortcuts footer +export function ShortcutsFooter({ theme }: { theme: EditorConfig['theme'] }) { + return ( +
+
+ + + B + {' '} + Brush + + + + G + {' '} + Fill + + + + 0-5 + {' '} + Elev + + + + [ ] + {' '} + Size + +
+
+ ); +} diff --git a/src/editor/index.ts b/src/editor/index.ts index 00920e02..7eb35414 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -60,8 +60,8 @@ export type { Editor3DCanvasProps } from './core/Editor3DCanvas'; export { EditorHeader } from './core/EditorHeader'; export type { EditorHeaderProps, MapListItem } from './core/EditorHeader'; -export { EditorPanels } from './core/EditorPanels'; -export type { EditorPanelsProps } from './core/EditorPanels'; +export { EditorPanels } from './core/panels'; +export type { EditorPanelsProps } from './core/panels'; export { EditorToolbar } from './core/EditorToolbar'; export type { EditorToolbarProps } from './core/EditorToolbar';