@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,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
+ }