/* 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/. */ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels'; import type { PanelImperativeHandle } from 'react-resizable-panels'; import { TooltipProvider } from '@/components/ui/tooltip'; import { MainToolbar } from './MainToolbar'; import { MobileToolbar } from './MobileToolbar'; import { HierarchyPanel } from './HierarchyPanel'; import { PropertiesPanel } from './PropertiesPanel'; import { AddElementPanel } from './AddElementPanel'; import { StatusBar } from './StatusBar'; import { ViewportContainer } from './ViewportContainer'; import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useActionLogger } from '@/hooks/useActionLogger'; import { usePrivacyDisclosure } from '@/hooks/usePrivacyDisclosure'; import { isSafeMode } from '@/lib/safe-mode'; import { ShieldAlert, Grip } from 'lucide-react'; import { usePanelDetachDrag } from '@/hooks/usePanelDetachDrag'; import { ExtensionDockHost } from '@/components/extensions/ExtensionDockHost'; import { useIfc } from '@/hooks/useIfc'; import { useViewerStore } from '@/store'; import { EntityContextMenu } from './EntityContextMenu'; import { useDuplicateShortcut } from './useDuplicateShortcut'; import { HoverTooltip } from './HoverTooltip'; import { BCFPanel } from './BCFPanel'; import { IDSPanel } from './IDSPanel'; import { LensPanel } from './LensPanel'; import { ClashPanel } from './ClashPanel'; import { ComparePanel } from './ComparePanel'; import { ListPanel } from './lists/ListPanel'; import { ScriptPanel } from './ScriptPanel'; import { GanttPanel } from './schedule/GanttPanel'; import { ExtensionsPanel } from '@/components/extensions/ExtensionsPanel'; import { CommandPalette } from './CommandPalette'; import { SearchModal } from './SearchModal'; import { SidebarDock } from './sidebar/SidebarDock'; import { FloatingPanelHost } from './dock/FloatingPanelHost'; import { PanelWindowHost } from './dock/PanelWindowHost'; import { closeActiveAnalysisExtension, getAnalysisExtensionById, getAnalysisExtensionsSnapshot, subscribeAnalysisExtensions, } from '@/services/analysis-extensions'; const BOTTOM_PANEL_MIN_HEIGHT = 120; const BOTTOM_PANEL_DEFAULT_HEIGHT = 300; const BOTTOM_PANEL_MAX_RATIO = 0.7; // max 70% of container /** Slim grip atop a bottom-strip panel — drag to lift it into a floating window, * or drag onto another screen to pop it out (#1208). */ function BottomPanelGrip({ id }: { id: 'gantt' | 'script' | 'lists' }) { const onPointerDown = usePanelDetachDrag(id); // Pointer-only drag affordance — not a real button (no keyboard action); // keyboard users dock / float via the sidebar rail / Alt+N (#1208). return (
); } export function ViewerLayout() { // Initialize keyboard shortcuts useKeyboardShortcuts(); // ⌘D / Ctrl+D to duplicate the current selection. useDuplicateShortcut(); // Bridge viewer state transitions into the extension action log // so the idle pattern miner can surface one-click tool suggestions. useActionLogger(); // Show the RFC §06 §7 privacy disclosure on first launch. usePrivacyDisclosure(); const shortcutsDialog = useKeyboardShortcutsDialog(); // Auto-load a model from ?model=. Used by the landing-page iframe to drop a // sample IFC into the viewer on first mount. Same-origin or CORS-friendly URLs only. const { addModel: autoloadAddModel } = useIfc(); const autoloadDoneRef = useRef(false); useEffect(() => { if (autoloadDoneRef.current) return; const params = new URLSearchParams(window.location.search); const modelUrl = params.get('model'); if (!modelUrl) return; autoloadDoneRef.current = true; (async () => { try { const res = await fetch(modelUrl); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const blob = await res.blob(); const filename = (() => { try { return new URL(modelUrl, window.location.href).pathname.split('/').pop() || 'model.ifc'; } catch { return 'model.ifc'; } })(); const file = new File([blob], filename, { type: blob.type || 'application/x-step' }); await autoloadAddModel(file); } catch (err) { console.error('[viewer] autoload from ?model=… failed:', err); } })(); }, [autoloadAddModel]); // Command palette state const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); // Ctrl+K / Cmd+K to open command palette useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); setCommandPaletteOpen((prev) => !prev); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); useEffect(() => { const openCommandPalette = () => setCommandPaletteOpen(true); const showShortcuts = () => shortcutsDialog.toggle(); window.addEventListener('ifc-lite:open-command-palette', openCommandPalette); window.addEventListener('ifc-lite:show-shortcuts', showShortcuts); return () => { window.removeEventListener('ifc-lite:open-command-palette', openCommandPalette); window.removeEventListener('ifc-lite:show-shortcuts', showShortcuts); }; }, [shortcutsDialog]); // Initialize theme on mount const theme = useViewerStore((s) => s.theme); const isMobile = useViewerStore((s) => s.isMobile); const setIsMobile = useViewerStore((s) => s.setIsMobile); const leftPanelCollapsed = useViewerStore((s) => s.leftPanelCollapsed); const rightPanelCollapsed = useViewerStore((s) => s.rightPanelCollapsed); const setLeftPanelCollapsed = useViewerStore((s) => s.setLeftPanelCollapsed); const setRightPanelCollapsed = useViewerStore((s) => s.setRightPanelCollapsed); const bcfPanelVisible = useViewerStore((s) => s.bcfPanelVisible); const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible); const activeTool = useViewerStore((s) => s.activeTool); const setActiveTool = useViewerStore((s) => s.setActiveTool); const idsPanelVisible = useViewerStore((s) => s.idsPanelVisible); const setIdsPanelVisible = useViewerStore((s) => s.setIdsPanelVisible); const extensionsPanelVisible = useViewerStore((s) => s.extensionsPanelVisible); const setExtensionsPanelVisible = useViewerStore((s) => s.setExtensionsPanelVisible); const listPanelVisible = useViewerStore((s) => s.listPanelVisible); const setListPanelVisible = useViewerStore((s) => s.setListPanelVisible); const lensPanelVisible = useViewerStore((s) => s.lensPanelVisible); const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible); const clashPanelVisible = useViewerStore((s) => s.clashPanelVisible); const setClashPanelVisible = useViewerStore((s) => s.setClashPanelVisible); const comparePanelVisible = useViewerStore((s) => s.comparePanelVisible); const setComparePanelVisible = useViewerStore((s) => s.setComparePanelVisible); const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible); const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible); const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible); const setGanttPanelVisible = useViewerStore((s) => s.setGanttPanelVisible); // The right pane is owned by the sidebar (#1208); here we only need to know // which BOTTOM panel (Script / Schedule / Lists) is docked vs detached, so the // bottom strip doesn't render a panel that is floating (#1201) or popped out. const floatingPanels = useViewerStore((s) => s.floatingPanels); const poppedOutIds = useViewerStore((s) => s.poppedOutIds); const detachedIds = useMemo( () => new Set([...floatingPanels.map((p) => p.id), ...poppedOutIds]), [floatingPanels, poppedOutIds], ); const ganttDocked = ganttPanelVisible && !detachedIds.has('gantt'); const scriptDocked = scriptPanelVisible && !detachedIds.has('script'); const listDocked = listPanelVisible && !detachedIds.has('lists'); const analysisExtensionState = useSyncExternalStore( subscribeAnalysisExtensions, getAnalysisExtensionsSnapshot, getAnalysisExtensionsSnapshot, ); const activeAnalysisExtension = getAnalysisExtensionById(analysisExtensionState.activeId); const activeRightAnalysisExtension = (activeAnalysisExtension?.placement ?? 'right') === 'right' ? activeAnalysisExtension : null; const activeBottomAnalysisExtension = activeAnalysisExtension?.placement === 'bottom' ? activeAnalysisExtension : null; // Panel refs for programmatic collapse/expand (command palette, keyboard shortcuts). // The right region is now the unified sidebar (#1208), which owns its own // collapse/hide state in `sidebarSlice`; only the left hierarchy pane is a // react-resizable Panel here. const leftPanelRef = useRef(null); // Sync store state → left Panel collapse/expand on desktop useEffect(() => { const panel = leftPanelRef.current; if (!panel) return; if (leftPanelCollapsed && !panel.isCollapsed()) panel.collapse(); else if (!leftPanelCollapsed && panel.isCollapsed()) panel.expand(); }, [leftPanelCollapsed]); // Bottom panel resize state (pixel height, persisted in ref to avoid re-renders during drag) const [bottomHeight, setBottomHeight] = useState(BOTTOM_PANEL_DEFAULT_HEIGHT); const containerRef = useRef(null); const isDraggingRef = useRef(false); const cleanupRef = useRef<(() => void) | null>(null); // Cleanup drag listeners on unmount useEffect(() => { return () => { cleanupRef.current?.(); }; }, []); const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); isDraggingRef.current = true; const startY = e.clientY; const startHeight = bottomHeight; const onMouseMove = (moveEvent: MouseEvent) => { if (!isDraggingRef.current) return; const container = containerRef.current; if (!container) return; const maxHeight = container.clientHeight * BOTTOM_PANEL_MAX_RATIO; const delta = startY - moveEvent.clientY; const newHeight = Math.min( maxHeight, Math.max(BOTTOM_PANEL_MIN_HEIGHT, startHeight + delta) ); setBottomHeight(newHeight); }; const cleanup = () => { isDraggingRef.current = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; cleanupRef.current = null; }; const onMouseUp = () => { cleanup(); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); document.body.style.cursor = 'row-resize'; document.body.style.userSelect = 'none'; cleanupRef.current = cleanup; }, [bottomHeight]); // Track the gap between the layout viewport (innerHeight) and the visual viewport. // On iOS Safari with bottom URL bar, dvh/innerHeight INCLUDES the URL bar area, // so anything at `bottom: 0` lands behind it. visualViewport.height excludes // the URL bar overlay, giving us the real visible bottom. const bottomViewportInset = useVisualViewportBottomInset(); // Hide mobile floating buttons when the empty-state "Load IFC" card is showing. const { models, geometryResult } = useIfc(); const hasModelsLoaded = models.size > 0 || ((geometryResult?.meshes?.length ?? 0) > 0); // Detect mobile viewport — use both width check AND touch capability useEffect(() => { const checkMobile = () => { const narrowScreen = window.innerWidth < 768; const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0; const mobile = narrowScreen || (hasTouchScreen && window.innerWidth < 1024); setIsMobile(mobile); // Auto-collapse panels on mobile if (mobile) { setLeftPanelCollapsed(true); setRightPanelCollapsed(true); } }; checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, [setIsMobile, setLeftPanelCollapsed, setRightPanelCollapsed]); // Keep DOM class in sync when theme changes (initial class is set by inline script in index.html) useEffect(() => { document.documentElement.classList.toggle('dark', theme === 'dark'); document.documentElement.classList.toggle('colorful', theme === 'colorful'); }, [theme]); const safeMode = isSafeMode(); return (
{safeMode && (
Safe mode: extensions and the active flavor are not loaded for this session. Append ?safe=0 or reload without the flag to resume.
)} {/* Keyboard Shortcuts Dialog */} {/* Global Overlays */} {/* Main Toolbar — use compact MobileToolbar on mobile */} {isMobile ? : } {/* Main Content Area - Desktop Layout */} {!isMobile && (
{/* Top: hierarchy | viewport split, with the unified sidebar (#1208) pinned to the right edge (its own activity bar + docked pane). */}
{/* Left Panel - Hierarchy */} { const collapsed = leftPanelRef.current?.isCollapsed() ?? false; if (collapsed !== leftPanelCollapsed) setLeftPanelCollapsed(collapsed); }} >
{/* Extension dock.left — collapses when no extension contributes. Sits beneath the hierarchy panel. */}
{/* Center - Viewport */} {/* data-floating-snap-bounds: edge-docked floating panels (#1201) snap to THIS region, not the whole window, so a dock never hides under the toolbar (its own close control with it) or over the hierarchy / sidebar (#1245). */}
{/* Unified workspace sidebar: activity bar + docked panel host. */}
{/* Bottom Panel - Lists / Script / Gantt / analysis ext (custom resizable). Launched from the sidebar rail but docked here (their home region). A panel that's been dragged out to float / another screen is skipped. */} {(listDocked || scriptDocked || ganttDocked || !!activeBottomAnalysisExtension) && (
{/* Drag handle (resize height) */}
{/* Detach grip — drag to float / pop the bottom panel onto another screen (hidden for analysis extensions, which own their chrome). */} {!activeBottomAnalysisExtension && ( )}
{activeBottomAnalysisExtension ? ( activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension }) ) : ganttDocked ? ( setGanttPanelVisible(false)} /> ) : scriptDocked ? ( setScriptPanelVisible(false)} /> ) : ( setListPanelVisible(false)} /> )}
)} {/* Floating / docked workspace-panel windows (#1201) */}
)} {/* Main Content Area - Mobile Layout */} {isMobile && (
{/* Full-screen Viewport */}
{/* Backdrop overlay when sheet is open */} {(!leftPanelCollapsed || !rightPanelCollapsed) && (
{ setLeftPanelCollapsed(true); setRightPanelCollapsed(true); }} /> )} {/* Mobile Bottom Sheet - Hierarchy */} {!leftPanelCollapsed && ( setLeftPanelCollapsed(true)} > )} {/* Mobile Bottom Sheet - Properties, BCF, IDS, or Lists */} {!rightPanelCollapsed && ( { setRightPanelCollapsed(true); if (scriptPanelVisible) setScriptPanelVisible(false); if (listPanelVisible) setListPanelVisible(false); if (ganttPanelVisible) setGanttPanelVisible(false); if (bcfPanelVisible) setBcfPanelVisible(false); if (lensPanelVisible) setLensPanelVisible(false); if (idsPanelVisible) setIdsPanelVisible(false); if (extensionsPanelVisible) setExtensionsPanelVisible(false); if (activeAnalysisExtension) closeActiveAnalysisExtension(); if (activeTool === 'addElement') setActiveTool('select'); }} > {activeBottomAnalysisExtension ? ( activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension }) ) : activeRightAnalysisExtension ? ( activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension }) ) : ganttPanelVisible ? ( setGanttPanelVisible(false)} /> ) : scriptPanelVisible ? ( setScriptPanelVisible(false)} /> ) : listPanelVisible ? ( setListPanelVisible(false)} /> ) : activeTool === 'addElement' ? ( setActiveTool('select')} /> ) : lensPanelVisible ? ( setLensPanelVisible(false)} /> ) : idsPanelVisible ? ( setIdsPanelVisible(false)} /> ) : bcfPanelVisible ? ( setBcfPanelVisible(false)} /> ) : extensionsPanelVisible ? ( setExtensionsPanelVisible(false)} /> ) : ( )} )} {/* Mobile Floating Buttons — top-left, brutalist vocabulary (tight radii, visible borders, uppercase caption) matching panel headers across the app. Hidden in the empty state so the "Load IFC" card stays unobstructed. */} {leftPanelCollapsed && rightPanelCollapsed && hasModelsLoaded && (
)}
)} {/* Extension dock.bottom slot — collapses to nothing when no extension contributes here. */} {!isMobile && (
)} {/* Status Bar — hidden on mobile to maximize viewport space */} {!isMobile && } {/* Panels popped out into OS / PiP windows (#1208) — portalled into the child documents; live-synced via the shared store. */}
); } /** * Tracks the gap between the layout viewport (innerHeight) and the visual viewport. * Returns the number of pixels the layout viewport extends below the visible area — * i.e. how tall the iOS Safari URL bar overlay (or virtual keyboard) is. */ function useVisualViewportBottomInset(): number { const [inset, setInset] = useState(0); useEffect(() => { const vv = window.visualViewport; if (!vv) return; const update = () => { const gap = window.innerHeight - vv.height - vv.offsetTop; setInset(Math.max(0, Math.round(gap))); }; update(); vv.addEventListener('resize', update); vv.addEventListener('scroll', update); return () => { vv.removeEventListener('resize', update); vv.removeEventListener('scroll', update); }; }, []); return inset; } /** * Mobile bottom sheet with three snap states (dismissed / default / expanded). * Drag the handle: down to shrink/dismiss, up to enlarge. Velocity-based flicks * cross thresholds instantly; otherwise the sheet snaps to the closest state. * * `bottomInset` lifts the sheet above the iOS Safari URL bar overlay. */ function MobileBottomSheet({ title, onClose, bottomInset, children, }: { title: ReactNode; onClose: () => void; bottomInset: number; children: ReactNode; }) { const sheetRef = useRef(null); const dragRef = useRef<{ startY: number; startT: number; startHeight: number; active: boolean }>({ startY: 0, startT: 0, startHeight: 0, active: false, }); const SPRING = 'height 220ms cubic-bezier(0.2, 0, 0, 1)'; const getSnapPoints = useCallback(() => { const h = window.visualViewport?.height ?? window.innerHeight; return { collapsed: 0, defaultH: Math.round(h * 0.6), expanded: Math.round(h * 0.92), }; }, []); const onPointerDown = useCallback((e: ReactPointerEvent) => { if (e.pointerType === 'mouse' && e.button !== 0) return; const sheet = sheetRef.current; if (!sheet) return; dragRef.current = { startY: e.clientY, startT: performance.now(), startHeight: sheet.getBoundingClientRect().height, active: true, }; sheet.style.transition = 'none'; e.currentTarget.setPointerCapture(e.pointerId); }, []); const onPointerMove = useCallback((e: ReactPointerEvent) => { const sheet = sheetRef.current; if (!dragRef.current.active || !sheet) return; const dy = e.clientY - dragRef.current.startY; const { expanded } = getSnapPoints(); const newHeight = Math.max(0, Math.min(expanded, dragRef.current.startHeight - dy)); sheet.style.height = `${newHeight}px`; }, [getSnapPoints]); const onPointerUp = useCallback((e: ReactPointerEvent) => { const sheet = sheetRef.current; if (!dragRef.current.active || !sheet) return; dragRef.current.active = false; const dy = e.clientY - dragRef.current.startY; const dt = Math.max(1, performance.now() - dragRef.current.startT); // Positive velocity = upward drag (intent: enlarge). const upwardVelocity = -dy / dt; // px/ms const { collapsed, defaultH, expanded } = getSnapPoints(); const currentHeight = sheet.getBoundingClientRect().height; sheet.style.transition = SPRING; const snapTo = (h: number) => { sheet.style.height = `${h}px`; }; // Velocity-driven decisions take precedence over position. if (upwardVelocity > 0.5) { snapTo(expanded); return; } if (upwardVelocity < -0.5) { // Downward flick: from expanded → default, from default → dismiss. if (dragRef.current.startHeight >= expanded - 8) { snapTo(defaultH); } else { snapTo(collapsed); window.setTimeout(onClose, 200); } return; } // Position-based snap: closest of the three targets. const targets: Array<{ state: 'collapsed' | 'default' | 'expanded'; h: number }> = [ { state: 'collapsed', h: collapsed }, { state: 'default', h: defaultH }, { state: 'expanded', h: expanded }, ]; let closest = targets[1]; for (const t of targets) { if (Math.abs(currentHeight - t.h) < Math.abs(currentHeight - closest.h)) closest = t; } snapTo(closest.h); if (closest.state === 'collapsed') window.setTimeout(onClose, 200); }, [getSnapPoints, onClose]); // Initial height = default snap. Recompute when viewport changes (URL bar collapses). useEffect(() => { const sheet = sheetRef.current; if (!sheet) return; const { defaultH } = getSnapPoints(); sheet.style.height = `${defaultH}px`; }, [getSnapPoints]); return (
{/* Drag affordance — generously sized for touch */}
{title}
{children}
); }