From 441517424d75fa6b92eb81f3a6b54301636b9b28 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 26 Feb 2026 01:05:34 -0500 Subject: [PATCH] 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 --------- Co-authored-by: Claude Opus 4.6 --- apps/editor/components/editor/index.tsx | 2 +- apps/editor/components/pascal-radio.tsx | 64 ++--- .../systems/ceiling/ceiling-system.tsx | 57 ++++- .../components/tools/door/door-tool.tsx | 10 + .../components/tools/wall/wall-tool.tsx | 7 +- .../components/tools/window/window-tool.tsx | 10 + .../ui/action-menu/furnish-tools.tsx | 13 +- .../components/ui/action-menu/index.tsx | 16 +- .../ui/action-menu/structure-tools.tsx | 13 +- .../components/ui/panels/ceiling-panel.tsx | 2 +- .../components/ui/panels/door-panel.tsx | 2 +- .../components/ui/panels/roof-panel.tsx | 2 +- .../components/ui/panels/slab-panel.tsx | 2 +- .../components/ui/panels/wall-panel.tsx | 2 +- .../components/ui/panels/window-panel.tsx | 2 +- .../components/ui/sidebar/app-sidebar.tsx | 78 +++++- .../components/ui/sidebar/icon-rail.tsx | 8 +- .../panels/site-panel/ceiling-tree-node.tsx | 27 ++- .../panels/site-panel/door-tree-node.tsx | 2 +- .../ui/sidebar/panels/site-panel/index.tsx | 226 ++++++++++-------- .../panels/site-panel/inline-rename-input.tsx | 4 +- .../panels/site-panel/item-tree-node.tsx | 27 ++- .../panels/site-panel/wall-tree-node.tsx | 34 ++- .../panels/site-panel/window-tree-node.tsx | 2 +- apps/editor/hooks/use-keyboard.ts | 3 + apps/editor/store/use-audio.tsx | 6 + apps/editor/store/use-editor.tsx | 37 ++- .../renderers/site/site-renderer.tsx | 8 +- .../renderers/zone/zone-renderer.tsx | 95 +++++++- .../viewer/src/components/viewer/index.tsx | 12 +- packages/viewer/src/store/use-viewer.ts | 25 +- 31 files changed, 542 insertions(+), 256 deletions(-) diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 6509a582..8f3d4e4f 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -57,7 +57,7 @@ export default function Editor({ projectId }: EditorProps) { - + {/* Editor only system to toggle zone visibility */} diff --git a/apps/editor/components/pascal-radio.tsx b/apps/editor/components/pascal-radio.tsx index 30ee227e..d882c6ab 100644 --- a/apps/editor/components/pascal-radio.tsx +++ b/apps/editor/components/pascal-radio.tsx @@ -1,10 +1,9 @@ 'use client' 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover' -import { Switch } from '@/components/ui/primitives/switch' import { Slider } from '@/components/ui/slider' import { cn } from '@/lib/utils' import useAudio from '@/store/use-audio' @@ -64,25 +63,23 @@ function shuffleArray(array: T[]): T[] { export function PascalRadio() { const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST)) - const [isPlaying, setIsPlaying] = useState(false) const [currentTrackIndex, setCurrentTrackIndex] = useState(0) - const { masterVolume, radioVolume, muted, autoplay, setAutoplay } = useAudio() + const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio() const soundRef = useRef(null) - const hasAutoplayedRef = useRef(false) const currentTrack = shuffledPlaylist[currentTrackIndex]! // Calculate effective volume (masterVolume * radioVolume, both are 0-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). const effectiveVolumeRef = useRef(effectiveVolume) const mutedRef = useRef(muted) - const isPlayingRef = useRef(isPlaying) + const isPlayingRef = useRef(isRadioPlaying) effectiveVolumeRef.current = effectiveVolume mutedRef.current = muted - isPlayingRef.current = isPlaying + isPlayingRef.current = isRadioPlaying const handleNext = useCallback(() => { setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length) @@ -122,44 +119,25 @@ export function PascalRadio() { soundRef.current.volume(muted ? 0 : effectiveVolume) // Pause if muted, resume if unmuted and was playing - if (muted && isPlaying) { + if (muted && isRadioPlaying) { soundRef.current.pause() - } else if (!muted && isPlaying && !soundRef.current.playing()) { + } else if (!muted && isRadioPlaying && !soundRef.current.playing()) { soundRef.current.play() + } else if (!isRadioPlaying && soundRef.current.playing()) { + soundRef.current.pause() } } - }, [effectiveVolume, muted, isPlaying]) - - // 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]) + }, [effectiveVolume, muted, isRadioPlaying]) const handlePlayPause = () => { if (!soundRef.current || muted) return - if (isPlaying) { + if (isRadioPlaying) { soundRef.current.pause() } else { soundRef.current.play() } - setIsPlaying(!isPlaying) + setRadioPlaying(!isRadioPlaying) } const handleVolumeChange = (value: number[]) => { @@ -168,14 +146,14 @@ export function PascalRadio() { return (
- + Radio Pascal
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() @@ -183,7 +161,7 @@ export function PascalRadio() { } }} > - {isPlaying ? : } + {isRadioPlaying ? : }
@@ -231,18 +209,6 @@ export function PascalRadio() { /> {radioVolume}%
- - {/* Autoplay setting */} -
- - -
diff --git a/apps/editor/components/systems/ceiling/ceiling-system.tsx b/apps/editor/components/systems/ceiling/ceiling-system.tsx index 03d862b3..fca2cc76 100644 --- a/apps/editor/components/systems/ceiling/ceiling-system.tsx +++ b/apps/editor/components/systems/ceiling/ceiling-system.tsx @@ -8,15 +8,43 @@ export const CeilingSystem = () => { const selectedItem = useEditor((state) => state.selectedItem) const movingNode = useEditor((state) => state.movingNode) const selectedIds = useViewer((state) => state.selection.selectedIds) + const activeLevelId = useViewer((state) => state.selection.levelId) + useEffect(() => { - const shouldShowGrid = + const nodes = useScene.getState().nodes + + const levelsToShowCeilings = new Set() + + const isCeilingToolActive = tool === 'ceiling' || selectedItem?.attachTo === 'ceiling' || - (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') || - selectedIds.some((id) => { - const node = useScene.getState().nodes[id as AnyNodeId] - return node?.type === 'ceiling' - }) + (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') + + if (isCeilingToolActive && activeLevelId) { + 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 ceilings.forEach((ceiling) => { @@ -24,11 +52,26 @@ export const CeilingSystem = () => { if (mesh) { const ceilingGrid = mesh.getObjectByName('ceiling-grid') 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.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 } diff --git a/apps/editor/components/tools/door/door-tool.tsx b/apps/editor/components/tools/door/door-tool.tsx index acd275c8..18a88925 100644 --- a/apps/editor/components/tools/door/door-tool.tsx +++ b/apps/editor/components/tools/door/door-tool.tsx @@ -183,7 +183,17 @@ export const DoorTool: React.FC = () => { useScene.getState().deleteNode(draft.id) 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({ + name, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index 8dc325fa..a95c05d8 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -83,11 +83,14 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => { const commitWallDrawing = (start: [number, number], end: [number, number]) => { const currentLevelId = useViewer.getState().selection.levelId - const { createNode } = useScene.getState() + const { createNode, nodes } = useScene.getState() 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) sfxEmitter.emit('sfx:structure-build') diff --git a/apps/editor/components/tools/window/window-tool.tsx b/apps/editor/components/tools/window/window-tool.tsx index 419deea8..67949f21 100644 --- a/apps/editor/components/tools/window/window-tool.tsx +++ b/apps/editor/components/tools/window/window-tool.tsx @@ -196,7 +196,17 @@ export const WindowTool: React.FC = () => { // Resume → create permanent node (single undoable action) 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({ + name, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, diff --git a/apps/editor/components/ui/action-menu/furnish-tools.tsx b/apps/editor/components/ui/action-menu/furnish-tools.tsx index 0fedf6f4..34ae9d5a 100644 --- a/apps/editor/components/ui/action-menu/furnish-tools.tsx +++ b/apps/editor/components/ui/action-menu/furnish-tools.tsx @@ -81,16 +81,10 @@ export function FurnishTools() { - - +
{/* Level buttons */}
- {levels.map((level) => ( + + {levels.length === 0 && ( +
+ {/* Vertical tree line */} +
+ {/* Horizontal branch line */} +
+ No levels yet +
+ )} + {[...levels].reverse().map((level, index) => ( ))} - {levels.length === 0 && ( -
- No levels yet -
- )}
{/* References dialog */} @@ -707,80 +723,84 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
- setIsEditing(false)} - onStartEditing={() => setIsEditing(true)} - defaultName={defaultName} - /> - {/* Camera snapshot button */} - - -
+
+ {/* Camera snapshot button */} + + + + + e.stopPropagation()} - title="Camera snapshot" > - - {zone.camera && ( - - )} - - - e.stopPropagation()} - > -
- {zone.camera && ( +
+ {zone.camera && ( + + )} - )} - - {zone.camera && ( - - )} -
- - - + {zone.camera && ( + + )} +
+
+
+ +
); } @@ -911,11 +931,11 @@ function BuildingItem({ }, [isBuildingActive]); return ( -
+
- - - +
+
+ + +
+
+ +
)}
@@ -1045,7 +1069,7 @@ export function SitePanel() { {siteNode && (
setPhase("site")} @@ -1068,9 +1092,9 @@ export function SitePanel() {
)} -
+
{/* When phase is site, show property line immediately under site header */} - {phase === "site" && } + {phase === "site" &&
} {/* Buildings List */} {buildings.length === 0 ? ( @@ -1078,7 +1102,7 @@ export function SitePanel() { No buildings yet
) : ( -
+
{buildings.map((building) => { const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id; diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx index 78e59afd..294916e8 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx @@ -57,7 +57,7 @@ export function InlineRenameInput({ if (!isEditing) { return ( -
+
@@ -88,7 +88,7 @@ export function InlineRenameInput({ onBlur={handleSave} placeholder={defaultName} 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 )} onClick={(e) => e.stopPropagation()} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx index 8f9c87f5..27704b7e 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx @@ -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 Image from "next/image"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { InlineRenameInput } from "./inline-rename-input"; import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; @@ -25,11 +25,32 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { const [isEditing, setIsEditing] = useState(false); const [expanded, setExpanded] = useState(true); 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 setSelection = useViewer((state) => state.setSelection); 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 = () => { setSelection({ selectedIds: [node.id] }); }; diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx index 70d82b36..a6935926 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx @@ -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 Image from "next/image"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { InlineRenameInput } from "./inline-rename-input"; import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; @@ -14,11 +14,32 @@ interface WallTreeNodeProps { export function WallTreeNode({ node, depth }: WallTreeNodeProps) { const [expanded, setExpanded] = 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 setSelection = useViewer((state) => state.setSelection); 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 = () => { setSelection({ selectedIds: [node.id] }); }; @@ -35,12 +56,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) { setHoveredId(null); }; - const wallLength = Math.sqrt( - 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)`; + const defaultName = "Wall"; return ( state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) - const defaultName = `Window (${node.width}×${node.height}m)` + const defaultName = "Window" return ( { if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { e.preventDefault() 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)) { e.preventDefault() useScene.temporal.getState().undo() diff --git a/apps/editor/store/use-audio.tsx b/apps/editor/store/use-audio.tsx index 4f43964d..4bda84bd 100644 --- a/apps/editor/store/use-audio.tsx +++ b/apps/editor/store/use-audio.tsx @@ -7,11 +7,14 @@ interface AudioState { masterVolume: number sfxVolume: number radioVolume: number + isRadioPlaying: boolean muted: boolean autoplay: boolean setMasterVolume: (v: number) => void setSfxVolume: (v: number) => void setRadioVolume: (v: number) => void + setRadioPlaying: (v: boolean) => void + toggleRadioPlaying: () => void toggleMute: () => void setAutoplay: (v: boolean) => void } @@ -22,11 +25,14 @@ const useAudio = create()( masterVolume: 70, sfxVolume: 50, radioVolume: 25, + isRadioPlaying: false, muted: false, autoplay: true, setMasterVolume: (v) => set({ masterVolume: v }), setSfxVolume: (v) => set({ sfxVolume: v }), setRadioVolume: (v) => set({ radioVolume: v }), + setRadioPlaying: (v) => set({ isRadioPlaying: v }), + toggleRadioPlaying: () => set((state) => ({ isRadioPlaying: !state.isRadioPlaying })), toggleMute: () => set((state) => ({ muted: !state.muted })), setAutoplay: (v) => set({ autoplay: v }), }), diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 3d0451af..6c625043 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -86,8 +86,8 @@ const useEditor = create()((set, get) => ({ set({ phase }) - // Clear tool and catalog when switching phases - set({ tool: null, catalogCategory: null }) + // Reset to select mode and clear tool/catalog when switching phases + set({ mode: 'select', tool: null, catalogCategory: null }) const viewer = useViewer.getState() const scene = useScene.getState() @@ -156,13 +156,20 @@ const useEditor = create()((set, get) => ({ selectedIds: [], zoneId: null, }) - } - // When entering build mode in structure phase with zones layer, activate zone tool - if (mode === 'build' && phase === 'structure' && structureLayer === 'zones') { - set({ tool: 'zone' }) + + // Ensure a tool is selected in build mode + if (!tool) { + if (phase === 'structure' && structureLayer === 'zones') { + 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 - else if (mode !== 'build' && tool) { + else if (tool) { set({ tool: null }) } }, @@ -170,27 +177,13 @@ const useEditor = create()((set, get) => ({ setTool: (tool) => set({ tool }), structureLayer: 'elements', setStructureLayer: (layer) => { - const { mode, tool } = get() - set({ structureLayer: layer }) + set({ structureLayer: layer, mode: 'select', tool: null }) const viewer = useViewer.getState() viewer.setSelection({ selectedIds: [], 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, setCatalogCategory: (category) => set({ catalogCategory: category }), diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index 3286a5f4..d1d01144 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -3,6 +3,7 @@ import { Html } from '@react-three/drei' import { useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' const Y_OFFSET = 0.01 @@ -61,8 +62,11 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { return createBoundaryLineGeometry(node.polygon.points) }, [node?.polygon?.points]) + const isEditor = useViewer((state) => state.isEditor) + // Edge distances for labels const edges = useMemo(() => { + if (!isEditor) return [] const polygon = node?.polygon?.points ?? [] if (polygon.length < 2) return [] 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) return { midX, midZ, dist } }) - }, [node?.polygon?.points]) + }, [node?.polygon?.points, isEditor]) const handlers = useNodeEvents(node, 'site') @@ -103,7 +107,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { {/* Edge distance labels */} - {edges.map((edge, i) => ( + {isEditor && edges.map((edge, i) => ( ): BufferGeometry => export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { const ref = useRef(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(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) @@ -181,7 +219,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { labelPosition: [centroid[0], 1, centroid[1]] }}>
{ display: 'flex', gap: '8px', 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}
+ {isEditing ? ( + 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()} + /> + ) : ( + <> + {node.name} + {isEditor && ( +
+ + + + +
+ )} + + )} +
{/* Floor fill */} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index acb05c90..a59fd45e 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -15,6 +15,9 @@ import PostProcessing from './post-processing' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' +import { useEffect } from 'react' +import useViewer from '../../store/use-viewer' + declare module '@react-three/fiber' { interface ThreeElements extends ThreeToJSXElements {} } @@ -24,9 +27,16 @@ extend(THREE as any) interface ViewerProps { children?: React.ReactNode selectionManager?: 'default' | 'custom' + isEditor?: boolean } -const Viewer: React.FC = ({ children, selectionManager = 'default' }) => { +const Viewer: React.FC = ({ children, selectionManager = 'default', isEditor = false }) => { + const setIsEditor = useViewer((state) => state.setIsEditor) + + useEffect(() => { + setIsEditor(isEditor) + }, [isEditor, setIsEditor]) + return ( void + selection: SelectionPath hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void @@ -61,6 +64,8 @@ type ViewerState = { const useViewer = create()( persist( (set) => ({ + isEditor: false, + setIsEditor: (isEditor) => set({ isEditor }), selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, hoveredId: null, setHoveredId: (id) => set({ hoveredId: id }), @@ -84,16 +89,18 @@ const useViewer = create()( set((state) => { 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) { - newSelection.levelId = null; - newSelection.zoneId = null; - newSelection.selectedIds = []; - } else if (updates.levelId !== undefined) { - newSelection.zoneId = null; - newSelection.selectedIds = []; - } else if (updates.zoneId !== undefined) { - newSelection.selectedIds = []; + if (updates.levelId === undefined) newSelection.levelId = null; + if (updates.zoneId === undefined) newSelection.zoneId = null; + if (updates.selectedIds === undefined) newSelection.selectedIds = []; + } + if (updates.levelId !== undefined) { + if (updates.zoneId === undefined) newSelection.zoneId = null; + if (updates.selectedIds === undefined) newSelection.selectedIds = []; + } + if (updates.zoneId !== undefined) { + if (updates.selectedIds === undefined) newSelection.selectedIds = []; } return { selection: newSelection };