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:
co-authored by
Claude Opus 4.6
parent
de6bfd8c00
commit
d3e17cac04
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user