/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
/**
* The docked sidebar's content pane (#1208, split added in #1266).
*
* Renders the active workspace panel, and, when the user splits it, a SECOND
* panel stacked beneath it with a draggable divider (Blender-style). Both halves
* reserve real layout space (the pane is a flex sibling of the viewport), so a
* split is "model | Information / IDS", never an overlay. Floating (#1201) stays
* a separate overlay channel.
*
* Each panel ships its own header (title + close), so the sidebar adds only a
* slim grab bar on top: a dot-grid grip you drag to detach, a Split control, and
* a chevron that collapses the pane to the rail. The two stacked panels read as
* a matched pair joined by one resize handle (which also carries the
* remove-split action), so neither half repeats a title.
*
* The detach drag is LIVE: on the first move the panel lifts straight out of the
* dock into a floating window (#1201) positioned exactly where it was, then
* tracks the cursor for the whole gesture. Release inside the viewport keeps it
* floating; release past the window edge hands it off to an OS / PiP window.
*
* Render precedence preserves the pre-existing right-slot behavior:
* right-placed analysis extension, then Add Element tool, then active panel,
* then Information.
*/
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
import { Grip, ChevronRight, Rows2, X, Check, GripHorizontal } from 'lucide-react';
import { useViewerStore } from '@/store';
import { WORKSPACE_PANELS, getPanelDef, type WorkspacePanelId } from '@/lib/panels/registry';
import { renderPanelBody } from '@/lib/panels/renderPanelBody';
import { usePanelControls } from '@/hooks/usePanelControls';
import { usePanelDetachDrag } from '@/hooks/usePanelDetachDrag';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ExtensionDockHost } from '@/components/extensions/ExtensionDockHost';
import { AddElementPanel } from '../AddElementPanel';
import {
closeActiveAnalysisExtension,
getAnalysisExtensionById,
getAnalysisExtensionsSnapshot,
subscribeAnalysisExtensions,
} from '@/services/analysis-extensions';
/** Right-pane panels eligible to share the docked split (#1266). */
const SIDE_PANELS = WORKSPACE_PANELS.filter((p) => p.region === 'side');
/** Dropdown that picks / switches / removes the lower split panel (#1266). */
function SplitMenu({ primaryId }: { primaryId: WorkspacePanelId }) {
const secondary = useViewerStore((s) => s.sidebarSecondaryPanel);
const setSecondary = useViewerStore((s) => s.setSidebarSecondaryPanel);
const closeFloatingPanel = useViewerStore((s) => s.closeFloatingPanel);
const setPanelPoppedOut = useViewerStore((s) => s.setPanelPoppedOut);
// Picking a panel that's currently floating / popped pulls it back inline.
const pick = (id: WorkspacePanelId) => {
closeFloatingPanel(id);
setPanelPoppedOut(id, false);
setSecondary(id);
};
const options = SIDE_PANELS.filter((p) => p.id !== primaryId);
return (
Split: stack a second panel below
{secondary ? 'Panel below' : 'Split: show below'}
{options.map((p) => (
pick(p.id)} className="gap-2">
{p.title}
{secondary === p.id && }
))}
{secondary && (
<>
setSecondary(null)} className="gap-2">
Remove split
>
)}
);
}
/** Slim grab bar atop the docked panel: drag the grip to lift it into a live
* floating window, Split to stack a second panel below, chevron to collapse the
* pane to the rail. Title-less and close-less: the panel body owns those. */
function PanelChromeBar({ detachId }: { detachId: WorkspacePanelId }) {
const setSidebarMode = useViewerStore((s) => s.setSidebarMode);
const onPointerDown = usePanelDetachDrag(detachId);
return (
Drag to float, or onto another screen to pop outCollapse to icons
);
}
/** The resize handle between the two split halves (#1266): a centered grip so
* it reads as draggable. Removing the split lives on the lower panel's own
* header close button (and the Split menu), so the divider stays clutter-free. */
function SplitDivider({ onResizeStart }: { onResizeStart: (e: React.MouseEvent) => void }) {
return (
);
}
/** Two stacked panels joined by one resize handle; both halves reserve space
* (#1266). The body of each half owns its own header, so the split adds no
* duplicate title chrome. */
function SplitContainer({
containerRef,
ratio,
onDividerDown,
primary,
secondaryId,
}: {
containerRef: React.RefObject;
ratio: number;
onDividerDown: (e: React.MouseEvent) => void;
primary: React.ReactNode;
secondaryId: WorkspacePanelId;
}) {
const setSecondary = useViewerStore((s) => s.setSidebarSecondaryPanel);
return (
{/* Top half: flex-basis is the ratio; min-height stops it collapsing. */}
{primary}
{/* Bottom half fills the rest; the body's own header carries its title +
close (closing it clears the split). */}
);
}
// Information fallback (or empty when Information is detached).
if (shown === null || shown === 'properties') {
// Empty (Information detached) or no split: render single.
if (shown === null || !secondaryActive) {
return (
);
}
// Information on top, a second panel below (the canonical example).
return (
{renderPanelBody('properties', () => {})}
>
}
secondaryId={secondaryPanel as WorkspacePanelId}
/>
);
}
// A docked analysis panel, optionally split with a second panel below.
if (!secondaryActive) {
return (
// data-detach-root lets usePanelDetachDrag lift from the current docked
// bounds instead of falling back to default float geometry.
{renderPanelBody(shown, () => closePanel(shown))}
);
}
return (
{renderPanelBody(shown, () => closePanel(shown))}
>
}
secondaryId={secondaryPanel as WorkspacePanelId}
/>
);
}