Feat/ux polish round2 (#123)
* Polish editor UI, radio, and state logic Multiple UI and state updates across the editor and viewer: - PascalRadio: Move radio play state into audio store (isRadioPlaying), update icons/behaviors (Volume2/VolumeX), remove autoplay UI, ensure pause/resume respects muted and radio playing state. - use-audio: Add isRadioPlaying, setRadioPlaying and toggleRadioPlaying to store; initialize state accordingly. - Action menu: Add layout/motion to container, refine transition, only show furnish/structure rows in build mode, tweak transition classes and hover/opacity behavior. - Furnish/Structure tools: Simplify inactive styling, change click behavior to only select tools (no direct deselect), remove "click to deselect" tooltip text. - Sidebar/Icon rail: Update logo link styling and image sizing; AppSidebar: add inline editable project title (optimistic local update + server update via updateProjectName), keyboard handling, and minor header layout changes. - Site panel: Add visual tree/branch lines, replace some icons, reorganize levels list and provide an Add level button, adjust spacing and layout for property/levels sections. - InlineRenameInput: Small height/spacing adjustments for inline inputs. - use-editor: Reset mode to 'select' when switching phases, ensure reasonable default tools when entering build mode, simplify setStructureLayer to reset mode/tool and viewer selection. - use-viewer: Improve selection hierarchy guard so children are only reset when not explicitly provided in updates. These changes improve UX consistency, state predictability when switching modes, and add inline project renaming with optimistic update. * Introduce isEditor mode and editor UX updates Add an isEditor flag (Viewer prop + store) and wire it from the Editor to enable editor-specific behaviors. Ceiling system: show ceiling grids per-level (or when ceiling tool active) by walking node ancestry and respecting active level/selection. Zone renderer: only show site edge labels in editor and add inline editable zone names (with hover/edit UI and save/escape handling). Auto-name new walls/doors/windows (incremental counts scoped by level) and simplify default names shown in various panels/tree nodes. Sidebar/site-panel: reverse level rendering, adjust tree-line styling, auto-expand parents when descendants are selected, and refactor zone row actions (camera view/capture/clear). UI polish: updated action-menu button styles and ensure keyboard shortcut 'b' switches to build mode. Misc: various tree node and item selection/hover improvements and minor refactors to support the above. * Fix sidebar flex layout and scrolling Adjust sidebar panel flexbox classes to ensure headers/controls don't collapse and content areas scroll correctly. Changes include adding shrink-0, flex-1 and min-h-0 to BuildingItem and buildings list containers, restructuring the active building panel so LevelsSection/LayerToggle are fixed height while ContentSection gets an overflow-y-auto scroll area, and making the site header non-shrinking with conditional overflow for the main panel when phase === "site". Uses the cn helper for conditional classes. * Fix TypeScript build errors with AnyNodeId indexing and type narrowing Cast string-typed node IDs and parentIds to AnyNodeId when indexing into the nodes Record, fix motion transition type literal, and remove redundant mode check in setMode. 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
7681e79d59
commit
441517424d
@@ -57,7 +57,7 @@ export default function Editor({ projectId }: EditorProps) {
|
|||||||
<SidebarProvider className="fixed z-20">
|
<SidebarProvider className="fixed z-20">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<Viewer selectionManager="custom">
|
<Viewer selectionManager="custom" isEditor={true}>
|
||||||
<SelectionManager />
|
<SelectionManager />
|
||||||
<ExportManager />
|
<ExportManager />
|
||||||
{/* Editor only system to toggle zone visibility */}
|
{/* Editor only system to toggle zone visibility */}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Howl } from 'howler'
|
import { Howl } from 'howler'
|
||||||
import { Disc3, Pause, Play, Settings2, SkipBack, SkipForward, Volume2 } from 'lucide-react'
|
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
|
||||||
import { Switch } from '@/components/ui/primitives/switch'
|
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import useAudio from '@/store/use-audio'
|
import useAudio from '@/store/use-audio'
|
||||||
@@ -64,25 +63,23 @@ function shuffleArray<T>(array: T[]): T[] {
|
|||||||
|
|
||||||
export function PascalRadio() {
|
export function PascalRadio() {
|
||||||
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
|
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
|
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
|
||||||
const { masterVolume, radioVolume, muted, autoplay, setAutoplay } = useAudio()
|
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
|
||||||
const soundRef = useRef<Howl | null>(null)
|
const soundRef = useRef<Howl | null>(null)
|
||||||
const hasAutoplayedRef = useRef(false)
|
|
||||||
|
|
||||||
const currentTrack = shuffledPlaylist[currentTrackIndex]!
|
const currentTrack = shuffledPlaylist[currentTrackIndex]!
|
||||||
|
|
||||||
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
|
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
|
||||||
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
|
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
|
||||||
|
|
||||||
// Keep a ref so the track-init effect can read current volume/muted/isPlaying
|
// Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
|
||||||
// without those values being part of its dependency array (which would restart the song).
|
// without those values being part of its dependency array (which would restart the song).
|
||||||
const effectiveVolumeRef = useRef(effectiveVolume)
|
const effectiveVolumeRef = useRef(effectiveVolume)
|
||||||
const mutedRef = useRef(muted)
|
const mutedRef = useRef(muted)
|
||||||
const isPlayingRef = useRef(isPlaying)
|
const isPlayingRef = useRef(isRadioPlaying)
|
||||||
effectiveVolumeRef.current = effectiveVolume
|
effectiveVolumeRef.current = effectiveVolume
|
||||||
mutedRef.current = muted
|
mutedRef.current = muted
|
||||||
isPlayingRef.current = isPlaying
|
isPlayingRef.current = isRadioPlaying
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
|
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
|
||||||
@@ -122,44 +119,25 @@ export function PascalRadio() {
|
|||||||
soundRef.current.volume(muted ? 0 : effectiveVolume)
|
soundRef.current.volume(muted ? 0 : effectiveVolume)
|
||||||
|
|
||||||
// Pause if muted, resume if unmuted and was playing
|
// Pause if muted, resume if unmuted and was playing
|
||||||
if (muted && isPlaying) {
|
if (muted && isRadioPlaying) {
|
||||||
soundRef.current.pause()
|
soundRef.current.pause()
|
||||||
} else if (!muted && isPlaying && !soundRef.current.playing()) {
|
} else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
|
||||||
soundRef.current.play()
|
soundRef.current.play()
|
||||||
|
} else if (!isRadioPlaying && soundRef.current.playing()) {
|
||||||
|
soundRef.current.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [effectiveVolume, muted, isPlaying])
|
}, [effectiveVolume, muted, isRadioPlaying])
|
||||||
|
|
||||||
// Autoplay on first user click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoplay || hasAutoplayedRef.current || muted) return
|
|
||||||
|
|
||||||
const handleFirstClick = () => {
|
|
||||||
if (!soundRef.current || hasAutoplayedRef.current) return
|
|
||||||
|
|
||||||
hasAutoplayedRef.current = true
|
|
||||||
soundRef.current.play()
|
|
||||||
setIsPlaying(true)
|
|
||||||
|
|
||||||
// Remove listener after first click
|
|
||||||
document.removeEventListener('click', handleFirstClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', handleFirstClick)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleFirstClick)
|
|
||||||
}
|
|
||||||
}, [autoplay, muted])
|
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
if (!soundRef.current || muted) return
|
if (!soundRef.current || muted) return
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isRadioPlaying) {
|
||||||
soundRef.current.pause()
|
soundRef.current.pause()
|
||||||
} else {
|
} else {
|
||||||
soundRef.current.play()
|
soundRef.current.play()
|
||||||
}
|
}
|
||||||
setIsPlaying(!isPlaying)
|
setRadioPlaying(!isRadioPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVolumeChange = (value: number[]) => {
|
const handleVolumeChange = (value: number[]) => {
|
||||||
@@ -168,14 +146,14 @@ export function PascalRadio() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md">
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md">
|
||||||
<Disc3 className={cn('h-4 w-4', isPlaying && 'animate-spin')} />
|
<Disc3 className={cn('h-4 w-4', isRadioPlaying && 'animate-spin')} />
|
||||||
<span className="hidden sm:inline">Radio Pascal</span>
|
<span className="hidden sm:inline">Radio Pascal</span>
|
||||||
<div
|
<div
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
|
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -183,7 +161,7 @@ export function PascalRadio() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
{isRadioPlaying ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -231,18 +209,6 @@ export function PascalRadio() {
|
|||||||
/>
|
/>
|
||||||
<span className="w-8 text-right text-xs text-muted-foreground">{radioVolume}%</span>
|
<span className="w-8 text-right text-xs text-muted-foreground">{radioVolume}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Autoplay setting */}
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t">
|
|
||||||
<label htmlFor="autoplay" className="text-sm font-medium cursor-pointer">
|
|
||||||
Autoplay
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id="autoplay"
|
|
||||||
checked={autoplay}
|
|
||||||
onCheckedChange={setAutoplay}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -8,15 +8,43 @@ export const CeilingSystem = () => {
|
|||||||
const selectedItem = useEditor((state) => state.selectedItem)
|
const selectedItem = useEditor((state) => state.selectedItem)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldShowGrid =
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
|
const levelsToShowCeilings = new Set<string>()
|
||||||
|
|
||||||
|
const isCeilingToolActive =
|
||||||
tool === 'ceiling' ||
|
tool === 'ceiling' ||
|
||||||
selectedItem?.attachTo === 'ceiling' ||
|
selectedItem?.attachTo === 'ceiling' ||
|
||||||
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') ||
|
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
|
||||||
selectedIds.some((id) => {
|
|
||||||
const node = useScene.getState().nodes[id as AnyNodeId]
|
if (isCeilingToolActive && activeLevelId) {
|
||||||
return node?.type === 'ceiling'
|
levelsToShowCeilings.add(activeLevelId)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
let currentId: string | null = id
|
||||||
|
let isCeilingRelated = false
|
||||||
|
let levelId: string | null = null
|
||||||
|
|
||||||
|
while (currentId && nodes[currentId as AnyNodeId]) {
|
||||||
|
const node = nodes[currentId as AnyNodeId]
|
||||||
|
if (node?.type === 'ceiling') {
|
||||||
|
isCeilingRelated = true
|
||||||
|
}
|
||||||
|
if (node?.type === 'level') {
|
||||||
|
levelId = node.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentId = node?.parentId as string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCeilingRelated && levelId) {
|
||||||
|
levelsToShowCeilings.add(levelId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ceilings = sceneRegistry.byType.ceiling
|
const ceilings = sceneRegistry.byType.ceiling
|
||||||
ceilings.forEach((ceiling) => {
|
ceilings.forEach((ceiling) => {
|
||||||
@@ -24,11 +52,26 @@ export const CeilingSystem = () => {
|
|||||||
if (mesh) {
|
if (mesh) {
|
||||||
const ceilingGrid = mesh.getObjectByName('ceiling-grid')
|
const ceilingGrid = mesh.getObjectByName('ceiling-grid')
|
||||||
if (ceilingGrid) {
|
if (ceilingGrid) {
|
||||||
|
let belongsToVisibleLevel = false
|
||||||
|
let currentId: string | null = ceiling
|
||||||
|
|
||||||
|
while (currentId && nodes[currentId as AnyNodeId]) {
|
||||||
|
const node = nodes[currentId as AnyNodeId]
|
||||||
|
if (node && levelsToShowCeilings.has(node.id)) {
|
||||||
|
belongsToVisibleLevel = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentId = node?.parentId as string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowGrid = belongsToVisibleLevel ||
|
||||||
|
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
||||||
|
|
||||||
ceilingGrid.visible = shouldShowGrid
|
ceilingGrid.visible = shouldShowGrid
|
||||||
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
|
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [tool, selectedItem, movingNode, selectedIds])
|
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId])
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,17 @@ export const DoorTool: React.FC = () => {
|
|||||||
useScene.getState().deleteNode(draft.id)
|
useScene.getState().deleteNode(draft.id)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
|
const levelId = getLevelId()
|
||||||
|
const state = useScene.getState()
|
||||||
|
const doorCount = Object.values(state.nodes).filter((n) => {
|
||||||
|
if (n.type !== 'door') return false
|
||||||
|
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||||
|
return wall?.parentId === levelId
|
||||||
|
}).length
|
||||||
|
const name = `Door ${doorCount + 1}`
|
||||||
|
|
||||||
const node = DoorNode.parse({
|
const node = DoorNode.parse({
|
||||||
|
name,
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side,
|
side,
|
||||||
|
|||||||
@@ -83,11 +83,14 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
|||||||
|
|
||||||
const commitWallDrawing = (start: [number, number], end: [number, number]) => {
|
const commitWallDrawing = (start: [number, number], end: [number, number]) => {
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
const { createNode } = useScene.getState()
|
const { createNode, nodes } = useScene.getState()
|
||||||
|
|
||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
const wall = WallNode.parse({ start, end })
|
const wallCount = Object.values(nodes).filter((n) => n.type === 'wall').length
|
||||||
|
const name = `Wall ${wallCount + 1}`
|
||||||
|
|
||||||
|
const wall = WallNode.parse({ name, start, end })
|
||||||
|
|
||||||
createNode(wall, currentLevelId)
|
createNode(wall, currentLevelId)
|
||||||
sfxEmitter.emit('sfx:structure-build')
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
|
|||||||
@@ -196,7 +196,17 @@ export const WindowTool: React.FC = () => {
|
|||||||
// Resume → create permanent node (single undoable action)
|
// Resume → create permanent node (single undoable action)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
|
const levelId = getLevelId()
|
||||||
|
const state = useScene.getState()
|
||||||
|
const windowCount = Object.values(state.nodes).filter((n) => {
|
||||||
|
if (n.type !== 'window') return false
|
||||||
|
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||||
|
return wall?.parentId === levelId
|
||||||
|
}).length
|
||||||
|
const name = `Window ${windowCount + 1}`
|
||||||
|
|
||||||
const node = WindowNode.parse({
|
const node = WindowNode.parse({
|
||||||
|
name,
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side,
|
side,
|
||||||
|
|||||||
@@ -81,16 +81,10 @@ export function FurnishTools() {
|
|||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-11 rounded-lg transition-all duration-300",
|
"size-11 rounded-lg transition-all duration-300",
|
||||||
isActive && "bg-primary shadow-lg shadow-primary/40 ring-2 ring-primary ring-offset-2 ring-offset-zinc-950 scale-110 z-10",
|
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
||||||
!isActive && hasActiveTool && "opacity-30 hover:opacity-60 scale-95 grayscale",
|
|
||||||
!isActive && !hasActiveTool && "opacity-60 hover:opacity-100 hover:bg-white/10 hover:scale-105",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isActive) {
|
if (!isActive) {
|
||||||
setActiveTool(null);
|
|
||||||
setCatalogCategory(null);
|
|
||||||
setMode("select");
|
|
||||||
} else {
|
|
||||||
setCatalogCategory(tool.catalogCategory);
|
setCatalogCategory(tool.catalogCategory);
|
||||||
setActiveTool("item");
|
setActiveTool("item");
|
||||||
if (mode !== "build") {
|
if (mode !== "build") {
|
||||||
@@ -99,7 +93,7 @@ export function FurnishTools() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant={isActive ? "default" : "ghost"}
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<NextImage
|
<NextImage
|
||||||
alt={tool.label}
|
alt={tool.label}
|
||||||
@@ -113,7 +107,6 @@ export function FurnishTools() {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
{tool.label}
|
{tool.label}
|
||||||
{isActive && " (Click to deselect)"}
|
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -19,15 +19,19 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
const tool = useEditor((state) => state.tool);
|
const tool = useEditor((state) => state.tool);
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
const transition = reducedMotion ? { duration: 0 } : undefined;
|
const transition = reducedMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { type: "spring" as const, bounce: 0.2, duration: 0.4 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<motion.div
|
||||||
|
layout
|
||||||
|
transition={transition}
|
||||||
className={cn(
|
className={cn(
|
||||||
"-translate-x-1/2 fixed bottom-6 left-1/2 z-50",
|
"-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-zinc-800 bg-zinc-950/90 shadow-2xl backdrop-blur-md",
|
||||||
"transition-all duration-200 ease-out",
|
"transition-colors duration-200 ease-out",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -67,7 +71,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{phase === "furnish" && (
|
{phase === "furnish" && mode === "build" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden border-zinc-800",
|
"overflow-hidden border-zinc-800",
|
||||||
@@ -105,7 +109,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
|
|
||||||
{/* Structure Tools Row - Animated */}
|
{/* Structure Tools Row - Animated */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{phase === "structure" && (
|
{phase === "structure" && mode === "build" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2",
|
"overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2",
|
||||||
@@ -147,7 +151,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
<div className="mx-1 h-5 w-px bg-zinc-700" />
|
<div className="mx-1 h-5 w-px bg-zinc-700" />
|
||||||
<CameraActions />
|
<CameraActions />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,16 +58,10 @@ export function StructureTools() {
|
|||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-11 rounded-lg transition-all duration-300',
|
'size-11 rounded-lg transition-all duration-300',
|
||||||
isActive && 'bg-primary shadow-lg shadow-primary/40 ring-2 ring-primary ring-offset-2 ring-offset-zinc-950 scale-110 z-10',
|
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
|
||||||
!isActive && hasActiveTool && 'opacity-30 hover:opacity-60 scale-95 grayscale',
|
|
||||||
!isActive && !hasActiveTool && isContextual && 'bg-white/5 hover:bg-white/10 hover:scale-105',
|
|
||||||
!isActive && !hasActiveTool && !isContextual && 'opacity-60 hover:opacity-100 hover:bg-white/10 hover:scale-105',
|
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isActive) {
|
if (!isActive) {
|
||||||
setTool(null)
|
|
||||||
setCatalogCategory(null)
|
|
||||||
} else {
|
|
||||||
setTool(tool.id)
|
setTool(tool.id)
|
||||||
setCatalogCategory(tool.catalogCategory ?? null)
|
setCatalogCategory(tool.catalogCategory ?? null)
|
||||||
|
|
||||||
@@ -78,7 +72,7 @@ export function StructureTools() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant={isActive ? 'default' : 'ghost'}
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<NextImage
|
<NextImage
|
||||||
alt={tool.label}
|
alt={tool.label}
|
||||||
@@ -92,7 +86,6 @@ export function StructureTools() {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
{tool.label}
|
{tool.label}
|
||||||
{isActive && ' (Click to deselect)'}
|
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function CeilingPanel() {
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||||
{node.name || `Ceiling (${area.toFixed(1)}m²)`}
|
{node.name || "Ceiling"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function DoorPanel() {
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||||
{node.name || `Door (${node.width}×${node.height}m)`}
|
{node.name || "Door"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function RoofPanel() {
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||||
{node.name || 'Gable Roof'}
|
{node.name || "Roof"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function SlabPanel() {
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||||
{node.name || `Slab (${area.toFixed(1)}m²)`}
|
{node.name || "Slab"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function WallPanel() {
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||||
{node.name || `Wall (${length.toFixed(2)}m)`}
|
{node.name || "Wall"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function WindowPanel() {
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
|
||||||
{node.name || `Window (${node.width}×${node.height}m)`}
|
{node.name || "Window"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { IconRail, type PanelId } from "./icon-rail";
|
import { IconRail, type PanelId } from "./icon-rail";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -11,9 +12,56 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SettingsPanel } from "./panels/settings-panel";
|
import { SettingsPanel } from "./panels/settings-panel";
|
||||||
import { SitePanel } from "./panels/site-panel";
|
import { SitePanel } from "./panels/site-panel";
|
||||||
|
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||||
|
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
||||||
|
const activeProject = useProjectStore((s) => s.activeProject);
|
||||||
|
|
||||||
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
|
const [titleValue, setTitleValue] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditingTitle) {
|
||||||
|
setTitleValue(activeProject?.name || "Untitled Project");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [isEditingTitle, activeProject?.name]);
|
||||||
|
|
||||||
|
const handleSaveTitle = useCallback(async () => {
|
||||||
|
const trimmed = titleValue.trim();
|
||||||
|
if (trimmed && activeProject && trimmed !== activeProject.name) {
|
||||||
|
// Optimistic update
|
||||||
|
useProjectStore.setState((state) => ({
|
||||||
|
activeProject: state.activeProject ? { ...state.activeProject, name: trimmed } : null,
|
||||||
|
projects: state.projects.map((p) => p.id === activeProject.id ? { ...p, name: trimmed } : p)
|
||||||
|
}));
|
||||||
|
// Server update
|
||||||
|
try {
|
||||||
|
await updateProjectName(activeProject.id, trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update project name:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
}, [titleValue, activeProject]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSaveTitle();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderPanelContent = () => {
|
const renderPanelContent = () => {
|
||||||
switch (activePanel) {
|
switch (activePanel) {
|
||||||
@@ -45,8 +93,32 @@ export function AppSidebar() {
|
|||||||
|
|
||||||
{/* Panel Content */}
|
{/* Panel Content */}
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<SidebarHeader className="flex-row items-center justify-between px-3 py-2">
|
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50">
|
||||||
<h3 className="font-semibold text-base">{getPanelTitle()}</h3>
|
{isEditingTitle ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={titleValue}
|
||||||
|
onChange={(e) => setTitleValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSaveTitle}
|
||||||
|
placeholder="Untitled Project"
|
||||||
|
className="w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-7 font-semibold text-lg"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 group/title cursor-pointer w-full h-7 border-b border-transparent"
|
||||||
|
onClick={() => setIsEditingTitle(true)}
|
||||||
|
>
|
||||||
|
<h1 className="font-semibold text-lg truncate flex-1">
|
||||||
|
{activeProject?.name || "Untitled Project"}
|
||||||
|
</h1>
|
||||||
|
<Pencil className="w-3.5 h-3.5 opacity-0 group-hover/title:opacity-100 transition-opacity text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{getPanelTitle()}
|
||||||
|
</span>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ export function IconRail({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-all hover:bg-primary/90 mb-1"
|
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/pascal-logo-shape.svg"
|
src="/pascal-logo-shape.svg"
|
||||||
alt="Pascal"
|
alt="Pascal"
|
||||||
width={16}
|
width={24}
|
||||||
height={16}
|
height={24}
|
||||||
className="h-4 w-4"
|
className="h-6 w-6 dark:invert"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CeilingNode } from "@pascal-app/core";
|
import { type AnyNodeId, CeilingNode, useScene } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
import { InlineRenameInput } from "./inline-rename-input";
|
||||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
import { TreeNodeActions } from "./tree-node-actions";
|
||||||
@@ -14,11 +14,32 @@ interface CeilingTreeNodeProps {
|
|||||||
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
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 isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
const nodes = useScene.getState().nodes;
|
||||||
|
let isDescendant = false;
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
let current = nodes[id as AnyNodeId];
|
||||||
|
while (current && current.parentId) {
|
||||||
|
if (current.parentId === node.id) {
|
||||||
|
isDescendant = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = nodes[current.parentId as AnyNodeId];
|
||||||
|
}
|
||||||
|
if (isDescendant) break;
|
||||||
|
}
|
||||||
|
if (isDescendant) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [selectedIds, node.id]);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setSelection({ selectedIds: [node.id] });
|
setSelection({ selectedIds: [node.id] });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
|
|||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
const defaultName = `Door (${node.width}×${node.height}m)`
|
const defaultName = "Door"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Layers,
|
Layers,
|
||||||
MapPin,
|
Pentagon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -132,11 +132,17 @@ function PropertyLineSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border/50">
|
<div className="border-b border-border/50 relative">
|
||||||
|
{/* Vertical tree line */}
|
||||||
|
<div className="absolute left-[21px] top-0 bottom-0 w-px bg-border/50" />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between pl-10 pr-3 py-2 relative">
|
||||||
|
{/* Horizontal branch line */}
|
||||||
|
<div className="absolute left-[21px] top-1/2 w-4 h-px bg-border/50" />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="w-4 h-4 text-muted-foreground" />
|
<Pentagon className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">Property Line</span>
|
<span className="text-sm font-medium">Property Line</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -153,7 +159,7 @@ function PropertyLineSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Measurements */}
|
{/* Measurements */}
|
||||||
<div className="flex gap-3 px-3 pb-2">
|
<div className="flex gap-3 pl-10 pr-3 pb-2 relative">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Area: <span className="text-foreground">{area.toFixed(1)} m²</span>
|
Area: <span className="text-foreground">{area.toFixed(1)} m²</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +171,7 @@ function PropertyLineSection() {
|
|||||||
|
|
||||||
{/* Vertex list (shown when editing) */}
|
{/* Vertex list (shown when editing) */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="px-3 pb-2">
|
<div className="pl-10 pr-3 pb-2 relative">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{points.map((point, index) => (
|
{points.map((point, index) => (
|
||||||
<div
|
<div
|
||||||
@@ -316,6 +322,7 @@ function LevelItem({
|
|||||||
setReferencesLevelId,
|
setReferencesLevelId,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
updateNode,
|
updateNode,
|
||||||
|
isLast,
|
||||||
}: {
|
}: {
|
||||||
level: LevelNode;
|
level: LevelNode;
|
||||||
selectedLevelId: string | null;
|
selectedLevelId: string | null;
|
||||||
@@ -323,6 +330,7 @@ function LevelItem({
|
|||||||
setReferencesLevelId: (id: string | null) => void;
|
setReferencesLevelId: (id: string | null) => void;
|
||||||
deleteNode: (id: AnyNodeId) => void;
|
deleteNode: (id: AnyNodeId) => void;
|
||||||
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void;
|
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void;
|
||||||
|
isLast?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -339,14 +347,19 @@ function LevelItem({
|
|||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center group/level border-b border-border/50 pr-2 transition-all duration-200",
|
"flex items-center group/level border-b border-border/50 pr-2 transition-all duration-200 relative",
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-accent/50 text-foreground"
|
? "bg-accent/50 text-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent/30 hover: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
|
<div
|
||||||
className="flex-1 flex items-center gap-2 pl-3 py-2 text-sm cursor-pointer min-w-0"
|
className="flex-1 flex items-center gap-2 pl-10 py-2 text-sm cursor-pointer min-w-0"
|
||||||
onClick={() => setSelection({ levelId: level.id })}
|
onClick={() => setSelection({ levelId: level.id })}
|
||||||
onDoubleClick={() => setIsEditing(true)}
|
onDoubleClick={() => setIsEditing(true)}
|
||||||
>
|
>
|
||||||
@@ -496,23 +509,30 @@ function LevelsSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col relative">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/50">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
||||||
Levels
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
|
|
||||||
onClick={handleAddLevel}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Level buttons */}
|
{/* Level buttons */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{levels.map((level) => (
|
<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"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
{levels.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground pl-10 pr-2 py-2 relative border-b border-border/50">
|
||||||
|
{/* 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" />
|
||||||
|
No levels yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{[...levels].reverse().map((level, index) => (
|
||||||
<LevelItem
|
<LevelItem
|
||||||
key={level.id}
|
key={level.id}
|
||||||
level={level}
|
level={level}
|
||||||
@@ -521,13 +541,9 @@ function LevelsSection() {
|
|||||||
setReferencesLevelId={setReferencesLevelId}
|
setReferencesLevelId={setReferencesLevelId}
|
||||||
deleteNode={deleteNode}
|
deleteNode={deleteNode}
|
||||||
updateNode={updateNode}
|
updateNode={updateNode}
|
||||||
|
isLast={index === levels.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{levels.length === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
|
||||||
No levels yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* References dialog */}
|
{/* References dialog */}
|
||||||
@@ -707,6 +723,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
<div className="flex-1 min-w-0 pr-1">
|
||||||
<InlineRenameInput
|
<InlineRenameInput
|
||||||
node={zone}
|
node={zone}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
@@ -714,6 +731,8 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
onStartEditing={() => setIsEditing(true)}
|
onStartEditing={() => setIsEditing(true)}
|
||||||
defaultName={defaultName}
|
defaultName={defaultName}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
{/* Camera snapshot button */}
|
{/* Camera snapshot button */}
|
||||||
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -782,6 +801,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,11 +931,11 @@ function BuildingItem({
|
|||||||
}, [isBuildingActive]);
|
}, [isBuildingActive]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className={cn("flex flex-col", isBuildingActive && "flex-1 min-h-0")}>
|
||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/building flex items-center h-10 border-b border-border/50 pr-2 transition-all duration-200",
|
"group/building flex items-center h-10 border-b border-border/50 pr-2 transition-all duration-200 shrink-0",
|
||||||
isBuildingActive
|
isBuildingActive
|
||||||
? "bg-accent/50 text-foreground"
|
? "bg-accent/50 text-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||||
@@ -1009,11 +1029,15 @@ function BuildingItem({
|
|||||||
|
|
||||||
{/* Tools and content for the active building */}
|
{/* Tools and content for the active building */}
|
||||||
{isBuildingActive && (
|
{isBuildingActive && (
|
||||||
<div className="flex flex-col animate-in fade-in slide-in-from-top-2 duration-200">
|
<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 />
|
<LevelsSection />
|
||||||
<LayerToggle />
|
<LayerToggle />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||||
<ContentSection />
|
<ContentSection />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1045,7 +1069,7 @@ export function SitePanel() {
|
|||||||
{siteNode && (
|
{siteNode && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between px-3 py-3 border-b border-border/50 cursor-pointer transition-colors",
|
"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"
|
phase === "site" ? "bg-accent/50 text-foreground" : "hover:bg-accent/30 text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
onClick={() => setPhase("site")}
|
onClick={() => setPhase("site")}
|
||||||
@@ -1068,9 +1092,9 @@ export function SitePanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto flex flex-col">
|
<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 */}
|
{/* When phase is site, show property line immediately under site header */}
|
||||||
{phase === "site" && <PropertyLineSection />}
|
{phase === "site" && <div className="shrink-0"><PropertyLineSection /></div>}
|
||||||
|
|
||||||
{/* Buildings List */}
|
{/* Buildings List */}
|
||||||
{buildings.length === 0 ? (
|
{buildings.length === 0 ? (
|
||||||
@@ -1078,7 +1102,7 @@ export function SitePanel() {
|
|||||||
No buildings yet
|
No buildings yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{buildings.map((building) => {
|
{buildings.map((building) => {
|
||||||
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
|
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function InlineRenameInput({
|
|||||||
|
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 group/rename min-w-0">
|
<div className="flex items-center gap-1 group/rename min-w-0 h-5">
|
||||||
<span
|
<span
|
||||||
className={cn("truncate border-b border-transparent", className)}
|
className={cn("truncate border-b border-transparent", className)}
|
||||||
>
|
>
|
||||||
@@ -88,7 +88,7 @@ export function InlineRenameInput({
|
|||||||
onBlur={handleSave}
|
onBlur={handleSave}
|
||||||
placeholder={defaultName}
|
placeholder={defaultName}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-auto text-sm leading-none",
|
"flex-1 w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-5 text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type AnyNodeId, ItemNode } from "@pascal-app/core";
|
import { type AnyNodeId, ItemNode, useScene } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
import { InlineRenameInput } from "./inline-rename-input";
|
||||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
import { TreeNodeActions } from "./tree-node-actions";
|
||||||
@@ -25,11 +25,32 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
||||||
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 isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
const nodes = useScene.getState().nodes;
|
||||||
|
let isDescendant = false;
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
let current = nodes[id as AnyNodeId];
|
||||||
|
while (current && current.parentId) {
|
||||||
|
if (current.parentId === node.id) {
|
||||||
|
isDescendant = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = nodes[current.parentId as AnyNodeId];
|
||||||
|
}
|
||||||
|
if (isDescendant) break;
|
||||||
|
}
|
||||||
|
if (isDescendant) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [selectedIds, node.id]);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setSelection({ selectedIds: [node.id] });
|
setSelection({ selectedIds: [node.id] });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { WallNode } from "@pascal-app/core";
|
import { type AnyNodeId, WallNode, useScene } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { InlineRenameInput } from "./inline-rename-input";
|
import { InlineRenameInput } from "./inline-rename-input";
|
||||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
import { TreeNodeActions } from "./tree-node-actions";
|
||||||
@@ -14,11 +14,32 @@ interface WallTreeNodeProps {
|
|||||||
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
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 isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
const nodes = useScene.getState().nodes;
|
||||||
|
let isDescendant = false;
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
let current = nodes[id as AnyNodeId];
|
||||||
|
while (current && current.parentId) {
|
||||||
|
if (current.parentId === node.id) {
|
||||||
|
isDescendant = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = nodes[current.parentId as AnyNodeId];
|
||||||
|
}
|
||||||
|
if (isDescendant) break;
|
||||||
|
}
|
||||||
|
if (isDescendant) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [selectedIds, node.id]);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setSelection({ selectedIds: [node.id] });
|
setSelection({ selectedIds: [node.id] });
|
||||||
};
|
};
|
||||||
@@ -35,12 +56,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
|||||||
setHoveredId(null);
|
setHoveredId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wallLength = Math.sqrt(
|
const defaultName = "Wall";
|
||||||
Math.pow(node.end[0] - node.start[0], 2) +
|
|
||||||
Math.pow(node.end[1] - node.start[1], 2),
|
|
||||||
).toFixed(1);
|
|
||||||
|
|
||||||
const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
|
|||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
const defaultName = `Window (${node.width}×${node.height}m)`
|
const defaultName = "Window"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export const useKeyboard = () => {
|
|||||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
|
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
useEditor.getState().setMode('build')
|
||||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useScene.temporal.getState().undo()
|
useScene.temporal.getState().undo()
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ interface AudioState {
|
|||||||
masterVolume: number
|
masterVolume: number
|
||||||
sfxVolume: number
|
sfxVolume: number
|
||||||
radioVolume: number
|
radioVolume: number
|
||||||
|
isRadioPlaying: boolean
|
||||||
muted: boolean
|
muted: boolean
|
||||||
autoplay: boolean
|
autoplay: boolean
|
||||||
setMasterVolume: (v: number) => void
|
setMasterVolume: (v: number) => void
|
||||||
setSfxVolume: (v: number) => void
|
setSfxVolume: (v: number) => void
|
||||||
setRadioVolume: (v: number) => void
|
setRadioVolume: (v: number) => void
|
||||||
|
setRadioPlaying: (v: boolean) => void
|
||||||
|
toggleRadioPlaying: () => void
|
||||||
toggleMute: () => void
|
toggleMute: () => void
|
||||||
setAutoplay: (v: boolean) => void
|
setAutoplay: (v: boolean) => void
|
||||||
}
|
}
|
||||||
@@ -22,11 +25,14 @@ const useAudio = create<AudioState>()(
|
|||||||
masterVolume: 70,
|
masterVolume: 70,
|
||||||
sfxVolume: 50,
|
sfxVolume: 50,
|
||||||
radioVolume: 25,
|
radioVolume: 25,
|
||||||
|
isRadioPlaying: false,
|
||||||
muted: false,
|
muted: false,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
setMasterVolume: (v) => set({ masterVolume: v }),
|
setMasterVolume: (v) => set({ masterVolume: v }),
|
||||||
setSfxVolume: (v) => set({ sfxVolume: v }),
|
setSfxVolume: (v) => set({ sfxVolume: v }),
|
||||||
setRadioVolume: (v) => set({ radioVolume: v }),
|
setRadioVolume: (v) => set({ radioVolume: v }),
|
||||||
|
setRadioPlaying: (v) => set({ isRadioPlaying: v }),
|
||||||
|
toggleRadioPlaying: () => set((state) => ({ isRadioPlaying: !state.isRadioPlaying })),
|
||||||
toggleMute: () => set((state) => ({ muted: !state.muted })),
|
toggleMute: () => set((state) => ({ muted: !state.muted })),
|
||||||
setAutoplay: (v) => set({ autoplay: v }),
|
setAutoplay: (v) => set({ autoplay: v }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
|
|
||||||
set({ phase })
|
set({ phase })
|
||||||
|
|
||||||
// Clear tool and catalog when switching phases
|
// Reset to select mode and clear tool/catalog when switching phases
|
||||||
set({ tool: null, catalogCategory: null })
|
set({ mode: 'select', tool: null, catalogCategory: null })
|
||||||
|
|
||||||
const viewer = useViewer.getState()
|
const viewer = useViewer.getState()
|
||||||
const scene = useScene.getState()
|
const scene = useScene.getState()
|
||||||
@@ -156,13 +156,20 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
selectedIds: [],
|
selectedIds: [],
|
||||||
zoneId: null,
|
zoneId: null,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
// When entering build mode in structure phase with zones layer, activate zone tool
|
// Ensure a tool is selected in build mode
|
||||||
if (mode === 'build' && phase === 'structure' && structureLayer === 'zones') {
|
if (!tool) {
|
||||||
|
if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
set({ tool: 'zone' })
|
set({ tool: 'zone' })
|
||||||
|
} else if (phase === 'structure' && structureLayer === 'elements') {
|
||||||
|
set({ tool: 'wall' })
|
||||||
|
} else if (phase === 'furnish') {
|
||||||
|
set({ tool: 'item', catalogCategory: 'furniture' })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// When leaving build mode, clear tool
|
// When leaving build mode, clear tool
|
||||||
else if (mode !== 'build' && tool) {
|
else if (tool) {
|
||||||
set({ tool: null })
|
set({ tool: null })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -170,27 +177,13 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setTool: (tool) => set({ tool }),
|
setTool: (tool) => set({ tool }),
|
||||||
structureLayer: 'elements',
|
structureLayer: 'elements',
|
||||||
setStructureLayer: (layer) => {
|
setStructureLayer: (layer) => {
|
||||||
const { mode, tool } = get()
|
set({ structureLayer: layer, mode: 'select', tool: null })
|
||||||
set({ structureLayer: layer })
|
|
||||||
|
|
||||||
const viewer = useViewer.getState()
|
const viewer = useViewer.getState()
|
||||||
viewer.setSelection({
|
viewer.setSelection({
|
||||||
selectedIds: [],
|
selectedIds: [],
|
||||||
zoneId: null,
|
zoneId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle tool changes based on layer
|
|
||||||
if (layer === 'zones') {
|
|
||||||
// In zones layer with build mode, activate zone tool
|
|
||||||
if (mode === 'build') {
|
|
||||||
set({ tool: 'zone' })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// In elements layer, clear zone tool if it was active
|
|
||||||
if (tool === 'zone') {
|
|
||||||
set({ tool: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
catalogCategory: null,
|
catalogCategory: null,
|
||||||
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Html } from '@react-three/drei'
|
|||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
const Y_OFFSET = 0.01
|
const Y_OFFSET = 0.01
|
||||||
@@ -61,8 +62,11 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
return createBoundaryLineGeometry(node.polygon.points)
|
return createBoundaryLineGeometry(node.polygon.points)
|
||||||
}, [node?.polygon?.points])
|
}, [node?.polygon?.points])
|
||||||
|
|
||||||
|
const isEditor = useViewer((state) => state.isEditor)
|
||||||
|
|
||||||
// Edge distances for labels
|
// Edge distances for labels
|
||||||
const edges = useMemo(() => {
|
const edges = useMemo(() => {
|
||||||
|
if (!isEditor) return []
|
||||||
const polygon = node?.polygon?.points ?? []
|
const polygon = node?.polygon?.points ?? []
|
||||||
if (polygon.length < 2) return []
|
if (polygon.length < 2) return []
|
||||||
return polygon.map(([x1, z1], i) => {
|
return polygon.map(([x1, z1], i) => {
|
||||||
@@ -72,7 +76,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
|
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
|
||||||
return { midX, midZ, dist }
|
return { midX, midZ, dist }
|
||||||
})
|
})
|
||||||
}, [node?.polygon?.points])
|
}, [node?.polygon?.points, isEditor])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'site')
|
const handlers = useNodeEvents(node, 'site')
|
||||||
|
|
||||||
@@ -103,7 +107,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
</line>
|
</line>
|
||||||
|
|
||||||
{/* Edge distance labels */}
|
{/* Edge distance labels */}
|
||||||
{edges.map((edge, i) => (
|
{isEditor && edges.map((edge, i) => (
|
||||||
<Html
|
<Html
|
||||||
center
|
center
|
||||||
key={`edge-${i}`}
|
key={`edge-${i}`}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useRegistry, type ZoneNode } from '@pascal-app/core'
|
import { useRegistry, type ZoneNode, useScene } from '@pascal-app/core'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef, useState, useEffect } from 'react'
|
||||||
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||||
import { color, float, uniform, uv } from 'three/tsl'
|
import { color, float, uniform, uv } from 'three/tsl'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
|
|
||||||
const Y_OFFSET = 0.01
|
const Y_OFFSET = 0.01
|
||||||
const WALL_HEIGHT = 2.3
|
const WALL_HEIGHT = 2.3
|
||||||
@@ -104,6 +105,43 @@ const createWallGeometry = (polygon: Array<[number, number]>): BufferGeometry =>
|
|||||||
|
|
||||||
export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const isEditor = useViewer((state) => state.isEditor)
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const [editValue, setEditValue] = useState(node.name || '')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setEditValue(node.name || '')
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
inputRef.current.select()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}, [isEditing, node.name])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const trimmed = editValue.trim()
|
||||||
|
if (trimmed !== node.name) {
|
||||||
|
updateNode(node.id, { name: trimmed || 'Zone' })
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useRegistry(node.id, 'zone', ref)
|
useRegistry(node.id, 'zone', ref)
|
||||||
|
|
||||||
@@ -181,7 +219,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
labelPosition: [centroid[0], 1, centroid[1]]
|
labelPosition: [centroid[0], 1, centroid[1]]
|
||||||
}}>
|
}}>
|
||||||
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
||||||
pointerEvents: 'none'
|
pointerEvents: isEditor ? 'auto' : 'none'
|
||||||
}}
|
}}
|
||||||
zIndexRange={[10, 0]}>
|
zIndexRange={[10, 0]}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -192,8 +230,57 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
cursor: isEditor && !isEditing ? 'text' : 'default',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => isEditor && setIsHovered(true)}
|
||||||
|
onMouseLeave={() => isEditor && setIsHovered(false)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isEditor && !isEditing) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsEditing(true)
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{node.name}</div>
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
placeholder="Zone"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
textShadow: 'inherit',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '1px solid white',
|
||||||
|
outline: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
width: `${Math.max(editValue.length, 4) + 1}ch`,
|
||||||
|
minWidth: '50px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{node.name}</span>
|
||||||
|
{isEditor && (
|
||||||
|
<div style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.2s', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
|
||||||
|
<path d="m15 5 4 4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
{/* Floor fill */}
|
{/* Floor fill */}
|
||||||
<mesh position={[0, Y_OFFSET, 0]} rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial} name="floor">
|
<mesh position={[0, Y_OFFSET, 0]} rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial} name="floor">
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import PostProcessing from './post-processing'
|
|||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { ViewerCamera } from './viewer-camera'
|
import { ViewerCamera } from './viewer-camera'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
declare module '@react-three/fiber' {
|
declare module '@react-three/fiber' {
|
||||||
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||||
}
|
}
|
||||||
@@ -24,9 +27,16 @@ extend(THREE as any)
|
|||||||
interface ViewerProps {
|
interface ViewerProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
selectionManager?: 'default' | 'custom'
|
selectionManager?: 'default' | 'custom'
|
||||||
|
isEditor?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => {
|
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default', isEditor = false }) => {
|
||||||
|
const setIsEditor = useViewer((state) => state.setIsEditor)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditor(isEditor)
|
||||||
|
}, [isEditor, setIsEditor])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ type Outliner = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ViewerState = {
|
type ViewerState = {
|
||||||
|
isEditor: boolean
|
||||||
|
setIsEditor: (isEditor: boolean) => void
|
||||||
|
|
||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||||
@@ -61,6 +64,8 @@ type ViewerState = {
|
|||||||
const useViewer = create<ViewerState>()(
|
const useViewer = create<ViewerState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
|
isEditor: false,
|
||||||
|
setIsEditor: (isEditor) => set({ isEditor }),
|
||||||
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
||||||
hoveredId: null,
|
hoveredId: null,
|
||||||
setHoveredId: (id) => set({ hoveredId: id }),
|
setHoveredId: (id) => set({ hoveredId: id }),
|
||||||
@@ -84,16 +89,18 @@ const useViewer = create<ViewerState>()(
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const newSelection = { ...state.selection, ...updates };
|
const newSelection = { ...state.selection, ...updates };
|
||||||
|
|
||||||
// Hierarchy Guard: If we change a high-level parent, reset the children
|
// Hierarchy Guard: If we change a high-level parent, reset the children unless explicitly provided
|
||||||
if (updates.buildingId !== undefined) {
|
if (updates.buildingId !== undefined) {
|
||||||
newSelection.levelId = null;
|
if (updates.levelId === undefined) newSelection.levelId = null;
|
||||||
newSelection.zoneId = null;
|
if (updates.zoneId === undefined) newSelection.zoneId = null;
|
||||||
newSelection.selectedIds = [];
|
if (updates.selectedIds === undefined) newSelection.selectedIds = [];
|
||||||
} else if (updates.levelId !== undefined) {
|
}
|
||||||
newSelection.zoneId = null;
|
if (updates.levelId !== undefined) {
|
||||||
newSelection.selectedIds = [];
|
if (updates.zoneId === undefined) newSelection.zoneId = null;
|
||||||
} else if (updates.zoneId !== undefined) {
|
if (updates.selectedIds === undefined) newSelection.selectedIds = [];
|
||||||
newSelection.selectedIds = [];
|
}
|
||||||
|
if (updates.zoneId !== undefined) {
|
||||||
|
if (updates.selectedIds === undefined) newSelection.selectedIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { selection: newSelection };
|
return { selection: newSelection };
|
||||||
|
|||||||
Reference in New Issue
Block a user