@ifc-lite/viewer 1.17.4 → 1.18.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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,669 @@
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
+ * SearchInline — always-visible search field in the MainToolbar.
7
+ *
8
+ * P0: Tier-0 linear scan over cached EntityTable columns.
9
+ * P1: Tier-1 per-model inverted token index, built post-load.
10
+ * P2: Vim-style n/N cycle after Enter-commit, plus recent-search MRU
11
+ * surfaced in the popover when the field is focused with empty query.
12
+ *
13
+ * Keyboard:
14
+ * • `/` or ⌘F / Ctrl+F → focus the field (focus-suppressed when an
15
+ * input/textarea/CodeMirror editor already has focus)
16
+ * • ↑ / ↓ → navigate result rows in the popover
17
+ * • Enter → select + frame the highlighted result,
18
+ * enter vim cycle mode, record recent
19
+ * • ⇧Enter → add to multi-selection (no frame, no cycle)
20
+ * • Esc → close popover; second Esc blurs the field;
21
+ * while cycling, Esc exits the cycle
22
+ * • n / N → step forward / backward through the cycle,
23
+ * framing each match (fires anywhere except
24
+ * inside other editable surfaces)
25
+ */
26
+
27
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
28
+ import { Search, Clock, X } from 'lucide-react';
29
+ import { useShallow } from 'zustand/react/shallow';
30
+ import { Input } from '@/components/ui/input';
31
+ import { useViewerStore } from '@/store';
32
+ import { toGlobalIdFromModels } from '@/store/globalId';
33
+ import { cn } from '@/lib/utils';
34
+ import { runTier0Scan, type SearchResult, type ScanModel } from '@/lib/search/tier0-scan';
35
+ import { queryTier1Indexes, type Tier1Index } from '@/lib/search/tier1-index';
36
+ import { useSearchIndex } from '@/hooks/useSearchIndex';
37
+ import {
38
+ loadRecentSearches,
39
+ pushRecentSearch,
40
+ clearRecentSearches,
41
+ } from '@/lib/search/recent-searches';
42
+
43
+ const DEBOUNCE_MS = 80;
44
+ const RESULT_LIMIT = 50;
45
+
46
+ /** True when an editable surface has focus and should swallow `/` / `n` keystrokes. */
47
+ function isEditableFocused(): boolean {
48
+ const el = document.activeElement;
49
+ if (!el) return false;
50
+ const tag = el.tagName;
51
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
52
+ if ((el as HTMLElement).isContentEditable) return true;
53
+ // CodeMirror 6 editor — its content host wears `.cm-content`.
54
+ if (el.closest?.('.cm-editor')) return true;
55
+ return false;
56
+ }
57
+
58
+ export function SearchInline() {
59
+ const inputRef = useRef<HTMLInputElement>(null);
60
+ const containerRef = useRef<HTMLDivElement>(null);
61
+ // Tracks the latest scheduled `frameSelection` timer so back-to-back
62
+ // selection changes (or unmount) don't leak orphaned timeouts. Without
63
+ // this, picking a different result inside the 50ms window — or
64
+ // unmounting the component — leaves a stale callback queued that fires
65
+ // on a now-unrelated camera state.
66
+ const frameTimerRef = useRef<number | null>(null);
67
+
68
+ const {
69
+ searchQuery,
70
+ searchOpen,
71
+ searchHighlightIndex,
72
+ searchIndexes,
73
+ searchVimCycle,
74
+ setSearchQuery,
75
+ setSearchOpen,
76
+ setSearchHighlightIndex,
77
+ closeSearch,
78
+ enterVimCycle,
79
+ exitVimCycle,
80
+ stepVimCycle,
81
+ setSearchModalOpen,
82
+ models,
83
+ setSelectedEntity,
84
+ setSelectedEntityId,
85
+ toggleEntitySelection,
86
+ cameraCallbacks,
87
+ } = useViewerStore(
88
+ useShallow((s) => ({
89
+ searchQuery: s.searchQuery,
90
+ searchOpen: s.searchOpen,
91
+ searchHighlightIndex: s.searchHighlightIndex,
92
+ searchIndexes: s.searchIndexes,
93
+ searchVimCycle: s.searchVimCycle,
94
+ setSearchQuery: s.setSearchQuery,
95
+ setSearchOpen: s.setSearchOpen,
96
+ setSearchHighlightIndex: s.setSearchHighlightIndex,
97
+ closeSearch: s.closeSearch,
98
+ enterVimCycle: s.enterVimCycle,
99
+ exitVimCycle: s.exitVimCycle,
100
+ stepVimCycle: s.stepVimCycle,
101
+ setSearchModalOpen: s.setSearchModalOpen,
102
+ models: s.models,
103
+ setSelectedEntity: s.setSelectedEntity,
104
+ setSelectedEntityId: s.setSelectedEntityId,
105
+ toggleEntitySelection: s.toggleEntitySelection,
106
+ cameraCallbacks: s.cameraCallbacks,
107
+ })),
108
+ );
109
+
110
+ // Kick off lazy Tier-1 index builds for any loaded model.
111
+ useSearchIndex();
112
+
113
+ // Recents list — loaded on mount, refreshed after each Enter commit.
114
+ const [recents, setRecents] = useState<string[]>(() => loadRecentSearches());
115
+
116
+ // Debounce the query so each keystroke doesn't trigger a 4M-entity scan.
117
+ const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
118
+ useEffect(() => {
119
+ const handle = window.setTimeout(() => setDebouncedQuery(searchQuery), DEBOUNCE_MS);
120
+ return () => window.clearTimeout(handle);
121
+ }, [searchQuery]);
122
+
123
+ // Clear any pending frame timer on unmount so a fire-and-forget
124
+ // callback can't outlive the component.
125
+ useEffect(() => () => {
126
+ if (frameTimerRef.current !== null) {
127
+ window.clearTimeout(frameTimerRef.current);
128
+ frameTimerRef.current = null;
129
+ }
130
+ }, []);
131
+
132
+ // Split models into two pools: those with a ready Tier-1 index, and
133
+ // those still relying on the Tier-0 linear scan. Recomputed only when
134
+ // either the federation or the index map changes identity.
135
+ const { tier0Models, tier1Indexes, indexingCount } = useMemo(() => {
136
+ const t0: ScanModel[] = [];
137
+ const t1: Tier1Index[] = [];
138
+ let building = 0;
139
+ for (const m of models.values()) {
140
+ if (!m.ifcDataStore) continue;
141
+ const record = searchIndexes.get(m.id);
142
+ if (record?.status === 'ready' && record.index) {
143
+ t1.push(record.index);
144
+ } else {
145
+ t0.push({ id: m.id, ifcDataStore: m.ifcDataStore });
146
+ if (record?.status === 'building') building += 1;
147
+ }
148
+ }
149
+ return { tier0Models: t0, tier1Indexes: t1, indexingCount: building };
150
+ }, [models, searchIndexes]);
151
+
152
+ /**
153
+ * Run the Tier-0/Tier-1 scan synchronously for an arbitrary query.
154
+ * Extracted from the debounced `results` memo so the Enter-commit
155
+ * path can flush against the LIVE `searchQuery` rather than the
156
+ * debounced snapshot — without it, hitting Enter inside the 80ms
157
+ * debounce window commits a hit from the previous query and records
158
+ * the wrong recent-search term, even though the input shows newer
159
+ * text (Codex P2: "Commit inline search against the current query").
160
+ */
161
+ const runScan = useCallback((q: string): SearchResult[] => {
162
+ if (!q.trim()) return [];
163
+ if (tier0Models.length === 0 && tier1Indexes.length === 0) return [];
164
+
165
+ const t1Results =
166
+ tier1Indexes.length > 0
167
+ ? queryTier1Indexes(tier1Indexes, q, { limit: RESULT_LIMIT })
168
+ : [];
169
+ const t0Results =
170
+ tier0Models.length > 0
171
+ ? runTier0Scan(tier0Models, q, { limit: RESULT_LIMIT })
172
+ : [];
173
+
174
+ if (t1Results.length === 0) return t0Results;
175
+ if (t0Results.length === 0) return t1Results;
176
+
177
+ // Merge + dedupe. Scores from Tier-0 and Tier-1 share the same ladder
178
+ // so a descending-score sort is stable between them.
179
+ const combined = [...t1Results, ...t0Results];
180
+ combined.sort((a, b) => {
181
+ if (b.score !== a.score) return b.score - a.score;
182
+ if (a.modelId !== b.modelId) return a.modelId < b.modelId ? -1 : 1;
183
+ return a.expressId - b.expressId;
184
+ });
185
+ const seen = new Set<string>();
186
+ const out: SearchResult[] = [];
187
+ for (const r of combined) {
188
+ const key = `${r.modelId}:${r.expressId}`;
189
+ if (seen.has(key)) continue;
190
+ seen.add(key);
191
+ out.push(r);
192
+ if (out.length >= RESULT_LIMIT) break;
193
+ }
194
+ return out;
195
+ }, [tier0Models, tier1Indexes]);
196
+
197
+ const results = useMemo<SearchResult[]>(
198
+ () => runScan(debouncedQuery),
199
+ [runScan, debouncedQuery],
200
+ );
201
+
202
+ // Keep the highlight index in range as results change.
203
+ useEffect(() => {
204
+ if (results.length === 0) {
205
+ if (searchHighlightIndex !== 0) setSearchHighlightIndex(0);
206
+ return;
207
+ }
208
+ if (searchHighlightIndex >= results.length) {
209
+ setSearchHighlightIndex(Math.max(0, results.length - 1));
210
+ }
211
+ }, [results, searchHighlightIndex, setSearchHighlightIndex]);
212
+
213
+ /** Apply selection + frame for a search result. Does NOT touch cycle state. */
214
+ const applySelection = useCallback(
215
+ (r: SearchResult, addToSelection: boolean) => {
216
+ const ref = { modelId: r.modelId, expressId: r.expressId };
217
+ const isLegacy = r.modelId === 'legacy' || r.modelId === '__legacy__' || models.size === 0;
218
+ const globalId = isLegacy ? r.expressId : toGlobalIdFromModels(models, r.modelId, r.expressId);
219
+
220
+ if (addToSelection) {
221
+ // Shift+Enter additive — TOGGLES rather than just adds, so a
222
+ // second Shift+Enter on the same row deselects (was: forced
223
+ // the user to clear the entire multi-selection to undo).
224
+ toggleEntitySelection(ref);
225
+ setSelectedEntityId(globalId);
226
+ return;
227
+ }
228
+
229
+ setSelectedEntityId(globalId);
230
+ setSelectedEntity(ref);
231
+ if (cameraCallbacks.frameSelection) {
232
+ if (frameTimerRef.current !== null) window.clearTimeout(frameTimerRef.current);
233
+ frameTimerRef.current = window.setTimeout(() => {
234
+ cameraCallbacks.frameSelection?.();
235
+ frameTimerRef.current = null;
236
+ }, 50);
237
+ }
238
+ },
239
+ [
240
+ cameraCallbacks,
241
+ models,
242
+ setSelectedEntity,
243
+ setSelectedEntityId,
244
+ toggleEntitySelection,
245
+ ],
246
+ );
247
+
248
+ /**
249
+ * Commit: select + frame + enter vim cycle + record recent.
250
+ *
251
+ * `overrideResults` / `overrideQuery` let the Enter-commit path
252
+ * pass freshly-scanned results from the LIVE `searchQuery` when
253
+ * the debounce hasn't settled yet — without that, the user's
254
+ * `n`/`N` cycle and the recorded recent both reflect the prior
255
+ * (debounced) query rather than what the input shows.
256
+ */
257
+ const commitResult = useCallback(
258
+ (
259
+ r: SearchResult,
260
+ index: number,
261
+ addToSelection: boolean,
262
+ overrideResults?: SearchResult[],
263
+ overrideQuery?: string,
264
+ ) => {
265
+ const cycleResults = overrideResults ?? results;
266
+ const cycleQuery = overrideQuery ?? debouncedQuery;
267
+ applySelection(r, addToSelection);
268
+ if (!addToSelection && cycleResults.length > 0) {
269
+ enterVimCycle(cycleQuery, cycleResults, index);
270
+ }
271
+ const trimmed = cycleQuery.trim();
272
+ if (trimmed) setRecents(pushRecentSearch(trimmed));
273
+ closeSearch();
274
+ },
275
+ [applySelection, closeSearch, debouncedQuery, enterVimCycle, results],
276
+ );
277
+
278
+ // Re-select + reframe when the vim cycle steps. Uses the results-array
279
+ // identity to distinguish entry (selection already done by commitResult)
280
+ // from subsequent steps (this effect drives the selection).
281
+ const handledCycleRef = useRef<{ results: SearchResult[]; index: number } | null>(null);
282
+ useEffect(() => {
283
+ if (!searchVimCycle) {
284
+ handledCycleRef.current = null;
285
+ return;
286
+ }
287
+ const last = handledCycleRef.current;
288
+ const isEntry = !last || last.results !== searchVimCycle.results;
289
+ handledCycleRef.current = {
290
+ results: searchVimCycle.results,
291
+ index: searchVimCycle.index,
292
+ };
293
+ if (isEntry) return; // selection was performed by commitResult.
294
+ const current = searchVimCycle.results[searchVimCycle.index];
295
+ if (current) applySelection(current, false);
296
+ }, [searchVimCycle, applySelection]);
297
+
298
+ /** Global `/` and ⌘F / Ctrl+F shortcuts to focus the field. */
299
+ useEffect(() => {
300
+ const handler = (e: globalThis.KeyboardEvent) => {
301
+ // ⌘F / Ctrl+F focuses regardless of what else has focus — we want
302
+ // to override the browser's native Find inside the viewer.
303
+ const isFindShortcut = (e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F') && !e.shiftKey;
304
+ if (isFindShortcut) {
305
+ e.preventDefault();
306
+ inputRef.current?.focus();
307
+ inputRef.current?.select();
308
+ setSearchOpen(true);
309
+ return;
310
+ }
311
+
312
+ // `/` only when no other input is focused — vim-style search summon.
313
+ if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey && !isEditableFocused()) {
314
+ e.preventDefault();
315
+ inputRef.current?.focus();
316
+ setSearchOpen(true);
317
+ }
318
+ };
319
+ window.addEventListener('keydown', handler);
320
+ return () => window.removeEventListener('keydown', handler);
321
+ }, [setSearchOpen]);
322
+
323
+ /** Global n / N / Esc cycle-control listener — active only while cycling. */
324
+ useEffect(() => {
325
+ if (!searchVimCycle) return;
326
+ const handler = (e: globalThis.KeyboardEvent) => {
327
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
328
+ // Don't swallow `n` / `N` when the user is typing elsewhere.
329
+ if (isEditableFocused()) return;
330
+ if (e.key === 'n') {
331
+ e.preventDefault();
332
+ stepVimCycle(1);
333
+ return;
334
+ }
335
+ if (e.key === 'N') {
336
+ e.preventDefault();
337
+ stepVimCycle(-1);
338
+ return;
339
+ }
340
+ if (e.key === 'Escape') {
341
+ e.preventDefault();
342
+ exitVimCycle();
343
+ }
344
+ };
345
+ window.addEventListener('keydown', handler);
346
+ return () => window.removeEventListener('keydown', handler);
347
+ }, [searchVimCycle, stepVimCycle, exitVimCycle]);
348
+
349
+ /** Click-outside closes the popover (but doesn't blur the field). */
350
+ useEffect(() => {
351
+ if (!searchOpen) return;
352
+ const handler = (e: MouseEvent) => {
353
+ const target = e.target as Node | null;
354
+ if (target && containerRef.current && !containerRef.current.contains(target)) {
355
+ setSearchOpen(false);
356
+ }
357
+ };
358
+ window.addEventListener('mousedown', handler);
359
+ return () => window.removeEventListener('mousedown', handler);
360
+ }, [searchOpen, setSearchOpen]);
361
+
362
+ const handleInputKeyDown = useCallback(
363
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
364
+ // Esc: first press closes popover, second blurs the field. Cycle
365
+ // exit is handled by the global listener, so we don't fight it here.
366
+ if (e.key === 'Escape') {
367
+ if (searchOpen) {
368
+ e.preventDefault();
369
+ setSearchOpen(false);
370
+ } else {
371
+ inputRef.current?.blur();
372
+ }
373
+ return;
374
+ }
375
+
376
+ if (!searchOpen && (e.key === 'ArrowDown' || e.key === 'Enter')) {
377
+ if (results.length > 0) setSearchOpen(true);
378
+ }
379
+
380
+ if (e.key === 'ArrowDown') {
381
+ e.preventDefault();
382
+ if (results.length === 0) return;
383
+ const next = (searchHighlightIndex + 1) % results.length;
384
+ setSearchHighlightIndex(next);
385
+ return;
386
+ }
387
+ if (e.key === 'ArrowUp') {
388
+ e.preventDefault();
389
+ if (results.length === 0) return;
390
+ const next = (searchHighlightIndex - 1 + results.length) % results.length;
391
+ setSearchHighlightIndex(next);
392
+ return;
393
+ }
394
+ if (e.key === 'Enter') {
395
+ e.preventDefault();
396
+ // ⌘↵ / Ctrl+↵ opens the advanced modal instead of committing — the
397
+ // inline query is preserved so the modal opens already populated.
398
+ if (e.metaKey || e.ctrlKey) {
399
+ setSearchOpen(false);
400
+ setSearchModalOpen(true);
401
+ return;
402
+ }
403
+ // Flush the debounce: if the user typed something that hasn't yet
404
+ // settled into `debouncedQuery`, re-scan synchronously against the
405
+ // LIVE `searchQuery`. The popover is showing stale results in that
406
+ // window (debounced still reflects the prior query) so committing
407
+ // `results[index]` would select the wrong entity. Match the input.
408
+ const live = searchQuery;
409
+ const useLive = live.trim() !== debouncedQuery.trim();
410
+ const liveResults = useLive ? runScan(live) : results;
411
+ if (liveResults.length === 0) return;
412
+ const idx = useLive
413
+ ? Math.min(searchHighlightIndex, liveResults.length - 1)
414
+ : searchHighlightIndex;
415
+ const target = liveResults[idx];
416
+ if (target) commitResult(target, idx, e.shiftKey, liveResults, live);
417
+ }
418
+ },
419
+ [commitResult, results, searchHighlightIndex, searchOpen, setSearchHighlightIndex, setSearchModalOpen, setSearchOpen],
420
+ );
421
+
422
+ const queryTrimmedLen = searchQuery.trim().length;
423
+ const showPopover = searchOpen && (results.length > 0 || queryTrimmedLen > 0 || recents.length > 0);
424
+ const showRecents = searchOpen && queryTrimmedLen === 0 && recents.length > 0;
425
+
426
+ return (
427
+ <div ref={containerRef} className="relative w-72">
428
+ <Input
429
+ ref={inputRef}
430
+ type="text"
431
+ placeholder="Search GUID, name, type… ( / )"
432
+ value={searchQuery}
433
+ leftIcon={<Search className="h-4 w-4" />}
434
+ onChange={(e) => {
435
+ setSearchQuery(e.target.value);
436
+ if (!searchOpen) setSearchOpen(true);
437
+ }}
438
+ onFocus={() => setSearchOpen(true)}
439
+ onKeyDown={handleInputKeyDown}
440
+ aria-label="Search entities"
441
+ aria-autocomplete="list"
442
+ aria-expanded={showPopover}
443
+ aria-controls="search-inline-popover"
444
+ />
445
+ {/* Vim cycle hint — shows below the input whenever a cycle is active
446
+ and the popover is closed. Clicking it exits the cycle. */}
447
+ {searchVimCycle && !showPopover && (
448
+ <VimCycleHint
449
+ query={searchVimCycle.query}
450
+ index={searchVimCycle.index}
451
+ total={searchVimCycle.results.length}
452
+ onExit={exitVimCycle}
453
+ />
454
+ )}
455
+ {showPopover && showRecents && (
456
+ <RecentsPopover
457
+ recents={recents}
458
+ onPick={(q) => {
459
+ setSearchQuery(q);
460
+ inputRef.current?.focus();
461
+ }}
462
+ onClear={() => {
463
+ clearRecentSearches();
464
+ setRecents([]);
465
+ }}
466
+ />
467
+ )}
468
+ {showPopover && !showRecents && (
469
+ <SearchPopover
470
+ results={results}
471
+ highlightIndex={searchHighlightIndex}
472
+ modelsCount={models.size}
473
+ indexingCount={indexingCount}
474
+ onSelect={(r, i, additive) => commitResult(r, i, additive)}
475
+ onHover={(i) => setSearchHighlightIndex(i)}
476
+ onOpenAdvanced={() => {
477
+ setSearchOpen(false);
478
+ setSearchModalOpen(true);
479
+ }}
480
+ />
481
+ )}
482
+ </div>
483
+ );
484
+ }
485
+
486
+ interface VimCycleHintProps {
487
+ query: string;
488
+ index: number;
489
+ total: number;
490
+ onExit: () => void;
491
+ }
492
+
493
+ function VimCycleHint({ query, index, total, onExit }: VimCycleHintProps) {
494
+ return (
495
+ <div
496
+ className="absolute left-0 right-0 top-full mt-1 flex items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-1.5 text-[11px] text-muted-foreground shadow-sm dark:border-zinc-800 dark:bg-zinc-950 z-40"
497
+ role="status"
498
+ aria-live="polite"
499
+ >
500
+ <span className="font-mono font-semibold text-zinc-700 dark:text-zinc-300">
501
+ {index + 1} / {total}
502
+ </span>
503
+ <span className="truncate">
504
+ <span className="opacity-70">cycling </span>
505
+ <span className="font-mono">&quot;{query}&quot;</span>
506
+ <span className="opacity-70"> — press </span>
507
+ <kbd className="rounded border border-zinc-300 bg-zinc-100 px-1 font-mono text-[10px] dark:border-zinc-700 dark:bg-zinc-900">n</kbd>
508
+ <span className="opacity-70"> / </span>
509
+ <kbd className="rounded border border-zinc-300 bg-zinc-100 px-1 font-mono text-[10px] dark:border-zinc-700 dark:bg-zinc-900">N</kbd>
510
+ </span>
511
+ <button
512
+ type="button"
513
+ className="ml-auto rounded p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800"
514
+ aria-label="Exit cycle"
515
+ onMouseDown={(e) => {
516
+ e.preventDefault();
517
+ onExit();
518
+ }}
519
+ >
520
+ <X className="h-3 w-3" />
521
+ </button>
522
+ </div>
523
+ );
524
+ }
525
+
526
+ interface RecentsPopoverProps {
527
+ recents: string[];
528
+ onPick: (query: string) => void;
529
+ onClear: () => void;
530
+ }
531
+
532
+ function RecentsPopover({ recents, onPick, onClear }: RecentsPopoverProps) {
533
+ return (
534
+ <div
535
+ id="search-inline-popover"
536
+ role="listbox"
537
+ className="absolute left-0 right-0 top-full mt-1 rounded-md border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-950 z-50"
538
+ >
539
+ <div className="flex items-center justify-between px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">
540
+ <span className="flex items-center gap-1">
541
+ <Clock className="h-3 w-3" />
542
+ Recent searches
543
+ </span>
544
+ <button
545
+ type="button"
546
+ className="text-[10px] normal-case hover:underline"
547
+ onMouseDown={(e) => {
548
+ e.preventDefault();
549
+ onClear();
550
+ }}
551
+ >
552
+ Clear
553
+ </button>
554
+ </div>
555
+ {recents.map((q) => (
556
+ <button
557
+ key={q}
558
+ type="button"
559
+ role="option"
560
+ aria-selected={false}
561
+ onMouseDown={(e) => {
562
+ e.preventDefault();
563
+ onPick(q);
564
+ }}
565
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-zinc-50 dark:hover:bg-zinc-900"
566
+ >
567
+ <Search className="h-3 w-3 text-muted-foreground" />
568
+ <span className="truncate font-mono">{q}</span>
569
+ </button>
570
+ ))}
571
+ </div>
572
+ );
573
+ }
574
+
575
+ interface SearchPopoverProps {
576
+ results: SearchResult[];
577
+ highlightIndex: number;
578
+ modelsCount: number;
579
+ indexingCount: number;
580
+ onSelect: (r: SearchResult, index: number, additive: boolean) => void;
581
+ onHover: (index: number) => void;
582
+ onOpenAdvanced: () => void;
583
+ }
584
+
585
+ function SearchPopover({
586
+ results,
587
+ highlightIndex,
588
+ modelsCount,
589
+ indexingCount,
590
+ onSelect,
591
+ onHover,
592
+ onOpenAdvanced,
593
+ }: SearchPopoverProps) {
594
+ if (results.length === 0) {
595
+ return (
596
+ <div
597
+ id="search-inline-popover"
598
+ role="listbox"
599
+ className="absolute left-0 right-0 top-full mt-1 rounded-md border border-zinc-200 bg-white px-3 py-4 text-xs text-muted-foreground shadow-lg dark:border-zinc-800 dark:bg-zinc-950 z-50"
600
+ >
601
+ {indexingCount > 0
602
+ ? `Indexing ${indexingCount} model${indexingCount === 1 ? '' : 's'}… results appear as rows become searchable.`
603
+ : 'No results — try a name, IFC type, or full GlobalId.'}
604
+ </div>
605
+ );
606
+ }
607
+
608
+ return (
609
+ <div
610
+ id="search-inline-popover"
611
+ role="listbox"
612
+ className="absolute left-0 right-0 top-full mt-1 max-h-96 overflow-y-auto rounded-md border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-950 z-50"
613
+ >
614
+ {results.map((r, i) => (
615
+ <button
616
+ key={`${r.modelId}:${r.expressId}`}
617
+ type="button"
618
+ role="option"
619
+ aria-selected={i === highlightIndex}
620
+ onMouseEnter={() => onHover(i)}
621
+ onMouseDown={(e) => {
622
+ // mousedown so the input doesn't blur first and tear down the popover.
623
+ e.preventDefault();
624
+ onSelect(r, i, e.shiftKey);
625
+ }}
626
+ className={cn(
627
+ 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
628
+ i === highlightIndex
629
+ ? 'bg-zinc-100 dark:bg-zinc-800'
630
+ : 'hover:bg-zinc-50 dark:hover:bg-zinc-900',
631
+ )}
632
+ >
633
+ <span className="shrink-0 rounded bg-zinc-200 px-1.5 py-0.5 font-mono text-[10px] uppercase text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
634
+ {r.typeName}
635
+ </span>
636
+ <span className="min-w-0 flex-1 truncate font-medium">
637
+ {r.name || <span className="italic text-muted-foreground">unnamed</span>}
638
+ </span>
639
+ {r.globalId && (
640
+ <span className="shrink-0 font-mono text-[10px] text-muted-foreground">
641
+ {r.globalId.slice(0, 8)}…
642
+ </span>
643
+ )}
644
+ {modelsCount > 1 && (
645
+ <span className="shrink-0 rounded border border-zinc-300 px-1 py-0.5 text-[10px] text-muted-foreground dark:border-zinc-700">
646
+ {r.modelId.slice(0, 6)}
647
+ </span>
648
+ )}
649
+ </button>
650
+ ))}
651
+ <div className="flex items-center gap-2 border-t border-zinc-200 px-3 py-1 text-[10px] text-muted-foreground dark:border-zinc-800">
652
+ <span>
653
+ {results.length} result{results.length === 1 ? '' : 's'} · ↑↓ · ↵ · ⇧↵ · Esc
654
+ {indexingCount > 0 && <span className="ml-2 opacity-80">· indexing {indexingCount}…</span>}
655
+ </span>
656
+ <button
657
+ type="button"
658
+ className="ml-auto hover:underline"
659
+ onMouseDown={(e) => {
660
+ e.preventDefault();
661
+ onOpenAdvanced();
662
+ }}
663
+ >
664
+ Advanced <kbd className="ml-0.5 rounded border border-zinc-300 bg-zinc-100 px-1 font-mono text-[9px] dark:border-zinc-700 dark:bg-zinc-900">⌘↵</kbd>
665
+ </button>
666
+ </div>
667
+ </div>
668
+ );
669
+ }