Feat/ux round 3 (#124)
* Tree: add isLast prop & draw connectors Introduce an optional isLast prop across all site panel tree node components and TreeNode so children know when they're the last sibling. TreeNodeWrapper now renders vertical and horizontal connector lines (adjusted by depth and isLast) and keeps the toggle button clickable (z-index). Children are passed isLast when mapped, so the UI can stop the vertical line for final siblings. Also bump several deps in apps/editor package.json and update the lockfile accordingly. * chore: clean up monorepo dependencies and update packages Move app/library dependencies out of root package.json into their respective workspaces. Root now only contains monorepo tooling (turbo, biome, supabase CLI, typescript, portless, ultracite). Updates: - next 16.1.0 → 16.1.6 - drizzle-orm ^0.39.0 → ^0.45.1 - drizzle-kit ^0.30.0 → ^0.31.9 - drizzle-zod ^0.5.1 → ^0.8.3 - three-bvh-csg ^0.0.17 → ^0.0.18 - supabase 2.75.3 → 2.76.15 - typescript 5.9.2 → 5.9.3 (all workspaces) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use semantic theme tokens and enable dark mode Replace hardcoded zinc color classes with semantic design tokens (text-muted-foreground, text-foreground, border, bg-background, bg-border) across the action menu, camera controls, control modes, and view toggles for consistent theming. Update separators and borders to use bg-border/border-border. Add useEffect in the editor root to add/remove the 'dark' class on document.body and apply 'dark text-foreground' to the root container so the UI renders in dark mode; also import useEffect. * Persistable sidebar width & resizer Introduce a zustand store to persist sidebar width (key: `sidebar-preferences`) and track dragging state. Default width is 288px (18rem) and is clamped between 288 and 800px. Wire the store into SidebarProvider to drive the CSS variable for width and expose a data-dragging attribute. Add a SidebarResizer component that handles pointer events to resize the sidebar, toggles dragging state, updates cursor/user-select, and disables transitions while dragging. Small class and structure adjustments ensure the resizer is rendered and resizing behaves smoothly. * Editor: floating action menu, selection/phase tweaks Add a FloatingActionMenu (move/duplicate/delete) that attaches to selected 3D nodes and integrates with editor/viewer state; includes new cursor SVG and layout wrapper to use the custom cursor. Improve selection and phase behavior: auto-select first building+level on scene load (moved/centralized in project scene hook and editor init), add resolveBuildingId helper, and enhance SelectionManager to auto-switch between structure/furnish based on clicked nodes and to preserve building/level context when changing selection. Update sidebar tree node click handlers to switch phases for structural vs item nodes and stop hiding nodes by phase (keep tree items visible across phases). Add keyboard escape behavior to clear selections while keeping building/level context and reset selected reference. Misc: register unified event listeners for selection, position floating menu over objects using three.js Box3, and various small integration fixes. * Add SceneLoader, loading states & editor fixes Add a SceneLoader component and associated CSS loader styles and wire it into Editor, Viewer and page-level flows so a loading UI displays while project/scene data loads. Replace useLayoutEffect in editor page with a client-mounted useEffect guard to avoid hydration issues. Introduce isSceneLoading state and setter in the project store and set/clear it in useProjectScene; include sceneGraph when creating a new project and clear the scene beforehand. Other changes: type-guard moving/duplicating logic in FloatingActionMenu and remove duplicated id on clones; refactor auto-selection logic for site/building/level and auto-select build/wall for empty levels in editor index and useProjectScene; optimize ZoneSystem to run per-frame (useFrame) and reduce unnecessary updates to visibility/label positions; add animated active background for layer toggle (motion) in site panel; add keyboard shortcuts to jump between levels (Ctrl/Cmd+ArrowUp/Down); simplify community-hub project-created handler to navigate straight to editor. These changes improve UX during load, enforce safer node operations, and optimize runtime rendering. * Improve selection logic and refine UI components Multiple editor and viewer updates: - Selection: enhance selection-manager to support modifier (Meta/Ctrl) multi-select, compute next selection logic, track modifier keys, prevent grid deselect races, and add debug logs. Adapted selection handlers throughout scene/editor components to pass native events and modifier state. - Sidebar / Site panel: refactor site panel to inline a LevelReferences UI (replacing the removed references dialog). Add upload flow for scan (.glb/.gltf) and guide images, handling file size/type checks, project asset upload/delete calls, and per-level reference lists. Add MultiSelectionBadge for showing/clearing multi-selection and animated expand/collapse for levels. Several tree-node components now use a shared handleTreeSelection helper and propagate click events properly. - UI polish & icons: change dark theme color tokens for better contrast; increase button sizes and icon sizes across action menus; swap some lucide icons for image assets (rotate, topview, select, build, mesh, floorplan, level, etc.) and add new public icons. - PascalRadio: replace popover with a framer-motion expandable panel, add outside-click handling, animated transitions, and minor UI tweaks. - Other: minor viewer hook/selection-manager updates and tweaks to control modes and view toggles to support image icons and consistent sizing. These changes improve multi-select reliability, user interactions in the sidebar, and overall visual polish with new icons and animations. * Fix TypeScript build errors in selection-manager and scene-loader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
441517424d
commit
5ecefbdc06
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import { RotateCcw, RotateCw, Rotate3D } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/primitives/button'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -28,12 +28,18 @@ export function CameraActions() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
|
||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
||||
onClick={orbitCCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<Image
|
||||
alt="Orbit Left"
|
||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
|
||||
height={30}
|
||||
src="/icons/rotate.png"
|
||||
width={30}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -45,12 +51,18 @@ export function CameraActions() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
|
||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
||||
onClick={orbitCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
<Image
|
||||
alt="Orbit Right"
|
||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={30}
|
||||
src="/icons/rotate.png"
|
||||
width={30}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -62,12 +74,18 @@ export function CameraActions() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 text-zinc-400 transition-all hover:text-sky-400"
|
||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
||||
onClick={goToTopView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Rotate3D className="h-4 w-4 -rotate-90" />
|
||||
<Image
|
||||
alt="Top View"
|
||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={30}
|
||||
src="/icons/topview.png"
|
||||
width={30}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { Hammer, MousePointer2, Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEditor, { Mode, Phase } from "@/store/use-editor";
|
||||
|
||||
type ModeConfig = {
|
||||
id: Mode;
|
||||
icon: typeof MousePointer2;
|
||||
icon?: LucideIcon;
|
||||
imageSrc?: string;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
color: string;
|
||||
@@ -24,7 +26,7 @@ type ModeConfig = {
|
||||
const allModes: ModeConfig[] = [
|
||||
{
|
||||
id: "select",
|
||||
icon: MousePointer2,
|
||||
imageSrc: "/icons/select.png",
|
||||
label: "Select",
|
||||
shortcut: "V",
|
||||
color: "hover:bg-blue-500/20 hover:text-blue-400",
|
||||
@@ -40,7 +42,7 @@ const allModes: ModeConfig[] = [
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
icon: Hammer,
|
||||
imageSrc: "/icons/build.png",
|
||||
label: "Build",
|
||||
shortcut: "B",
|
||||
color: "hover:bg-green-500/20 hover:text-green-400",
|
||||
@@ -98,22 +100,39 @@ export function ControlModes() {
|
||||
{availableModes.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = mode === m.id;
|
||||
const isImageMode = Boolean(m.imageSrc);
|
||||
|
||||
return (
|
||||
<Tooltip key={m.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all",
|
||||
"text-zinc-400",
|
||||
!isActive && m.color,
|
||||
isActive && m.activeColor
|
||||
"h-9 w-9 transition-all",
|
||||
"text-muted-foreground",
|
||||
!isImageMode && !isActive && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
|
||||
isImageMode && !isActive && "hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => handleModeClick(m.id)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{m.imageSrc ? (
|
||||
<Image
|
||||
alt={m.label}
|
||||
className={cn(
|
||||
"h-[26px] w-[26px] object-contain transition-[opacity,filter] duration-200",
|
||||
!isActive && "opacity-60 grayscale",
|
||||
isActive && "opacity-100 grayscale-0"
|
||||
)}
|
||||
height={26}
|
||||
src={m.imageSrc}
|
||||
width={26}
|
||||
/>
|
||||
) : (
|
||||
Icon && <Icon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
transition={transition}
|
||||
className={cn(
|
||||
"-translate-x-1/2 fixed bottom-6 left-1/2 z-50",
|
||||
"rounded-2xl border border-zinc-800 bg-zinc-950/90 shadow-2xl backdrop-blur-md",
|
||||
"rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md",
|
||||
"transition-colors duration-200 ease-out",
|
||||
className,
|
||||
)}
|
||||
@@ -40,7 +40,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
{mode === "build" && tool === "item" && catalogCategory && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-zinc-800 border-b px-2 py-2",
|
||||
"overflow-hidden border-border border-b px-2 py-2",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
@@ -74,7 +74,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
{phase === "furnish" && mode === "build" && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-zinc-800",
|
||||
"overflow-hidden border-border",
|
||||
"max-h-20 border-b px-2 py-2 opacity-100",
|
||||
)}
|
||||
initial={{
|
||||
@@ -112,7 +112,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
{phase === "structure" && mode === "build" && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2",
|
||||
"overflow-hidden border-border max-h-20 border-b px-2 py-2",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
@@ -146,9 +146,9 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
{/* Control Mode Row - Always visible, centered */}
|
||||
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
|
||||
<ControlModes />
|
||||
<div className="mx-1 h-5 w-px bg-zinc-700" />
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<ViewToggles />
|
||||
<div className="mx-1 h-5 w-px bg-zinc-700" />
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<CameraActions />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ViewToggles() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 w-8 text-zinc-400 transition-all',
|
||||
'h-9 w-9 text-muted-foreground transition-all',
|
||||
cameraMode === 'orthographic'
|
||||
? 'bg-violet-500/20 text-violet-400'
|
||||
: 'hover:text-violet-400',
|
||||
@@ -94,7 +94,7 @@ export function ViewToggles() {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
<Camera className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -107,7 +107,7 @@ export function ViewToggles() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 w-8 text-zinc-400 transition-all',
|
||||
'h-9 w-9 text-muted-foreground transition-all',
|
||||
levelMode !== 'stacked'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'hover:text-amber-400',
|
||||
@@ -116,9 +116,9 @@ export function ViewToggles() {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{levelMode === 'solo' && <Diamond className="h-4 w-4" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-4 w-4" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-4 w-4" />}
|
||||
{levelMode === 'solo' && <Diamond className="h-5 w-5" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-5 w-5" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-5 w-5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -131,7 +131,7 @@ export function ViewToggles() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 w-8 text-zinc-400 transition-all p-0',
|
||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
||||
wallMode !== 'cutaway'
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
@@ -142,7 +142,7 @@ export function ViewToggles() {
|
||||
>
|
||||
{(() => {
|
||||
const Icon = wallModeConfig[wallMode].icon
|
||||
return <Icon className="h-5 w-5" />
|
||||
return <Icon className="h-[26px] w-[26px]" />
|
||||
})()}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -156,7 +156,7 @@ export function ViewToggles() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 w-8 text-zinc-400 transition-all p-0',
|
||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
||||
showScans
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
@@ -165,7 +165,7 @@ export function ViewToggles() {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Scans" className="h-5 w-5 object-contain" src="/icons/mesh.png" />
|
||||
<img alt="Scans" className="h-[26px] w-[26px] object-contain" src="/icons/mesh.png" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -178,7 +178,7 @@ export function ViewToggles() {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 w-8 text-zinc-400 transition-all p-0',
|
||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
||||
showGuides
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
@@ -187,7 +187,7 @@ export function ViewToggles() {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Guides" className="h-5 w-5 object-contain" src="/icons/floorplan.png" />
|
||||
<img alt="Guides" className="h-[26px] w-[26px] object-contain" src="/icons/floorplan.png" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import { Input } from "@/components/ui/primitives/input";
|
||||
import { Separator } from "@/components/ui/primitives/separator";
|
||||
@@ -31,6 +33,28 @@ const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarStore = {
|
||||
width: number;
|
||||
setWidth: (width: number) => void;
|
||||
isDragging: boolean;
|
||||
setIsDragging: (isDragging: boolean) => void;
|
||||
};
|
||||
|
||||
export const useSidebarStore = create<SidebarStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
width: 288, // 18rem = 288px
|
||||
setWidth: (width) => set({ width: Math.max(288, Math.min(width, 800)) }),
|
||||
isDragging: false,
|
||||
setIsDragging: (isDragging) => set({ isDragging }),
|
||||
}),
|
||||
{
|
||||
name: "sidebar-preferences",
|
||||
partialize: (state) => ({ width: state.width }), // Only persist width
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
@@ -67,6 +91,8 @@ function SidebarProvider({
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
const sidebarWidth = useSidebarStore((state) => state.width);
|
||||
const isDragging = useSidebarStore((state) => state.isDragging);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
@@ -136,9 +162,10 @@ function SidebarProvider({
|
||||
className,
|
||||
)}
|
||||
data-slot="sidebar-wrapper"
|
||||
data-dragging={isDragging}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width": `${sidebarWidth}px`,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
@@ -152,6 +179,52 @@ function SidebarProvider({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarResizer({ side }: { side: "left" | "right" }) {
|
||||
const setWidth = useSidebarStore((state) => state.setWidth);
|
||||
const setIsDragging = useSidebarStore((state) => state.setIsDragging);
|
||||
const isResizing = React.useRef(false);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
isResizing.current = true;
|
||||
setIsDragging(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
const newWidth = side === "left" ? e.clientX : window.innerWidth - e.clientX;
|
||||
setWidth(Math.max(288, Math.min(newWidth, 800)));
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
isResizing.current = false;
|
||||
setIsDragging(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [setWidth, side]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={handlePointerDown}
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 w-2 cursor-col-resize z-50 hover:bg-primary/50 transition-colors",
|
||||
side === "left" ? "-right-1" : "-left-1"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
@@ -219,6 +292,7 @@ function Sidebar({
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[dragging=true]/sidebar-wrapper:transition-none",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
@@ -230,6 +304,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 pointer-events-auto",
|
||||
"group-data-[dragging=true]/sidebar-wrapper:transition-none",
|
||||
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,11 +318,12 @@ 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 pointer-events-auto"
|
||||
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 relative"
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
>
|
||||
{children}
|
||||
<SidebarResizer side={side} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const LOADERS = [
|
||||
'pascal-loader-1',
|
||||
'pascal-loader-2',
|
||||
'pascal-loader-3',
|
||||
'pascal-loader-4',
|
||||
'pascal-loader-5',
|
||||
]
|
||||
|
||||
interface SceneLoaderProps {
|
||||
className?: string
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {
|
||||
const [loaderClass, setLoaderClass] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Pick a random loader on mount
|
||||
setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!)
|
||||
}, [])
|
||||
|
||||
if (!loaderClass) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-100 flex items-center justify-center bg-background/80 backdrop-blur-md transition-opacity duration-300",
|
||||
fullScreen ? "fixed inset-0" : "absolute inset-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn(loaderClass, "text-foreground opacity-80")} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
interface BuildingTreeNodeProps {
|
||||
node: BuildingNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) {
|
||||
export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const createNode = useScene((state) => state.createNode);
|
||||
const isSelected = useViewer((state) => state.selection.buildingId === node.id);
|
||||
@@ -47,6 +48,7 @@ export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) {
|
||||
onClick={handleClick}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
actions={
|
||||
<div className="flex items-center gap-0.5">
|
||||
<TreeNodeActions node={node} />
|
||||
@@ -64,8 +66,8 @@ export function BuildingTreeNode({ node, depth }: BuildingTreeNodeProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{node.children.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,18 @@ import { type AnyNodeId, CeilingNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
interface CeilingTreeNodeProps {
|
||||
node: CeilingNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
@@ -40,8 +42,12 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
@@ -62,6 +68,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
@@ -83,10 +90,11 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{node.children.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
|
||||
@@ -4,18 +4,21 @@ import { DoorNode } from "@pascal-app/core"
|
||||
import { useViewer } from "@pascal-app/viewer"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import useEditor from "@/store/use-editor"
|
||||
import { InlineRenameInput } from "./inline-rename-input"
|
||||
import { TreeNodeWrapper } from "./tree-node"
|
||||
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node"
|
||||
import { TreeNodeActions } from "./tree-node-actions"
|
||||
|
||||
interface DoorTreeNodeProps {
|
||||
node: DoorNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
|
||||
export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
@@ -24,6 +27,7 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
@@ -38,13 +42,20 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
|
||||
hasChildren={false}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
onClick={() => setSelection({ selectedIds: [node.id] })}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure")
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,30 +7,40 @@ import {
|
||||
type SiteNode,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
type ScanNode,
|
||||
type GuideNode,
|
||||
ScanNode as ScanNodeSchema,
|
||||
GuideNode as GuideNodeSchema,
|
||||
} from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import {
|
||||
Box,
|
||||
Building2,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
Image as ImageIcon,
|
||||
Layers,
|
||||
Loader2,
|
||||
Pentagon,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { TreeNode } from "./tree-node";
|
||||
import { ReferencesDialog } from "./references-dialog";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store';
|
||||
import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
|
||||
|
||||
// Preset colors for zones
|
||||
const PRESET_COLORS = [
|
||||
@@ -315,11 +325,188 @@ function CameraPopover({
|
||||
}
|
||||
|
||||
|
||||
function ReferenceItem({ refNode, isLastRow, setSelectedReferenceId, handleDelete }: {
|
||||
refNode: ScanNode | GuideNode;
|
||||
isLastRow: boolean;
|
||||
setSelectedReferenceId: (id: string) => void;
|
||||
handleDelete: (id: string, e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative group/ref flex items-center border-b border-border/50 text-xs pr-2 transition-colors hover:bg-accent/30 h-8 select-none">
|
||||
<div className={cn("absolute w-px bg-border/50 pointer-events-none z-10", isLastRow ? "top-0 bottom-1/2" : "top-0 bottom-0")} style={{ left: 45 }} />
|
||||
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none z-10" style={{ left: 45, width: 8 }} />
|
||||
|
||||
<div
|
||||
className="flex-1 flex items-center gap-2 pl-[60px] py-0 h-8 text-muted-foreground group-hover/ref:text-foreground cursor-pointer min-w-0"
|
||||
onClick={() => setSelectedReferenceId(refNode.id)}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
{refNode.type === 'scan' ? <img src="/icons/mesh.png" alt="Scan" className="w-3.5 h-3.5 shrink-0 object-contain opacity-70 group-hover/ref:opacity-100 transition-opacity" /> : <img src="/icons/floorplan.png" alt="Guide" className="w-3.5 h-3.5 shrink-0 object-contain opacity-70 group-hover/ref:opacity-100 transition-opacity" />}
|
||||
<InlineRenameInput
|
||||
node={refNode}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 group-hover/ref:opacity-100 w-5 h-5 flex items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors cursor-pointer shrink-0 z-20"
|
||||
onClick={(e) => handleDelete(refNode.id, e)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLevel?: boolean }) {
|
||||
const nodes = useScene((s) => s.nodes);
|
||||
const createNode = useScene((s) => s.createNode);
|
||||
const deleteNode = useScene((s) => s.deleteNode);
|
||||
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId);
|
||||
const activeProject = useProjectStore((s) => s.activeProject);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingType, setUploadingType] = useState<'scan'|'guide'|null>(null);
|
||||
|
||||
const scanInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const references = Object.values(nodes).filter(
|
||||
(node): node is ScanNode | GuideNode =>
|
||||
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
||||
);
|
||||
|
||||
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
|
||||
const projectId = activeProject?.id;
|
||||
if (!projectId) {
|
||||
setUploadError('No active project. Please open a project first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-detect type based on file extension/mime type
|
||||
const isScan = file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
|
||||
if (!isScan && !isImage) {
|
||||
setUploadError('Invalid file type. Please upload a .glb/.gltf scan or an image.');
|
||||
return;
|
||||
}
|
||||
|
||||
const type = isScan ? 'scan' : 'guide';
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
setUploadingType(type);
|
||||
const result = await uploadProjectAsset(projectId, file, type);
|
||||
setUploading(false);
|
||||
setUploadingType(null);
|
||||
|
||||
if (!result.success) {
|
||||
setUploadError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const Schema = type === 'scan' ? ScanNodeSchema : GuideNodeSchema;
|
||||
const node = Schema.parse({
|
||||
url: result.url,
|
||||
name: file.name,
|
||||
parentId: levelId,
|
||||
});
|
||||
createNode(node, levelId as AnyNodeId);
|
||||
setSelectedReferenceId(node.id);
|
||||
};
|
||||
|
||||
const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined;
|
||||
const projectId = activeProject?.id;
|
||||
|
||||
if (
|
||||
projectId &&
|
||||
refNode?.url &&
|
||||
(refNode.url.startsWith('http://') || refNode.url.startsWith('https://'))
|
||||
) {
|
||||
deleteProjectAssetByUrl(projectId, refNode.url);
|
||||
}
|
||||
deleteNode(nodeId as AnyNodeId);
|
||||
};
|
||||
|
||||
const rows = [
|
||||
{ type: 'upload' as const },
|
||||
...references.map(ref => ({ type: 'ref' as const, data: ref }))
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative">
|
||||
{!isLastLevel && (
|
||||
<div className="absolute top-0 bottom-0 w-px bg-border/50 pointer-events-none z-10" style={{ left: 21 }} />
|
||||
)}
|
||||
|
||||
{rows.map((row, i) => {
|
||||
const isLastRow = i === rows.length - 1;
|
||||
|
||||
if (row.type === 'upload') {
|
||||
return (
|
||||
<div key="upload" className="relative group/ref border-b border-border/50">
|
||||
<div className={cn("absolute w-px bg-border/50 pointer-events-none z-10", isLastRow ? "top-0 bottom-1/2" : "top-0 bottom-0")} style={{ left: 45 }} />
|
||||
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none z-10" style={{ left: 45, width: 8 }} />
|
||||
|
||||
<button
|
||||
className="flex items-center gap-2 w-full pl-[60px] pr-2 py-0 h-8 text-xs text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed select-none"
|
||||
disabled={uploading}
|
||||
onClick={() => scanInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{uploading ? `Uploading ${uploadingType}...` : "Upload scan/floorplan"}
|
||||
</button>
|
||||
|
||||
<input ref={scanInputRef} type="file" accept=".glb,.gltf,image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleAddAsset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ref = row.data as ScanNode | GuideNode;
|
||||
return (
|
||||
<ReferenceItem
|
||||
key={ref.id}
|
||||
refNode={ref}
|
||||
isLastRow={isLastRow}
|
||||
setSelectedReferenceId={setSelectedReferenceId}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{uploadError && (
|
||||
<div className="relative pl-[60px] pr-2 py-1 text-[10px] text-destructive border-b border-border/50 bg-destructive/5 select-none min-h-8 flex items-center">
|
||||
<div className="absolute top-0 bottom-0 w-px bg-border/50 pointer-events-none z-10" style={{ left: 45 }} />
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelItem({
|
||||
level,
|
||||
selectedLevelId,
|
||||
setSelection,
|
||||
setReferencesLevelId,
|
||||
deleteNode,
|
||||
updateNode,
|
||||
isLast,
|
||||
@@ -327,7 +514,6 @@ function LevelItem({
|
||||
level: LevelNode;
|
||||
selectedLevelId: string | null;
|
||||
setSelection: (selection: any) => void;
|
||||
setReferencesLevelId: (id: string | null) => void;
|
||||
deleteNode: (id: AnyNodeId) => void;
|
||||
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void;
|
||||
isLast?: boolean;
|
||||
@@ -336,6 +522,11 @@ function LevelItem({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const isSelected = selectedLevelId === level.id;
|
||||
const [isExpanded, setIsExpanded] = useState(isSelected);
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(isSelected);
|
||||
}, [isSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
@@ -344,37 +535,66 @@ function LevelItem({
|
||||
}, [isSelected]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={cn(
|
||||
"flex items-center group/level border-b border-border/50 pr-2 transition-all duration-200 relative",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
<div className={cn("absolute left-[21px] top-0 w-px bg-border/50 pointer-events-none", isLast ? "bottom-1/2" : "bottom-0")} />
|
||||
{/* Horizontal branch line */}
|
||||
<div className="absolute left-[21px] top-1/2 w-4 h-px bg-border/50 pointer-events-none" />
|
||||
|
||||
<div className="flex flex-col relative">
|
||||
<div
|
||||
className="flex-1 flex items-center gap-2 pl-10 py-2 text-sm cursor-pointer min-w-0"
|
||||
onClick={() => setSelection({ levelId: level.id })}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
ref={itemRef}
|
||||
className={cn(
|
||||
"flex items-center group/level border-b border-border/50 pr-2 transition-all duration-200 relative h-8 select-none",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Layers className={cn(
|
||||
"w-3.5 h-3.5 shrink-0 transition-all duration-200",
|
||||
!isSelected && "opacity-60 grayscale"
|
||||
{/* Vertical tree line */}
|
||||
<div className={cn("absolute left-[21px] w-px bg-border/50 pointer-events-none z-10", isLast && !isExpanded ? "top-0 bottom-1/2" : "top-0 bottom-0")} />
|
||||
{/* Horizontal branch line */}
|
||||
<div className="absolute left-[21px] top-1/2 w-[11px] h-px bg-border/50 pointer-events-none z-10" />
|
||||
<div className={cn(
|
||||
"absolute left-[32px] top-[10px] w-4 h-[12px] pointer-events-none z-10 transition-colors duration-200",
|
||||
isSelected ? "bg-accent/50" : "bg-background group-hover/level:bg-accent/30"
|
||||
)} />
|
||||
<InlineRenameInput
|
||||
node={level}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={`Level ${level.level}`}
|
||||
/>
|
||||
</div>
|
||||
{/* Line down to children */}
|
||||
{isExpanded && (
|
||||
<div className="absolute left-[45px] top-[16px] bottom-0 w-px bg-border/50 pointer-events-none z-10" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center pl-[28px] pr-1 z-20 relative h-8">
|
||||
<button
|
||||
className="w-4 h-4 flex items-center justify-center shrink-0 z-20 bg-inherit cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isSelected) {
|
||||
setSelection({ levelId: level.id });
|
||||
} else {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 -rotate-90 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center gap-2 py-0 text-sm cursor-pointer min-w-0 h-8 pl-0.5"
|
||||
onClick={() => setSelection({ levelId: level.id })}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
<img
|
||||
src="/icons/level.png"
|
||||
className={cn("w-4 h-4 object-contain shrink-0 transition-all duration-200", !isSelected && "opacity-60 grayscale")}
|
||||
alt="Level"
|
||||
/>
|
||||
<InlineRenameInput
|
||||
node={level}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={`Level ${level.level}`}
|
||||
/>
|
||||
</div>
|
||||
{/* Camera snapshot button */}
|
||||
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -456,12 +676,6 @@ function LevelItem({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" side="right" className="w-40 p-1">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded text-sm hover:bg-accent cursor-pointer"
|
||||
onClick={() => setReferencesLevelId(level.id)}
|
||||
>
|
||||
References
|
||||
</button>
|
||||
{level.level !== 0 && (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded text-sm hover:bg-accent hover:text-red-600 cursor-pointer"
|
||||
@@ -474,6 +688,20 @@ function LevelItem({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<LevelReferences levelId={level.id} isLastLevel={isLast} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -486,8 +714,6 @@ function LevelsSection() {
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
|
||||
const [referencesLevelId, setReferencesLevelId] = useState<string | null>(null);
|
||||
|
||||
const building = selectedBuildingId
|
||||
? (nodes[selectedBuildingId] as BuildingNode)
|
||||
: null;
|
||||
@@ -511,24 +737,27 @@ function LevelsSection() {
|
||||
return (
|
||||
<div className="flex flex-col relative">
|
||||
{/* Level buttons */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<button
|
||||
className="flex items-center gap-2 pl-10 py-2 text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer transition-all duration-200 border-b border-border/50 relative"
|
||||
className="flex items-center gap-2 pl-0 py-0 text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer transition-all duration-200 border-b border-border/50 relative h-8 select-none"
|
||||
onClick={handleAddLevel}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
<div className="absolute left-[21px] top-0 bottom-0 w-px bg-border/50 pointer-events-none" />
|
||||
{/* Horizontal branch line */}
|
||||
<div className="absolute left-[21px] top-1/2 w-4 h-px bg-border/50 pointer-events-none" />
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add level
|
||||
<div className="absolute left-[21px] top-1/2 w-[11px] h-px bg-border/50 pointer-events-none z-10" />
|
||||
|
||||
<div className="flex items-center pl-[38px] pr-1 z-10 relative">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className="truncate">Add level</span>
|
||||
</button>
|
||||
{levels.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground pl-10 pr-2 py-2 relative border-b border-border/50">
|
||||
<div className="text-xs text-muted-foreground pl-[38px] pr-2 py-0 relative border-b border-border/50 h-8 flex items-center select-none">
|
||||
{/* Vertical tree line */}
|
||||
<div className="absolute left-[21px] top-0 bottom-1/2 w-px bg-border/50 pointer-events-none" />
|
||||
{/* Horizontal branch line */}
|
||||
<div className="absolute left-[21px] top-1/2 w-4 h-px bg-border/50 pointer-events-none" />
|
||||
<div className="absolute left-[21px] top-1/2 w-[11px] h-px bg-border/50 pointer-events-none" />
|
||||
No levels yet
|
||||
</div>
|
||||
)}
|
||||
@@ -538,24 +767,12 @@ function LevelsSection() {
|
||||
level={level}
|
||||
selectedLevelId={selectedLevelId}
|
||||
setSelection={setSelection}
|
||||
setReferencesLevelId={setReferencesLevelId}
|
||||
deleteNode={deleteNode}
|
||||
updateNode={updateNode}
|
||||
isLast={index === levels.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* References dialog */}
|
||||
{referencesLevelId && (
|
||||
<ReferencesDialog
|
||||
levelId={referencesLevelId}
|
||||
open={!!referencesLevelId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setReferencesLevelId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -566,13 +783,18 @@ function LayerToggle() {
|
||||
const phase = useEditor((state) => state.phase);
|
||||
const setPhase = useEditor((state) => state.setPhase);
|
||||
|
||||
const activeTab =
|
||||
phase === "structure" && structureLayer === "elements" ? "structure" :
|
||||
phase === "furnish" ? "furnish" :
|
||||
phase === "structure" && structureLayer === "zones" ? "zones" : "none";
|
||||
|
||||
return (
|
||||
<div className="flex items-center p-1 bg-accent/20 gap-1 border-b border-border/50">
|
||||
<div className="flex items-center p-1 bg-accent/20 gap-1 border-b border-border/50 relative">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
phase === "structure" && structureLayer === "elements"
|
||||
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
|
||||
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
activeTab === "structure"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -580,36 +802,56 @@ function LayerToggle() {
|
||||
setStructureLayer("elements");
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/icons/room.png"
|
||||
alt="Structure"
|
||||
className={cn("w-6 h-6 mb-1", !(phase === "structure" && structureLayer === "elements") && "opacity-50 grayscale")}
|
||||
/>
|
||||
Structure
|
||||
{activeTab === "structure" && (
|
||||
<motion.div
|
||||
layoutId="layerToggleActiveBg"
|
||||
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<img
|
||||
src="/icons/room.png"
|
||||
alt="Structure"
|
||||
className={cn("w-6 h-6 mb-1 transition-all", activeTab !== "structure" && "opacity-50 grayscale")}
|
||||
/>
|
||||
Structure
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
phase === "furnish"
|
||||
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
|
||||
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
activeTab === "furnish"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
setPhase("furnish");
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/icons/couch.png"
|
||||
alt="Furnish"
|
||||
className={cn("w-6 h-6 mb-1", phase !== "furnish" && "opacity-50 grayscale")}
|
||||
/>
|
||||
Furnish
|
||||
{activeTab === "furnish" && (
|
||||
<motion.div
|
||||
layoutId="layerToggleActiveBg"
|
||||
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<img
|
||||
src="/icons/couch.png"
|
||||
alt="Furnish"
|
||||
className={cn("w-6 h-6 mb-1 transition-all", activeTab !== "furnish" && "opacity-50 grayscale")}
|
||||
/>
|
||||
Furnish
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
phase === "structure" && structureLayer === "zones"
|
||||
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
|
||||
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
|
||||
activeTab === "zones"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -617,18 +859,27 @@ function LayerToggle() {
|
||||
setStructureLayer("zones");
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/icons/kitchen.png"
|
||||
alt="Zones"
|
||||
className={cn("w-6 h-6 mb-1", !(phase === "structure" && structureLayer === "zones") && "opacity-50 grayscale")}
|
||||
/>
|
||||
Zones
|
||||
{activeTab === "zones" && (
|
||||
<motion.div
|
||||
layoutId="layerToggleActiveBg"
|
||||
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<img
|
||||
src="/icons/kitchen.png"
|
||||
alt="Zones"
|
||||
className={cn("w-6 h-6 mb-1 transition-all", activeTab !== "zones" && "opacity-50 grayscale")}
|
||||
/>
|
||||
Zones
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
function ZoneItem({ zone, isLast }: { zone: ZoneNode, isLast?: boolean }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||
const deleteNode = useScene((state) => state.deleteNode);
|
||||
@@ -680,7 +931,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={cn(
|
||||
"flex items-center h-8 cursor-pointer group/row text-sm px-3 select-none border-b border-border/50 transition-all duration-200",
|
||||
"relative flex items-center h-8 cursor-pointer group/row text-sm px-3 select-none border-b border-border/50 transition-all duration-200",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground"
|
||||
: isHovered
|
||||
@@ -692,6 +943,11 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
onMouseEnter={() => setHoveredId(zone.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
<div className={cn("absolute w-px bg-border/50 pointer-events-none", isLast ? "top-0 bottom-1/2" : "top-0 bottom-0")} style={{ left: 8 }} />
|
||||
{/* Horizontal branch line */}
|
||||
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none" style={{ left: 8, width: 4 }} />
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -805,6 +1061,28 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectionBadge() {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
|
||||
if (selectedIds.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="sticky top-4 z-50 pointer-events-none flex justify-center w-full h-0 overflow-visible">
|
||||
<div className="pointer-events-auto flex items-center gap-2.5 px-0.5 pl-2 py-4 bg-primary text-primary-foreground text-xs font-medium rounded-full shadow-lg shadow-black/10 border border-primary/20 backdrop-blur-md">
|
||||
<span>{selectedIds.length} objects selected</span>
|
||||
<button
|
||||
onClick={() => setSelection({ selectedIds: [] })}
|
||||
className="hover:bg-primary-foreground/20 p-1.5 rounded-full transition-colors cursor-pointer"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSection() {
|
||||
const nodes = useScene((state) => state.nodes);
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
||||
@@ -853,8 +1131,8 @@ function ContentSection() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{levelZones.map((zone) => (
|
||||
<ZoneItem key={zone.id} zone={zone} />
|
||||
{levelZones.map((zone, index) => (
|
||||
<ZoneItem key={zone.id} zone={zone} isLast={index === levelZones.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -865,28 +1143,9 @@ function ContentSection() {
|
||||
const childNode = nodes[childId];
|
||||
if (!childNode || childNode.type === "zone") return false;
|
||||
|
||||
// In structure mode, show structural elements (walls, slabs, etc.) and doors/windows
|
||||
if (phase === "structure") {
|
||||
if (childNode.type === "item") {
|
||||
const category = childNode.asset?.category?.toLowerCase() || "";
|
||||
// Only show doors and windows in structure mode
|
||||
return category === "door" || category === "window";
|
||||
}
|
||||
// Show all other structural elements (walls, slabs, ceiling, roof)
|
||||
return true;
|
||||
}
|
||||
|
||||
// In furnish mode, only show items that are NOT doors or windows
|
||||
if (phase === "furnish") {
|
||||
if (childNode.type === "item") {
|
||||
const category = childNode.asset?.category?.toLowerCase() || "";
|
||||
// Hide doors and windows in furnish mode
|
||||
return category !== "door" && category !== "window";
|
||||
}
|
||||
// Hide structural elements in furnish mode
|
||||
return false;
|
||||
}
|
||||
|
||||
// We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode
|
||||
// This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible
|
||||
// and selectable in both modes, ensuring seamless transition in the tree view.
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -900,8 +1159,8 @@ function ContentSection() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{elementChildren.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={0} />
|
||||
{elementChildren.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={0} isLast={index === elementChildren.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -931,8 +1190,13 @@ function BuildingItem({
|
||||
}, [isBuildingActive]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", isBuildingActive && "flex-1 min-h-0")}>
|
||||
<div
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className={cn("flex flex-col shrink-0 overflow-hidden", isBuildingActive && "flex-1 min-h-0")}
|
||||
>
|
||||
<motion.div
|
||||
layout="position"
|
||||
ref={itemRef}
|
||||
className={cn(
|
||||
"group/building flex items-center h-10 border-b border-border/50 pr-2 transition-all duration-200 shrink-0",
|
||||
@@ -1025,21 +1289,32 @@ function BuildingItem({
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tools and content for the active building */}
|
||||
{isBuildingActive && (
|
||||
<div className="flex flex-col flex-1 min-h-0 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="shrink-0 flex flex-col">
|
||||
<LevelsSection />
|
||||
<LayerToggle />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<ContentSection />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{isBuildingActive && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, flex: 0 }}
|
||||
animate={{ opacity: 1, flex: "1 1 0%" }}
|
||||
exit={{ opacity: 0, flex: "0 0 0px" }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className="flex flex-col w-full overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full">
|
||||
<div className="shrink-0 flex flex-col">
|
||||
<LevelsSection />
|
||||
<LayerToggle />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 relative">
|
||||
<MultiSelectionBadge />
|
||||
<ContentSection />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1064,61 +1339,77 @@ export function SitePanel() {
|
||||
.filter((node): node is BuildingNode => node?.type === "building");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Site Header */}
|
||||
{siteNode && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-3 border-b border-border/50 cursor-pointer transition-colors shrink-0",
|
||||
phase === "site" ? "bg-accent/50 text-foreground" : "hover:bg-accent/30 text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setPhase("site")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/icons/site.png"
|
||||
className={cn("w-5 h-5 object-contain transition-all", phase !== "site" && "opacity-60 grayscale")}
|
||||
alt="Site"
|
||||
<LayoutGroup>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Site Header */}
|
||||
{siteNode && (
|
||||
<motion.div
|
||||
layout="position"
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-3 border-b border-border/50 cursor-pointer transition-colors shrink-0",
|
||||
phase === "site" ? "bg-accent/50 text-foreground" : "hover:bg-accent/30 text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setPhase("site")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/icons/site.png"
|
||||
className={cn("w-5 h-5 object-contain transition-all", phase !== "site" && "opacity-60 grayscale")}
|
||||
alt="Site"
|
||||
/>
|
||||
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
|
||||
</div>
|
||||
<CameraPopover
|
||||
nodeId={siteNode.id as AnyNodeId}
|
||||
hasCamera={!!siteNode.camera}
|
||||
open={siteCameraOpen}
|
||||
onOpenChange={setSiteCameraOpen}
|
||||
buttonClassName={cn("transition-colors", phase === "site" ? "hover:bg-black/5 dark:hover:bg-white/10" : "hover:bg-accent")}
|
||||
/>
|
||||
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
|
||||
</div>
|
||||
<CameraPopover
|
||||
nodeId={siteNode.id as AnyNodeId}
|
||||
hasCamera={!!siteNode.camera}
|
||||
open={siteCameraOpen}
|
||||
onOpenChange={setSiteCameraOpen}
|
||||
buttonClassName={cn("transition-colors", phase === "site" ? "hover:bg-black/5 dark:hover:bg-white/10" : "hover:bg-accent")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn("flex-1 flex flex-col min-h-0", phase === "site" && "overflow-y-auto")}>
|
||||
{/* When phase is site, show property line immediately under site header */}
|
||||
{phase === "site" && <div className="shrink-0"><PropertyLineSection /></div>}
|
||||
|
||||
{/* Buildings List */}
|
||||
{buildings.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No buildings yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{buildings.map((building) => {
|
||||
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
|
||||
|
||||
return (
|
||||
<BuildingItem
|
||||
key={building.id}
|
||||
building={building}
|
||||
isBuildingActive={isBuildingActive}
|
||||
buildingCameraOpen={buildingCameraOpen}
|
||||
setBuildingCameraOpen={setBuildingCameraOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div layout className={cn("flex-1 flex flex-col min-h-0", phase === "site" && "overflow-y-auto")}>
|
||||
{/* When phase is site, show property line immediately under site header */}
|
||||
<AnimatePresence initial={false}>
|
||||
{phase === "site" && (
|
||||
<motion.div
|
||||
layout="position"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className="shrink-0 overflow-hidden"
|
||||
>
|
||||
<PropertyLineSection />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Buildings List */}
|
||||
{buildings.length === 0 ? (
|
||||
<motion.div layout="position" className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No buildings yet
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col flex-1 min-h-0">
|
||||
{buildings.map((building) => {
|
||||
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
|
||||
|
||||
return (
|
||||
<BuildingItem
|
||||
key={building.id}
|
||||
building={building}
|
||||
isBuildingActive={isBuildingActive}
|
||||
buildingCameraOpen={buildingCameraOpen}
|
||||
setBuildingCameraOpen={setBuildingCameraOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { type AnyNodeId, ItemNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
@@ -19,9 +20,10 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
interface ItemTreeNodeProps {
|
||||
node: ItemNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||
export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
||||
@@ -51,8 +53,12 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "structure") {
|
||||
useEditor.getState().setPhase("furnish");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
@@ -72,6 +78,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
@@ -93,10 +100,11 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{hasChildren && node.children.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
{hasChildren && node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,10 @@ import { TreeNodeActions } from "./tree-node-actions";
|
||||
interface LevelTreeNodeProps {
|
||||
node: LevelNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
|
||||
export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.levelId === node.id);
|
||||
@@ -48,10 +49,11 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
|
||||
onDoubleClick={handleDoubleClick}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{node.children.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type GuideNode,
|
||||
GuideNode as GuideNodeSchema,
|
||||
type LevelNode,
|
||||
type ScanNode,
|
||||
ScanNode as ScanNodeSchema,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/primitives/dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/primitives/popover'
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB — matches server action bodySizeLimit
|
||||
|
||||
interface ReferencesDialogProps {
|
||||
levelId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDialogProps) {
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||
const activeProject = useProjectStore((s) => s.activeProject)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const scanInputRef = useRef<HTMLInputElement>(null)
|
||||
const guideInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAddScan = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
|
||||
const projectId = activeProject?.id
|
||||
if (!projectId) {
|
||||
setUploadError('No active project. Please open a project first.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploadError(null)
|
||||
setUploading(true)
|
||||
const result = await uploadProjectAsset(projectId, file, 'scan')
|
||||
setUploading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setUploadError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const node = ScanNodeSchema.parse({
|
||||
url: result.url,
|
||||
name: file.name,
|
||||
parentId: levelId,
|
||||
})
|
||||
createNode(node, levelId as AnyNodeId)
|
||||
// Auto-select and close dialog
|
||||
setSelectedReferenceId(node.id)
|
||||
onOpenChange(false)
|
||||
},
|
||||
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
|
||||
)
|
||||
|
||||
const handleAddGuide = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
|
||||
const projectId = activeProject?.id
|
||||
if (!projectId) {
|
||||
setUploadError('No active project. Please open a project first.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploadError(null)
|
||||
setUploading(true)
|
||||
const result = await uploadProjectAsset(projectId, file, 'guide')
|
||||
setUploading(false)
|
||||
|
||||
if (!result.success) {
|
||||
setUploadError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const node = GuideNodeSchema.parse({
|
||||
url: result.url,
|
||||
name: file.name,
|
||||
parentId: levelId,
|
||||
})
|
||||
createNode(node, levelId as AnyNodeId)
|
||||
// Auto-select and close dialog
|
||||
setSelectedReferenceId(node.id)
|
||||
onOpenChange(false)
|
||||
},
|
||||
[levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject],
|
||||
)
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(nodeId: string) => {
|
||||
setSelectedReferenceId(nodeId)
|
||||
onOpenChange(false)
|
||||
},
|
||||
[setSelectedReferenceId, onOpenChange],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (nodeId: string) => {
|
||||
const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined
|
||||
const projectId = activeProject?.id
|
||||
|
||||
// Delete storage asset first (before removing from scene)
|
||||
if (
|
||||
projectId &&
|
||||
refNode?.url &&
|
||||
(refNode.url.startsWith('http://') || refNode.url.startsWith('https://'))
|
||||
) {
|
||||
const result = await deleteProjectAssetByUrl(projectId, refNode.url)
|
||||
if (!result.success) {
|
||||
setUploadError(`Failed to delete asset: ${result.error}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
deleteNode(nodeId as AnyNodeId)
|
||||
},
|
||||
[deleteNode, nodes, activeProject],
|
||||
)
|
||||
|
||||
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||
if (!level) return null
|
||||
|
||||
// Find all scan and guide children of this level
|
||||
const references = Object.values(nodes).filter(
|
||||
(node): node is ScanNode | GuideNode =>
|
||||
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>References — {level.name || `Level ${level.level}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||
{references.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No references yet. Add a 3D scan or guide image.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{references.map((ref) => (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent/50 group"
|
||||
>
|
||||
{ref.type === 'scan' ? (
|
||||
<Box className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Image className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate">
|
||||
{ref.name || (ref.type === 'scan' ? '3D Scan' : 'Guide Image')}
|
||||
</span>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
|
||||
onClick={() => handleEdit(ref.id)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-destructive/10 text-destructive cursor-pointer"
|
||||
onClick={() => handleDelete(ref.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="text-xs text-destructive px-1 pb-1">{uploadError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||
<input
|
||||
ref={scanInputRef}
|
||||
type="file"
|
||||
accept=".glb,.gltf"
|
||||
className="hidden"
|
||||
onChange={handleAddScan}
|
||||
/>
|
||||
<input
|
||||
ref={guideInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAddGuide}
|
||||
/>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{uploading ? 'Uploading…' : 'Add'}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-1">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
|
||||
onClick={() => scanInputRef.current?.click()}
|
||||
>
|
||||
<Box className="w-4 h-4" />
|
||||
3D Scan
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
|
||||
onClick={() => guideInputRef.current?.click()}
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
Guide Image
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,24 +2,31 @@ import { RoofNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
interface RoofTreeNodeProps {
|
||||
node: RoofNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
||||
export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
@@ -41,6 +48,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
@@ -62,6 +70,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,24 +2,31 @@ import { SlabNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
interface SlabTreeNodeProps {
|
||||
node: SlabNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
|
||||
export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
@@ -40,6 +47,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
@@ -61,6 +69,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,53 @@ import { AnyNodeId, useScene } from "@pascal-app/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { forwardRef, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
export function handleTreeSelection(
|
||||
e: React.MouseEvent,
|
||||
nodeId: string,
|
||||
selectedIds: string[],
|
||||
setSelection: (s: any) => void
|
||||
) {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (selectedIds.includes(nodeId)) {
|
||||
setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) });
|
||||
} else {
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.shiftKey && selectedIds.length > 0) {
|
||||
const lastSelectedId = selectedIds[selectedIds.length - 1];
|
||||
if (lastSelectedId) {
|
||||
const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'));
|
||||
const nodeIds = nodes.map(n => n.getAttribute('data-treenode-id') as string);
|
||||
|
||||
const startIndex = nodeIds.indexOf(lastSelectedId);
|
||||
const endIndex = nodeIds.indexOf(nodeId);
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
const start = Math.min(startIndex, endIndex);
|
||||
const end = Math.max(startIndex, endIndex);
|
||||
const range = nodeIds.slice(start, end + 1);
|
||||
|
||||
// We can keep the previous selections that were outside the range if we want,
|
||||
// but standard file system shift-click replaces the selection with the range.
|
||||
setSelection({ selectedIds: range });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Fallback: if range selection fails (e.g. node not visible in tree), just add to selection
|
||||
if (!selectedIds.includes(nodeId)) {
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
setSelection({ selectedIds: [nodeId] });
|
||||
return false;
|
||||
}
|
||||
import { BuildingTreeNode } from "./building-tree-node";
|
||||
import { CeilingTreeNode } from "./ceiling-tree-node";
|
||||
import { DoorTreeNode } from "./door-tree-node";
|
||||
@@ -16,47 +63,49 @@ import { ZoneTreeNode } from "./zone-tree-node";
|
||||
interface TreeNodeProps {
|
||||
nodeId: AnyNodeId;
|
||||
depth?: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
|
||||
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
const node = useScene((state) => state.nodes[nodeId]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case "building":
|
||||
return <BuildingTreeNode node={node} depth={depth} />;
|
||||
return <BuildingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "ceiling":
|
||||
return <CeilingTreeNode node={node} depth={depth} />;
|
||||
return <CeilingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "level":
|
||||
return <LevelTreeNode node={node} depth={depth} />;
|
||||
return <LevelTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "slab":
|
||||
return <SlabTreeNode node={node} depth={depth} />;
|
||||
return <SlabTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "wall":
|
||||
return <WallTreeNode node={node} depth={depth} />;
|
||||
return <WallTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "roof":
|
||||
return <RoofTreeNode node={node} depth={depth} />;
|
||||
return <RoofTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "item":
|
||||
return <ItemTreeNode node={node} depth={depth} />;
|
||||
return <ItemTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "door":
|
||||
return <DoorTreeNode node={node} depth={depth} />;
|
||||
return <DoorTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "window":
|
||||
return <WindowTreeNode node={node} depth={depth} />;
|
||||
return <WindowTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "zone":
|
||||
return <ZoneTreeNode node={node} depth={depth} />;
|
||||
return <ZoneTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TreeNodeWrapperProps {
|
||||
nodeId?: string;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
@@ -65,56 +114,80 @@ interface TreeNodeWrapperProps {
|
||||
isSelected?: boolean;
|
||||
isHovered?: boolean;
|
||||
isVisible?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
function TreeNodeWrapper(
|
||||
{
|
||||
icon,
|
||||
label,
|
||||
depth,
|
||||
hasChildren,
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
actions,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isVisible = true,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
function TreeNodeWrapper(
|
||||
{
|
||||
nodeId,
|
||||
icon,
|
||||
label,
|
||||
depth,
|
||||
hasChildren,
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
actions,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isVisible = true,
|
||||
isLast,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && rowRef.current) {
|
||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, [isSelected]);
|
||||
useEffect(() => {
|
||||
if (isSelected && rowRef.current) {
|
||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
"flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-border/50 transition-all duration-200",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground"
|
||||
: isHovered
|
||||
? "bg-accent/30 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
||||
!isVisible && "opacity-50"
|
||||
)}
|
||||
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<button
|
||||
className="w-4 h-4 flex items-center justify-center shrink-0"
|
||||
return (
|
||||
<div ref={ref} data-treenode-id={nodeId}>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
"relative flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground border-r-white border-r-3"
|
||||
: isHovered
|
||||
? "bg-accent/30 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
||||
!isVisible && "opacity-50"
|
||||
)}
|
||||
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-px bg-border/50 pointer-events-none",
|
||||
isLast ? "top-0 bottom-1/2" : "top-0 bottom-0"
|
||||
)}
|
||||
style={{ left: (depth - 1) * 12 + 20 }}
|
||||
/>
|
||||
{/* Horizontal branch line */}
|
||||
<div
|
||||
className="absolute top-1/2 h-px bg-border/50 pointer-events-none"
|
||||
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
|
||||
/>
|
||||
{/* Line down to children */}
|
||||
{hasChildren && expanded && (
|
||||
<div
|
||||
className="absolute top-1/2 bottom-0 w-px bg-border/50 pointer-events-none"
|
||||
style={{ left: depth * 12 + 20 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="w-4 h-4 flex items-center justify-center shrink-0 z-10 bg-inherit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
@@ -149,7 +222,19 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expanded && children}
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && children && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@ import { type AnyNodeId, WallNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
interface WallTreeNodeProps {
|
||||
node: WallNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
@@ -40,8 +42,12 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
@@ -60,6 +66,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
@@ -81,10 +88,11 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
>
|
||||
{node.children.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
|
||||
@@ -4,18 +4,21 @@ import { WindowNode } from "@pascal-app/core"
|
||||
import { useViewer } from "@pascal-app/viewer"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import useEditor from "@/store/use-editor"
|
||||
import { InlineRenameInput } from "./inline-rename-input"
|
||||
import { TreeNodeWrapper } from "./tree-node"
|
||||
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node"
|
||||
import { TreeNodeActions } from "./tree-node-actions"
|
||||
|
||||
interface WindowTreeNodeProps {
|
||||
node: WindowNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
|
||||
export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
@@ -24,6 +27,7 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/window.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
@@ -38,13 +42,20 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
|
||||
hasChildren={false}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
onClick={() => setSelection({ selectedIds: [node.id] })}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure")
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,9 +8,10 @@ import { TreeNodeActions } from "./tree-node-actions";
|
||||
interface ZoneTreeNodeProps {
|
||||
node: ZoneNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
|
||||
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
@@ -64,6 +65,7 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user