Merge pull request #137 from pascalorg/feat/editor-preview

Add editor preview mode
This commit is contained in:
Aymeric Rabot
2026-03-06 23:44:30 -05:00
committed by GitHub
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