From d3e17cac04185c410d8fb7e68105befb1060326c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 6 Mar 2026 23:43:10 -0500 Subject: [PATCH] Add editor preview mode for in-place viewer experience Adds a Preview button next to Radio Pascal that switches the editor into a viewer-like experience without leaving the page. The preview mode swaps in the viewer's selection manager (hierarchical drill-down), zone system, camera controls (left-click pan, auto-navigate), and interactive item system while hiding editor-only UI (tools, panels, sidebar, grid). Back arrow in the viewer overlay returns to the editor. Also widens the default sidebar from 288px to 432px for better project title visibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/app/viewer/[id]/viewer-overlay.tsx | 33 ++++++-- .../editor/custom-camera-controls.tsx | 84 +++++++++++++++++-- apps/editor/components/editor/index.tsx | 73 ++++++++++------ apps/editor/components/preview-button.tsx | 16 ++++ .../components/ui/sidebar/app-sidebar.tsx | 6 ++ apps/editor/store/use-editor.tsx | 13 +++ 6 files changed, 182 insertions(+), 43 deletions(-) create mode 100644 apps/editor/components/preview-button.tsx diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx index 03ff3721..d626c39d 100644 --- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx +++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx @@ -68,6 +68,8 @@ interface ViewerOverlayProps { owner?: ProjectOwner | null canShowScans?: boolean canShowGuides?: boolean + onBack?: () => void + hideCollections?: boolean } export const ViewerOverlay = ({ @@ -75,6 +77,8 @@ export const ViewerOverlay = ({ owner, canShowScans = true, canShowGuides = true, + onBack, + hideCollections, }: ViewerOverlayProps) => { const selection = useViewer((s) => s.selection) const nodes = useScene((s) => s.nodes) @@ -130,12 +134,21 @@ export const ViewerOverlay = ({
{/* Project info + back */}
- - - + {onBack ? ( + + ) : ( + + + + )}
{projectName || 'Untitled'} @@ -248,9 +261,11 @@ export const ViewerOverlay = ({
{/* Collections Panel - Top Right */} -
- -
+ {!hideCollections && ( +
+ +
+ )} {/* Controls Panel - Bottom Center */}
diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index 21dfb4da..2c3314cd 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -5,14 +5,20 @@ import { useViewer } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { Vector3 } from 'three' +import { Box3, Vector3 } from 'three' import { EDITOR_LAYER } from '@/lib/constants' +import useEditor from '@/store/use-editor' const currentTarget = new Vector3() +const tempBox = new Box3() +const tempCenter = new Vector3() +const tempSize = new Vector3() export const CustomCameraControls = () => { const controls = useRef(null!) - const currentLevelId = useViewer((state) => state.selection.levelId) + const isPreviewMode = useEditor((s) => s.isPreviewMode) + const selection = useViewer((s) => s.selection) + const currentLevelId = selection.levelId const firstLoad = useRef(true) const camera = useThree((state) => state.camera) @@ -23,6 +29,7 @@ export const CustomCameraControls = () => { }, [camera, raycaster]) useEffect(() => { + if (isPreviewMode) return // Preview mode uses auto-navigate instead let targetY = 0 if (currentLevelId) { const levelMesh = sceneRegistry.nodes.get(currentLevelId) @@ -41,7 +48,7 @@ export const CustomCameraControls = () => { currentTarget.z, true, ) - }, [currentLevelId]) + }, [currentLevelId, isPreviewMode]) // Configure mouse buttons based on control mode and camera mode const cameraMode = useViewer((state) => state.cameraMode) @@ -53,12 +60,14 @@ export const CustomCameraControls = () => { : CameraControlsImpl.ACTION.DOLLY return { - left: CameraControlsImpl.ACTION.NONE, + left: isPreviewMode + ? CameraControlsImpl.ACTION.SCREEN_PAN + : CameraControlsImpl.ACTION.NONE, middle: CameraControlsImpl.ACTION.SCREEN_PAN, right: CameraControlsImpl.ACTION.ROTATE, wheel: wheelAction, } - }, [cameraMode]) + }, [cameraMode, isPreviewMode]) useEffect(() => { const keyState = { @@ -81,11 +90,15 @@ export const CustomCameraControls = () => { ? CameraControlsImpl.ACTION.ZOOM : CameraControlsImpl.ACTION.DOLLY controls.current.mouseButtons.wheel = wheelAction - controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE - if (space) { + if (isPreviewMode) { + // In preview mode, left-click is always pan (viewer-style) controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN + } else if (space) { + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN + } else { + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE } } @@ -137,7 +150,62 @@ export const CustomCameraControls = () => { document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) } - }, [cameraMode]) + }, [cameraMode, isPreviewMode]) + + // Preview mode: auto-navigate camera to selected node (viewer behavior) + const previewTargetNodeId = isPreviewMode + ? (selection.zoneId ?? selection.levelId ?? selection.buildingId) + : null + + useEffect(() => { + if (!isPreviewMode || !controls.current) return + + const nodes = useScene.getState().nodes + let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null + + if (!previewTargetNodeId) { + const site = Object.values(nodes).find((n) => n.type === 'site') + node = site || null + } + if (!node) return + + // Check if node has a saved camera + if (node.camera) { + const { position, target } = node.camera + requestAnimationFrame(() => { + if (!controls.current) return + controls.current.setLookAt( + position[0], position[1], position[2], + target[0], target[1], target[2], + true, + ) + }) + return + } + + if (!previewTargetNodeId) return + + // Calculate camera position from bounding box + const object3D = sceneRegistry.nodes.get(previewTargetNodeId) + if (!object3D) return + + tempBox.setFromObject(object3D) + tempBox.getCenter(tempCenter) + tempBox.getSize(tempSize) + + const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) + const distance = Math.max(maxDim * 2, 15) + + controls.current.setLookAt( + tempCenter.x + distance * 0.7, + tempCenter.y + distance * 0.5, + tempCenter.z + distance * 0.7, + tempCenter.x, + tempCenter.y, + tempCenter.z, + true, + ) + }, [isPreviewMode, previewTargetNodeId]) useEffect(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index ee41bdd3..e3a329e7 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -1,15 +1,18 @@ 'use client' import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' -import { useViewer, Viewer } from '@pascal-app/viewer' +import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { useProjectScene } from '@/features/community/lib/models/hooks' import { useProjectStore } from '@/features/community/lib/projects/store' import { useKeyboard } from '@/hooks/use-keyboard' import { initSFXBus } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' +import { ViewerOverlay } from '@/app/viewer/[id]/viewer-overlay' +import { ViewerZoneSystem } from '@/app/viewer/[id]/viewer-zone-system' import { FeedbackDialog } from '../feedback-dialog' import { PascalRadio } from '../pascal-radio' +import { PreviewButton } from '../preview-button' import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneSystem } from '../systems/zone/zone-system' @@ -102,6 +105,8 @@ export default function Editor({ projectId }: EditorProps) { const isProjectLoading = useProjectStore((state) => state.isLoading) const isSceneLoading = useProjectStore((state) => state.isSceneLoading) const isLoading = isProjectLoading || isSceneLoading + const isPreviewMode = useEditor((s) => s.isPreviewMode) + const activeProject = useProjectStore((s) => s.activeProject) useEffect(() => { if (projectId) { @@ -121,40 +126,56 @@ export default function Editor({ projectId }: EditorProps) { return (
{isLoading && } - - - - {/* Top-right controls */} -
-
- -
-
- -
-
+ {isPreviewMode ? ( + useEditor.getState().setPreviewMode(false)} + /> + ) : ( + <> + + + + + {/* Top-right controls */} +
+
+ +
+
+ +
+
+ +
+
+ + + + + + )} - - - }> - - - + + {!isPreviewMode && } + {!isPreviewMode && } - {/* Editor only system to toggle zone visibility */} - + {/* Swap zone systems: viewer drill-down vs editor layer toggle */} + {isPreviewMode ? : } - {/* */} - - + {!isPreviewMode && ( + + )} + {!isPreviewMode && } - + {!isPreviewMode && } + {isPreviewMode && } - + {!isPreviewMode && }
) diff --git a/apps/editor/components/preview-button.tsx b/apps/editor/components/preview-button.tsx new file mode 100644 index 00000000..97af7158 --- /dev/null +++ b/apps/editor/components/preview-button.tsx @@ -0,0 +1,16 @@ +'use client' + +import { Eye } from 'lucide-react' +import useEditor from '@/store/use-editor' + +export function PreviewButton() { + return ( + + ) +} diff --git a/apps/editor/components/ui/sidebar/app-sidebar.tsx b/apps/editor/components/ui/sidebar/app-sidebar.tsx index 247a729f..4766e6aa 100644 --- a/apps/editor/components/ui/sidebar/app-sidebar.tsx +++ b/apps/editor/components/ui/sidebar/app-sidebar.tsx @@ -19,6 +19,7 @@ import { Sidebar, SidebarContent, SidebarHeader, + useSidebarStore, } from "@/components/ui/primitives/sidebar"; import { Popover, @@ -108,6 +109,11 @@ export function AppSidebar() { useEffect(() => { setMounted(true); + // Widen default sidebar (288px → 432px) for better project title visibility + const store = useSidebarStore.getState(); + if (store.width <= 288) { + store.setWidth(432); + } }, []); useEffect(() => { diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 61191140..1d97d24f 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -76,6 +76,9 @@ type EditorState = { // Generic hole editing (works for slabs, ceilings, and any future polygon nodes) editingHole: { nodeId: string; holeIndex: number } | null setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void + // Preview mode (viewer-like experience inside the editor) + isPreviewMode: boolean + setPreviewMode: (preview: boolean) => void } const useEditor = create()((set, get) => ({ @@ -219,6 +222,16 @@ const useEditor = create()((set, get) => ({ setSpaces: (spaces) => set({ spaces }), editingHole: null, setEditingHole: (hole) => set({ editingHole: hole }), + isPreviewMode: false, + setPreviewMode: (preview) => { + if (preview) { + set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null }) + // Clear zone/item selection for clean viewer drill-down hierarchy + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + } else { + set({ isPreviewMode: false }) + } + }, })) export default useEditor