@ifc-lite/viewer 1.0.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.
Files changed (52) hide show
  1. package/LICENSE +373 -0
  2. package/components.json +22 -0
  3. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  4. package/dist/assets/geometry.worker-DpnHtNr3.ts +157 -0
  5. package/dist/assets/ifc_lite_wasm_bg-Cd3m3f2h.wasm +0 -0
  6. package/dist/assets/index-DKe9Oy-s.css +1 -0
  7. package/dist/assets/index-Dzz3WVwq.js +637 -0
  8. package/dist/ifc_lite_wasm_bg.wasm +0 -0
  9. package/dist/index.html +13 -0
  10. package/dist/web-ifc.wasm +0 -0
  11. package/index.html +12 -0
  12. package/package.json +52 -0
  13. package/postcss.config.js +6 -0
  14. package/public/ifc_lite_wasm_bg.wasm +0 -0
  15. package/public/web-ifc.wasm +0 -0
  16. package/src/App.tsx +13 -0
  17. package/src/components/Viewport.tsx +723 -0
  18. package/src/components/ui/button.tsx +58 -0
  19. package/src/components/ui/collapsible.tsx +11 -0
  20. package/src/components/ui/context-menu.tsx +174 -0
  21. package/src/components/ui/dropdown-menu.tsx +175 -0
  22. package/src/components/ui/input.tsx +49 -0
  23. package/src/components/ui/progress.tsx +26 -0
  24. package/src/components/ui/scroll-area.tsx +47 -0
  25. package/src/components/ui/separator.tsx +27 -0
  26. package/src/components/ui/tabs.tsx +56 -0
  27. package/src/components/ui/tooltip.tsx +31 -0
  28. package/src/components/viewer/AxisHelper.tsx +125 -0
  29. package/src/components/viewer/BoxSelectionOverlay.tsx +53 -0
  30. package/src/components/viewer/EntityContextMenu.tsx +220 -0
  31. package/src/components/viewer/HierarchyPanel.tsx +363 -0
  32. package/src/components/viewer/HoverTooltip.tsx +82 -0
  33. package/src/components/viewer/KeyboardShortcutsDialog.tsx +104 -0
  34. package/src/components/viewer/MainToolbar.tsx +441 -0
  35. package/src/components/viewer/PropertiesPanel.tsx +288 -0
  36. package/src/components/viewer/StatusBar.tsx +141 -0
  37. package/src/components/viewer/ToolOverlays.tsx +311 -0
  38. package/src/components/viewer/ViewCube.tsx +195 -0
  39. package/src/components/viewer/ViewerLayout.tsx +190 -0
  40. package/src/components/viewer/Viewport.tsx +1136 -0
  41. package/src/components/viewer/ViewportContainer.tsx +49 -0
  42. package/src/components/viewer/ViewportOverlays.tsx +185 -0
  43. package/src/hooks/useIfc.ts +168 -0
  44. package/src/hooks/useKeyboardShortcuts.ts +142 -0
  45. package/src/index.css +177 -0
  46. package/src/lib/utils.ts +45 -0
  47. package/src/main.tsx +18 -0
  48. package/src/store.ts +471 -0
  49. package/src/webgpu-types.d.ts +20 -0
  50. package/tailwind.config.js +72 -0
  51. package/tsconfig.json +16 -0
  52. package/vite.config.ts +45 -0
@@ -0,0 +1,311 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Tool-specific overlays for measure and section tools
7
+ */
8
+
9
+ import { useCallback } from 'react';
10
+ import { X, Trash2, Ruler, Slice } from 'lucide-react';
11
+ import { Button } from '@/components/ui/button';
12
+ import { useViewerStore, type Measurement } from '@/store';
13
+
14
+ export function ToolOverlays() {
15
+ const activeTool = useViewerStore((s) => s.activeTool);
16
+
17
+ if (activeTool === 'measure') {
18
+ return <MeasureOverlay />;
19
+ }
20
+
21
+ if (activeTool === 'section') {
22
+ return <SectionOverlay />;
23
+ }
24
+
25
+ return null;
26
+ }
27
+
28
+ function MeasureOverlay() {
29
+ const measurements = useViewerStore((s) => s.measurements);
30
+ const pendingMeasurePoint = useViewerStore((s) => s.pendingMeasurePoint);
31
+ const deleteMeasurement = useViewerStore((s) => s.deleteMeasurement);
32
+ const clearMeasurements = useViewerStore((s) => s.clearMeasurements);
33
+ const setActiveTool = useViewerStore((s) => s.setActiveTool);
34
+
35
+ const handleClear = useCallback(() => {
36
+ clearMeasurements();
37
+ }, [clearMeasurements]);
38
+
39
+ const handleClose = useCallback(() => {
40
+ setActiveTool('select');
41
+ }, [setActiveTool]);
42
+
43
+ const handleDeleteMeasurement = useCallback((id: string) => {
44
+ deleteMeasurement(id);
45
+ }, [deleteMeasurement]);
46
+
47
+ // Calculate total distance
48
+ const totalDistance = measurements.reduce((sum, m) => sum + m.distance, 0);
49
+
50
+ return (
51
+ <>
52
+ {/* Measure Tool Panel */}
53
+ <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg p-3 min-w-64 z-30">
54
+ <div className="flex items-center justify-between gap-4 mb-3">
55
+ <div className="flex items-center gap-2">
56
+ <Ruler className="h-4 w-4 text-primary" />
57
+ <span className="font-medium text-sm">Measure Tool</span>
58
+ </div>
59
+ <div className="flex items-center gap-1">
60
+ <Button variant="ghost" size="icon-sm" onClick={handleClear} title="Clear all">
61
+ <Trash2 className="h-4 w-4" />
62
+ </Button>
63
+ <Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
64
+ <X className="h-4 w-4" />
65
+ </Button>
66
+ </div>
67
+ </div>
68
+
69
+ <div className="text-xs text-muted-foreground mb-3">
70
+ Click on the model to place measurement points
71
+ </div>
72
+
73
+ {measurements.length > 0 ? (
74
+ <div className="space-y-2">
75
+ {measurements.map((m, i) => (
76
+ <MeasurementItem
77
+ key={m.id}
78
+ measurement={m}
79
+ index={i}
80
+ onDelete={handleDeleteMeasurement}
81
+ />
82
+ ))}
83
+ {measurements.length > 1 && (
84
+ <div className="flex items-center justify-between border-t pt-2 mt-2 text-sm font-medium">
85
+ <span>Total</span>
86
+ <span className="font-mono">{formatDistance(totalDistance)}</span>
87
+ </div>
88
+ )}
89
+ </div>
90
+ ) : (
91
+ <div className="text-center py-4 text-muted-foreground text-sm">
92
+ No measurements yet
93
+ </div>
94
+ )}
95
+ </div>
96
+
97
+ {/* Instruction hint */}
98
+ <div className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-4 py-2 rounded-full text-sm shadow-lg z-30">
99
+ {pendingMeasurePoint
100
+ ? 'Click to set end point'
101
+ : 'Click on model to set start point'}
102
+ </div>
103
+
104
+ {/* Render measurement lines and labels */}
105
+ <MeasurementOverlays measurements={measurements} pending={pendingMeasurePoint} />
106
+ </>
107
+ );
108
+ }
109
+
110
+ interface MeasurementItemProps {
111
+ measurement: Measurement;
112
+ index: number;
113
+ onDelete: (id: string) => void;
114
+ }
115
+
116
+ function MeasurementItem({ measurement, index, onDelete }: MeasurementItemProps) {
117
+ return (
118
+ <div className="flex items-center justify-between bg-muted/50 rounded px-2 py-1 text-sm">
119
+ <span className="text-muted-foreground">#{index + 1}</span>
120
+ <span className="font-mono">{formatDistance(measurement.distance)}</span>
121
+ <Button
122
+ variant="ghost"
123
+ size="icon-sm"
124
+ className="h-5 w-5"
125
+ onClick={() => onDelete(measurement.id)}
126
+ >
127
+ <X className="h-3 w-3" />
128
+ </Button>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ interface MeasurementOverlaysProps {
134
+ measurements: Measurement[];
135
+ pending: { screenX: number; screenY: number } | null;
136
+ }
137
+
138
+ function MeasurementOverlays({ measurements, pending }: MeasurementOverlaysProps) {
139
+ return (
140
+ <>
141
+ {/* Completed measurements */}
142
+ {measurements.map((m) => (
143
+ <div key={m.id}>
144
+ {/* Line connecting start and end */}
145
+ <svg
146
+ className="absolute inset-0 pointer-events-none z-20"
147
+ style={{ overflow: 'visible' }}
148
+ >
149
+ <line
150
+ x1={m.start.screenX}
151
+ y1={m.start.screenY}
152
+ x2={m.end.screenX}
153
+ y2={m.end.screenY}
154
+ stroke="hsl(var(--primary))"
155
+ strokeWidth="2"
156
+ strokeDasharray="5,5"
157
+ />
158
+ <circle
159
+ cx={m.start.screenX}
160
+ cy={m.start.screenY}
161
+ r="4"
162
+ fill="hsl(var(--primary))"
163
+ />
164
+ <circle
165
+ cx={m.end.screenX}
166
+ cy={m.end.screenY}
167
+ r="4"
168
+ fill="hsl(var(--primary))"
169
+ />
170
+ </svg>
171
+
172
+ {/* Distance label at midpoint */}
173
+ <div
174
+ className="absolute pointer-events-none z-20 bg-primary text-primary-foreground px-2 py-0.5 rounded text-xs font-mono -translate-x-1/2 -translate-y-1/2"
175
+ style={{
176
+ left: (m.start.screenX + m.end.screenX) / 2,
177
+ top: (m.start.screenY + m.end.screenY) / 2,
178
+ }}
179
+ >
180
+ {formatDistance(m.distance)}
181
+ </div>
182
+ </div>
183
+ ))}
184
+
185
+ {/* Pending point */}
186
+ {pending && (
187
+ <svg
188
+ className="absolute inset-0 pointer-events-none z-20"
189
+ style={{ overflow: 'visible' }}
190
+ >
191
+ <circle
192
+ cx={pending.screenX}
193
+ cy={pending.screenY}
194
+ r="6"
195
+ fill="none"
196
+ stroke="hsl(var(--primary))"
197
+ strokeWidth="2"
198
+ />
199
+ <circle
200
+ cx={pending.screenX}
201
+ cy={pending.screenY}
202
+ r="3"
203
+ fill="hsl(var(--primary))"
204
+ />
205
+ </svg>
206
+ )}
207
+ </>
208
+ );
209
+ }
210
+
211
+ function SectionOverlay() {
212
+ const sectionPlane = useViewerStore((s) => s.sectionPlane);
213
+ const setSectionPlaneAxis = useViewerStore((s) => s.setSectionPlaneAxis);
214
+ const setSectionPlanePosition = useViewerStore((s) => s.setSectionPlanePosition);
215
+ const toggleSectionPlane = useViewerStore((s) => s.toggleSectionPlane);
216
+ const flipSectionPlane = useViewerStore((s) => s.flipSectionPlane);
217
+ const setActiveTool = useViewerStore((s) => s.setActiveTool);
218
+
219
+ const handleClose = useCallback(() => {
220
+ setActiveTool('select');
221
+ }, [setActiveTool]);
222
+
223
+ const handleAxisChange = useCallback((axis: 'x' | 'y' | 'z') => {
224
+ setSectionPlaneAxis(axis);
225
+ }, [setSectionPlaneAxis]);
226
+
227
+ const handlePositionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
228
+ setSectionPlanePosition(Number(e.target.value));
229
+ }, [setSectionPlanePosition]);
230
+
231
+ return (
232
+ <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg p-3 min-w-72 z-30">
233
+ <div className="flex items-center justify-between gap-4 mb-3">
234
+ <div className="flex items-center gap-2">
235
+ <Slice className="h-4 w-4 text-primary" />
236
+ <span className="font-medium text-sm">Section Plane</span>
237
+ </div>
238
+ <Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
239
+ <X className="h-4 w-4" />
240
+ </Button>
241
+ </div>
242
+
243
+ <div className="space-y-4">
244
+ {/* Axis Selection */}
245
+ <div>
246
+ <label className="text-xs text-muted-foreground mb-2 block">Axis</label>
247
+ <div className="flex gap-1">
248
+ {(['x', 'y', 'z'] as const).map((axis) => (
249
+ <Button
250
+ key={axis}
251
+ variant={sectionPlane.axis === axis ? 'default' : 'outline'}
252
+ size="sm"
253
+ className="flex-1"
254
+ onClick={() => handleAxisChange(axis)}
255
+ >
256
+ {axis.toUpperCase()}
257
+ </Button>
258
+ ))}
259
+ </div>
260
+ </div>
261
+
262
+ {/* Position Slider */}
263
+ <div>
264
+ <div className="flex items-center justify-between mb-2">
265
+ <label className="text-xs text-muted-foreground">Position</label>
266
+ <span className="text-xs font-mono">{sectionPlane.position}%</span>
267
+ </div>
268
+ <input
269
+ type="range"
270
+ min="0"
271
+ max="100"
272
+ value={sectionPlane.position}
273
+ onChange={handlePositionChange}
274
+ className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
275
+ />
276
+ </div>
277
+
278
+ {/* Actions */}
279
+ <div className="flex gap-2">
280
+ <Button
281
+ variant={sectionPlane.enabled ? 'default' : 'outline'}
282
+ size="sm"
283
+ className="flex-1"
284
+ onClick={toggleSectionPlane}
285
+ >
286
+ {sectionPlane.enabled ? 'Disable' : 'Enable'}
287
+ </Button>
288
+ <Button variant="outline" size="sm" className="flex-1" onClick={flipSectionPlane}>
289
+ Flip
290
+ </Button>
291
+ </div>
292
+
293
+ <div className="text-xs text-muted-foreground text-center">
294
+ Section plane cuts the model along the selected axis
295
+ </div>
296
+ </div>
297
+ </div>
298
+ );
299
+ }
300
+
301
+ function formatDistance(meters: number): string {
302
+ if (meters < 0.01) {
303
+ return `${(meters * 1000).toFixed(1)} mm`;
304
+ } else if (meters < 1) {
305
+ return `${(meters * 100).toFixed(1)} cm`;
306
+ } else if (meters < 1000) {
307
+ return `${meters.toFixed(2)} m`;
308
+ } else {
309
+ return `${(meters / 1000).toFixed(2)} km`;
310
+ }
311
+ }
@@ -0,0 +1,195 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ interface ViewCubeProps {
9
+ onViewChange?: (view: string) => void;
10
+ onDrag?: (deltaX: number, deltaY: number) => void;
11
+ rotationX?: number;
12
+ rotationY?: number;
13
+ }
14
+
15
+ export interface ViewCubeRef {
16
+ updateRotation: (x: number, y: number) => void;
17
+ }
18
+
19
+ const FACE_VIEWS: Record<string, { rx: number; ry: number }> = {
20
+ front: { rx: 0, ry: 0 },
21
+ back: { rx: 0, ry: 180 },
22
+ top: { rx: -90, ry: 0 },
23
+ bottom: { rx: 90, ry: 0 },
24
+ right: { rx: 0, ry: -90 },
25
+ left: { rx: 0, ry: 90 },
26
+ };
27
+
28
+ const FACES = [
29
+ { id: 'front', label: 'FRONT', transform: (h: number) => `translateZ(${h}px)` },
30
+ { id: 'back', label: 'BACK', transform: (h: number) => `translateZ(${-h}px) rotateY(180deg)` },
31
+ { id: 'top', label: 'TOP', transform: (h: number) => `translateY(${-h}px) rotateX(90deg)` },
32
+ { id: 'bottom', label: 'BTM', transform: (h: number) => `translateY(${h}px) rotateX(-90deg)` },
33
+ { id: 'right', label: 'RIGHT', transform: (h: number) => `translateX(${h}px) rotateY(90deg)` },
34
+ { id: 'left', label: 'LEFT', transform: (h: number) => `translateX(${-h}px) rotateY(-90deg)` },
35
+ ];
36
+
37
+ export const ViewCube = forwardRef<ViewCubeRef, ViewCubeProps>(
38
+ ({ onViewChange, onDrag, rotationX = -25, rotationY = 45 }, ref) => {
39
+ const [hovered, setHovered] = useState<string | null>(null);
40
+ const [isMouseDown, setIsMouseDown] = useState(false);
41
+ const dragStartRef = useRef<{ x: number; y: number } | null>(null);
42
+ const didDragRef = useRef(false);
43
+ const isDraggingRef = useRef(false);
44
+ const onDragRef = useRef(onDrag);
45
+ const rotationContainerRef = useRef<HTMLDivElement>(null);
46
+ const rafRef = useRef<number | null>(null);
47
+ const pendingRotationRef = useRef<{ x: number; y: number } | null>(null);
48
+
49
+ // Keep onDrag ref up to date
50
+ useEffect(() => {
51
+ onDragRef.current = onDrag;
52
+ }, [onDrag]);
53
+
54
+ // Expose updateRotation method via ref for direct updates (no React re-renders)
55
+ useImperativeHandle(ref, () => ({
56
+ updateRotation: (x: number, y: number) => {
57
+ if (!rotationContainerRef.current) return;
58
+
59
+ // Store pending rotation
60
+ pendingRotationRef.current = { x, y };
61
+
62
+ // Cancel any pending animation frame
63
+ if (rafRef.current !== null) {
64
+ cancelAnimationFrame(rafRef.current);
65
+ }
66
+
67
+ // Batch updates via requestAnimationFrame for smooth 60fps
68
+ rafRef.current = requestAnimationFrame(() => {
69
+ if (rotationContainerRef.current && pendingRotationRef.current) {
70
+ rotationContainerRef.current.style.transform = `rotateX(${pendingRotationRef.current.x}deg) rotateY(${pendingRotationRef.current.y}deg)`;
71
+ pendingRotationRef.current = null;
72
+ }
73
+ rafRef.current = null;
74
+ });
75
+ },
76
+ }), []);
77
+
78
+ // Initial rotation from props (only on mount)
79
+ useEffect(() => {
80
+ if (rotationContainerRef.current) {
81
+ rotationContainerRef.current.style.transform = `rotateX(${rotationX}deg) rotateY(${rotationY}deg)`;
82
+ }
83
+ }, []); // Empty deps - only set initial rotation
84
+
85
+ const size = 60;
86
+ const half = size / 2;
87
+
88
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
89
+ // Track mouse position for potential drag
90
+ dragStartRef.current = { x: e.clientX, y: e.clientY };
91
+ didDragRef.current = false;
92
+ isDraggingRef.current = false;
93
+ setIsMouseDown(true);
94
+ }, []);
95
+
96
+ // Document-level mouse handlers
97
+ useEffect(() => {
98
+ if (!isMouseDown) {
99
+ document.body.style.cursor = '';
100
+ return;
101
+ }
102
+
103
+ const handleDocumentMouseMove = (e: MouseEvent) => {
104
+ if (!dragStartRef.current) return;
105
+
106
+ const deltaX = e.clientX - dragStartRef.current.x;
107
+ const deltaY = e.clientY - dragStartRef.current.y;
108
+
109
+ // Start dragging after threshold (distinguishes from clicks)
110
+ if (!isDraggingRef.current && (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5)) {
111
+ isDraggingRef.current = true;
112
+ didDragRef.current = true;
113
+ document.body.style.cursor = 'grabbing';
114
+ }
115
+
116
+ if (isDraggingRef.current) {
117
+ onDragRef.current?.(deltaX * 2, deltaY * 2);
118
+ dragStartRef.current = { x: e.clientX, y: e.clientY };
119
+ }
120
+ };
121
+
122
+ const handleDocumentMouseUp = () => {
123
+ setIsMouseDown(false);
124
+ isDraggingRef.current = false;
125
+ dragStartRef.current = null;
126
+ document.body.style.cursor = '';
127
+ // Reset didDragRef after a brief delay to allow click to check it
128
+ setTimeout(() => {
129
+ didDragRef.current = false;
130
+ }, 50);
131
+ };
132
+
133
+ document.addEventListener('mousemove', handleDocumentMouseMove);
134
+ document.addEventListener('mouseup', handleDocumentMouseUp);
135
+
136
+ return () => {
137
+ document.removeEventListener('mousemove', handleDocumentMouseMove);
138
+ document.removeEventListener('mouseup', handleDocumentMouseUp);
139
+ document.body.style.cursor = '';
140
+ };
141
+ }, [isMouseDown]);
142
+
143
+ const handleFaceClick = useCallback((face: string) => {
144
+ // Only trigger click if we didn't drag
145
+ if (!didDragRef.current) {
146
+ onViewChange?.(face);
147
+ }
148
+ }, [onViewChange]);
149
+
150
+ return (
151
+ <div
152
+ className="relative select-none"
153
+ style={{
154
+ width: size,
155
+ height: size,
156
+ perspective: 200,
157
+ }}
158
+ onMouseDown={handleMouseDown}
159
+ >
160
+ <div
161
+ ref={rotationContainerRef}
162
+ className="relative w-full h-full"
163
+ style={{
164
+ transformStyle: 'preserve-3d',
165
+ transform: `rotateX(${rotationX}deg) rotateY(${rotationY}deg)`,
166
+ }}
167
+ >
168
+ {FACES.map(({ id, label, transform }) => (
169
+ <button
170
+ key={id}
171
+ type="button"
172
+ className={cn(
173
+ 'absolute w-full h-full flex items-center justify-center text-[10px] font-bold transition-colors cursor-pointer',
174
+ 'bg-card/95 border border-border/50',
175
+ hovered === id ? 'bg-primary/30 border-primary text-primary' : 'hover:bg-muted'
176
+ )}
177
+ style={{
178
+ transform: transform(half),
179
+ backfaceVisibility: 'hidden',
180
+ }}
181
+ onMouseEnter={() => setHovered(id)}
182
+ onMouseLeave={() => setHovered(null)}
183
+ onClick={() => handleFaceClick(id)}
184
+ >
185
+ {label}
186
+ </button>
187
+ ))}
188
+ </div>
189
+ </div>
190
+ );
191
+ });
192
+
193
+ ViewCube.displayName = 'ViewCube';
194
+
195
+ export { FACE_VIEWS };
@@ -0,0 +1,190 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { useEffect } from 'react';
6
+ import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels';
7
+ import { TooltipProvider } from '@/components/ui/tooltip';
8
+ import { MainToolbar } from './MainToolbar';
9
+ import { HierarchyPanel } from './HierarchyPanel';
10
+ import { PropertiesPanel } from './PropertiesPanel';
11
+ import { StatusBar } from './StatusBar';
12
+ import { ViewportContainer } from './ViewportContainer';
13
+ import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
14
+ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
15
+ import { useViewerStore } from '@/store';
16
+ import { EntityContextMenu } from './EntityContextMenu';
17
+ import { HoverTooltip } from './HoverTooltip';
18
+ import { BoxSelectionOverlay } from './BoxSelectionOverlay';
19
+
20
+ export function ViewerLayout() {
21
+ // Initialize keyboard shortcuts
22
+ useKeyboardShortcuts();
23
+ const shortcutsDialog = useKeyboardShortcutsDialog();
24
+
25
+ // Initialize theme on mount
26
+ const theme = useViewerStore((s) => s.theme);
27
+ const isMobile = useViewerStore((s) => s.isMobile);
28
+ const setIsMobile = useViewerStore((s) => s.setIsMobile);
29
+ const leftPanelCollapsed = useViewerStore((s) => s.leftPanelCollapsed);
30
+ const rightPanelCollapsed = useViewerStore((s) => s.rightPanelCollapsed);
31
+ const setLeftPanelCollapsed = useViewerStore((s) => s.setLeftPanelCollapsed);
32
+ const setRightPanelCollapsed = useViewerStore((s) => s.setRightPanelCollapsed);
33
+
34
+ // Detect mobile viewport
35
+ useEffect(() => {
36
+ const checkMobile = () => {
37
+ const mobile = window.innerWidth < 768;
38
+ setIsMobile(mobile);
39
+ // Auto-collapse panels on mobile
40
+ if (mobile) {
41
+ setLeftPanelCollapsed(true);
42
+ setRightPanelCollapsed(true);
43
+ }
44
+ };
45
+
46
+ checkMobile();
47
+ window.addEventListener('resize', checkMobile);
48
+ return () => window.removeEventListener('resize', checkMobile);
49
+ }, [setIsMobile, setLeftPanelCollapsed, setRightPanelCollapsed]);
50
+
51
+ useEffect(() => {
52
+ document.documentElement.classList.toggle('dark', theme === 'dark');
53
+ }, [theme]);
54
+
55
+ return (
56
+ <TooltipProvider delayDuration={300}>
57
+ <div className="flex flex-col h-screen w-screen overflow-hidden bg-background text-foreground">
58
+ {/* Keyboard Shortcuts Dialog */}
59
+ <KeyboardShortcutsDialog open={shortcutsDialog.open} onClose={shortcutsDialog.close} />
60
+
61
+ {/* Global Overlays */}
62
+ <EntityContextMenu />
63
+ <HoverTooltip />
64
+ <BoxSelectionOverlay />
65
+
66
+ {/* Main Toolbar */}
67
+ <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
68
+
69
+ {/* Main Content Area - Desktop Layout */}
70
+ {!isMobile && (
71
+ <PanelGroup orientation="horizontal" className="flex-1 min-h-0">
72
+ {/* Left Panel - Hierarchy */}
73
+ <Panel
74
+ id="left-panel"
75
+ defaultSize={20}
76
+ minSize={10}
77
+ collapsible
78
+ collapsedSize={0}
79
+ >
80
+ <div className="h-full w-full overflow-hidden">
81
+ <HierarchyPanel />
82
+ </div>
83
+ </Panel>
84
+
85
+ <PanelResizeHandle className="w-1.5 bg-border hover:bg-primary/50 active:bg-primary/70 transition-colors cursor-col-resize" />
86
+
87
+ {/* Center - Viewport */}
88
+ <Panel id="viewport-panel" defaultSize={60} minSize={30}>
89
+ <div className="h-full w-full overflow-hidden">
90
+ <ViewportContainer />
91
+ </div>
92
+ </Panel>
93
+
94
+ <PanelResizeHandle className="w-1.5 bg-border hover:bg-primary/50 active:bg-primary/70 transition-colors cursor-col-resize" />
95
+
96
+ {/* Right Panel - Properties */}
97
+ <Panel
98
+ id="right-panel"
99
+ defaultSize={20}
100
+ minSize={10}
101
+ collapsible
102
+ collapsedSize={0}
103
+ >
104
+ <div className="h-full w-full overflow-hidden">
105
+ <PropertiesPanel />
106
+ </div>
107
+ </Panel>
108
+ </PanelGroup>
109
+ )}
110
+
111
+ {/* Main Content Area - Mobile Layout */}
112
+ {isMobile && (
113
+ <div className="flex-1 min-h-0 relative">
114
+ {/* Full-screen Viewport */}
115
+ <div className="h-full w-full">
116
+ <ViewportContainer />
117
+ </div>
118
+
119
+ {/* Mobile Bottom Sheet - Hierarchy */}
120
+ {!leftPanelCollapsed && (
121
+ <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
122
+ <div className="flex items-center justify-between p-2 border-b">
123
+ <span className="font-medium text-sm">Hierarchy</span>
124
+ <button
125
+ className="p-1 hover:bg-muted rounded"
126
+ onClick={() => setLeftPanelCollapsed(true)}
127
+ >
128
+ <span className="sr-only">Close</span>
129
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
130
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
131
+ </svg>
132
+ </button>
133
+ </div>
134
+ <div className="h-[calc(50vh-48px)] overflow-auto">
135
+ <HierarchyPanel />
136
+ </div>
137
+ </div>
138
+ )}
139
+
140
+ {/* Mobile Bottom Sheet - Properties */}
141
+ {!rightPanelCollapsed && (
142
+ <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
143
+ <div className="flex items-center justify-between p-2 border-b">
144
+ <span className="font-medium text-sm">Properties</span>
145
+ <button
146
+ className="p-1 hover:bg-muted rounded"
147
+ onClick={() => setRightPanelCollapsed(true)}
148
+ >
149
+ <span className="sr-only">Close</span>
150
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
151
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
152
+ </svg>
153
+ </button>
154
+ </div>
155
+ <div className="h-[calc(50vh-48px)] overflow-auto">
156
+ <PropertiesPanel />
157
+ </div>
158
+ </div>
159
+ )}
160
+
161
+ {/* Mobile Action Buttons */}
162
+ <div className="absolute bottom-4 left-4 right-4 flex justify-center gap-2 z-30">
163
+ <button
164
+ className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
165
+ onClick={() => {
166
+ setRightPanelCollapsed(true);
167
+ setLeftPanelCollapsed(!leftPanelCollapsed);
168
+ }}
169
+ >
170
+ Hierarchy
171
+ </button>
172
+ <button
173
+ className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
174
+ onClick={() => {
175
+ setLeftPanelCollapsed(true);
176
+ setRightPanelCollapsed(!rightPanelCollapsed);
177
+ }}
178
+ >
179
+ Properties
180
+ </button>
181
+ </div>
182
+ </div>
183
+ )}
184
+
185
+ {/* Status Bar */}
186
+ <StatusBar />
187
+ </div>
188
+ </TooltipProvider>
189
+ );
190
+ }