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 };