feat(editor): studio mode, inspector panel UX, consolidated display + references (#364)

Editor-package side of the hosted editor UI pass:

- Add `workspaceMode` ('edit' | 'studio') to `useEditor`. Studio forces a
  3D-only view, clears selection, and the canvas hides the bottom action bar,
  inspector, selection manager, handles and tools (kept level selector, view
  toggles and camera toolbars).
- Inspector panel (`PanelWrapper`): collapses to its header by default with a
  single-click toggle, is draggable from the header via a centered grip, and is
  clamped to the viewer column (`data-viewer-bounds`) so it can't slide under
  the sidebar or top bar.
- Lower the floating action menu / building menu / arrow-handle `zIndexRange`
  below the chrome overlay so the inspector sits above scene HTML helpers.
- Merge the bottom bar's separate Scans + Guides toggles into a single
  References split-button + popover with per-type sections.
- Export `WorkspaceMode`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-02 17:08:10 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e450d8b474
commit ba8b141006
9 changed files with 479 additions and 27 deletions
@@ -174,10 +174,12 @@ function RightColumn({
)} )}
{/* Canvas area */} {/* Canvas area */}
<div className="relative flex-1 overflow-hidden">{children}</div> <div className="relative flex-1 overflow-hidden">{children}</div>
{/* 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 && (
<div <div
className="pointer-events-none absolute inset-0 z-30" className="pointer-events-none absolute inset-0 z-30"
data-viewer-bounds
style={{ transform: 'translateZ(0)' }} style={{ transform: 'translateZ(0)' }}
> >
{overlays} {overlays}
@@ -542,7 +542,7 @@ export function FloatingActionMenu() {
pointerEvents: 'auto', pointerEvents: 'auto',
touchAction: 'none', touchAction: 'none',
}} }}
zIndexRange={[100, 0]} zIndexRange={[25, 0]}
> >
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}> <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
<NodeActionMenu <NodeActionMenu
@@ -57,7 +57,7 @@ export function FloatingBuildingActionMenu() {
pointerEvents: 'auto', pointerEvents: 'auto',
touchAction: 'none', touchAction: 'none',
}} }}
zIndexRange={[100, 0]} zIndexRange={[25, 0]}
> >
<NodeActionMenu <NodeActionMenu
onMove={handleMove} onMove={handleMove}
+22 -12
View File
@@ -589,23 +589,28 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
isVersionPreviewMode, isVersionPreviewMode,
isLoading, isLoading,
isFirstPersonMode, isFirstPersonMode,
isStudioMode,
onThumbnailCapture, onThumbnailCapture,
}: { }: {
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
isLoading: boolean isLoading: boolean
isFirstPersonMode: boolean isFirstPersonMode: boolean
isStudioMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => 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 ( return (
<> <>
{!isFirstPersonMode && <SelectionManager />} {!(isFirstPersonMode || isStudioMode) && <SelectionManager />}
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />} {!noEditing && <BoxSelectTool />}
{!(isVersionPreviewMode || isFirstPersonMode) && <NodeArrowHandles />} {!noEditing && <NodeArrowHandles />}
{!(isVersionPreviewMode || isFirstPersonMode) && <GroupRotateHandle />} {!noEditing && <GroupRotateHandle />}
{!(isVersionPreviewMode || isFirstPersonMode) && <WallOpeningHighlights />} {!noEditing && <WallOpeningHighlights />}
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />} {!noEditing && <WallMoveSideHandles />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />} {!noEditing && <FloatingActionMenu />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />} {!noEditing && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />} {!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager /> <ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />} {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
@@ -614,7 +619,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<RoofEditSystem /> <RoofEditSystem />
<StairEditSystem /> <StairEditSystem />
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />} {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
{!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />} {!(isLoading || noEditing) && <ToolManager />}
{isFirstPersonMode && <FirstPersonControls />} {isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls /> <CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} /> <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
@@ -803,6 +808,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
isVersionPreviewMode, isVersionPreviewMode,
isLoading, isLoading,
isFirstPersonMode, isFirstPersonMode,
isStudioMode,
hasLoadedInitialScene, hasLoadedInitialScene,
showLoader, showLoader,
onThumbnailCapture, onThumbnailCapture,
@@ -810,6 +816,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
isLoading: boolean isLoading: boolean
isFirstPersonMode: boolean isFirstPersonMode: boolean
isStudioMode: boolean
hasLoadedInitialScene: boolean hasLoadedInitialScene: boolean
showLoader: boolean showLoader: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
@@ -921,6 +928,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
<ViewerSceneContent <ViewerSceneContent
isFirstPersonMode={isFirstPersonMode} isFirstPersonMode={isFirstPersonMode}
isLoading={isLoading} isLoading={isLoading}
isStudioMode={isStudioMode}
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
onThumbnailCapture={onThumbnailCapture} onThumbnailCapture={onThumbnailCapture}
/> />
@@ -958,8 +966,9 @@ export default function Editor({
commandPaletteEmptyAction, commandPaletteEmptyAction,
}: EditorProps) { }: EditorProps) {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) 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({ const { isLoadingSceneRef } = useAutoSave({
onSave, onSave,
@@ -1109,6 +1118,7 @@ export default function Editor({
hasLoadedInitialScene={hasLoadedInitialScene} hasLoadedInitialScene={hasLoadedInitialScene}
isFirstPersonMode={isFirstPersonMode} isFirstPersonMode={isFirstPersonMode}
isLoading={isLoading} isLoading={isLoading}
isStudioMode={isStudioMode}
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
onThumbnailCapture={onThumbnailCapture} onThumbnailCapture={onThumbnailCapture}
showLoader={showLoader} showLoader={showLoader}
@@ -1163,12 +1173,12 @@ export default function Editor({
overlays={ overlays={
<> <>
{!isCaptureMode && <FloatingLevelSelector />} {!isCaptureMode && <FloatingLevelSelector />}
{!(isVersionPreviewMode || isCaptureMode) && ( {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<ActionMenu /> <ActionMenu />
</div> </div>
)} )}
{!(isVersionPreviewMode || isCaptureMode) && ( {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<PanelManager inspectorFooter={inspectorFooter} /> <PanelManager inspectorFooter={inspectorFooter} />
</div> </div>
@@ -84,7 +84,7 @@ function DimensionLabel({
center center
position={position as unknown as [number, number, number]} position={position as unknown as [number, number, number]}
style={{ pointerEvents: 'none', userSelect: 'none' }} style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[40, 0]} zIndexRange={[25, 0]}
> >
<div <div
className="whitespace-nowrap font-bold font-mono text-[13px]" className="whitespace-nowrap font-bold font-mono text-[13px]"
@@ -590,6 +590,247 @@ function ScansControl() {
) )
} }
// ── References (merged scans + guides) ──────────────────────────────────────
// Bottom-bar control that folds the separate Scans and Guides toggles into one
// "References" split button + a popover holding both, each with its own
// visibility toggle, upload, and per-item opacity/delete.
function ReferenceListSection({
title,
iconSrc,
noun,
emptyText,
nodes,
show,
setShow,
onError,
}: {
title: string
iconSrc: string
noun: string
emptyText: string
nodes: (GuideNode | ScanNode)[]
show: boolean
setShow: (show: boolean) => 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 (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src={iconSrc} />
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">{title}</p>
{hasItems && (
<p className="text-muted-foreground text-xs">
{nodes.length} {noun}
{nodes.length !== 1 ? 's' : ''} on this level
</p>
)}
</div>
<button
aria-label={show ? `Hide ${title.toLowerCase()}` : `Show ${title.toLowerCase()}`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
onClick={() => setShow(!show)}
type="button"
>
{show ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
<UploadButton onError={onError} />
</div>
{hasItems ? (
<div className="max-h-40 space-y-2 overflow-y-auto pr-1">
{nodes.map((node, index) => (
<div
className={cn(
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
selectedReferenceId === node.id
? 'border-foreground/35 bg-white/10'
: 'border-border/45',
)}
key={node.id}
>
<div className="flex min-w-0 items-center gap-2">
<button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => handleSelect(node.id)}
type="button"
>
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src={iconSrc}
/>
<p className="truncate font-medium text-foreground text-sm">
{node.name || `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${index + 1}`}
</p>
{selectedReferenceId === node.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button
aria-label={`Delete ${noun}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={(event) => {
event.stopPropagation()
deleteNode(node.id)
if (selectedReferenceId === node.id) {
setSelectedReferenceId(null)
}
}}
type="button"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
<SliderControl
label="Opacity"
max={100}
min={0}
onChange={(value) =>
updateNode(node.id, { opacity: Math.round(Math.min(100, Math.max(0, value))) })
}
precision={0}
step={1}
unit="%"
value={node.opacity}
/>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-3 text-muted-foreground text-sm">
{emptyText}
</div>
)}
</div>
)
}
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<string | null>(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 (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
<ActionButton
className={cn(
'rounded-r-none p-0',
anyVisible
? 'bg-white/15'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`References: ${anyVisible ? 'Visible' : 'Hidden'}`}
onClick={toggleAll}
size="icon"
variant="ghost"
>
<div className="relative">
<img
alt="References"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png"
/>
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{total}
</span>
</div>
</ActionButton>
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-label="Reference settings"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
anyVisible
? isOpen
? 'bg-white/10'
: 'bg-white/5 hover:bg-white/8'
: isOpen
? 'bg-white/8'
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
)}
type="button"
>
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
</button>
</PopoverTrigger>
</div>
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
<div className="space-y-3">
{uploadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
{uploadError}
</div>
)}
<ReferenceListSection
emptyText="No scans on this level yet."
iconSrc="/icons/mesh.png"
nodes={scans}
noun="scan"
onError={setUploadError}
setShow={setShowScans}
show={showScans}
title="Scans"
/>
<div className="h-px bg-border/45" />
<ReferenceListSection
emptyText="No guide images on this level yet."
iconSrc="/icons/floorplan.png"
nodes={guides}
noun="guide image"
onError={setUploadError}
setShow={setShowGuides}
show={showGuides}
title="Guide images"
/>
</div>
</PopoverContent>
</Popover>
)
}
// ── Reference floor control ──────────────────────────────────────────────────────────────────── // ── Reference floor control ────────────────────────────────────────────────────────────────────
function ReferenceFloorControl() { function ReferenceFloorControl() {
@@ -753,8 +994,7 @@ export { GridSnapControl }
export function SecondaryToggles() { export function SecondaryToggles() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ScansControl /> <ReferencesControl />
<GuidesControl />
</div> </div>
) )
} }
@@ -1,11 +1,46 @@
'use client' 'use client'
import { ChevronLeft, RotateCcw, X } from 'lucide-react' import { ChevronDown, ChevronLeft, GripHorizontal, RotateCcw, X } from 'lucide-react'
import Image from 'next/image' 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 { useIsMobile } from '../../../hooks/use-mobile'
import { cn } from '../../../lib/utils' 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 * Host-supplied inspector footer (e.g. community's "Save as preset"). The
* `PanelManager` provides it so every panel — including kind-owned * `PanelManager` provides it so every panel — including kind-owned
@@ -47,6 +82,103 @@ export function PanelWrapper({
const contextFooter = useContext(InspectorFooterContext) const contextFooter = useContext(InspectorFooterContext)
const resolvedFooter = footer ?? contextFooter const resolvedFooter = footer ?? contextFooter
const panelRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 ( return (
<div <div
className={cn( className={cn(
@@ -62,12 +194,30 @@ export function PanelWrapper({
: 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-154px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground', : 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-154px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
className, className,
)} )}
style={isMobile ? undefined : { width }} ref={panelRef}
style={
isMobile
? undefined
: {
width,
transform: offset ? `translate(${offset.x}px, ${offset.y}px)` : undefined,
}
}
> >
{/* 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 && ( {!isMobile && (
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3"> <div
<div className="flex items-center gap-2"> className={cn(
'relative flex select-none items-center justify-between px-3 py-3',
!collapsed && 'border-border/50 border-b',
isDragging ? 'cursor-grabbing' : 'cursor-grab',
)}
onPointerDown={handleHeaderPointerDown}
onPointerMove={handleHeaderPointerMove}
onPointerUp={handleHeaderPointerUp}
>
<div className="flex min-w-0 items-center gap-2">
{onBack && ( {onBack && (
<button <button
className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground" className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
@@ -94,6 +244,9 @@ export function PanelWrapper({
</h2> </h2>
</div> </div>
{/* Centered grip — purely a visual drag affordance. */}
<GripHorizontal className="-translate-x-1/2 pointer-events-none absolute left-1/2 h-4 w-4 text-muted-foreground/40" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onReset && ( {onReset && (
<button <button
@@ -104,6 +257,17 @@ export function PanelWrapper({
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
</button> </button>
)} )}
<button
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => setCollapsed((c) => !c)}
type="button"
>
<ChevronDown
className={cn('h-4 w-4 transition-transform', collapsed ? '' : 'rotate-180')}
/>
</button>
{onClose && ( {onClose && (
<button <button
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground" className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
@@ -117,10 +281,12 @@ export function PanelWrapper({
</div> </div>
)} )}
{/* Content */} {/* Content — hidden while the panel is collapsed (desktop). */}
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div> {!(collapsed && !isMobile) && (
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
)}
{resolvedFooter && ( {resolvedFooter && !(collapsed && !isMobile) && (
<div className="shrink-0 border-border/50 border-t p-3">{resolvedFooter}</div> <div className="shrink-0 border-border/50 border-t p-3">{resolvedFooter}</div>
)} )}
</div> </div>
+1
View File
@@ -210,6 +210,7 @@ export type {
SplitOrientation, SplitOrientation,
ToolDefaults, ToolDefaults,
ViewMode, ViewMode,
WorkspaceMode,
} from './store/use-editor' } from './store/use-editor'
export { default as useEditor } from './store/use-editor' export { default as useEditor } from './store/use-editor'
export { export {
+33
View File
@@ -47,6 +47,7 @@ const MAX_FLOORPLAN_PANE_RATIO = 0.85
export type ViewMode = '3d' | '2d' | 'split' export type ViewMode = '3d' | '2d' | 'split'
export type SplitOrientation = 'horizontal' | 'vertical' export type SplitOrientation = 'horizontal' | 'vertical'
export type WorkspaceMode = 'edit' | 'studio'
// Snapshot capture is invoked from two surfaces with different policies. // Snapshot capture is invoked from two surfaces with different policies.
// `standard` mirrors the existing user-driven UX — pick region / viewport / // `standard` mirrors the existing user-driven UX — pick region / viewport /
@@ -328,6 +329,12 @@ type EditorState = {
isFirstPersonMode: boolean isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null _viewModeBeforeFirstPerson: ViewMode | null
setFirstPersonMode: (enabled: boolean) => void 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 activeSidebarPanel: string
setActiveSidebarPanel: (id: string) => void setActiveSidebarPanel: (id: string) => void
floorplanPaneRatio: number floorplanPaneRatio: number
@@ -851,6 +858,32 @@ const useEditor = create<EditorState>()(
}) })
} }
}, },
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, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }), setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio, floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,