@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.
- package/LICENSE +373 -0
- package/components.json +22 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/geometry.worker-DpnHtNr3.ts +157 -0
- package/dist/assets/ifc_lite_wasm_bg-Cd3m3f2h.wasm +0 -0
- package/dist/assets/index-DKe9Oy-s.css +1 -0
- package/dist/assets/index-Dzz3WVwq.js +637 -0
- package/dist/ifc_lite_wasm_bg.wasm +0 -0
- package/dist/index.html +13 -0
- package/dist/web-ifc.wasm +0 -0
- package/index.html +12 -0
- package/package.json +52 -0
- package/postcss.config.js +6 -0
- package/public/ifc_lite_wasm_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/App.tsx +13 -0
- package/src/components/Viewport.tsx +723 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/context-menu.tsx +174 -0
- package/src/components/ui/dropdown-menu.tsx +175 -0
- package/src/components/ui/input.tsx +49 -0
- package/src/components/ui/progress.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +47 -0
- package/src/components/ui/separator.tsx +27 -0
- package/src/components/ui/tabs.tsx +56 -0
- package/src/components/ui/tooltip.tsx +31 -0
- package/src/components/viewer/AxisHelper.tsx +125 -0
- package/src/components/viewer/BoxSelectionOverlay.tsx +53 -0
- package/src/components/viewer/EntityContextMenu.tsx +220 -0
- package/src/components/viewer/HierarchyPanel.tsx +363 -0
- package/src/components/viewer/HoverTooltip.tsx +82 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +104 -0
- package/src/components/viewer/MainToolbar.tsx +441 -0
- package/src/components/viewer/PropertiesPanel.tsx +288 -0
- package/src/components/viewer/StatusBar.tsx +141 -0
- package/src/components/viewer/ToolOverlays.tsx +311 -0
- package/src/components/viewer/ViewCube.tsx +195 -0
- package/src/components/viewer/ViewerLayout.tsx +190 -0
- package/src/components/viewer/Viewport.tsx +1136 -0
- package/src/components/viewer/ViewportContainer.tsx +49 -0
- package/src/components/viewer/ViewportOverlays.tsx +185 -0
- package/src/hooks/useIfc.ts +168 -0
- package/src/hooks/useKeyboardShortcuts.ts +142 -0
- package/src/index.css +177 -0
- package/src/lib/utils.ts +45 -0
- package/src/main.tsx +18 -0
- package/src/store.ts +471 -0
- package/src/webgpu-types.d.ts +20 -0
- package/tailwind.config.js +72 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +45 -0
|
@@ -0,0 +1,363 @@
|
|
|
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 { useMemo, useState, useCallback, useRef } from 'react';
|
|
6
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
7
|
+
import {
|
|
8
|
+
Search,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
Building2,
|
|
11
|
+
Layers,
|
|
12
|
+
MapPin,
|
|
13
|
+
FolderKanban,
|
|
14
|
+
Square,
|
|
15
|
+
Box,
|
|
16
|
+
DoorOpen,
|
|
17
|
+
Eye,
|
|
18
|
+
EyeOff,
|
|
19
|
+
} from 'lucide-react';
|
|
20
|
+
import { Input } from '@/components/ui/input';
|
|
21
|
+
import { Button } from '@/components/ui/button';
|
|
22
|
+
import { cn } from '@/lib/utils';
|
|
23
|
+
import { useViewerStore } from '@/store';
|
|
24
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
25
|
+
|
|
26
|
+
interface TreeNode {
|
|
27
|
+
id: number;
|
|
28
|
+
name: string;
|
|
29
|
+
type: string;
|
|
30
|
+
depth: number;
|
|
31
|
+
hasChildren: boolean;
|
|
32
|
+
isExpanded: boolean;
|
|
33
|
+
isVisible: boolean;
|
|
34
|
+
elementCount?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TYPE_ICONS: Record<string, React.ElementType> = {
|
|
38
|
+
IfcProject: FolderKanban,
|
|
39
|
+
IfcSite: MapPin,
|
|
40
|
+
IfcBuilding: Building2,
|
|
41
|
+
IfcBuildingStorey: Layers,
|
|
42
|
+
IfcSpace: Box,
|
|
43
|
+
IfcWall: Square,
|
|
44
|
+
IfcWallStandardCase: Square,
|
|
45
|
+
IfcDoor: DoorOpen,
|
|
46
|
+
default: Box,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function HierarchyPanel() {
|
|
50
|
+
const { ifcDataStore } = useIfc();
|
|
51
|
+
const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
|
|
52
|
+
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
53
|
+
const selectedStorey = useViewerStore((s) => s.selectedStorey);
|
|
54
|
+
const setSelectedStorey = useViewerStore((s) => s.setSelectedStorey);
|
|
55
|
+
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
56
|
+
const hideEntities = useViewerStore((s) => s.hideEntities);
|
|
57
|
+
const showEntities = useViewerStore((s) => s.showEntities);
|
|
58
|
+
const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility);
|
|
59
|
+
const isEntityVisible = useViewerStore((s) => s.isEntityVisible);
|
|
60
|
+
|
|
61
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
62
|
+
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
|
63
|
+
|
|
64
|
+
// Get storey elements mapping for visibility toggle
|
|
65
|
+
const storeyElementsMap = useMemo(() => {
|
|
66
|
+
if (!ifcDataStore?.spatialHierarchy) return new Map<number, number[]>();
|
|
67
|
+
return ifcDataStore.spatialHierarchy.byStorey;
|
|
68
|
+
}, [ifcDataStore]);
|
|
69
|
+
|
|
70
|
+
// Build spatial tree data
|
|
71
|
+
const treeData = useMemo((): TreeNode[] => {
|
|
72
|
+
if (!ifcDataStore?.spatialHierarchy) return [];
|
|
73
|
+
|
|
74
|
+
const hierarchy = ifcDataStore.spatialHierarchy;
|
|
75
|
+
const nodes: TreeNode[] = [];
|
|
76
|
+
|
|
77
|
+
// Add project
|
|
78
|
+
nodes.push({
|
|
79
|
+
id: hierarchy.project.expressId,
|
|
80
|
+
name: hierarchy.project.name || 'Project',
|
|
81
|
+
type: 'IfcProject',
|
|
82
|
+
depth: 0,
|
|
83
|
+
hasChildren: hierarchy.byStorey.size > 0,
|
|
84
|
+
isExpanded: true,
|
|
85
|
+
isVisible: true,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Add storeys sorted by elevation
|
|
89
|
+
const storeysArray = Array.from(hierarchy.byStorey.entries()) as [number, number[]][];
|
|
90
|
+
const storeys = storeysArray
|
|
91
|
+
.map(([id, elements]: [number, number[]]) => ({
|
|
92
|
+
id,
|
|
93
|
+
name: ifcDataStore.entities.getName(id) || `Storey #${id}`,
|
|
94
|
+
elevation: hierarchy.storeyElevations.get(id) ?? 0,
|
|
95
|
+
elements,
|
|
96
|
+
}))
|
|
97
|
+
.sort((a, b) => b.elevation - a.elevation);
|
|
98
|
+
|
|
99
|
+
for (const storey of storeys) {
|
|
100
|
+
const isStoreyExpanded = expandedNodes.has(storey.id);
|
|
101
|
+
|
|
102
|
+
nodes.push({
|
|
103
|
+
id: storey.id,
|
|
104
|
+
name: storey.name,
|
|
105
|
+
type: 'IfcBuildingStorey',
|
|
106
|
+
depth: 1,
|
|
107
|
+
hasChildren: storey.elements.length > 0,
|
|
108
|
+
isExpanded: isStoreyExpanded,
|
|
109
|
+
isVisible: true,
|
|
110
|
+
elementCount: storey.elements.length,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Add storey elements if expanded
|
|
114
|
+
if (isStoreyExpanded && storey.elements.length > 0) {
|
|
115
|
+
for (const elementId of storey.elements) {
|
|
116
|
+
const entityType = ifcDataStore.entities.getTypeName(elementId) || 'Unknown';
|
|
117
|
+
const entityName = ifcDataStore.entities.getName(elementId) || `${entityType} #${elementId}`;
|
|
118
|
+
|
|
119
|
+
nodes.push({
|
|
120
|
+
id: elementId,
|
|
121
|
+
name: entityName,
|
|
122
|
+
type: entityType,
|
|
123
|
+
depth: 2,
|
|
124
|
+
hasChildren: false,
|
|
125
|
+
isExpanded: false,
|
|
126
|
+
isVisible: isEntityVisible(elementId),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return nodes;
|
|
133
|
+
}, [ifcDataStore, expandedNodes, isEntityVisible, hiddenEntities]);
|
|
134
|
+
|
|
135
|
+
// Filter nodes based on search
|
|
136
|
+
const filteredNodes = useMemo(() => {
|
|
137
|
+
if (!searchQuery.trim()) return treeData;
|
|
138
|
+
const query = searchQuery.toLowerCase();
|
|
139
|
+
return treeData.filter(node =>
|
|
140
|
+
node.name.toLowerCase().includes(query) ||
|
|
141
|
+
node.type.toLowerCase().includes(query)
|
|
142
|
+
);
|
|
143
|
+
}, [treeData, searchQuery]);
|
|
144
|
+
|
|
145
|
+
const parentRef = useRef<HTMLDivElement>(null);
|
|
146
|
+
|
|
147
|
+
const virtualizer = useVirtualizer({
|
|
148
|
+
count: filteredNodes.length,
|
|
149
|
+
getScrollElement: () => parentRef.current,
|
|
150
|
+
estimateSize: () => 36,
|
|
151
|
+
overscan: 10,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const toggleExpand = useCallback((id: number) => {
|
|
155
|
+
setExpandedNodes(prev => {
|
|
156
|
+
const next = new Set(prev);
|
|
157
|
+
if (next.has(id)) {
|
|
158
|
+
next.delete(id);
|
|
159
|
+
} else {
|
|
160
|
+
next.add(id);
|
|
161
|
+
}
|
|
162
|
+
return next;
|
|
163
|
+
});
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
// Toggle visibility for a node - if storey, toggle all elements
|
|
167
|
+
const handleVisibilityToggle = useCallback((node: TreeNode) => {
|
|
168
|
+
if (node.type === 'IfcBuildingStorey') {
|
|
169
|
+
const elements = storeyElementsMap.get(node.id) || [];
|
|
170
|
+
if (elements.length === 0) return;
|
|
171
|
+
|
|
172
|
+
// Check if all elements are visible
|
|
173
|
+
const allVisible = elements.every(id => isEntityVisible(id));
|
|
174
|
+
|
|
175
|
+
if (allVisible) {
|
|
176
|
+
// Hide all elements in storey
|
|
177
|
+
hideEntities(elements);
|
|
178
|
+
} else {
|
|
179
|
+
// Show all elements in storey
|
|
180
|
+
showEntities(elements);
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// Single element toggle
|
|
184
|
+
toggleEntityVisibility(node.id);
|
|
185
|
+
}
|
|
186
|
+
}, [storeyElementsMap, isEntityVisible, hideEntities, showEntities, toggleEntityVisibility]);
|
|
187
|
+
|
|
188
|
+
// Check if storey is fully visible (all elements visible)
|
|
189
|
+
const isStoreyVisible = useCallback((storeyId: number) => {
|
|
190
|
+
const elements = storeyElementsMap.get(storeyId) || [];
|
|
191
|
+
if (elements.length === 0) return true;
|
|
192
|
+
return elements.every(id => isEntityVisible(id));
|
|
193
|
+
}, [storeyElementsMap, isEntityVisible]);
|
|
194
|
+
|
|
195
|
+
const handleNodeClick = useCallback((node: TreeNode) => {
|
|
196
|
+
if (node.type === 'IfcBuildingStorey') {
|
|
197
|
+
setSelectedStorey(selectedStorey === node.id ? null : node.id);
|
|
198
|
+
} else {
|
|
199
|
+
setSelectedEntityId(node.id);
|
|
200
|
+
}
|
|
201
|
+
}, [selectedStorey, setSelectedStorey, setSelectedEntityId]);
|
|
202
|
+
|
|
203
|
+
if (!ifcDataStore) {
|
|
204
|
+
return (
|
|
205
|
+
<div className="h-full flex flex-col border-r bg-card">
|
|
206
|
+
<div className="p-3 border-b">
|
|
207
|
+
<h2 className="font-semibold text-sm">Model Hierarchy</h2>
|
|
208
|
+
</div>
|
|
209
|
+
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
|
210
|
+
Load an IFC file to view hierarchy
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="h-full flex flex-col border-r bg-card">
|
|
218
|
+
{/* Header */}
|
|
219
|
+
<div className="p-3 border-b space-y-2">
|
|
220
|
+
<h2 className="font-semibold text-sm">Model Hierarchy</h2>
|
|
221
|
+
<Input
|
|
222
|
+
placeholder="Search elements..."
|
|
223
|
+
value={searchQuery}
|
|
224
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
225
|
+
leftIcon={<Search className="h-4 w-4" />}
|
|
226
|
+
className="h-8 text-sm"
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Tree */}
|
|
231
|
+
<div ref={parentRef} className="flex-1 overflow-auto scrollbar-thin">
|
|
232
|
+
<div
|
|
233
|
+
style={{
|
|
234
|
+
height: `${virtualizer.getTotalSize()}px`,
|
|
235
|
+
width: '100%',
|
|
236
|
+
position: 'relative',
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
240
|
+
const node = filteredNodes[virtualRow.index];
|
|
241
|
+
const Icon = TYPE_ICONS[node.type] || TYPE_ICONS.default;
|
|
242
|
+
const isSelected = node.type === 'IfcBuildingStorey'
|
|
243
|
+
? selectedStorey === node.id
|
|
244
|
+
: selectedEntityId === node.id;
|
|
245
|
+
// For storeys, check if all elements are visible
|
|
246
|
+
const nodeVisible = node.type === 'IfcBuildingStorey'
|
|
247
|
+
? isStoreyVisible(node.id)
|
|
248
|
+
: isEntityVisible(node.id);
|
|
249
|
+
const nodeHidden = !nodeVisible;
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div
|
|
253
|
+
key={node.id}
|
|
254
|
+
style={{
|
|
255
|
+
position: 'absolute',
|
|
256
|
+
top: 0,
|
|
257
|
+
left: 0,
|
|
258
|
+
width: '100%',
|
|
259
|
+
height: `${virtualRow.size}px`,
|
|
260
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
<div
|
|
264
|
+
className={cn(
|
|
265
|
+
'flex items-center gap-1 px-2 py-1.5 cursor-pointer hover:bg-muted/50 border-l-2 border-transparent transition-colors group',
|
|
266
|
+
isSelected && 'bg-primary/10 border-l-primary',
|
|
267
|
+
nodeHidden && 'opacity-50'
|
|
268
|
+
)}
|
|
269
|
+
style={{ paddingLeft: `${node.depth * 16 + 8}px` }}
|
|
270
|
+
onClick={(e) => {
|
|
271
|
+
// Only handle click if not clicking on a button
|
|
272
|
+
if ((e.target as HTMLElement).closest('button') === null) {
|
|
273
|
+
handleNodeClick(node);
|
|
274
|
+
}
|
|
275
|
+
}}
|
|
276
|
+
onMouseDown={(e) => {
|
|
277
|
+
// Prevent text selection when clicking
|
|
278
|
+
if ((e.target as HTMLElement).closest('button') === null) {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
}
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
{/* Expand/Collapse */}
|
|
284
|
+
{node.hasChildren ? (
|
|
285
|
+
<button
|
|
286
|
+
onClick={(e) => {
|
|
287
|
+
e.stopPropagation();
|
|
288
|
+
toggleExpand(node.id);
|
|
289
|
+
}}
|
|
290
|
+
className="p-0.5 hover:bg-muted rounded"
|
|
291
|
+
>
|
|
292
|
+
<ChevronRight
|
|
293
|
+
className={cn(
|
|
294
|
+
'h-3.5 w-3.5 transition-transform',
|
|
295
|
+
node.isExpanded && 'rotate-90'
|
|
296
|
+
)}
|
|
297
|
+
/>
|
|
298
|
+
</button>
|
|
299
|
+
) : (
|
|
300
|
+
<div className="w-4.5" />
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{/* Visibility Toggle */}
|
|
304
|
+
<button
|
|
305
|
+
onClick={(e) => {
|
|
306
|
+
e.stopPropagation();
|
|
307
|
+
handleVisibilityToggle(node);
|
|
308
|
+
}}
|
|
309
|
+
className={cn(
|
|
310
|
+
'p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity',
|
|
311
|
+
nodeHidden && 'opacity-100'
|
|
312
|
+
)}
|
|
313
|
+
>
|
|
314
|
+
{nodeVisible ? (
|
|
315
|
+
<Eye className="h-3 w-3 text-muted-foreground" />
|
|
316
|
+
) : (
|
|
317
|
+
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
|
318
|
+
)}
|
|
319
|
+
</button>
|
|
320
|
+
|
|
321
|
+
{/* Type Icon */}
|
|
322
|
+
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
323
|
+
|
|
324
|
+
{/* Name */}
|
|
325
|
+
<span className={cn(
|
|
326
|
+
'flex-1 text-sm truncate',
|
|
327
|
+
nodeHidden && 'line-through'
|
|
328
|
+
)}>{node.name}</span>
|
|
329
|
+
|
|
330
|
+
{/* Element Count */}
|
|
331
|
+
{node.elementCount !== undefined && (
|
|
332
|
+
<span className="text-xs text-muted-foreground">
|
|
333
|
+
{node.elementCount}
|
|
334
|
+
</span>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
})}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{/* Quick Filter */}
|
|
344
|
+
{selectedStorey && (
|
|
345
|
+
<div className="p-2 border-t bg-primary/5">
|
|
346
|
+
<div className="flex items-center justify-between text-xs">
|
|
347
|
+
<span className="text-muted-foreground">
|
|
348
|
+
Filtered to storey
|
|
349
|
+
</span>
|
|
350
|
+
<Button
|
|
351
|
+
variant="ghost"
|
|
352
|
+
size="sm"
|
|
353
|
+
className="h-6 text-xs"
|
|
354
|
+
onClick={() => setSelectedStorey(null)}
|
|
355
|
+
>
|
|
356
|
+
Clear filter
|
|
357
|
+
</Button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
* Hover tooltip showing entity info on mouseover
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import { useViewerStore } from '@/store';
|
|
11
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
12
|
+
|
|
13
|
+
// Type icons mapping
|
|
14
|
+
const TYPE_ICONS: Record<string, string> = {
|
|
15
|
+
IfcWall: '🧱',
|
|
16
|
+
IfcWallStandardCase: '🧱',
|
|
17
|
+
IfcDoor: '🚪',
|
|
18
|
+
IfcWindow: '🪟',
|
|
19
|
+
IfcSlab: '⬜',
|
|
20
|
+
IfcColumn: '🏛️',
|
|
21
|
+
IfcBeam: '➖',
|
|
22
|
+
IfcStair: '🪜',
|
|
23
|
+
IfcRailing: '🚧',
|
|
24
|
+
IfcRoof: '🏠',
|
|
25
|
+
IfcSpace: '📦',
|
|
26
|
+
IfcBuildingStorey: '🏢',
|
|
27
|
+
IfcBuilding: '🏗️',
|
|
28
|
+
IfcSite: '📍',
|
|
29
|
+
IfcProject: '📁',
|
|
30
|
+
IfcFurnishingElement: '🪑',
|
|
31
|
+
IfcFlowSegment: '〰️',
|
|
32
|
+
IfcFlowTerminal: '⚡',
|
|
33
|
+
IfcCurtainWall: '🔲',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function HoverTooltip() {
|
|
37
|
+
const hoverState = useViewerStore((s) => s.hoverState);
|
|
38
|
+
const hoverTooltipsEnabled = useViewerStore((s) => s.hoverTooltipsEnabled);
|
|
39
|
+
const { ifcDataStore } = useIfc();
|
|
40
|
+
|
|
41
|
+
const entityInfo = useMemo(() => {
|
|
42
|
+
if (!hoverState.entityId || !ifcDataStore) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const name = ifcDataStore.entities.getName(hoverState.entityId);
|
|
47
|
+
const type = ifcDataStore.entities.getTypeName(hoverState.entityId);
|
|
48
|
+
|
|
49
|
+
return { name, type };
|
|
50
|
+
}, [hoverState.entityId, ifcDataStore]);
|
|
51
|
+
|
|
52
|
+
if (!hoverTooltipsEnabled || !hoverState.entityId || !entityInfo) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const icon = TYPE_ICONS[entityInfo.type] || '📄';
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className="fixed z-40 px-3 py-2 bg-popover text-popover-foreground rounded-md shadow-lg border pointer-events-none"
|
|
61
|
+
style={{
|
|
62
|
+
left: hoverState.screenX + 16,
|
|
63
|
+
top: hoverState.screenY + 16,
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<div className="flex items-center gap-2">
|
|
67
|
+
<span className="text-base">{icon}</span>
|
|
68
|
+
<span className="font-medium text-sm">
|
|
69
|
+
{entityInfo.name || entityInfo.type}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
{entityInfo.name && (
|
|
73
|
+
<div className="text-xs text-muted-foreground mt-0.5">
|
|
74
|
+
{entityInfo.type}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
<div className="text-xs text-muted-foreground">
|
|
78
|
+
#{hoverState.entityId}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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, useEffect, useCallback } from 'react';
|
|
6
|
+
import { X } from 'lucide-react';
|
|
7
|
+
import { Button } from '@/components/ui/button';
|
|
8
|
+
import { KEYBOARD_SHORTCUTS } from '@/hooks/useKeyboardShortcuts';
|
|
9
|
+
|
|
10
|
+
interface KeyboardShortcutsDialogProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDialogProps) {
|
|
16
|
+
// Close on escape
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
19
|
+
if (e.key === 'Escape' && open) {
|
|
20
|
+
onClose();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
24
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
25
|
+
}, [open, onClose]);
|
|
26
|
+
|
|
27
|
+
if (!open) return null;
|
|
28
|
+
|
|
29
|
+
// Group shortcuts by category
|
|
30
|
+
const grouped = KEYBOARD_SHORTCUTS.reduce((acc, shortcut) => {
|
|
31
|
+
if (!acc[shortcut.category]) {
|
|
32
|
+
acc[shortcut.category] = [];
|
|
33
|
+
}
|
|
34
|
+
acc[shortcut.category].push(shortcut);
|
|
35
|
+
return acc;
|
|
36
|
+
}, {} as Record<string, typeof KEYBOARD_SHORTCUTS[number][]>);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
40
|
+
<div className="bg-card border rounded-lg shadow-xl w-full max-w-md m-4">
|
|
41
|
+
{/* Header */}
|
|
42
|
+
<div className="flex items-center justify-between p-4 border-b">
|
|
43
|
+
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
|
|
44
|
+
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
|
45
|
+
<X className="h-4 w-4" />
|
|
46
|
+
</Button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/* Content */}
|
|
50
|
+
<div className="p-4 max-h-96 overflow-y-auto">
|
|
51
|
+
{Object.entries(grouped).map(([category, shortcuts]) => (
|
|
52
|
+
<div key={category} className="mb-4 last:mb-0">
|
|
53
|
+
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
|
54
|
+
{category}
|
|
55
|
+
</h3>
|
|
56
|
+
<div className="space-y-1">
|
|
57
|
+
{shortcuts.map((shortcut) => (
|
|
58
|
+
<div
|
|
59
|
+
key={shortcut.key + shortcut.description}
|
|
60
|
+
className="flex items-center justify-between py-1"
|
|
61
|
+
>
|
|
62
|
+
<span className="text-sm">{shortcut.description}</span>
|
|
63
|
+
<kbd className="px-2 py-0.5 text-xs bg-muted rounded border font-mono">
|
|
64
|
+
{shortcut.key}
|
|
65
|
+
</kbd>
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Footer */}
|
|
74
|
+
<div className="p-4 border-t text-center">
|
|
75
|
+
<span className="text-xs text-muted-foreground">
|
|
76
|
+
Press <kbd className="px-1 py-0.5 bg-muted rounded border font-mono text-xs">?</kbd> to toggle this dialog
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Hook to manage shortcuts dialog state
|
|
85
|
+
export function useKeyboardShortcutsDialog() {
|
|
86
|
+
const [open, setOpen] = useState(false);
|
|
87
|
+
|
|
88
|
+
const toggle = useCallback(() => setOpen((o) => !o), []);
|
|
89
|
+
const close = useCallback(() => setOpen(false), []);
|
|
90
|
+
|
|
91
|
+
// Listen for '?' key to toggle
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
94
|
+
if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
toggle();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
100
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
101
|
+
}, [toggle]);
|
|
102
|
+
|
|
103
|
+
return { open, toggle, close };
|
|
104
|
+
}
|