@ifc-lite/viewer 1.19.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +59 -43
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +496 -0
- package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +10 -9
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/index.html +1 -1
- package/package.json +15 -10
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +79 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +60 -15
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/generated/mcp-catalog.json +82 -0
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +12 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-BraHBeoi.js +0 -81583
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +0 -1
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
|
-
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
5
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
|
6
6
|
import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels';
|
|
7
7
|
import type { PanelImperativeHandle } from 'react-resizable-panels';
|
|
8
8
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
|
9
9
|
import { MainToolbar } from './MainToolbar';
|
|
10
|
+
import { MobileToolbar } from './MobileToolbar';
|
|
10
11
|
import { HierarchyPanel } from './HierarchyPanel';
|
|
11
12
|
import { PropertiesPanel } from './PropertiesPanel';
|
|
12
13
|
import { AddElementPanel } from './AddElementPanel';
|
|
@@ -14,6 +15,7 @@ import { StatusBar } from './StatusBar';
|
|
|
14
15
|
import { ViewportContainer } from './ViewportContainer';
|
|
15
16
|
import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
|
|
16
17
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
|
18
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
17
19
|
import { useViewerStore } from '@/store';
|
|
18
20
|
import { EntityContextMenu } from './EntityContextMenu';
|
|
19
21
|
import { useDuplicateShortcut } from './useDuplicateShortcut';
|
|
@@ -176,10 +178,22 @@ export function ViewerLayout() {
|
|
|
176
178
|
cleanupRef.current = cleanup;
|
|
177
179
|
}, [bottomHeight]);
|
|
178
180
|
|
|
179
|
-
//
|
|
181
|
+
// Track the gap between the layout viewport (innerHeight) and the visual viewport.
|
|
182
|
+
// On iOS Safari with bottom URL bar, dvh/innerHeight INCLUDES the URL bar area,
|
|
183
|
+
// so anything at `bottom: 0` lands behind it. visualViewport.height excludes
|
|
184
|
+
// the URL bar overlay, giving us the real visible bottom.
|
|
185
|
+
const bottomViewportInset = useVisualViewportBottomInset();
|
|
186
|
+
|
|
187
|
+
// Hide mobile floating buttons when the empty-state "Load IFC" card is showing.
|
|
188
|
+
const { models, geometryResult } = useIfc();
|
|
189
|
+
const hasModelsLoaded = models.size > 0 || ((geometryResult?.meshes?.length ?? 0) > 0);
|
|
190
|
+
|
|
191
|
+
// Detect mobile viewport — use both width check AND touch capability
|
|
180
192
|
useEffect(() => {
|
|
181
193
|
const checkMobile = () => {
|
|
182
|
-
const
|
|
194
|
+
const narrowScreen = window.innerWidth < 768;
|
|
195
|
+
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
196
|
+
const mobile = narrowScreen || (hasTouchScreen && window.innerWidth < 1024);
|
|
183
197
|
setIsMobile(mobile);
|
|
184
198
|
// Auto-collapse panels on mobile
|
|
185
199
|
if (mobile) {
|
|
@@ -202,7 +216,7 @@ export function ViewerLayout() {
|
|
|
202
216
|
|
|
203
217
|
return (
|
|
204
218
|
<TooltipProvider delayDuration={300}>
|
|
205
|
-
<div className="flex flex-col h-screen w-screen overflow-hidden bg-background text-foreground">
|
|
219
|
+
<div className="flex flex-col h-screen h-[100dvh] w-screen overflow-hidden bg-background text-foreground">
|
|
206
220
|
{/* Keyboard Shortcuts Dialog */}
|
|
207
221
|
<KeyboardShortcutsDialog open={shortcutsDialog.open} onClose={shortcutsDialog.close} />
|
|
208
222
|
|
|
@@ -212,9 +226,9 @@ export function ViewerLayout() {
|
|
|
212
226
|
<CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
|
|
213
227
|
<SearchModal />
|
|
214
228
|
|
|
215
|
-
{/* Main Toolbar */}
|
|
216
|
-
<MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
|
|
217
|
-
<DesktopEntitlementBanner />
|
|
229
|
+
{/* Main Toolbar — use compact MobileToolbar on mobile */}
|
|
230
|
+
{isMobile ? <MobileToolbar /> : <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />}
|
|
231
|
+
{!isMobile && <DesktopEntitlementBanner />}
|
|
218
232
|
|
|
219
233
|
{/* Main Content Area - Desktop Layout */}
|
|
220
234
|
{!isMobile && (
|
|
@@ -309,112 +323,291 @@ export function ViewerLayout() {
|
|
|
309
323
|
|
|
310
324
|
{/* Main Content Area - Mobile Layout */}
|
|
311
325
|
{isMobile && (
|
|
312
|
-
<div className="flex-1 min-h-0 relative">
|
|
326
|
+
<div className="flex-1 min-h-0 relative overflow-hidden">
|
|
313
327
|
{/* Full-screen Viewport */}
|
|
314
328
|
<div className="h-full w-full">
|
|
315
329
|
<ViewportContainer />
|
|
316
330
|
</div>
|
|
317
331
|
|
|
332
|
+
{/* Backdrop overlay when sheet is open */}
|
|
333
|
+
{(!leftPanelCollapsed || !rightPanelCollapsed) && (
|
|
334
|
+
<div
|
|
335
|
+
className="absolute inset-0 bg-black/40 z-30 animate-in fade-in duration-200"
|
|
336
|
+
onClick={() => {
|
|
337
|
+
setLeftPanelCollapsed(true);
|
|
338
|
+
setRightPanelCollapsed(true);
|
|
339
|
+
}}
|
|
340
|
+
/>
|
|
341
|
+
)}
|
|
342
|
+
|
|
318
343
|
{/* Mobile Bottom Sheet - Hierarchy */}
|
|
319
344
|
{!leftPanelCollapsed && (
|
|
320
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
<span className="sr-only">Close</span>
|
|
328
|
-
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
329
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
330
|
-
</svg>
|
|
331
|
-
</button>
|
|
332
|
-
</div>
|
|
333
|
-
<div className="h-[calc(50vh-48px)] overflow-auto">
|
|
334
|
-
<HierarchyPanel />
|
|
335
|
-
</div>
|
|
336
|
-
</div>
|
|
345
|
+
<MobileBottomSheet
|
|
346
|
+
title="Hierarchy"
|
|
347
|
+
bottomInset={bottomViewportInset}
|
|
348
|
+
onClose={() => setLeftPanelCollapsed(true)}
|
|
349
|
+
>
|
|
350
|
+
<HierarchyPanel />
|
|
351
|
+
</MobileBottomSheet>
|
|
337
352
|
)}
|
|
338
353
|
|
|
339
354
|
{/* Mobile Bottom Sheet - Properties, BCF, IDS, or Lists */}
|
|
340
355
|
{!rightPanelCollapsed && (
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
</span>
|
|
346
|
-
<button
|
|
347
|
-
className="p-1 hover:bg-muted rounded"
|
|
348
|
-
onClick={() => {
|
|
349
|
-
setRightPanelCollapsed(true);
|
|
350
|
-
if (scriptPanelVisible) setScriptPanelVisible(false);
|
|
351
|
-
if (listPanelVisible) setListPanelVisible(false);
|
|
352
|
-
if (ganttPanelVisible) setGanttPanelVisible(false);
|
|
353
|
-
if (bcfPanelVisible) setBcfPanelVisible(false);
|
|
354
|
-
if (lensPanelVisible) setLensPanelVisible(false);
|
|
355
|
-
if (idsPanelVisible) setIdsPanelVisible(false);
|
|
356
|
-
if (activeAnalysisExtension) closeActiveAnalysisExtension();
|
|
357
|
-
}}
|
|
358
|
-
>
|
|
359
|
-
<span className="sr-only">Close</span>
|
|
360
|
-
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
361
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
362
|
-
</svg>
|
|
363
|
-
</button>
|
|
364
|
-
</div>
|
|
365
|
-
<div className="h-[calc(50vh-48px)] overflow-auto">
|
|
366
|
-
{activeBottomAnalysisExtension ? (
|
|
367
|
-
activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
368
|
-
) : activeRightAnalysisExtension ? (
|
|
369
|
-
activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
370
|
-
) : ganttPanelVisible ? (
|
|
371
|
-
<GanttPanel onClose={() => setGanttPanelVisible(false)} />
|
|
372
|
-
) : scriptPanelVisible ? (
|
|
373
|
-
<ScriptPanel onClose={() => setScriptPanelVisible(false)} />
|
|
374
|
-
) : listPanelVisible ? (
|
|
375
|
-
<ListPanel onClose={() => setListPanelVisible(false)} />
|
|
376
|
-
) : activeTool === 'addElement' ? (
|
|
377
|
-
<AddElementPanel onClose={() => setActiveTool('select')} />
|
|
378
|
-
) : lensPanelVisible ? (
|
|
379
|
-
<LensPanel onClose={() => setLensPanelVisible(false)} />
|
|
380
|
-
) : idsPanelVisible ? (
|
|
381
|
-
<IDSPanel onClose={() => setIdsPanelVisible(false)} />
|
|
382
|
-
) : bcfPanelVisible ? (
|
|
383
|
-
<BCFPanel onClose={() => setBcfPanelVisible(false)} />
|
|
384
|
-
) : (
|
|
385
|
-
<PropertiesPanel />
|
|
386
|
-
)}
|
|
387
|
-
</div>
|
|
388
|
-
</div>
|
|
389
|
-
)}
|
|
390
|
-
|
|
391
|
-
{/* Mobile Action Buttons */}
|
|
392
|
-
<div className="absolute bottom-4 left-4 right-4 flex justify-center gap-2 z-30">
|
|
393
|
-
<button
|
|
394
|
-
className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
|
|
395
|
-
onClick={() => {
|
|
356
|
+
<MobileBottomSheet
|
|
357
|
+
title={activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : activeTool === 'addElement' ? 'Add element' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
|
|
358
|
+
bottomInset={bottomViewportInset}
|
|
359
|
+
onClose={() => {
|
|
396
360
|
setRightPanelCollapsed(true);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
setLeftPanelCollapsed(true);
|
|
406
|
-
setRightPanelCollapsed(!rightPanelCollapsed);
|
|
361
|
+
if (scriptPanelVisible) setScriptPanelVisible(false);
|
|
362
|
+
if (listPanelVisible) setListPanelVisible(false);
|
|
363
|
+
if (ganttPanelVisible) setGanttPanelVisible(false);
|
|
364
|
+
if (bcfPanelVisible) setBcfPanelVisible(false);
|
|
365
|
+
if (lensPanelVisible) setLensPanelVisible(false);
|
|
366
|
+
if (idsPanelVisible) setIdsPanelVisible(false);
|
|
367
|
+
if (activeAnalysisExtension) closeActiveAnalysisExtension();
|
|
368
|
+
if (activeTool === 'addElement') setActiveTool('select');
|
|
407
369
|
}}
|
|
408
370
|
>
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
371
|
+
{activeBottomAnalysisExtension ? (
|
|
372
|
+
activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
373
|
+
) : activeRightAnalysisExtension ? (
|
|
374
|
+
activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
375
|
+
) : ganttPanelVisible ? (
|
|
376
|
+
<GanttPanel onClose={() => setGanttPanelVisible(false)} />
|
|
377
|
+
) : scriptPanelVisible ? (
|
|
378
|
+
<ScriptPanel onClose={() => setScriptPanelVisible(false)} />
|
|
379
|
+
) : listPanelVisible ? (
|
|
380
|
+
<ListPanel onClose={() => setListPanelVisible(false)} />
|
|
381
|
+
) : activeTool === 'addElement' ? (
|
|
382
|
+
<AddElementPanel onClose={() => setActiveTool('select')} />
|
|
383
|
+
) : lensPanelVisible ? (
|
|
384
|
+
<LensPanel onClose={() => setLensPanelVisible(false)} />
|
|
385
|
+
) : idsPanelVisible ? (
|
|
386
|
+
<IDSPanel onClose={() => setIdsPanelVisible(false)} />
|
|
387
|
+
) : bcfPanelVisible ? (
|
|
388
|
+
<BCFPanel onClose={() => setBcfPanelVisible(false)} />
|
|
389
|
+
) : (
|
|
390
|
+
<PropertiesPanel />
|
|
391
|
+
)}
|
|
392
|
+
</MobileBottomSheet>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{/* Mobile Floating Buttons — top-left, brutalist vocabulary (tight radii, visible
|
|
396
|
+
borders, uppercase caption) matching panel headers across the app.
|
|
397
|
+
Hidden in the empty state so the "Load IFC" card stays unobstructed. */}
|
|
398
|
+
{leftPanelCollapsed && rightPanelCollapsed && hasModelsLoaded && (
|
|
399
|
+
<div className="absolute top-4 left-4 flex flex-col gap-2.5 z-20">
|
|
400
|
+
<button
|
|
401
|
+
className="flex flex-col items-center gap-1 group touch-manipulation"
|
|
402
|
+
onClick={() => {
|
|
403
|
+
setRightPanelCollapsed(true);
|
|
404
|
+
setLeftPanelCollapsed(false);
|
|
405
|
+
}}
|
|
406
|
+
aria-label="Open Hierarchy"
|
|
407
|
+
>
|
|
408
|
+
<span className="grid place-items-center min-h-[44px] min-w-[44px] bg-background/90 backdrop-blur-sm border border-border rounded-md group-active:bg-foreground group-active:text-background transition-colors">
|
|
409
|
+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h10M4 18h7" /></svg>
|
|
410
|
+
</span>
|
|
411
|
+
<span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground leading-none">Hierarchy</span>
|
|
412
|
+
</button>
|
|
413
|
+
<button
|
|
414
|
+
className="flex flex-col items-center gap-1 group touch-manipulation"
|
|
415
|
+
onClick={() => {
|
|
416
|
+
setLeftPanelCollapsed(true);
|
|
417
|
+
setRightPanelCollapsed(false);
|
|
418
|
+
}}
|
|
419
|
+
aria-label="Open Properties"
|
|
420
|
+
>
|
|
421
|
+
<span className="grid place-items-center min-h-[44px] min-w-[44px] bg-background/90 backdrop-blur-sm border border-border rounded-md group-active:bg-foreground group-active:text-background transition-colors">
|
|
422
|
+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
|
|
423
|
+
</span>
|
|
424
|
+
<span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground leading-none">Properties</span>
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
412
428
|
</div>
|
|
413
429
|
)}
|
|
414
430
|
|
|
415
|
-
{/* Status Bar */}
|
|
416
|
-
<StatusBar />
|
|
431
|
+
{/* Status Bar — hidden on mobile to maximize viewport space */}
|
|
432
|
+
{!isMobile && <StatusBar />}
|
|
417
433
|
</div>
|
|
418
434
|
</TooltipProvider>
|
|
419
435
|
);
|
|
420
436
|
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Tracks the gap between the layout viewport (innerHeight) and the visual viewport.
|
|
440
|
+
* Returns the number of pixels the layout viewport extends below the visible area —
|
|
441
|
+
* i.e. how tall the iOS Safari URL bar overlay (or virtual keyboard) is.
|
|
442
|
+
*/
|
|
443
|
+
function useVisualViewportBottomInset(): number {
|
|
444
|
+
const [inset, setInset] = useState(0);
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
const vv = window.visualViewport;
|
|
447
|
+
if (!vv) return;
|
|
448
|
+
const update = () => {
|
|
449
|
+
const gap = window.innerHeight - vv.height - vv.offsetTop;
|
|
450
|
+
setInset(Math.max(0, Math.round(gap)));
|
|
451
|
+
};
|
|
452
|
+
update();
|
|
453
|
+
vv.addEventListener('resize', update);
|
|
454
|
+
vv.addEventListener('scroll', update);
|
|
455
|
+
return () => {
|
|
456
|
+
vv.removeEventListener('resize', update);
|
|
457
|
+
vv.removeEventListener('scroll', update);
|
|
458
|
+
};
|
|
459
|
+
}, []);
|
|
460
|
+
return inset;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Mobile bottom sheet with three snap states (dismissed / default / expanded).
|
|
465
|
+
* Drag the handle: down to shrink/dismiss, up to enlarge. Velocity-based flicks
|
|
466
|
+
* cross thresholds instantly; otherwise the sheet snaps to the closest state.
|
|
467
|
+
*
|
|
468
|
+
* `bottomInset` lifts the sheet above the iOS Safari URL bar overlay.
|
|
469
|
+
*/
|
|
470
|
+
function MobileBottomSheet({
|
|
471
|
+
title,
|
|
472
|
+
onClose,
|
|
473
|
+
bottomInset,
|
|
474
|
+
children,
|
|
475
|
+
}: {
|
|
476
|
+
title: ReactNode;
|
|
477
|
+
onClose: () => void;
|
|
478
|
+
bottomInset: number;
|
|
479
|
+
children: ReactNode;
|
|
480
|
+
}) {
|
|
481
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
482
|
+
const dragRef = useRef<{ startY: number; startT: number; startHeight: number; active: boolean }>({
|
|
483
|
+
startY: 0,
|
|
484
|
+
startT: 0,
|
|
485
|
+
startHeight: 0,
|
|
486
|
+
active: false,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const SPRING = 'height 220ms cubic-bezier(0.2, 0, 0, 1)';
|
|
490
|
+
|
|
491
|
+
const getSnapPoints = useCallback(() => {
|
|
492
|
+
const h = window.visualViewport?.height ?? window.innerHeight;
|
|
493
|
+
return {
|
|
494
|
+
collapsed: 0,
|
|
495
|
+
defaultH: Math.round(h * 0.6),
|
|
496
|
+
expanded: Math.round(h * 0.92),
|
|
497
|
+
};
|
|
498
|
+
}, []);
|
|
499
|
+
|
|
500
|
+
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
501
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
502
|
+
const sheet = sheetRef.current;
|
|
503
|
+
if (!sheet) return;
|
|
504
|
+
dragRef.current = {
|
|
505
|
+
startY: e.clientY,
|
|
506
|
+
startT: performance.now(),
|
|
507
|
+
startHeight: sheet.getBoundingClientRect().height,
|
|
508
|
+
active: true,
|
|
509
|
+
};
|
|
510
|
+
sheet.style.transition = 'none';
|
|
511
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
512
|
+
}, []);
|
|
513
|
+
|
|
514
|
+
const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
515
|
+
const sheet = sheetRef.current;
|
|
516
|
+
if (!dragRef.current.active || !sheet) return;
|
|
517
|
+
const dy = e.clientY - dragRef.current.startY;
|
|
518
|
+
const { expanded } = getSnapPoints();
|
|
519
|
+
const newHeight = Math.max(0, Math.min(expanded, dragRef.current.startHeight - dy));
|
|
520
|
+
sheet.style.height = `${newHeight}px`;
|
|
521
|
+
}, [getSnapPoints]);
|
|
522
|
+
|
|
523
|
+
const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
524
|
+
const sheet = sheetRef.current;
|
|
525
|
+
if (!dragRef.current.active || !sheet) return;
|
|
526
|
+
dragRef.current.active = false;
|
|
527
|
+
const dy = e.clientY - dragRef.current.startY;
|
|
528
|
+
const dt = Math.max(1, performance.now() - dragRef.current.startT);
|
|
529
|
+
// Positive velocity = upward drag (intent: enlarge).
|
|
530
|
+
const upwardVelocity = -dy / dt; // px/ms
|
|
531
|
+
const { collapsed, defaultH, expanded } = getSnapPoints();
|
|
532
|
+
const currentHeight = sheet.getBoundingClientRect().height;
|
|
533
|
+
|
|
534
|
+
sheet.style.transition = SPRING;
|
|
535
|
+
|
|
536
|
+
const snapTo = (h: number) => {
|
|
537
|
+
sheet.style.height = `${h}px`;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// Velocity-driven decisions take precedence over position.
|
|
541
|
+
if (upwardVelocity > 0.5) {
|
|
542
|
+
snapTo(expanded);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (upwardVelocity < -0.5) {
|
|
546
|
+
// Downward flick: from expanded → default, from default → dismiss.
|
|
547
|
+
if (dragRef.current.startHeight >= expanded - 8) {
|
|
548
|
+
snapTo(defaultH);
|
|
549
|
+
} else {
|
|
550
|
+
snapTo(collapsed);
|
|
551
|
+
window.setTimeout(onClose, 200);
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Position-based snap: closest of the three targets.
|
|
557
|
+
const targets: Array<{ state: 'collapsed' | 'default' | 'expanded'; h: number }> = [
|
|
558
|
+
{ state: 'collapsed', h: collapsed },
|
|
559
|
+
{ state: 'default', h: defaultH },
|
|
560
|
+
{ state: 'expanded', h: expanded },
|
|
561
|
+
];
|
|
562
|
+
let closest = targets[1];
|
|
563
|
+
for (const t of targets) {
|
|
564
|
+
if (Math.abs(currentHeight - t.h) < Math.abs(currentHeight - closest.h)) closest = t;
|
|
565
|
+
}
|
|
566
|
+
snapTo(closest.h);
|
|
567
|
+
if (closest.state === 'collapsed') window.setTimeout(onClose, 200);
|
|
568
|
+
}, [getSnapPoints, onClose]);
|
|
569
|
+
|
|
570
|
+
// Initial height = default snap. Recompute when viewport changes (URL bar collapses).
|
|
571
|
+
useEffect(() => {
|
|
572
|
+
const sheet = sheetRef.current;
|
|
573
|
+
if (!sheet) return;
|
|
574
|
+
const { defaultH } = getSnapPoints();
|
|
575
|
+
sheet.style.height = `${defaultH}px`;
|
|
576
|
+
}, [getSnapPoints]);
|
|
577
|
+
|
|
578
|
+
return (
|
|
579
|
+
<div
|
|
580
|
+
ref={sheetRef}
|
|
581
|
+
className="absolute inset-x-0 flex flex-col bg-background border-t rounded-t-2xl shadow-2xl z-40 animate-in slide-in-from-bottom duration-300"
|
|
582
|
+
style={{ bottom: `${bottomInset}px` }}
|
|
583
|
+
>
|
|
584
|
+
{/* Drag affordance — generously sized for touch */}
|
|
585
|
+
<div
|
|
586
|
+
className="grid place-items-center pt-3 pb-2 cursor-grab active:cursor-grabbing touch-none select-none"
|
|
587
|
+
onPointerDown={onPointerDown}
|
|
588
|
+
onPointerMove={onPointerMove}
|
|
589
|
+
onPointerUp={onPointerUp}
|
|
590
|
+
onPointerCancel={onPointerUp}
|
|
591
|
+
role="button"
|
|
592
|
+
aria-label="Drag to resize or dismiss"
|
|
593
|
+
>
|
|
594
|
+
<div className="w-10 h-1.5 rounded-full bg-muted-foreground/40" />
|
|
595
|
+
</div>
|
|
596
|
+
<div className="flex items-center justify-between px-4 pb-2 shrink-0">
|
|
597
|
+
<span className="font-semibold text-sm">{title}</span>
|
|
598
|
+
<button
|
|
599
|
+
className="p-2 -mr-2 hover:bg-muted rounded-full active:bg-muted/80 touch-manipulation"
|
|
600
|
+
onClick={onClose}
|
|
601
|
+
aria-label="Close"
|
|
602
|
+
>
|
|
603
|
+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
604
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
605
|
+
</svg>
|
|
606
|
+
</button>
|
|
607
|
+
</div>
|
|
608
|
+
<div className="flex-1 min-h-0 overflow-auto overscroll-contain border-t">
|
|
609
|
+
{children}
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|