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) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-03-06 23:43:10 -05:00
co-authored by Claude Opus 4.6
parent de6bfd8c00
commit d3e17cac04
6 changed files with 182 additions and 43 deletions
@@ -68,6 +68,8 @@ interface ViewerOverlayProps {
owner?: ProjectOwner | null owner?: ProjectOwner | null
canShowScans?: boolean canShowScans?: boolean
canShowGuides?: boolean canShowGuides?: boolean
onBack?: () => void
hideCollections?: boolean
} }
export const ViewerOverlay = ({ export const ViewerOverlay = ({
@@ -75,6 +77,8 @@ export const ViewerOverlay = ({
owner, owner,
canShowScans = true, canShowScans = true,
canShowGuides = true, canShowGuides = true,
onBack,
hideCollections,
}: ViewerOverlayProps) => { }: ViewerOverlayProps) => {
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
@@ -130,12 +134,21 @@ export const ViewerOverlay = ({
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden min-w-[200px]"> <div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden min-w-[200px]">
{/* Project info + back */} {/* Project info + back */}
<div className="flex items-center gap-3 px-3 py-2.5"> <div className="flex items-center gap-3 px-3 py-2.5">
{onBack ? (
<button
onClick={onBack}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</button>
) : (
<Link <Link
href="/" href="/"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
> >
<ArrowLeft className="h-4 w-4 text-muted-foreground" /> <ArrowLeft className="h-4 w-4 text-muted-foreground" />
</Link> </Link>
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate"> <div className="text-sm font-medium text-foreground truncate">
{projectName || 'Untitled'} {projectName || 'Untitled'}
@@ -248,9 +261,11 @@ export const ViewerOverlay = ({
</div> </div>
{/* Collections Panel - Top Right */} {/* Collections Panel - Top Right */}
{!hideCollections && (
<div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground"> <div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground">
<CollectionsPanel /> <CollectionsPanel />
</div> </div>
)}
{/* Controls Panel - Bottom Center */} {/* Controls Panel - Bottom Center */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 dark text-foreground"> <div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 dark text-foreground">
@@ -5,14 +5,20 @@ import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Vector3 } from 'three' import { Box3, Vector3 } from 'three'
import { EDITOR_LAYER } from '@/lib/constants' import { EDITOR_LAYER } from '@/lib/constants'
import useEditor from '@/store/use-editor'
const currentTarget = new Vector3() const currentTarget = new Vector3()
const tempBox = new Box3()
const tempCenter = new Vector3()
const tempSize = new Vector3()
export const CustomCameraControls = () => { export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!) const controls = useRef<CameraControlsImpl>(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 firstLoad = useRef(true)
const camera = useThree((state) => state.camera) const camera = useThree((state) => state.camera)
@@ -23,6 +29,7 @@ export const CustomCameraControls = () => {
}, [camera, raycaster]) }, [camera, raycaster])
useEffect(() => { useEffect(() => {
if (isPreviewMode) return // Preview mode uses auto-navigate instead
let targetY = 0 let targetY = 0
if (currentLevelId) { if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId) const levelMesh = sceneRegistry.nodes.get(currentLevelId)
@@ -41,7 +48,7 @@ export const CustomCameraControls = () => {
currentTarget.z, currentTarget.z,
true, true,
) )
}, [currentLevelId]) }, [currentLevelId, isPreviewMode])
// Configure mouse buttons based on control mode and camera mode // Configure mouse buttons based on control mode and camera mode
const cameraMode = useViewer((state) => state.cameraMode) const cameraMode = useViewer((state) => state.cameraMode)
@@ -53,12 +60,14 @@ export const CustomCameraControls = () => {
: CameraControlsImpl.ACTION.DOLLY : CameraControlsImpl.ACTION.DOLLY
return { return {
left: CameraControlsImpl.ACTION.NONE, left: isPreviewMode
? CameraControlsImpl.ACTION.SCREEN_PAN
: CameraControlsImpl.ACTION.NONE,
middle: CameraControlsImpl.ACTION.SCREEN_PAN, middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE, right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction, wheel: wheelAction,
} }
}, [cameraMode]) }, [cameraMode, isPreviewMode])
useEffect(() => { useEffect(() => {
const keyState = { const keyState = {
@@ -81,11 +90,15 @@ export const CustomCameraControls = () => {
? CameraControlsImpl.ACTION.ZOOM ? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY : CameraControlsImpl.ACTION.DOLLY
controls.current.mouseButtons.wheel = wheelAction controls.current.mouseButtons.wheel = wheelAction
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE 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 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('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp) 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(() => { useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
+31 -10
View File
@@ -1,15 +1,18 @@
'use client' 'use client'
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' 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 { useEffect } from 'react'
import { useProjectScene } from '@/features/community/lib/models/hooks' import { useProjectScene } from '@/features/community/lib/models/hooks'
import { useProjectStore } from '@/features/community/lib/projects/store' import { useProjectStore } from '@/features/community/lib/projects/store'
import { useKeyboard } from '@/hooks/use-keyboard' import { useKeyboard } from '@/hooks/use-keyboard'
import { initSFXBus } from '@/lib/sfx-bus' import { initSFXBus } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor' 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 { FeedbackDialog } from '../feedback-dialog'
import { PascalRadio } from '../pascal-radio' import { PascalRadio } from '../pascal-radio'
import { PreviewButton } from '../preview-button'
import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
import { ZoneSystem } from '../systems/zone/zone-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 isProjectLoading = useProjectStore((state) => state.isLoading)
const isSceneLoading = useProjectStore((state) => state.isSceneLoading) const isSceneLoading = useProjectStore((state) => state.isSceneLoading)
const isLoading = isProjectLoading || isSceneLoading const isLoading = isProjectLoading || isSceneLoading
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const activeProject = useProjectStore((s) => s.activeProject)
useEffect(() => { useEffect(() => {
if (projectId) { if (projectId) {
@@ -121,12 +126,23 @@ export default function Editor({ projectId }: EditorProps) {
return ( return (
<div className="w-full h-full dark text-foreground"> <div className="w-full h-full dark text-foreground">
{isLoading && <SceneLoader />} {isLoading && <SceneLoader />}
{isPreviewMode ? (
<ViewerOverlay
projectName={activeProject?.name}
onBack={() => useEditor.getState().setPreviewMode(false)}
/>
) : (
<>
<ActionMenu /> <ActionMenu />
<PanelManager /> <PanelManager />
<HelperManager /> <HelperManager />
{/* Top-right controls */} {/* Top-right controls */}
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2"> <div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
<div className="pointer-events-auto">
<PreviewButton />
</div>
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<PascalRadio /> <PascalRadio />
</div> </div>
@@ -138,23 +154,28 @@ export default function Editor({ projectId }: EditorProps) {
<SidebarProvider className="fixed z-20"> <SidebarProvider className="fixed z-20">
<AppSidebar /> <AppSidebar />
</SidebarProvider> </SidebarProvider>
</>
)}
<ErrorBoundary key={projectId} fallback={<EditorSceneCrashFallback />}> <ErrorBoundary key={projectId} fallback={<EditorSceneCrashFallback />}>
<Viewer selectionManager="custom"> <Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
<SelectionManager /> {!isPreviewMode && <SelectionManager />}
<FloatingActionMenu /> {!isPreviewMode && <FloatingActionMenu />}
<ExportManager /> <ExportManager />
{/* Editor only system to toggle zone visibility */} {/* Swap zone systems: viewer drill-down vs editor layer toggle */}
<ZoneSystem /> {isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem /> <CeilingSystem />
{/* <Stats /> */} {!isPreviewMode && (
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} /> <Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
<ToolManager /> )}
{!isPreviewMode && <ToolManager />}
<CustomCameraControls /> <CustomCameraControls />
<ThumbnailGenerator projectId={projectId} /> <ThumbnailGenerator projectId={projectId} />
<PresetThumbnailGenerator /> <PresetThumbnailGenerator />
<SiteEdgeLabels /> {!isPreviewMode && <SiteEdgeLabels />}
{isPreviewMode && <InteractiveSystem />}
</Viewer> </Viewer>
<ZoneLabelEditorSystem /> {!isPreviewMode && <ZoneLabelEditorSystem />}
</ErrorBoundary> </ErrorBoundary>
</div> </div>
) )
+16
View File
@@ -0,0 +1,16 @@
'use client'
import { Eye } from 'lucide-react'
import useEditor from '@/store/use-editor'
export function PreviewButton() {
return (
<button
onClick={() => useEditor.getState().setPreviewMode(true)}
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-accent/90 transition-colors"
>
<Eye className="h-4 w-4 shrink-0" />
<span className="hidden sm:inline whitespace-nowrap">Preview</span>
</button>
)
}
@@ -19,6 +19,7 @@ import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarHeader, SidebarHeader,
useSidebarStore,
} from "@/components/ui/primitives/sidebar"; } from "@/components/ui/primitives/sidebar";
import { import {
Popover, Popover,
@@ -108,6 +109,11 @@ export function AppSidebar() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
// Widen default sidebar (288px → 432px) for better project title visibility
const store = useSidebarStore.getState();
if (store.width <= 288) {
store.setWidth(432);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
+13
View File
@@ -76,6 +76,9 @@ type EditorState = {
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes) // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
editingHole: { nodeId: string; holeIndex: number } | null editingHole: { nodeId: string; holeIndex: number } | null
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void 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<EditorState>()((set, get) => ({ const useEditor = create<EditorState>()((set, get) => ({
@@ -219,6 +222,16 @@ const useEditor = create<EditorState>()((set, get) => ({
setSpaces: (spaces) => set({ spaces }), setSpaces: (spaces) => set({ spaces }),
editingHole: null, editingHole: null,
setEditingHole: (hole) => set({ editingHole: hole }), 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 export default useEditor