sidebar + starting controls

This commit is contained in:
wass08
2026-01-19 10:56:23 +09:00
parent cba00f7cc7
commit a35b982b4f
10 changed files with 314 additions and 17 deletions
+29 -1
View File
@@ -5,11 +5,12 @@ import {
initSpatialGridSync,
ItemNode,
sceneRegistry,
useRegistry,
useScene,
WallNode,
} from "@pascal-app/core";
import { useGridEvents, useViewer, Viewer } from "@pascal-app/viewer";
import { Stats } from "@react-three/drei";
import { CameraControls, CameraControlsImpl, Stats } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useMemo, useRef } from "react";
import { Color, Object3D } from "three";
@@ -54,10 +55,37 @@ export default function Editor() {
<Grid cellColor="#666" sectionColor="#999" fadeDistance={30} />
<Passes />
<ToolManager />
<EditorCameraManager />
</Viewer>
</div>
);
}
const EditorCameraManager = () => {
const controls = useRef<CameraControlsImpl>(null!);
const currentLevelId = useViewer((state) => state.currentLevelId);
useEffect(() => {
let targetY = 0;
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId);
if (levelMesh) {
targetY = levelMesh.position.y;
}
}
const { position } = controls.current.camera;
controls.current.setLookAt(
position.x,
targetY + 5,
position.z,
0,
targetY,
0,
true,
);
}, [currentLevelId]);
return <CameraControls ref={controls} />;
};
const TestUndo = () => {
const { undo, redo, futureStates, pastStates } = useScene.temporal.getState();
@@ -132,7 +132,7 @@ function SidebarProvider({
<TooltipProvider delayDuration={0}>
<div
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar pointer-events-none",
className,
)}
data-slot="sidebar-wrapper"
@@ -229,7 +229,7 @@ function Sidebar({
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex pointer-events-auto",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -243,7 +243,7 @@ function Sidebar({
{...props}
>
<div
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm pointer-events-auto"
data-sidebar="sidebar"
data-slot="sidebar-inner"
>
@@ -1,12 +0,0 @@
import { useScene } from "@pascal-app/core";
export function SitePanel() {
const rootNodes = useScene((state) => state.rootNodeIds);
return (
<div>
{rootNodes.map((nodeId) => (
<div key={nodeId}>{nodeId}</div>
))}
</div>
);
}
@@ -0,0 +1,62 @@
import { BuildingNode, LevelNode, useScene } from "@pascal-app/core";
import { Building2, Plus } from "lucide-react";
import { useState } from "react";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
interface BuildingTreeNodeProps {
node: BuildingNode;
depth: number;
}
export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) {
const [expanded, setExpanded] = useState(true);
const createNode = useScene((state) => state.createNode);
const handleClick = () => {
// Handle building selection
};
const handleAddLevel = (e: React.MouseEvent) => {
e.stopPropagation();
const newLevel = LevelNode.parse({
level: node.children.length,
children: [],
parentId: node.id,
});
createNode(newLevel, node.id);
};
return (
<TreeNodeWrapper
icon={<Building2 className="w-3.5 h-3.5" />}
label={node.name || "Building"}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
actions={
<Tooltip>
<TooltipTrigger asChild>
<button
className="w-5 h-5 flex items-center justify-center rounded hover:bg-accent"
onClick={handleAddLevel}
>
<Plus className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Add new level</TooltipContent>
</Tooltip>
}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
);
}
@@ -0,0 +1,14 @@
import { useScene } from "@pascal-app/core";
import { TreeNode } from "./tree-node";
export function SitePanel() {
const rootNodeIds = useScene((state) => state.rootNodeIds);
return (
<div className="py-1">
{rootNodeIds.map((nodeId) => (
<TreeNode key={nodeId} nodeId={nodeId} />
))}
</div>
);
}
@@ -0,0 +1,26 @@
import { ItemNode } from "@pascal-app/core";
import { Box } from "lucide-react";
import { TreeNodeWrapper } from "./tree-node";
interface ItemTreeNodeProps {
node: ItemNode;
depth: number;
}
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
const handleClick = () => {
// Handle item selection
};
return (
<TreeNodeWrapper
icon={<Box className="w-3.5 h-3.5" />}
label={node.name || node.asset.name || "Item"}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
/>
);
}
@@ -0,0 +1,39 @@
import { LevelNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Layers } from "lucide-react";
import { useState } from "react";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
interface LevelTreeNodeProps {
node: LevelNode;
depth: number;
}
export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
const [expanded, setExpanded] = useState(true);
const currentLevelId = useViewer((state) => state.currentLevelId);
const setCurrentLevel = useViewer((state) => state.setCurrentLevelId);
const handleClick = () => {
setCurrentLevel(node.id);
};
const isSelected = currentLevelId === node.id;
return (
<TreeNodeWrapper
icon={<Layers className="w-3.5 h-3.5" />}
label={node.name || `Level ${node.level}`}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
isSelected={isSelected}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
);
}
@@ -0,0 +1,101 @@
import { AnyNodeId, useScene } from "@pascal-app/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { BuildingTreeNode } from "./building-tree-node";
import { LevelTreeNode } from "./level-tree-node";
import { WallTreeNode } from "./wall-tree-node";
import { ItemTreeNode } from "./item-tree-node";
interface TreeNodeProps {
nodeId: AnyNodeId;
depth?: number;
}
export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
const node = useScene((state) => state.nodes[nodeId]);
if (!node) return null;
switch (node.type) {
case "building":
return <BuildingTreeNode node={node} depth={depth} />;
case "level":
return <LevelTreeNode node={node} depth={depth} />;
case "wall":
return <WallTreeNode node={node} depth={depth} />;
case "item":
return <ItemTreeNode node={node} depth={depth} />;
default:
return null;
}
}
interface TreeNodeWrapperProps {
icon: React.ReactNode;
label: string;
depth: number;
hasChildren: boolean;
expanded: boolean;
onToggle: () => void;
onClick: () => void;
actions?: React.ReactNode;
children?: React.ReactNode;
isSelected?: boolean;
}
export function TreeNodeWrapper({
icon,
label,
depth,
hasChildren,
expanded,
onToggle,
onClick,
actions,
children,
isSelected,
}: TreeNodeWrapperProps) {
return (
<div>
<div
className={cn(
"flex items-center h-7 hover:bg-accent/50 cursor-pointer group",
"text-sm",
isSelected ? "text-primary-foreground bg-primary/80" : "text-muted-foreground"
)}
style={{ paddingLeft: depth * 12 + 4 }}
>
<button
className="w-4 h-4 flex items-center justify-center shrink-0"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
{hasChildren ? (
expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)
) : null}
</button>
<div
className="flex items-center gap-1.5 flex-1 min-w-0"
onClick={onClick}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{icon}
</span>
<span className="truncate">{label}</span>
</div>
{actions && (
<div className="opacity-0 group-hover:opacity-100 pr-1">
{actions}
</div>
)}
</div>
{expanded && children}
</div>
);
}
@@ -0,0 +1,39 @@
import { WallNode, useScene } from "@pascal-app/core";
import { Square } from "lucide-react";
import { useState } from "react";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
interface WallTreeNodeProps {
node: WallNode;
depth: number;
}
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
const handleClick = () => {
// Handle wall selection
useScene.getState().markDirty(node.id);
};
const wallLength = Math.sqrt(
Math.pow(node.end[0] - node.start[0], 2) +
Math.pow(node.end[1] - node.start[1], 2)
).toFixed(1);
return (
<TreeNodeWrapper
icon={<Square className="w-3.5 h-3.5" />}
label={node.name || `Wall (${wallLength}m)`}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
);
}