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
+24 -9
View File
@@ -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 = ({
<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 */}
<div className="flex items-center gap-3 px-3 py-2.5">
<Link
href="/"
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" />
</Link>
{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
href="/"
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" />
</Link>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{projectName || 'Untitled'}
@@ -248,9 +261,11 @@ export const ViewerOverlay = ({
</div>
{/* Collections Panel - Top Right */}
<div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground">
<CollectionsPanel />
</div>
{!hideCollections && (
<div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground">
<CollectionsPanel />
</div>
)}
{/* Controls Panel - Bottom Center */}
<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 { 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<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 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) => {
+47 -26
View File
@@ -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 (
<div className="w-full h-full dark text-foreground">
{isLoading && <SceneLoader />}
<ActionMenu />
<PanelManager />
<HelperManager />
{/* Top-right controls */}
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
<div className="pointer-events-auto">
<PascalRadio />
</div>
<div className="pointer-events-auto">
<FeedbackDialog projectId={projectId} />
</div>
</div>
{isPreviewMode ? (
<ViewerOverlay
projectName={activeProject?.name}
onBack={() => useEditor.getState().setPreviewMode(false)}
/>
) : (
<>
<ActionMenu />
<PanelManager />
<HelperManager />
{/* Top-right controls */}
<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">
<PascalRadio />
</div>
<div className="pointer-events-auto">
<FeedbackDialog projectId={projectId} />
</div>
</div>
<SidebarProvider className="fixed z-20">
<AppSidebar />
</SidebarProvider>
</>
)}
<SidebarProvider className="fixed z-20">
<AppSidebar />
</SidebarProvider>
<ErrorBoundary key={projectId} fallback={<EditorSceneCrashFallback />}>
<Viewer selectionManager="custom">
<SelectionManager />
<FloatingActionMenu />
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
{!isPreviewMode && <SelectionManager />}
{!isPreviewMode && <FloatingActionMenu />}
<ExportManager />
{/* Editor only system to toggle zone visibility */}
<ZoneSystem />
{/* Swap zone systems: viewer drill-down vs editor layer toggle */}
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
{/* <Stats /> */}
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
<ToolManager />
{!isPreviewMode && (
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
)}
{!isPreviewMode && <ToolManager />}
<CustomCameraControls />
<ThumbnailGenerator projectId={projectId} />
<PresetThumbnailGenerator />
<SiteEdgeLabels />
{!isPreviewMode && <SiteEdgeLabels />}
{isPreviewMode && <InteractiveSystem />}
</Viewer>
<ZoneLabelEditorSystem />
{!isPreviewMode && <ZoneLabelEditorSystem />}
</ErrorBoundary>
</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,
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(() => {
+13
View File
@@ -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<EditorState>()((set, get) => ({
@@ -219,6 +222,16 @@ const useEditor = create<EditorState>()((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