diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index 842b2a10..e3ed4aa6 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -174,10 +174,12 @@ function RightColumn({ )} {/* Canvas area */}
{children}
- {/* Overlays scoped to the viewer column */} + {/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the + draggable region the floating inspector clamps itself to. */} {overlays && (
{overlays} diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index ad12e968..0b43110e 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -542,7 +542,7 @@ export function FloatingActionMenu() { pointerEvents: 'auto', touchAction: 'none', }} - zIndexRange={[100, 0]} + zIndexRange={[25, 0]} >
void }) { + // Studio mode is a clean render/snapshot surface — no selection or editing + // affordances. It mirrors version-preview's chrome gating on the canvas. + const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode return ( <> - {!isFirstPersonMode && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isFirstPersonMode || isStudioMode) && } + {!noEditing && } + {!noEditing && } + {!noEditing && } + {!noEditing && } + {!noEditing && } + {!noEditing && } + {!noEditing && } {!isFirstPersonMode && } {isFirstPersonMode ? : } @@ -614,7 +619,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!(isLoading || isFirstPersonMode) && } - {!(isLoading || isVersionPreviewMode || isFirstPersonMode) && } + {!(isLoading || noEditing) && } {isFirstPersonMode && } @@ -803,6 +808,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ isVersionPreviewMode, isLoading, isFirstPersonMode, + isStudioMode, hasLoadedInitialScene, showLoader, onThumbnailCapture, @@ -810,6 +816,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean + isStudioMode: boolean hasLoadedInitialScene: boolean showLoader: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void @@ -921,6 +928,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ @@ -958,8 +966,9 @@ export default function Editor({ commandPaletteEmptyAction, }: EditorProps) { const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) + const isStudioMode = useEditor((s) => s.workspaceMode === 'studio') - useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode }) + useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode }) const { isLoadingSceneRef } = useAutoSave({ onSave, @@ -1109,6 +1118,7 @@ export default function Editor({ hasLoadedInitialScene={hasLoadedInitialScene} isFirstPersonMode={isFirstPersonMode} isLoading={isLoading} + isStudioMode={isStudioMode} isVersionPreviewMode={isVersionPreviewMode} onThumbnailCapture={onThumbnailCapture} showLoader={showLoader} @@ -1163,12 +1173,12 @@ export default function Editor({ overlays={ <> {!isCaptureMode && } - {!(isVersionPreviewMode || isCaptureMode) && ( + {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
)} - {!(isVersionPreviewMode || isCaptureMode) && ( + {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 54b29090..e19dbe30 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -84,7 +84,7 @@ function DimensionLabel({ center position={position as unknown as [number, number, number]} style={{ pointerEvents: 'none', userSelect: 'none' }} - zIndexRange={[40, 0]} + zIndexRange={[25, 0]} >
void + onError: (message: string | null) => void +}) { + const setSelection = useViewer((state) => state.setSelection) + const updateNode = useScene((state) => state.updateNode) + const deleteNode = useScene((state) => state.deleteNode) + const selectedReferenceId = useEditor((state) => state.selectedReferenceId) + const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) + const hasItems = nodes.length > 0 + + const handleSelect = useCallback( + (id: AnyNodeId) => { + setShow(true) + setSelectedReferenceId(id) + setSelection({ selectedIds: [], zoneId: null }) + }, + [setShow, setSelectedReferenceId, setSelection], + ) + + return ( +
+
+ + + +
+

{title}

+ {hasItems && ( +

+ {nodes.length} {noun} + {nodes.length !== 1 ? 's' : ''} on this level +

+ )} +
+ + +
+ + {hasItems ? ( +
+ {nodes.map((node, index) => ( +
+
+ + +
+ + updateNode(node.id, { opacity: Math.round(Math.min(100, Math.max(0, value))) }) + } + precision={0} + step={1} + unit="%" + value={node.opacity} + /> +
+ ))} +
+ ) : ( +
+ {emptyText} +
+ )} +
+ ) +} + +function ReferencesControl() { + const showScans = useViewer((state) => state.showScans) + const setShowScans = useViewer((state) => state.setShowScans) + const showGuides = useViewer((state) => state.showGuides) + const setShowGuides = useViewer((state) => state.setShowGuides) + const [isOpen, setIsOpen] = useState(false) + const [uploadError, setUploadError] = useState(null) + + const scans = useLevelScans() + const guides = useLevelGuides() + const total = scans.length + guides.length + const anyVisible = showScans || showGuides + + const toggleAll = useCallback(() => { + const next = !anyVisible + setShowScans(next) + setShowGuides(next) + }, [anyVisible, setShowScans, setShowGuides]) + + return ( + +
+ +
+ References + + {total} + +
+
+ + + + +
+ + +
+ {uploadError && ( +
+ {uploadError} +
+ )} + +
+ +
+ + + ) +} + // ── Reference floor control ──────────────────────────────────────────────────────────────────── function ReferenceFloorControl() { @@ -753,8 +994,7 @@ export { GridSnapControl } export function SecondaryToggles() { return (
- - +
) } diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index c38c9f85..b4ae3344 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -1,11 +1,46 @@ 'use client' -import { ChevronLeft, RotateCcw, X } from 'lucide-react' +import { ChevronDown, ChevronLeft, GripHorizontal, RotateCcw, X } from 'lucide-react' import Image from 'next/image' -import { createContext, useContext } from 'react' +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useRef, + useState, +} from 'react' import { useIsMobile } from '../../../hooks/use-mobile' import { cn } from '../../../lib/utils' +const DRAG_MARGIN = 8 +// Pointer travel (px) below which a header press is treated as a click +// (toggles collapse) rather than a drag. +const CLICK_SLOP = 4 + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), Math.max(min, max)) +} + +/** + * Bounds the panel is allowed to occupy — the viewer column (tagged with + * `data-viewer-bounds`) so it can't slide under the sidebar or top bar. + * Falls back to the viewport when the marker isn't found. + */ +function getDragBounds(el: HTMLElement | null): { + left: number + top: number + right: number + bottom: number +} { + const region = el?.closest('[data-viewer-bounds]') + const rect = region?.getBoundingClientRect() + if (!rect) { + return { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight } + } + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } +} + /** * Host-supplied inspector footer (e.g. community's "Save as preset"). The * `PanelManager` provides it so every panel — including kind-owned @@ -47,6 +82,103 @@ export function PanelWrapper({ const contextFooter = useContext(InspectorFooterContext) const resolvedFooter = footer ?? contextFooter + const panelRef = useRef(null) + + // The whole panel is collapsed to just its header by default; the chevron + // expands it to reveal the inspector body. + const [collapsed, setCollapsed] = useState(true) + + // Drag-to-reposition from the header. `offset` is a translation applied on + // top of the default `top-20 right-4` anchor; null until first dragged. + // Dragging is clamped so no edge of the panel leaves the viewport. + const [offset, setOffset] = useState<{ x: number; y: number } | null>(null) + const [isDragging, setIsDragging] = useState(false) + const dragRef = useRef<{ + startX: number + startY: number + baseX: number + baseY: number + rectLeft: number + rectTop: number + width: number + height: number + minLeft: number + maxLeft: number + minTop: number + maxTop: number + moved: boolean + } | null>(null) + + const handleHeaderPointerDown = useCallback( + (e: React.PointerEvent) => { + // Buttons (close / reset / collapse) handle their own clicks. + if ((e.target as HTMLElement).closest('button')) return + const rect = panelRef.current?.getBoundingClientRect() + if (!rect) return + const bounds = getDragBounds(panelRef.current) + const base = offset ?? { x: 0, y: 0 } + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + baseX: base.x, + baseY: base.y, + rectLeft: rect.left, + rectTop: rect.top, + width: rect.width, + height: rect.height, + minLeft: bounds.left + DRAG_MARGIN, + maxLeft: bounds.right - rect.width - DRAG_MARGIN, + minTop: bounds.top + DRAG_MARGIN, + maxTop: bounds.bottom - rect.height - DRAG_MARGIN, + moved: false, + } + setIsDragging(true) + e.currentTarget.setPointerCapture(e.pointerId) + }, + [offset], + ) + + const handleHeaderPointerMove = useCallback((e: React.PointerEvent) => { + const drag = dragRef.current + if (!drag) return + const dx = e.clientX - drag.startX + const dy = e.clientY - drag.startY + // Hold position until the press clearly becomes a drag, so a click can + // still toggle collapse. + if (!drag.moved && Math.hypot(dx, dy) <= CLICK_SLOP) return + drag.moved = true + const left = clamp(drag.rectLeft + dx, drag.minLeft, drag.maxLeft) + const top = clamp(drag.rectTop + dy, drag.minTop, drag.maxTop) + setOffset({ x: drag.baseX + (left - drag.rectLeft), y: drag.baseY + (top - drag.rectTop) }) + }, []) + + const handleHeaderPointerUp = useCallback((e: React.PointerEvent) => { + const drag = dragRef.current + if (!drag) return + dragRef.current = null + setIsDragging(false) + e.currentTarget.releasePointerCapture(e.pointerId) + // A press that never turned into a drag is a click → toggle collapse. + if (!drag.moved) setCollapsed((c) => !c) + }, []) + + // Expanding can grow the panel past an edge if it was dragged there while + // collapsed — nudge it back inside the viewer bounds. + useLayoutEffect(() => { + if (isMobile || collapsed) return + const el = panelRef.current + if (!el) return + const rect = el.getBoundingClientRect() + const bounds = getDragBounds(el) + const left = clamp(rect.left, bounds.left + DRAG_MARGIN, bounds.right - rect.width - DRAG_MARGIN) + const top = clamp(rect.top, bounds.top + DRAG_MARGIN, bounds.bottom - rect.height - DRAG_MARGIN) + const dx = left - rect.left + const dy = top - rect.top + if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) { + setOffset((prev) => ({ x: (prev?.x ?? 0) + dx, y: (prev?.y ?? 0) + dy })) + } + }, [collapsed, isMobile]) + return (
- {/* Header — desktop only; mobile sheet provides its own header */} + {/* Header — desktop only; mobile sheet provides its own header. Doubles + as the drag handle (grip in the middle) for repositioning the panel. */} {!isMobile && ( -
-
+
+
{onBack && (
+ {/* Centered grip — purely a visual drag affordance. */} + +
{onReset && ( )} + {onClose && (
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5023ce55..54d22c76 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -210,6 +210,7 @@ export type { SplitOrientation, ToolDefaults, ViewMode, + WorkspaceMode, } from './store/use-editor' export { default as useEditor } from './store/use-editor' export { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index c8c32d01..69dfebfb 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -47,6 +47,7 @@ const MAX_FLOORPLAN_PANE_RATIO = 0.85 export type ViewMode = '3d' | '2d' | 'split' export type SplitOrientation = 'horizontal' | 'vertical' +export type WorkspaceMode = 'edit' | 'studio' // Snapshot capture is invoked from two surfaces with different policies. // `standard` mirrors the existing user-driven UX — pick region / viewport / @@ -328,6 +329,12 @@ type EditorState = { isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null setFirstPersonMode: (enabled: boolean) => void + // Workspace mode: 'edit' is the full editing surface; 'studio' is the + // render/snapshot surface (clean canvas, no editing chrome or selection). + // Entering studio forces a 3D-only view and restores the prior view on exit. + workspaceMode: WorkspaceMode + _viewModeBeforeStudio: ViewMode | null + setWorkspaceMode: (mode: WorkspaceMode) => void activeSidebarPanel: string setActiveSidebarPanel: (id: string) => void floorplanPaneRatio: number @@ -851,6 +858,32 @@ const useEditor = create()( }) } }, + workspaceMode: 'edit' as WorkspaceMode, + _viewModeBeforeStudio: null as ViewMode | null, + setWorkspaceMode: (mode) => { + if (get().workspaceMode === mode) return + if (mode === 'studio') { + const currentViewMode = get().viewMode + set({ + workspaceMode: 'studio', + _viewModeBeforeStudio: currentViewMode, + viewMode: '3d', + isFloorplanOpen: false, + mode: 'select', + tool: null, + catalogCategory: null, + }) + // Clear selection so no edit affordances bleed into the clean canvas. + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + } else { + const prevMode = get()._viewModeBeforeStudio + set({ + workspaceMode: 'edit', + _viewModeBeforeStudio: null, + ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}), + }) + } + }, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL, setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }), floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,