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:
co-authored by
Claude Opus 4.8
parent
e450d8b474
commit
ba8b141006
@@ -174,10 +174,12 @@ function RightColumn({
|
||||
)}
|
||||
{/* Canvas area */}
|
||||
<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 && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-30"
|
||||
data-viewer-bounds
|
||||
style={{ transform: 'translateZ(0)' }}
|
||||
>
|
||||
{overlays}
|
||||
|
||||
@@ -542,7 +542,7 @@ export function FloatingActionMenu() {
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
zIndexRange={[100, 0]}
|
||||
zIndexRange={[25, 0]}
|
||||
>
|
||||
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
||||
<NodeActionMenu
|
||||
|
||||
@@ -57,7 +57,7 @@ export function FloatingBuildingActionMenu() {
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
zIndexRange={[100, 0]}
|
||||
zIndexRange={[25, 0]}
|
||||
>
|
||||
<NodeActionMenu
|
||||
onMove={handleMove}
|
||||
|
||||
@@ -589,23 +589,28 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
isVersionPreviewMode,
|
||||
isLoading,
|
||||
isFirstPersonMode,
|
||||
isStudioMode,
|
||||
onThumbnailCapture,
|
||||
}: {
|
||||
isVersionPreviewMode: boolean
|
||||
isLoading: boolean
|
||||
isFirstPersonMode: boolean
|
||||
isStudioMode: boolean
|
||||
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 (
|
||||
<>
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <NodeArrowHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <GroupRotateHandle />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallOpeningHighlights />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
|
||||
{!(isFirstPersonMode || isStudioMode) && <SelectionManager />}
|
||||
{!noEditing && <BoxSelectTool />}
|
||||
{!noEditing && <NodeArrowHandles />}
|
||||
{!noEditing && <GroupRotateHandle />}
|
||||
{!noEditing && <WallOpeningHighlights />}
|
||||
{!noEditing && <WallMoveSideHandles />}
|
||||
{!noEditing && <FloatingActionMenu />}
|
||||
{!noEditing && <FloatingBuildingActionMenu />}
|
||||
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||
<ExportManager />
|
||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
@@ -614,7 +619,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
|
||||
{!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
|
||||
{!(isLoading || noEditing) && <ToolManager />}
|
||||
{isFirstPersonMode && <FirstPersonControls />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
@@ -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({
|
||||
<ViewerSceneContent
|
||||
isFirstPersonMode={isFirstPersonMode}
|
||||
isLoading={isLoading}
|
||||
isStudioMode={isStudioMode}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
onThumbnailCapture={onThumbnailCapture}
|
||||
/>
|
||||
@@ -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 && <FloatingLevelSelector />}
|
||||
{!(isVersionPreviewMode || isCaptureMode) && (
|
||||
{!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
|
||||
<div className="pointer-events-auto">
|
||||
<ActionMenu />
|
||||
</div>
|
||||
)}
|
||||
{!(isVersionPreviewMode || isCaptureMode) && (
|
||||
{!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
|
||||
<div className="pointer-events-auto">
|
||||
<PanelManager inspectorFooter={inspectorFooter} />
|
||||
</div>
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
<div
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ReferenceFloorControl() {
|
||||
@@ -753,8 +994,7 @@ export { GridSnapControl }
|
||||
export function SecondaryToggles() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<ScansControl />
|
||||
<GuidesControl />
|
||||
<ReferencesControl />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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 (
|
||||
<div
|
||||
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',
|
||||
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 && (
|
||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
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 && (
|
||||
<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"
|
||||
@@ -94,6 +244,9 @@ export function PanelWrapper({
|
||||
</h2>
|
||||
</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">
|
||||
{onReset && (
|
||||
<button
|
||||
@@ -104,6 +257,17 @@ export function PanelWrapper({
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</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 && (
|
||||
<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"
|
||||
@@ -117,10 +281,12 @@ export function PanelWrapper({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
||||
{/* Content — hidden while the panel is collapsed (desktop). */}
|
||||
{!(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>
|
||||
|
||||
@@ -210,6 +210,7 @@ export type {
|
||||
SplitOrientation,
|
||||
ToolDefaults,
|
||||
ViewMode,
|
||||
WorkspaceMode,
|
||||
} from './store/use-editor'
|
||||
export { default as useEditor } from './store/use-editor'
|
||||
export {
|
||||
|
||||
@@ -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<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,
|
||||
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
||||
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
||||
|
||||
Reference in New Issue
Block a user