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 && (
+
+
+ )
+}
+
// ── 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 && (
-