@ifc-lite/viewer 1.17.6 → 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.
- package/.turbo/turbo-build.log +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +69 -10
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +70 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -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">"{query}"</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
|
+
}
|