Merge pull request #137 from pascalorg/feat/editor-preview
Add editor preview mode
This commit is contained in:
@@ -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