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 */}
|
{/* 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}
|
||||||
|
|||||||
@@ -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). */}
|
||||||
|
{!(collapsed && !isMobile) && (
|
||||||
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
<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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user