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
|
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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user