@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,766 @@
|
|
|
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
|
+
* SearchModalFilterBuilder — chip palette over the unified
|
|
7
|
+
* `FilterRule[]`. Storey / IFC type / Predefined type / Name / Property /
|
|
8
|
+
* Quantity rules with AND/OR + IsSet/IsNotSet, schema-aware dropdowns
|
|
9
|
+
* (storeys + types load eagerly, pset/qto names lazily), and saved
|
|
10
|
+
* preset persistence.
|
|
11
|
+
*
|
|
12
|
+
* UI-only: this component owns rule editing, not run lifecycle. The
|
|
13
|
+
* parent `SearchModalFilter` reads the same slice state and triggers
|
|
14
|
+
* the path-B evaluator from a single Run button.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
18
|
+
import { Plus, Trash2, X, Bookmark, Save } from 'lucide-react';
|
|
19
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
20
|
+
import { useViewerStore } from '@/store';
|
|
21
|
+
import { Button } from '@/components/ui/button';
|
|
22
|
+
import { Input } from '@/components/ui/input';
|
|
23
|
+
import {
|
|
24
|
+
DropdownMenu,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
DropdownMenuContent,
|
|
27
|
+
DropdownMenuItem,
|
|
28
|
+
DropdownMenuSeparator,
|
|
29
|
+
DropdownMenuLabel,
|
|
30
|
+
} from '@/components/ui/dropdown-menu';
|
|
31
|
+
import { COMMON_IFC_TYPES } from '@/lib/search/common-ifc-types';
|
|
32
|
+
import {
|
|
33
|
+
Rule,
|
|
34
|
+
type FilterRule,
|
|
35
|
+
type SetOp,
|
|
36
|
+
type StringOp,
|
|
37
|
+
type ValueOp,
|
|
38
|
+
type NumericOp,
|
|
39
|
+
type Combinator,
|
|
40
|
+
} from '@/lib/search/filter-rules';
|
|
41
|
+
import {
|
|
42
|
+
discoverFilterSchema,
|
|
43
|
+
discoverPropertyAndQuantitySchema,
|
|
44
|
+
} from '@/lib/search/filter-schema';
|
|
45
|
+
import {
|
|
46
|
+
loadSavedFilters,
|
|
47
|
+
saveFilter,
|
|
48
|
+
deleteSavedFilter,
|
|
49
|
+
type SavedFilterPreset,
|
|
50
|
+
} from '@/lib/search/saved-filters';
|
|
51
|
+
|
|
52
|
+
// ── Op constants ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const SET_OPS: SetOp[] = ['in', 'notIn'];
|
|
55
|
+
const STRING_OPS: StringOp[] = ['eq', 'ne', 'contains', 'notContains', 'startsWith'];
|
|
56
|
+
const VALUE_OPS: ValueOp[] = [
|
|
57
|
+
'eq', 'ne', 'contains', 'notContains', 'gt', 'gte', 'lt', 'lte', 'isSet', 'isNotSet',
|
|
58
|
+
];
|
|
59
|
+
const NUMERIC_OPS: NumericOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
|
|
60
|
+
|
|
61
|
+
const OP_LABEL: Record<string, string> = {
|
|
62
|
+
in: 'is one of', notIn: 'is not one of',
|
|
63
|
+
eq: '=', ne: '≠',
|
|
64
|
+
contains: 'contains', notContains: 'does not contain',
|
|
65
|
+
startsWith: 'starts with',
|
|
66
|
+
gt: '>', gte: '≥', lt: '<', lte: '≤',
|
|
67
|
+
isSet: 'is set', isNotSet: 'is not set',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const RULE_KIND_LABEL: Record<FilterRule['kind'], string> = {
|
|
71
|
+
storey: 'Storey',
|
|
72
|
+
ifcType: 'IFC Type',
|
|
73
|
+
predefinedType: 'Predefined Type',
|
|
74
|
+
name: 'Name',
|
|
75
|
+
property: 'Property',
|
|
76
|
+
quantity: 'Quantity',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function SearchModalFilterBuilder() {
|
|
80
|
+
const {
|
|
81
|
+
filter,
|
|
82
|
+
schemaMap,
|
|
83
|
+
models,
|
|
84
|
+
activeModelId,
|
|
85
|
+
searchQuery,
|
|
86
|
+
setFilterCombinator,
|
|
87
|
+
setFilterLimit,
|
|
88
|
+
addFilterRule,
|
|
89
|
+
updateFilterRule,
|
|
90
|
+
removeFilterRule,
|
|
91
|
+
clearFilterRules,
|
|
92
|
+
setFilterSchema,
|
|
93
|
+
setFilterPsetQtoSchema,
|
|
94
|
+
setSearchFilter,
|
|
95
|
+
} = useViewerStore(
|
|
96
|
+
useShallow((s) => ({
|
|
97
|
+
filter: s.searchFilter,
|
|
98
|
+
schemaMap: s.searchFilterSchema,
|
|
99
|
+
models: s.models,
|
|
100
|
+
activeModelId: s.activeModelId,
|
|
101
|
+
searchQuery: s.searchQuery,
|
|
102
|
+
setFilterCombinator: s.setFilterCombinator,
|
|
103
|
+
setFilterLimit: s.setFilterLimit,
|
|
104
|
+
addFilterRule: s.addFilterRule,
|
|
105
|
+
updateFilterRule: s.updateFilterRule,
|
|
106
|
+
removeFilterRule: s.removeFilterRule,
|
|
107
|
+
clearFilterRules: s.clearFilterRules,
|
|
108
|
+
setFilterSchema: s.setFilterSchema,
|
|
109
|
+
setFilterPsetQtoSchema: s.setFilterPsetQtoSchema,
|
|
110
|
+
setSearchFilter: s.setSearchFilter,
|
|
111
|
+
})),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const [savedPresets, setSavedPresets] = useState<SavedFilterPreset[]>(() => loadSavedFilters());
|
|
115
|
+
|
|
116
|
+
const activeModel = activeModelId ? models.get(activeModelId) : undefined;
|
|
117
|
+
const activeStore = activeModel?.ifcDataStore ?? null;
|
|
118
|
+
const schemaEntry = activeModelId ? schemaMap.get(activeModelId) : undefined;
|
|
119
|
+
|
|
120
|
+
// Cheap schema discovery — runs once per active model.
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!activeModelId || !activeStore) return;
|
|
123
|
+
if (schemaMap.has(activeModelId)) return;
|
|
124
|
+
setFilterSchema(activeModelId, discoverFilterSchema(activeStore));
|
|
125
|
+
}, [activeModelId, activeStore, schemaMap, setFilterSchema]);
|
|
126
|
+
|
|
127
|
+
// Lazy pset/qto schema — fired the first time a property/quantity rule appears.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!activeModelId || !activeStore) return;
|
|
130
|
+
const entry = schemaMap.get(activeModelId);
|
|
131
|
+
if (entry?.psetQto) return;
|
|
132
|
+
const needs = filter.rules.some((r) => r.kind === 'property' || r.kind === 'quantity');
|
|
133
|
+
if (!needs) return;
|
|
134
|
+
setFilterPsetQtoSchema(activeModelId, discoverPropertyAndQuantitySchema(activeStore));
|
|
135
|
+
}, [activeModelId, activeStore, filter.rules, schemaMap, setFilterPsetQtoSchema]);
|
|
136
|
+
|
|
137
|
+
const ifcTypeOptions = useMemo<string[]>(() => {
|
|
138
|
+
if (schemaEntry?.basic.ifcTypes && schemaEntry.basic.ifcTypes.length > 0) {
|
|
139
|
+
return schemaEntry.basic.ifcTypes;
|
|
140
|
+
}
|
|
141
|
+
return COMMON_IFC_TYPES.slice();
|
|
142
|
+
}, [schemaEntry]);
|
|
143
|
+
const storeyOptions = schemaEntry?.basic.storeys ?? [];
|
|
144
|
+
|
|
145
|
+
// ── Rule construction ─────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
const addRuleOfKind = useCallback((kind: FilterRule['kind']) => {
|
|
148
|
+
let rule: FilterRule;
|
|
149
|
+
switch (kind) {
|
|
150
|
+
case 'storey': rule = Rule.storey([], 'in'); break;
|
|
151
|
+
case 'ifcType': rule = Rule.ifcType([], 'in'); break;
|
|
152
|
+
case 'predefinedType': rule = Rule.predefinedType([], 'in'); break;
|
|
153
|
+
case 'name': rule = Rule.name('contains', ''); break;
|
|
154
|
+
case 'property': rule = Rule.property('', '', 'eq', ''); break;
|
|
155
|
+
case 'quantity': rule = Rule.quantity('', '', 'gt', 0); break;
|
|
156
|
+
}
|
|
157
|
+
addFilterRule(rule);
|
|
158
|
+
}, [addFilterRule]);
|
|
159
|
+
|
|
160
|
+
const promoteSearchQuery = useCallback(() => {
|
|
161
|
+
const q = searchQuery.trim();
|
|
162
|
+
if (!q) return;
|
|
163
|
+
addFilterRule(Rule.name('contains', q));
|
|
164
|
+
}, [addFilterRule, searchQuery]);
|
|
165
|
+
|
|
166
|
+
// ── Preset handlers ─────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const handleSavePreset = useCallback(() => {
|
|
169
|
+
if (filter.rules.length === 0) return;
|
|
170
|
+
// eslint-disable-next-line no-alert
|
|
171
|
+
const name = window.prompt('Save filter as…', '');
|
|
172
|
+
if (!name) return;
|
|
173
|
+
setSavedPresets(saveFilter(name, filter.combinator, filter.rules));
|
|
174
|
+
}, [filter.combinator, filter.rules]);
|
|
175
|
+
|
|
176
|
+
const handleLoadPreset = useCallback((preset: SavedFilterPreset) => {
|
|
177
|
+
setSearchFilter({
|
|
178
|
+
rules: preset.rules.map((r) => ({ ...r }) as FilterRule),
|
|
179
|
+
combinator: preset.combinator,
|
|
180
|
+
limit: filter.limit,
|
|
181
|
+
});
|
|
182
|
+
}, [filter.limit, setSearchFilter]);
|
|
183
|
+
|
|
184
|
+
const handleDeletePreset = useCallback((name: string) => {
|
|
185
|
+
setSavedPresets(deleteSavedFilter(name));
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div className="flex flex-col gap-3 p-4">
|
|
190
|
+
{/* ── Toolbar: AND/OR · Limit · promote-query · Presets · Save · Reset ── */}
|
|
191
|
+
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
192
|
+
<CombinatorToggle value={filter.combinator} onChange={setFilterCombinator} />
|
|
193
|
+
|
|
194
|
+
<div className="ml-1 flex items-center gap-1">
|
|
195
|
+
<label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
196
|
+
Limit
|
|
197
|
+
</label>
|
|
198
|
+
<Input
|
|
199
|
+
type="number"
|
|
200
|
+
min={0}
|
|
201
|
+
value={filter.limit}
|
|
202
|
+
onChange={(e) => setFilterLimit(Number.parseInt(e.target.value, 10) || 0)}
|
|
203
|
+
className="h-7 w-20 text-xs"
|
|
204
|
+
/>
|
|
205
|
+
<span className="text-[10px] text-muted-foreground">0 = none</span>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{searchQuery.trim().length > 0 && (
|
|
209
|
+
<Button
|
|
210
|
+
type="button"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
size="sm"
|
|
213
|
+
onClick={promoteSearchQuery}
|
|
214
|
+
className="h-7 gap-1 text-[11px]"
|
|
215
|
+
title="Add a Name contains rule from the search bar query"
|
|
216
|
+
>
|
|
217
|
+
<Plus className="h-3 w-3" />
|
|
218
|
+
Add “{truncate(searchQuery.trim(), 18)}” as rule
|
|
219
|
+
</Button>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
<div className="ml-auto flex items-center gap-1">
|
|
223
|
+
<PresetMenu
|
|
224
|
+
presets={savedPresets}
|
|
225
|
+
onLoad={handleLoadPreset}
|
|
226
|
+
onDelete={handleDeletePreset}
|
|
227
|
+
/>
|
|
228
|
+
<Button
|
|
229
|
+
type="button"
|
|
230
|
+
variant="ghost"
|
|
231
|
+
size="sm"
|
|
232
|
+
onClick={handleSavePreset}
|
|
233
|
+
disabled={filter.rules.length === 0}
|
|
234
|
+
className="h-7 gap-1 text-[11px]"
|
|
235
|
+
title="Save the current rules as a named preset"
|
|
236
|
+
>
|
|
237
|
+
<Save className="h-3 w-3" /> Save
|
|
238
|
+
</Button>
|
|
239
|
+
{filter.rules.length > 0 && (
|
|
240
|
+
<Button
|
|
241
|
+
type="button"
|
|
242
|
+
variant="ghost"
|
|
243
|
+
size="sm"
|
|
244
|
+
onClick={clearFilterRules}
|
|
245
|
+
className="h-7 gap-1 text-[11px] text-muted-foreground"
|
|
246
|
+
>
|
|
247
|
+
<X className="h-3 w-3" /> Reset
|
|
248
|
+
</Button>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* ── Rules list ──────────────────────────────────────────────────── */}
|
|
254
|
+
<div className="flex flex-col gap-2">
|
|
255
|
+
{filter.rules.length === 0 && (
|
|
256
|
+
<p className="rounded border border-dashed border-zinc-300 bg-zinc-50 px-3 py-3 text-center text-xs italic text-muted-foreground dark:border-zinc-800 dark:bg-zinc-900/30">
|
|
257
|
+
Add a rule to start filtering — pick by storey, IFC type, name, property, or quantity.
|
|
258
|
+
</p>
|
|
259
|
+
)}
|
|
260
|
+
{filter.rules.map((rule, i) => (
|
|
261
|
+
<RuleRow
|
|
262
|
+
key={i}
|
|
263
|
+
rule={rule}
|
|
264
|
+
ifcTypeOptions={ifcTypeOptions}
|
|
265
|
+
storeyOptions={storeyOptions}
|
|
266
|
+
psetQto={schemaEntry?.psetQto ?? null}
|
|
267
|
+
onChange={(next) => updateFilterRule(i, next)}
|
|
268
|
+
onRemove={() => removeFilterRule(i)}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
<AddRuleMenu onAdd={addRuleOfKind} />
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Sub-components ────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
function CombinatorToggle({
|
|
280
|
+
value,
|
|
281
|
+
onChange,
|
|
282
|
+
}: {
|
|
283
|
+
value: Combinator;
|
|
284
|
+
onChange: (next: Combinator) => void;
|
|
285
|
+
}) {
|
|
286
|
+
return (
|
|
287
|
+
<div
|
|
288
|
+
className="inline-flex rounded border border-zinc-200 bg-white p-0.5 text-[11px] dark:border-zinc-800 dark:bg-zinc-950"
|
|
289
|
+
title="AND requires every rule to match. OR matches any rule."
|
|
290
|
+
>
|
|
291
|
+
{(['AND', 'OR'] as const).map((c) => (
|
|
292
|
+
<button
|
|
293
|
+
key={c}
|
|
294
|
+
type="button"
|
|
295
|
+
onClick={() => onChange(c)}
|
|
296
|
+
className={`rounded px-2 py-0.5 font-mono font-medium transition-colors ${
|
|
297
|
+
value === c
|
|
298
|
+
? 'bg-primary text-primary-foreground'
|
|
299
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
300
|
+
}`}
|
|
301
|
+
>
|
|
302
|
+
{c}
|
|
303
|
+
</button>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function PresetMenu({
|
|
310
|
+
presets,
|
|
311
|
+
onLoad,
|
|
312
|
+
onDelete,
|
|
313
|
+
}: {
|
|
314
|
+
presets: SavedFilterPreset[];
|
|
315
|
+
onLoad: (preset: SavedFilterPreset) => void;
|
|
316
|
+
onDelete: (name: string) => void;
|
|
317
|
+
}) {
|
|
318
|
+
if (presets.length === 0) {
|
|
319
|
+
return (
|
|
320
|
+
<Button
|
|
321
|
+
type="button"
|
|
322
|
+
variant="ghost"
|
|
323
|
+
size="sm"
|
|
324
|
+
disabled
|
|
325
|
+
className="h-7 gap-1 text-[11px] text-muted-foreground"
|
|
326
|
+
title="Save a preset first"
|
|
327
|
+
>
|
|
328
|
+
<Bookmark className="h-3 w-3" /> Presets
|
|
329
|
+
</Button>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return (
|
|
333
|
+
<DropdownMenu>
|
|
334
|
+
<DropdownMenuTrigger asChild>
|
|
335
|
+
<Button
|
|
336
|
+
type="button"
|
|
337
|
+
variant="ghost"
|
|
338
|
+
size="sm"
|
|
339
|
+
className="h-7 gap-1 text-[11px]"
|
|
340
|
+
>
|
|
341
|
+
<Bookmark className="h-3 w-3" /> Presets
|
|
342
|
+
</Button>
|
|
343
|
+
</DropdownMenuTrigger>
|
|
344
|
+
<DropdownMenuContent align="end" className="w-72">
|
|
345
|
+
<DropdownMenuLabel className="text-[10px] uppercase">Saved presets</DropdownMenuLabel>
|
|
346
|
+
<DropdownMenuSeparator />
|
|
347
|
+
{presets.map((p) => (
|
|
348
|
+
<DropdownMenuItem
|
|
349
|
+
key={p.name}
|
|
350
|
+
onSelect={() => onLoad(p)}
|
|
351
|
+
className="flex items-start justify-between gap-2"
|
|
352
|
+
>
|
|
353
|
+
<div className="flex flex-col">
|
|
354
|
+
<span className="font-medium">{p.name}</span>
|
|
355
|
+
<span className="text-[10px] text-muted-foreground">
|
|
356
|
+
{p.rules.length} rule{p.rules.length === 1 ? '' : 's'} · {p.combinator}
|
|
357
|
+
</span>
|
|
358
|
+
</div>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
aria-label={`Delete preset ${p.name}`}
|
|
362
|
+
onClick={(e) => {
|
|
363
|
+
e.stopPropagation();
|
|
364
|
+
onDelete(p.name);
|
|
365
|
+
}}
|
|
366
|
+
className="rounded p-1 text-muted-foreground hover:bg-zinc-100 hover:text-destructive dark:hover:bg-zinc-800"
|
|
367
|
+
>
|
|
368
|
+
<Trash2 className="h-3 w-3" />
|
|
369
|
+
</button>
|
|
370
|
+
</DropdownMenuItem>
|
|
371
|
+
))}
|
|
372
|
+
</DropdownMenuContent>
|
|
373
|
+
</DropdownMenu>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function AddRuleMenu({
|
|
378
|
+
onAdd,
|
|
379
|
+
}: {
|
|
380
|
+
onAdd: (kind: FilterRule['kind']) => void;
|
|
381
|
+
}) {
|
|
382
|
+
return (
|
|
383
|
+
<DropdownMenu>
|
|
384
|
+
<DropdownMenuTrigger asChild>
|
|
385
|
+
<Button variant="ghost" size="sm" className="h-7 gap-1 self-start text-xs">
|
|
386
|
+
<Plus className="h-3 w-3" />
|
|
387
|
+
Add rule
|
|
388
|
+
</Button>
|
|
389
|
+
</DropdownMenuTrigger>
|
|
390
|
+
<DropdownMenuContent align="start">
|
|
391
|
+
<DropdownMenuLabel className="text-[10px] uppercase">Filter dimension</DropdownMenuLabel>
|
|
392
|
+
<DropdownMenuSeparator />
|
|
393
|
+
{(Object.keys(RULE_KIND_LABEL) as FilterRule['kind'][]).map((k) => (
|
|
394
|
+
<DropdownMenuItem key={k} onSelect={() => onAdd(k)}>
|
|
395
|
+
{RULE_KIND_LABEL[k]}
|
|
396
|
+
</DropdownMenuItem>
|
|
397
|
+
))}
|
|
398
|
+
</DropdownMenuContent>
|
|
399
|
+
</DropdownMenu>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
interface RuleRowProps {
|
|
404
|
+
rule: FilterRule;
|
|
405
|
+
ifcTypeOptions: string[];
|
|
406
|
+
storeyOptions: ReadonlyArray<readonly [string, number | null]>;
|
|
407
|
+
psetQto: { psets: ReadonlyArray<readonly [string, ReadonlyArray<string>]>; qtos: ReadonlyArray<readonly [string, ReadonlyArray<readonly [string, string]>]> } | null;
|
|
408
|
+
onChange: (next: FilterRule) => void;
|
|
409
|
+
onRemove: () => void;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function RuleRow({ rule, ifcTypeOptions, storeyOptions, psetQto, onChange, onRemove }: RuleRowProps) {
|
|
413
|
+
return (
|
|
414
|
+
<div className="flex flex-wrap items-center gap-1.5 rounded border border-zinc-200 bg-white px-2 py-1.5 dark:border-zinc-800 dark:bg-zinc-950">
|
|
415
|
+
<span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
|
416
|
+
{RULE_KIND_LABEL[rule.kind]}
|
|
417
|
+
</span>
|
|
418
|
+
|
|
419
|
+
{rule.kind === 'storey' && (
|
|
420
|
+
<SetRuleEditor
|
|
421
|
+
values={rule.values}
|
|
422
|
+
op={rule.op}
|
|
423
|
+
options={storeyOptions.map(([name, elev]) => ({
|
|
424
|
+
label: elev != null ? `${name} (${elev.toFixed(2)} m)` : name,
|
|
425
|
+
value: name,
|
|
426
|
+
}))}
|
|
427
|
+
onChange={(values, op) => onChange(Rule.storey(values, op))}
|
|
428
|
+
/>
|
|
429
|
+
)}
|
|
430
|
+
|
|
431
|
+
{rule.kind === 'ifcType' && (
|
|
432
|
+
<SetRuleEditor
|
|
433
|
+
values={rule.values}
|
|
434
|
+
op={rule.op}
|
|
435
|
+
options={ifcTypeOptions.map((t) => ({ label: t, value: t }))}
|
|
436
|
+
onChange={(values, op) => onChange(Rule.ifcType(values, op))}
|
|
437
|
+
/>
|
|
438
|
+
)}
|
|
439
|
+
|
|
440
|
+
{rule.kind === 'predefinedType' && (
|
|
441
|
+
<PredefinedTypeEditor
|
|
442
|
+
values={rule.values}
|
|
443
|
+
op={rule.op}
|
|
444
|
+
onChange={(values, op) => onChange(Rule.predefinedType(values, op))}
|
|
445
|
+
/>
|
|
446
|
+
)}
|
|
447
|
+
|
|
448
|
+
{rule.kind === 'name' && (
|
|
449
|
+
<NameEditor
|
|
450
|
+
op={rule.op}
|
|
451
|
+
value={rule.value}
|
|
452
|
+
onChange={(op, value) => onChange(Rule.name(op, value))}
|
|
453
|
+
/>
|
|
454
|
+
)}
|
|
455
|
+
|
|
456
|
+
{rule.kind === 'property' && (
|
|
457
|
+
<PropertyEditor rule={rule} psetQto={psetQto} onChange={onChange} />
|
|
458
|
+
)}
|
|
459
|
+
|
|
460
|
+
{rule.kind === 'quantity' && (
|
|
461
|
+
<QuantityEditor rule={rule} psetQto={psetQto} onChange={onChange} />
|
|
462
|
+
)}
|
|
463
|
+
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={onRemove}
|
|
467
|
+
aria-label="Remove rule"
|
|
468
|
+
className="ml-auto rounded p-1 text-muted-foreground hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800"
|
|
469
|
+
>
|
|
470
|
+
<Trash2 className="h-3 w-3" />
|
|
471
|
+
</button>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── Per-kind editors ──────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
interface SetRuleEditorProps {
|
|
479
|
+
values: string[];
|
|
480
|
+
op: SetOp;
|
|
481
|
+
options: Array<{ label: string; value: string }>;
|
|
482
|
+
onChange: (values: string[], op: SetOp) => void;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function SetRuleEditor({ values, op, options, onChange }: SetRuleEditorProps) {
|
|
486
|
+
const toggle = (v: string) => {
|
|
487
|
+
const next = values.includes(v) ? values.filter((x) => x !== v) : [...values, v];
|
|
488
|
+
onChange(next, op);
|
|
489
|
+
};
|
|
490
|
+
return (
|
|
491
|
+
<>
|
|
492
|
+
<OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
|
|
493
|
+
<DropdownMenu>
|
|
494
|
+
<DropdownMenuTrigger asChild>
|
|
495
|
+
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs font-mono">
|
|
496
|
+
{values.length === 0 ? 'Pick values…' : `${values.length} selected`}
|
|
497
|
+
</Button>
|
|
498
|
+
</DropdownMenuTrigger>
|
|
499
|
+
<DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
|
|
500
|
+
{options.length === 0 && (
|
|
501
|
+
<DropdownMenuItem disabled className="text-muted-foreground italic">
|
|
502
|
+
No options available — load a model first.
|
|
503
|
+
</DropdownMenuItem>
|
|
504
|
+
)}
|
|
505
|
+
{options.map((o) => (
|
|
506
|
+
<DropdownMenuItem
|
|
507
|
+
key={o.value}
|
|
508
|
+
onSelect={(e) => {
|
|
509
|
+
// Keep the menu open for multi-select.
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
toggle(o.value);
|
|
512
|
+
}}
|
|
513
|
+
className="font-mono"
|
|
514
|
+
>
|
|
515
|
+
<span className="mr-2 inline-block w-3 text-center">
|
|
516
|
+
{values.includes(o.value) ? '✓' : ''}
|
|
517
|
+
</span>
|
|
518
|
+
{o.label}
|
|
519
|
+
</DropdownMenuItem>
|
|
520
|
+
))}
|
|
521
|
+
</DropdownMenuContent>
|
|
522
|
+
</DropdownMenu>
|
|
523
|
+
{values.length > 0 && (
|
|
524
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
525
|
+
{values.map((v) => (
|
|
526
|
+
<span
|
|
527
|
+
key={v}
|
|
528
|
+
className="inline-flex items-center gap-1 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-mono dark:bg-zinc-800"
|
|
529
|
+
>
|
|
530
|
+
{v}
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
aria-label={`Remove ${v}`}
|
|
534
|
+
onClick={() => toggle(v)}
|
|
535
|
+
className="text-muted-foreground hover:text-foreground"
|
|
536
|
+
>
|
|
537
|
+
×
|
|
538
|
+
</button>
|
|
539
|
+
</span>
|
|
540
|
+
))}
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
543
|
+
</>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function PredefinedTypeEditor({
|
|
548
|
+
values,
|
|
549
|
+
op,
|
|
550
|
+
onChange,
|
|
551
|
+
}: {
|
|
552
|
+
values: string[];
|
|
553
|
+
op: SetOp;
|
|
554
|
+
onChange: (values: string[], op: SetOp) => void;
|
|
555
|
+
}) {
|
|
556
|
+
// Predefined types aren't materialised in the parser today — pick
|
|
557
|
+
// them via free-text. The user enters comma-separated values.
|
|
558
|
+
const text = values.join(', ');
|
|
559
|
+
return (
|
|
560
|
+
<>
|
|
561
|
+
<OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
|
|
562
|
+
<Input
|
|
563
|
+
placeholder="e.g. SOLIDWALL, PARTITIONING"
|
|
564
|
+
value={text}
|
|
565
|
+
onChange={(e) =>
|
|
566
|
+
onChange(
|
|
567
|
+
e.target.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0),
|
|
568
|
+
op,
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
className="h-7 w-72 text-xs font-mono"
|
|
572
|
+
/>
|
|
573
|
+
</>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function NameEditor({
|
|
578
|
+
op,
|
|
579
|
+
value,
|
|
580
|
+
onChange,
|
|
581
|
+
}: {
|
|
582
|
+
op: StringOp;
|
|
583
|
+
value: string;
|
|
584
|
+
onChange: (op: StringOp, value: string) => void;
|
|
585
|
+
}) {
|
|
586
|
+
return (
|
|
587
|
+
<>
|
|
588
|
+
<OpDropdown ops={STRING_OPS} value={op} onChange={(next) => onChange(next, value)} />
|
|
589
|
+
<Input
|
|
590
|
+
placeholder="text"
|
|
591
|
+
value={value}
|
|
592
|
+
onChange={(e) => onChange(op, e.target.value)}
|
|
593
|
+
className="h-7 w-56 text-xs font-mono"
|
|
594
|
+
/>
|
|
595
|
+
</>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
interface PropertyEditorProps {
|
|
600
|
+
rule: Extract<FilterRule, { kind: 'property' }>;
|
|
601
|
+
psetQto: RuleRowProps['psetQto'];
|
|
602
|
+
onChange: (next: FilterRule) => void;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function PropertyEditor({ rule, psetQto, onChange }: PropertyEditorProps) {
|
|
606
|
+
const psetNames = useMemo(() => (psetQto ? psetQto.psets.map(([n]) => n) : []), [psetQto]);
|
|
607
|
+
const propNames = useMemo(() => {
|
|
608
|
+
if (!psetQto) return [];
|
|
609
|
+
const entry = psetQto.psets.find(([n]) => n === rule.setName);
|
|
610
|
+
return entry ? Array.from(entry[1]) : [];
|
|
611
|
+
}, [psetQto, rule.setName]);
|
|
612
|
+
|
|
613
|
+
const valueless = rule.op === 'isSet' || rule.op === 'isNotSet';
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<>
|
|
617
|
+
<FreeOrPickInput
|
|
618
|
+
placeholder="Pset_… (e.g. Pset_WallCommon)"
|
|
619
|
+
value={rule.setName}
|
|
620
|
+
options={psetNames}
|
|
621
|
+
widthClass="w-52"
|
|
622
|
+
onChange={(next) => onChange({ ...rule, setName: next, propertyName: '' })}
|
|
623
|
+
/>
|
|
624
|
+
<span className="text-muted-foreground">.</span>
|
|
625
|
+
<FreeOrPickInput
|
|
626
|
+
placeholder="prop name"
|
|
627
|
+
value={rule.propertyName}
|
|
628
|
+
options={propNames}
|
|
629
|
+
widthClass="w-44"
|
|
630
|
+
onChange={(next) => onChange({ ...rule, propertyName: next })}
|
|
631
|
+
/>
|
|
632
|
+
<OpDropdown ops={VALUE_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
|
|
633
|
+
{!valueless && (
|
|
634
|
+
<Input
|
|
635
|
+
placeholder="value"
|
|
636
|
+
value={rule.value}
|
|
637
|
+
onChange={(e) => onChange({ ...rule, value: e.target.value })}
|
|
638
|
+
className="h-7 w-40 text-xs font-mono"
|
|
639
|
+
/>
|
|
640
|
+
)}
|
|
641
|
+
</>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
interface QuantityEditorProps {
|
|
646
|
+
rule: Extract<FilterRule, { kind: 'quantity' }>;
|
|
647
|
+
psetQto: RuleRowProps['psetQto'];
|
|
648
|
+
onChange: (next: FilterRule) => void;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function QuantityEditor({ rule, psetQto, onChange }: QuantityEditorProps) {
|
|
652
|
+
const qsetNames = useMemo(() => (psetQto ? psetQto.qtos.map(([n]) => n) : []), [psetQto]);
|
|
653
|
+
const qtyNames = useMemo(() => {
|
|
654
|
+
if (!psetQto) return [];
|
|
655
|
+
const entry = psetQto.qtos.find(([n]) => n === rule.setName);
|
|
656
|
+
return entry ? entry[1].map(([n]) => n) : [];
|
|
657
|
+
}, [psetQto, rule.setName]);
|
|
658
|
+
|
|
659
|
+
return (
|
|
660
|
+
<>
|
|
661
|
+
<FreeOrPickInput
|
|
662
|
+
placeholder="Qto_… (e.g. Qto_WallBaseQuantities)"
|
|
663
|
+
value={rule.setName}
|
|
664
|
+
options={qsetNames}
|
|
665
|
+
widthClass="w-56"
|
|
666
|
+
onChange={(next) => onChange({ ...rule, setName: next, quantityName: '' })}
|
|
667
|
+
/>
|
|
668
|
+
<span className="text-muted-foreground">.</span>
|
|
669
|
+
<FreeOrPickInput
|
|
670
|
+
placeholder="quantity name"
|
|
671
|
+
value={rule.quantityName}
|
|
672
|
+
options={qtyNames}
|
|
673
|
+
widthClass="w-44"
|
|
674
|
+
onChange={(next) => onChange({ ...rule, quantityName: next })}
|
|
675
|
+
/>
|
|
676
|
+
<OpDropdown ops={NUMERIC_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
|
|
677
|
+
<Input
|
|
678
|
+
type="number"
|
|
679
|
+
placeholder="value"
|
|
680
|
+
value={rule.value}
|
|
681
|
+
onChange={(e) => onChange({ ...rule, value: Number.parseFloat(e.target.value) || 0 })}
|
|
682
|
+
className="h-7 w-32 text-xs font-mono"
|
|
683
|
+
/>
|
|
684
|
+
</>
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Building-block widgets ───────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
function OpDropdown<T extends string>({
|
|
691
|
+
ops,
|
|
692
|
+
value,
|
|
693
|
+
onChange,
|
|
694
|
+
}: {
|
|
695
|
+
ops: ReadonlyArray<T>;
|
|
696
|
+
value: T;
|
|
697
|
+
onChange: (next: T) => void;
|
|
698
|
+
}) {
|
|
699
|
+
return (
|
|
700
|
+
<DropdownMenu>
|
|
701
|
+
<DropdownMenuTrigger asChild>
|
|
702
|
+
<Button variant="outline" size="sm" className="h-7 min-w-[3.5rem] gap-1 text-xs font-mono">
|
|
703
|
+
{OP_LABEL[value] ?? value}
|
|
704
|
+
</Button>
|
|
705
|
+
</DropdownMenuTrigger>
|
|
706
|
+
<DropdownMenuContent>
|
|
707
|
+
{ops.map((op) => (
|
|
708
|
+
<DropdownMenuItem key={op} onSelect={() => onChange(op)} className="font-mono">
|
|
709
|
+
{OP_LABEL[op] ?? op}
|
|
710
|
+
<span className="ml-2 text-[10px] text-muted-foreground">{op}</span>
|
|
711
|
+
</DropdownMenuItem>
|
|
712
|
+
))}
|
|
713
|
+
</DropdownMenuContent>
|
|
714
|
+
</DropdownMenu>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Free-text input that exposes a small dropdown of known options when
|
|
720
|
+
* the schema knows them. Users can either pick from the menu or type a
|
|
721
|
+
* value not present in the schema (useful for typos / custom psets).
|
|
722
|
+
*/
|
|
723
|
+
function FreeOrPickInput({
|
|
724
|
+
placeholder,
|
|
725
|
+
value,
|
|
726
|
+
options,
|
|
727
|
+
widthClass,
|
|
728
|
+
onChange,
|
|
729
|
+
}: {
|
|
730
|
+
placeholder: string;
|
|
731
|
+
value: string;
|
|
732
|
+
options: ReadonlyArray<string>;
|
|
733
|
+
widthClass: string;
|
|
734
|
+
onChange: (next: string) => void;
|
|
735
|
+
}) {
|
|
736
|
+
return (
|
|
737
|
+
<div className="relative inline-flex items-center gap-1">
|
|
738
|
+
<Input
|
|
739
|
+
placeholder={placeholder}
|
|
740
|
+
value={value}
|
|
741
|
+
onChange={(e) => onChange(e.target.value)}
|
|
742
|
+
className={`h-7 ${widthClass} text-xs font-mono`}
|
|
743
|
+
/>
|
|
744
|
+
{options.length > 0 && (
|
|
745
|
+
<DropdownMenu>
|
|
746
|
+
<DropdownMenuTrigger asChild>
|
|
747
|
+
<Button variant="ghost" size="sm" className="h-7 px-1 text-[10px] text-muted-foreground" title="Pick from schema">
|
|
748
|
+
▾
|
|
749
|
+
</Button>
|
|
750
|
+
</DropdownMenuTrigger>
|
|
751
|
+
<DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
|
|
752
|
+
{options.map((o) => (
|
|
753
|
+
<DropdownMenuItem key={o} onSelect={() => onChange(o)} className="font-mono">
|
|
754
|
+
{o}
|
|
755
|
+
</DropdownMenuItem>
|
|
756
|
+
))}
|
|
757
|
+
</DropdownMenuContent>
|
|
758
|
+
</DropdownMenu>
|
|
759
|
+
)}
|
|
760
|
+
</div>
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function truncate(s: string, max: number): string {
|
|
765
|
+
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
766
|
+
}
|