419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import type { AnyNodeId } from '@pascal-app/core'
|
|
import { LevelNode, useScene } from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import {
|
|
AppWindow,
|
|
ArrowRight,
|
|
Box,
|
|
Building2,
|
|
Camera,
|
|
Copy,
|
|
DoorOpen,
|
|
Eye,
|
|
EyeOff,
|
|
FileJson,
|
|
Grid3X3,
|
|
Hexagon,
|
|
Layers,
|
|
Map,
|
|
Maximize2,
|
|
Minimize2,
|
|
Moon,
|
|
MousePointer2,
|
|
Package,
|
|
PaintBucket,
|
|
PencilLine,
|
|
Plus,
|
|
Redo2,
|
|
Square,
|
|
SquareStack,
|
|
Sun,
|
|
Trash2,
|
|
Undo2,
|
|
Video,
|
|
} from 'lucide-react'
|
|
import { useEffect } from 'react'
|
|
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
|
import { runRedo, runUndo } from '../../../lib/history'
|
|
import { useCommandRegistry } from '../../../store/use-command-registry'
|
|
import type { StructureTool } from '../../../store/use-editor'
|
|
import useEditor from '../../../store/use-editor'
|
|
import { useCommandPalette } from './index'
|
|
|
|
export function EditorCommands() {
|
|
const register = useCommandRegistry((s) => s.register)
|
|
const { navigateTo, setInputValue, setOpen } = useCommandPalette()
|
|
|
|
const setPhase = useEditor((s) => s.setPhase)
|
|
const setMode = useEditor((s) => s.setMode)
|
|
const setTool = useEditor((s) => s.setTool)
|
|
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
|
const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
|
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
|
|
|
const exportScene = useViewer((s) => s.exportScene)
|
|
|
|
// Re-register when exportScene availability changes (it's a conditional action)
|
|
useEffect(() => {
|
|
const run = (fn: () => void) => {
|
|
fn()
|
|
setOpen(false)
|
|
}
|
|
|
|
const activateTool = (tool: StructureTool) => {
|
|
run(() => {
|
|
setPhase('structure')
|
|
setMode('build')
|
|
if (tool === 'zone') setStructureLayer('zones')
|
|
setTool(tool)
|
|
})
|
|
}
|
|
|
|
return register([
|
|
// ── Scene ────────────────────────────────────────────────────────────
|
|
{
|
|
id: 'editor.tool.wall',
|
|
label: 'Wall Tool',
|
|
group: 'Scene',
|
|
icon: <Square className="h-4 w-4" />,
|
|
keywords: ['draw', 'build', 'structure'],
|
|
execute: () => activateTool('wall'),
|
|
},
|
|
{
|
|
id: 'editor.tool.slab',
|
|
label: 'Slab Tool',
|
|
group: 'Scene',
|
|
icon: <Layers className="h-4 w-4" />,
|
|
keywords: ['floor', 'build'],
|
|
execute: () => activateTool('slab'),
|
|
},
|
|
{
|
|
id: 'editor.tool.ceiling',
|
|
label: 'Ceiling Tool',
|
|
group: 'Scene',
|
|
icon: <Grid3X3 className="h-4 w-4" />,
|
|
keywords: ['top', 'build'],
|
|
execute: () => activateTool('ceiling'),
|
|
},
|
|
{
|
|
id: 'editor.tool.door',
|
|
label: 'Door Tool',
|
|
group: 'Scene',
|
|
icon: <DoorOpen className="h-4 w-4" />,
|
|
keywords: ['opening', 'entrance'],
|
|
execute: () => activateTool('door'),
|
|
},
|
|
{
|
|
id: 'editor.tool.window',
|
|
label: 'Window Tool',
|
|
group: 'Scene',
|
|
icon: <AppWindow className="h-4 w-4" />,
|
|
keywords: ['opening', 'glass'],
|
|
execute: () => activateTool('window'),
|
|
},
|
|
{
|
|
id: 'editor.tool.item',
|
|
label: 'Item Tool',
|
|
group: 'Scene',
|
|
icon: <Package className="h-4 w-4" />,
|
|
keywords: ['furniture', 'object', 'asset', 'furnish'],
|
|
execute: () => activateTool('item'),
|
|
},
|
|
{
|
|
id: 'editor.tool.stair',
|
|
label: 'Stair Tool',
|
|
group: 'Scene',
|
|
icon: <ArrowRight className="h-4 w-4" />,
|
|
keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'],
|
|
execute: () => activateTool('stair'),
|
|
},
|
|
{
|
|
id: 'editor.tool.zone',
|
|
label: 'Zone Tool',
|
|
group: 'Scene',
|
|
icon: <Hexagon className="h-4 w-4" />,
|
|
keywords: ['area', 'room', 'space'],
|
|
execute: () => activateTool('zone'),
|
|
},
|
|
{
|
|
id: 'editor.delete-selection',
|
|
label: 'Delete Selection',
|
|
group: 'Scene',
|
|
icon: <Trash2 className="h-4 w-4" />,
|
|
keywords: ['remove', 'erase'],
|
|
shortcut: ['⌫'],
|
|
when: () => useViewer.getState().selection.selectedIds.length > 0,
|
|
execute: () =>
|
|
run(() => {
|
|
const { selectedIds } = useViewer.getState().selection
|
|
useScene.getState().deleteNodes(selectedIds as any[])
|
|
}),
|
|
},
|
|
{
|
|
id: 'editor.mode.material-paint',
|
|
label: 'Material Paint',
|
|
group: 'Scene',
|
|
icon: <PaintBucket className="h-4 w-4" />,
|
|
keywords: ['paint', 'material', 'texture', 'bucket', 'surface'],
|
|
shortcut: ['P'],
|
|
execute: () =>
|
|
run(() => {
|
|
primeMaterialPaintFromSelection()
|
|
setPhase('structure')
|
|
setStructureLayer('elements')
|
|
setMode('material-paint')
|
|
}),
|
|
},
|
|
|
|
// ── Levels ───────────────────────────────────────────────────────────
|
|
{
|
|
id: 'editor.level.goto',
|
|
label: 'Go to Level',
|
|
group: 'Levels',
|
|
icon: <ArrowRight className="h-4 w-4" />,
|
|
keywords: ['level', 'floor', 'go', 'navigate', 'switch', 'select'],
|
|
navigate: true,
|
|
when: () => Object.values(useScene.getState().nodes).some((n) => n.type === 'level'),
|
|
execute: () => navigateTo('goto-level'),
|
|
},
|
|
{
|
|
id: 'editor.level.add',
|
|
label: 'Add Level',
|
|
group: 'Levels',
|
|
icon: <Plus className="h-4 w-4" />,
|
|
keywords: ['level', 'floor', 'add', 'create', 'new'],
|
|
execute: () =>
|
|
run(() => {
|
|
const { nodes } = useScene.getState()
|
|
const building = Object.values(nodes).find((n) => n.type === 'building')
|
|
if (!building) return
|
|
const newLevel = LevelNode.parse({
|
|
level: building.children.length,
|
|
children: [],
|
|
parentId: building.id,
|
|
})
|
|
useScene.getState().createNode(newLevel, building.id)
|
|
useViewer.getState().setSelection({ levelId: newLevel.id })
|
|
}),
|
|
},
|
|
{
|
|
id: 'editor.level.rename',
|
|
label: 'Rename Level',
|
|
group: 'Levels',
|
|
icon: <PencilLine className="h-4 w-4" />,
|
|
keywords: ['level', 'floor', 'rename', 'name'],
|
|
navigate: true,
|
|
when: () => !!useViewer.getState().selection.levelId,
|
|
execute: () => {
|
|
const activeLevelId = useViewer.getState().selection.levelId
|
|
if (!activeLevelId) return
|
|
const level = useScene.getState().nodes[activeLevelId as AnyNodeId] as LevelNode
|
|
setInputValue(level?.name ?? '')
|
|
navigateTo('rename-level')
|
|
},
|
|
},
|
|
{
|
|
id: 'editor.level.delete',
|
|
label: 'Delete Level',
|
|
group: 'Levels',
|
|
icon: <Trash2 className="h-4 w-4" />,
|
|
keywords: ['level', 'floor', 'delete', 'remove'],
|
|
when: () => {
|
|
const levelId = useViewer.getState().selection.levelId
|
|
if (!levelId) return false
|
|
const node = useScene.getState().nodes[levelId as AnyNodeId] as LevelNode
|
|
return node?.type === 'level' && node.level !== 0
|
|
},
|
|
execute: () =>
|
|
run(() => {
|
|
const activeLevelId = useViewer.getState().selection.levelId
|
|
if (!activeLevelId) return
|
|
deleteLevelWithFallbackSelection(activeLevelId as AnyNodeId)
|
|
}),
|
|
},
|
|
|
|
// ── Viewer Controls ──────────────────────────────────────────────────
|
|
{
|
|
id: 'editor.viewer.wall-mode',
|
|
label: 'Wall Mode',
|
|
group: 'Viewer Controls',
|
|
icon: <Layers className="h-4 w-4" />,
|
|
keywords: ['wall', 'cutaway', 'up', 'down', 'view'],
|
|
badge: () => {
|
|
const mode = useViewer.getState().wallMode
|
|
return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode]
|
|
},
|
|
navigate: true,
|
|
execute: () => navigateTo('wall-mode'),
|
|
},
|
|
{
|
|
id: 'editor.viewer.level-mode',
|
|
label: 'Level Mode',
|
|
group: 'Viewer Controls',
|
|
icon: <SquareStack className="h-4 w-4" />,
|
|
keywords: ['level', 'floor', 'exploded', 'stacked', 'solo'],
|
|
badge: () => {
|
|
const mode = useViewer.getState().levelMode
|
|
return { manual: 'Manual', stacked: 'Stacked', exploded: 'Exploded', solo: 'Solo' }[mode]
|
|
},
|
|
navigate: true,
|
|
execute: () => navigateTo('level-mode'),
|
|
},
|
|
{
|
|
id: 'editor.viewer.camera-mode',
|
|
label: () => {
|
|
const mode = useViewer.getState().cameraMode
|
|
return `Camera: Switch to ${mode === 'perspective' ? 'Orthographic' : 'Perspective'}`
|
|
},
|
|
group: 'Viewer Controls',
|
|
icon: <Video className="h-4 w-4" />,
|
|
keywords: ['camera', 'ortho', 'perspective', '2d', '3d', 'view'],
|
|
execute: () =>
|
|
run(() => {
|
|
const { cameraMode, setCameraMode } = useViewer.getState()
|
|
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
|
|
}),
|
|
},
|
|
{
|
|
id: 'editor.viewer.theme',
|
|
label: () => {
|
|
const theme = useViewer.getState().theme
|
|
return theme === 'dark' ? 'Switch to Light Theme' : 'Switch to Dark Theme'
|
|
},
|
|
group: 'Viewer Controls',
|
|
icon: <Sun className="h-4 w-4" />, // icon is static; label conveys the action
|
|
keywords: ['theme', 'dark', 'light', 'appearance', 'color'],
|
|
execute: () =>
|
|
run(() => {
|
|
const { theme, setTheme } = useViewer.getState()
|
|
setTheme(theme === 'dark' ? 'light' : 'dark')
|
|
}),
|
|
},
|
|
{
|
|
id: 'editor.viewer.camera-snapshot',
|
|
label: 'Camera Snapshot',
|
|
group: 'Viewer Controls',
|
|
icon: <Camera className="h-4 w-4" />,
|
|
keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],
|
|
navigate: true,
|
|
execute: () => navigateTo('camera-view'),
|
|
},
|
|
|
|
// ── View ─────────────────────────────────────────────────────────────
|
|
{
|
|
id: 'editor.view.preview',
|
|
label: () => (isPreviewMode ? 'Exit Preview' : 'Enter Preview'),
|
|
group: 'View',
|
|
icon: isPreviewMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />,
|
|
keywords: ['preview', 'view', 'read-only', 'present'],
|
|
execute: () => run(() => setPreviewMode(!isPreviewMode)),
|
|
},
|
|
{
|
|
id: 'editor.view.fullscreen',
|
|
label: 'Toggle Fullscreen',
|
|
group: 'View',
|
|
icon: <Maximize2 className="h-4 w-4" />,
|
|
keywords: ['fullscreen', 'maximize', 'expand', 'window'],
|
|
execute: () =>
|
|
run(() => {
|
|
if (document.fullscreenElement) document.exitFullscreen()
|
|
else document.documentElement.requestFullscreen()
|
|
}),
|
|
},
|
|
|
|
// ── History ──────────────────────────────────────────────────────────
|
|
{
|
|
id: 'editor.history.undo',
|
|
label: 'Undo',
|
|
group: 'History',
|
|
icon: <Undo2 className="h-4 w-4" />,
|
|
keywords: ['undo', 'revert', 'back'],
|
|
execute: () => run(() => runUndo()),
|
|
},
|
|
{
|
|
id: 'editor.history.redo',
|
|
label: 'Redo',
|
|
group: 'History',
|
|
icon: <Redo2 className="h-4 w-4" />,
|
|
keywords: ['redo', 'forward', 'repeat'],
|
|
execute: () => run(() => runRedo()),
|
|
},
|
|
|
|
// ── Export & Share ───────────────────────────────────────────────────
|
|
{
|
|
id: 'editor.export.json',
|
|
label: 'Export Scene (JSON)',
|
|
group: 'Export & Share',
|
|
icon: <FileJson className="h-4 w-4" />,
|
|
keywords: ['export', 'download', 'json', 'save', 'data'],
|
|
execute: () =>
|
|
run(() => {
|
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], {
|
|
type: 'application/json',
|
|
})
|
|
const url = URL.createObjectURL(blob)
|
|
Object.assign(document.createElement('a'), {
|
|
href: url,
|
|
download: `scene_${new Date().toISOString().split('T')[0]}.json`,
|
|
}).click()
|
|
URL.revokeObjectURL(url)
|
|
}),
|
|
},
|
|
...(exportScene
|
|
? [
|
|
{
|
|
id: 'editor.export.glb',
|
|
label: 'Export 3D Model (GLB)',
|
|
group: 'Export & Share',
|
|
icon: <Box className="h-4 w-4" />,
|
|
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
|
execute: () => run(() => exportScene()),
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
id: 'editor.export.share-link',
|
|
label: 'Copy Share Link',
|
|
group: 'Export & Share',
|
|
icon: <Copy className="h-4 w-4" />,
|
|
keywords: ['share', 'copy', 'url', 'link'],
|
|
execute: () => run(() => navigator.clipboard.writeText(window.location.href)),
|
|
},
|
|
{
|
|
id: 'editor.export.screenshot',
|
|
label: 'Take Screenshot',
|
|
group: 'Export & Share',
|
|
icon: <Camera className="h-4 w-4" />,
|
|
keywords: ['screenshot', 'capture', 'image', 'photo', 'png'],
|
|
execute: () =>
|
|
run(() => {
|
|
const canvas = document.querySelector('canvas')
|
|
if (!canvas) return
|
|
Object.assign(document.createElement('a'), {
|
|
href: canvas.toDataURL('image/png'),
|
|
download: `screenshot_${new Date().toISOString().split('T')[0]}.png`,
|
|
}).click()
|
|
}),
|
|
},
|
|
])
|
|
}, [
|
|
register,
|
|
navigateTo,
|
|
setInputValue,
|
|
setOpen,
|
|
setPhase,
|
|
setMode,
|
|
setTool,
|
|
setStructureLayer,
|
|
isPreviewMode,
|
|
setPreviewMode,
|
|
exportScene,
|
|
])
|
|
|
|
return null
|
|
}
|