"use client"; import { useEffect, useState } from "react"; import { Command } from "cmdk"; import { create } from "zustand"; import { AppWindow, ArrowRight, Building2, Camera, ChevronRight, Copy, DoorOpen, Eye, EyeOff, FileJson, Hexagon, Layers, Map, Maximize2, Minimize2, Moon, MousePointer2, Package, PencilLine, Plus, Redo2, Search, Square, SquareStack, Sun, Trash2, Undo2, Video, Box, Grid3X3, } from "lucide-react"; import { Dialog, DialogContent } from "@/components/ui/primitives/dialog"; import useEditor from "@/store/use-editor"; import type { StructureTool } from "@/store/use-editor"; import { useViewer } from "@pascal-app/viewer"; import { emitter, LevelNode, useScene } from "@pascal-app/core"; import type { AnyNodeId } from "@pascal-app/core"; import { useShallow } from "zustand/shallow"; // --------------------------------------------------------------------------- // Open-state store — imported by icon-rail to trigger the palette // --------------------------------------------------------------------------- interface CommandPaletteStore { open: boolean; setOpen: (open: boolean) => void; } export const useCommandPalette = create((set) => ({ open: false, setOpen: (open) => set({ open }), })); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function Shortcut({ keys }: { keys: string[] }) { return ( {keys.map((k) => ( {k} ))} ); } function Item({ icon, label, onSelect, shortcut, disabled = false, keywords = [], badge, navigate = false, }: { icon: React.ReactNode; label: string; onSelect: () => void; shortcut?: string[]; disabled?: boolean; keywords?: string[]; badge?: string; navigate?: boolean; }) { return ( {icon} {label} {badge && ( {badge} )} {shortcut && } {(badge || navigate) && } ); } function OptionItem({ label, isActive = false, onSelect, icon, disabled = false, }: { label: string; isActive?: boolean; onSelect: () => void; icon?: React.ReactNode; disabled?: boolean; }) { return ( {isActive ?
: icon } {label} ); } // --------------------------------------------------------------------------- // Sub-page label map // --------------------------------------------------------------------------- const PAGE_LABEL: Record = { "wall-mode": "Wall Mode", "level-mode": "Level Mode", "rename-level": "Rename Level", "goto-level": "Go to Level", "camera-view": "Camera Snapshot", "camera-scope": "", // dynamic — overridden in breadcrumb }; // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function CommandPalette() { const { open, setOpen } = useCommandPalette(); const [meta, setMeta] = useState("⌘"); const [isFullscreen, setIsFullscreen] = useState(false); const [pages, setPages] = useState([]); const [inputValue, setInputValue] = useState(""); const [cameraScope, setCameraScope] = useState<{ nodeId: string; label: string } | null>(null); const page = pages[pages.length - 1]; const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } = useEditor(); const cameraMode = useViewer((s) => s.cameraMode); const setCameraMode = useViewer((s) => s.setCameraMode); const levelMode = useViewer((s) => s.levelMode); const setLevelMode = useViewer((s) => s.setLevelMode); const wallMode = useViewer((s) => s.wallMode); const setWallMode = useViewer((s) => s.setWallMode); const theme = useViewer((s) => s.theme); const setTheme = useViewer((s) => s.setTheme); const selection = useViewer((s) => s.selection); const exportScene = useViewer((s) => s.exportScene); const activeLevelId = selection.levelId; const activeLevelNode = useScene((s) => activeLevelId ? s.nodes[activeLevelId] : null); const isLevelZero = activeLevelNode?.type === "level" && (activeLevelNode as LevelNode).level === 0; // Reactive snapshot status for the selected camera scope const cameraScopeNode = useScene((s) => cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null); const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera; const allLevels = useScene( useShallow((s) => (Object.values(s.nodes).filter((n) => n.type === "level") as LevelNode[]).sort( (a, b) => a.level - b.level ) ) ); const hasSelection = selection.selectedIds.length > 0; // Platform detection useEffect(() => { setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘" : "Ctrl"); }, []); // Fullscreen tracking useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", handler); return () => document.removeEventListener("fullscreenchange", handler); }, []); // Cmd/Ctrl+K global shortcut useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setOpen(true); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [setOpen]); // Reset sub-pages when palette closes useEffect(() => { if (!open) { setPages([]); setInputValue(""); setCameraScope(null); } }, [open]); // --------------------------------------------------------------------------- // Navigation helpers // --------------------------------------------------------------------------- const goBack = () => { const leavingPage = pages[pages.length - 1]; if (leavingPage === "camera-scope") setCameraScope(null); setPages((p) => p.slice(0, -1)); setInputValue(""); }; const navigateTo = (p: string) => { // Pre-fill the rename input with the current level name if (p === "rename-level" && activeLevelId) { const level = useScene.getState().nodes[activeLevelId] as LevelNode; setInputValue(level?.name ?? ""); } else { setInputValue(""); } setPages((prev) => [...prev, p]); }; const navigateToCameraScope = (nodeId: string, label: string) => { setCameraScope({ nodeId, label }); setInputValue(""); setPages((prev) => [...prev, "camera-scope"]); }; // --------------------------------------------------------------------------- // Action helpers // --------------------------------------------------------------------------- const run = (fn: () => void) => { fn(); setOpen(false); }; const activateTool = (tool: StructureTool) => { run(() => { setPhase("structure"); setMode("build"); if (tool === "zone") setStructureLayer("zones"); setTool(tool); }); }; const wallModeLabel: Record<"cutaway" | "up" | "down", string> = { cutaway: "Cutaway", up: "Up", down: "Down" }; const levelModeLabel: Record<"manual" | "stacked" | "exploded" | "solo", string> = { manual: "Manual", stacked: "Stacked", exploded: "Exploded", solo: "Solo", }; const deleteSelection = () => { if (!hasSelection) return; run(() => { useScene.getState().deleteNodes(selection.selectedIds as any[]); }); }; // Level management const addLevel = () => 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 }); }); const deleteActiveLevel = () => { if (!activeLevelId || isLevelZero) return; run(() => { useScene.getState().deleteNode(activeLevelId as AnyNodeId); const { nodes } = useScene.getState(); const level0 = Object.values(nodes).find( (n) => n.type === "level" && (n as LevelNode).level === 0 ); if (level0) useViewer.getState().setSelection({ levelId: level0.id as `level_${string}` }); }); }; const confirmRename = () => { if (!activeLevelId || !inputValue.trim()) return; run(() => { useScene.getState().updateNode(activeLevelId as AnyNodeId, { name: inputValue.trim() } as any); }); }; // Camera snapshot (scoped to the currently selected camera scope) const takeSnapshot = () => { if (!cameraScope) return; run(() => emitter.emit("camera-controls:capture", { nodeId: cameraScope.nodeId as AnyNodeId })); }; const viewSnapshot = () => { if (!cameraScope || !hasScopeSnapshot) return; run(() => emitter.emit("camera-controls:view", { nodeId: cameraScope.nodeId as AnyNodeId })); }; const clearSnapshot = () => { if (!cameraScope || !hasScopeSnapshot) return; run(() => { useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any); }); }; // Export helpers const exportJson = () => run(() => { const { nodes, rootNodeIds } = useScene.getState(); const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement("a"), { href: url, download: `scene_${new Date().toISOString().split("T")[0]}.json`, }); a.click(); URL.revokeObjectURL(url); }); const copyShareLink = () => run(() => { navigator.clipboard.writeText(window.location.href); }); const takeScreenshot = () => run(() => { const canvas = document.querySelector("canvas"); if (!canvas) return; const a = Object.assign(document.createElement("a"), { href: canvas.toDataURL("image/png"), download: `screenshot_${new Date().toISOString().split("T")[0]}.png`, }); a.click(); }); const toggleFullscreen = () => run(() => { if (document.fullscreenElement) { document.exitFullscreen(); } else { document.documentElement.requestFullscreen(); } }); // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return ( { if (e.key === "Backspace" && !inputValue && pages.length > 0) { e.preventDefault(); goBack(); } }} > {/* Search bar */}
{page && ( )}
No commands found. {/* ── Root view ─────────────────────────────────────────────── */} {!page && ( <> {/* Scene / Tools */} } label="Wall Tool" onSelect={() => activateTool("wall")} keywords={["draw", "build", "structure"]} /> } label="Slab Tool" onSelect={() => activateTool("slab")} keywords={["floor", "build"]} /> } label="Ceiling Tool" onSelect={() => activateTool("ceiling")} keywords={["top", "build"]} /> } label="Door Tool" onSelect={() => activateTool("door")} keywords={["opening", "entrance"]} /> } label="Window Tool" onSelect={() => activateTool("window")} keywords={["opening", "glass"]} /> } label="Item Tool" onSelect={() => activateTool("item")} keywords={["furniture", "object", "asset", "furnish"]} /> } label="Zone Tool" onSelect={() => activateTool("zone")} keywords={["area", "room", "space"]} /> } label="Delete Selection" onSelect={deleteSelection} disabled={!hasSelection} shortcut={["⌫"]} keywords={["remove", "erase"]} /> {/* Levels */} } label="Go to Level" navigate onSelect={() => navigateTo("goto-level")} disabled={allLevels.length === 0} keywords={["level", "floor", "go", "navigate", "switch", "select"]} /> } label="Add Level" onSelect={addLevel} keywords={["level", "floor", "add", "create", "new"]} /> } label="Rename Level" navigate onSelect={() => navigateTo("rename-level")} disabled={!activeLevelId} keywords={["level", "floor", "rename", "name"]} /> } label="Delete Level" onSelect={deleteActiveLevel} disabled={!activeLevelId || isLevelZero} keywords={["level", "floor", "delete", "remove"]} /> {/* Viewer Controls */} } label="Wall Mode" badge={wallModeLabel[wallMode]} onSelect={() => navigateTo("wall-mode")} keywords={["wall", "cutaway", "up", "down", "view"]} /> } label="Level Mode" badge={levelModeLabel[levelMode]} onSelect={() => navigateTo("level-mode")} keywords={["level", "floor", "exploded", "stacked", "solo"]} /> } label={`Camera: Switch to ${cameraMode === "perspective" ? "Orthographic" : "Perspective"}`} onSelect={() => run(() => setCameraMode( cameraMode === "perspective" ? "orthographic" : "perspective" ) ) } keywords={["camera", "ortho", "perspective", "2d", "3d", "view"]} /> : } label={theme === "dark" ? "Switch to Light Theme" : "Switch to Dark Theme"} onSelect={() => run(() => setTheme(theme === "dark" ? "light" : "dark"))} keywords={["theme", "dark", "light", "appearance", "color"]} /> } label="Camera Snapshot" navigate onSelect={() => navigateTo("camera-view")} keywords={["camera", "snapshot", "capture", "save", "view", "bookmark"]} /> {/* View / Mode */} : } label={isPreviewMode ? "Exit Preview" : "Enter Preview"} onSelect={() => run(() => setPreviewMode(!isPreviewMode))} keywords={["preview", "view", "read-only", "present"]} /> ) : ( ) } label={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} onSelect={toggleFullscreen} keywords={["fullscreen", "maximize", "expand", "window"]} /> {/* History */} } label="Undo" onSelect={() => run(() => useScene.temporal.getState().undo())} shortcut={[meta, "Z"]} keywords={["undo", "revert", "back"]} /> } label="Redo" onSelect={() => run(() => useScene.temporal.getState().redo())} shortcut={[meta, "⇧", "Z"]} keywords={["redo", "forward", "repeat"]} /> {/* Export / Share */} } label="Export Scene (JSON)" onSelect={exportJson} keywords={["export", "download", "json", "save", "data"]} /> {exportScene && ( } label="Export 3D Model (GLB)" onSelect={() => run(() => exportScene())} keywords={["export", "glb", "gltf", "3d", "model", "download"]} /> )} } label="Copy Share Link" onSelect={copyShareLink} keywords={["share", "copy", "url", "link"]} /> } label="Take Screenshot" onSelect={takeScreenshot} keywords={["screenshot", "capture", "image", "photo", "png"]} /> )} {/* ── Wall Mode sub-page ────────────────────────────────────── */} {page === "wall-mode" && ( {(["cutaway", "up", "down"] as const).map((mode) => ( run(() => setWallMode(mode))} /> ))} )} {/* ── Level Mode sub-page ───────────────────────────────────── */} {page === "level-mode" && ( {(["stacked", "exploded", "solo"] as const).map((mode) => ( run(() => setLevelMode(mode))} /> ))} )} {/* ── Go to Level sub-page ──────────────────────────────────── */} {page === "goto-level" && ( {allLevels.map((level) => ( run(() => useViewer.getState().setSelection({ levelId: level.id })) } /> ))} )} {/* ── Rename Level sub-page ─────────────────────────────────── */} {page === "rename-level" && ( {inputValue.trim() ? ( <>Rename to "{inputValue.trim()}" ) : ( Type a new name above… )} )} {/* ── Camera Snapshot: scope picker ─────────────────────────── */} {page === "camera-view" && ( } onSelect={() => { const { rootNodeIds } = useScene.getState(); const siteId = rootNodeIds[0]; if (siteId) navigateToCameraScope(siteId, "Site"); }} /> } onSelect={() => { const building = Object.values(useScene.getState().nodes).find( (n) => n.type === "building" ); if (building) navigateToCameraScope(building.id, "Building"); }} /> } disabled={!activeLevelId} onSelect={() => { if (activeLevelId) navigateToCameraScope(activeLevelId, "Level"); }} /> } disabled={!hasSelection} onSelect={() => { const firstId = selection.selectedIds[0]; if (firstId) navigateToCameraScope(firstId, "Selection"); }} /> )} {/* ── Camera Snapshot: actions for selected scope ───────────── */} {page === "camera-scope" && cameraScope && ( } onSelect={takeSnapshot} /> {hasScopeSnapshot && ( } onSelect={viewSnapshot} /> )} {hasScopeSnapshot && ( } onSelect={clearSnapshot} /> )} )} {/* Footer hint */}
navigate select {page ? ( back ) : ( close )}
); }