@ifc-lite/viewer 1.25.2 → 1.27.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 (116) hide show
  1. package/.turbo/turbo-build.log +40 -30
  2. package/CHANGELOG.md +110 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +13 -9
  38. package/src/components/extensions/FlavorDialog.tsx +18 -2
  39. package/src/components/extensions/FlavorListView.tsx +12 -3
  40. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  41. package/src/components/mcp/data.ts +6 -0
  42. package/src/components/mcp/playground-dispatcher.ts +277 -0
  43. package/src/components/mcp/types.ts +2 -1
  44. package/src/components/ui/combo-input.tsx +163 -0
  45. package/src/components/ui/tabs.tsx +1 -1
  46. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  47. package/src/components/viewer/ClashPanel.tsx +370 -0
  48. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  49. package/src/components/viewer/CommandPalette.tsx +14 -15
  50. package/src/components/viewer/MainToolbar.tsx +155 -175
  51. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  52. package/src/components/viewer/SearchInline.tsx +62 -2
  53. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  54. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  55. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  56. package/src/components/viewer/SearchModal.tsx +19 -6
  57. package/src/components/viewer/ViewerLayout.tsx +5 -0
  58. package/src/components/viewer/Viewport.tsx +64 -9
  59. package/src/components/viewer/ViewportContainer.tsx +45 -3
  60. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  61. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  62. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  63. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  64. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  65. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  66. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  67. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  68. package/src/generated/mcp-catalog.json +4 -0
  69. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  70. package/src/hooks/ingest/streamCleanup.ts +45 -0
  71. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  72. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  73. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  74. package/src/hooks/source-key.ts +35 -0
  75. package/src/hooks/useAlignmentLines3D.ts +139 -0
  76. package/src/hooks/useClash.ts +420 -0
  77. package/src/hooks/useGridLines3D.ts +140 -0
  78. package/src/hooks/useIfcFederation.ts +16 -2
  79. package/src/hooks/useIfcLoader.ts +5 -7
  80. package/src/lib/clash/persistence.ts +308 -0
  81. package/src/lib/geo/effective-georef.test.ts +66 -0
  82. package/src/lib/length-unit-scale.ts +41 -0
  83. package/src/lib/lists/adapter.ts +136 -11
  84. package/src/lib/lists/export/csv.ts +47 -0
  85. package/src/lib/lists/export/index.ts +49 -0
  86. package/src/lib/lists/export/model.ts +111 -0
  87. package/src/lib/lists/export/pdf.ts +67 -0
  88. package/src/lib/lists/export/xlsx.ts +83 -0
  89. package/src/lib/lists/index.ts +2 -0
  90. package/src/lib/search/filter-evaluate.test.ts +81 -0
  91. package/src/lib/search/filter-evaluate.ts +59 -87
  92. package/src/lib/search/filter-match.ts +167 -0
  93. package/src/lib/search/filter-rules.test.ts +25 -0
  94. package/src/lib/search/filter-rules.ts +75 -2
  95. package/src/lib/search/filter-schema.ts +0 -0
  96. package/src/lib/slab-edit.test.ts +72 -0
  97. package/src/lib/slab-edit.ts +159 -19
  98. package/src/sdk/adapters/export-adapter.ts +3 -3
  99. package/src/sdk/adapters/query-adapter.ts +3 -3
  100. package/src/services/extensions/host.ts +13 -0
  101. package/src/store/constants.ts +33 -25
  102. package/src/store/index.ts +29 -8
  103. package/src/store/slices/clashSlice.ts +251 -0
  104. package/src/store/slices/listSlice.ts +6 -0
  105. package/src/store/slices/mutationSlice.ts +14 -6
  106. package/src/store/slices/searchSlice.ts +29 -3
  107. package/src/store/slices/visibilitySlice.test.ts +23 -5
  108. package/src/store/slices/visibilitySlice.ts +18 -8
  109. package/src/utils/nativeSpatialDataStore.ts +6 -0
  110. package/src/utils/serverDataModel.test.ts +6 -0
  111. package/src/utils/serverDataModel.ts +7 -0
  112. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  113. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  114. package/dist/assets/index-Bws3UAkj.css +0 -1
  115. package/dist/assets/raw-R2QfzPAR.js +0 -1
  116. package/dist/assets/server-client-Ctk8_Bof.js +0 -626
@@ -53,6 +53,19 @@ import {
53
53
  type BCFTopic,
54
54
  } from '@ifc-lite/bcf';
55
55
  import { parseIDS, validateIDS, type IDSDocument } from '@ifc-lite/ids';
56
+ import { GeometryProcessor, type MeshData } from '@ifc-lite/geometry';
57
+ import {
58
+ createClashEngine,
59
+ disciplineMatrixRules,
60
+ groupClashes,
61
+ type Clash,
62
+ type ClashMode,
63
+ type ClashResult,
64
+ type ClashRule,
65
+ type GroupOptions,
66
+ } from '@ifc-lite/clash';
67
+ import { elementsFromStep } from '@ifc-lite/clash/step';
68
+ import { createBCFFromClashResult } from '@ifc-lite/clash/bcf';
56
69
  import { CATALOG, paramsFor } from './data';
57
70
  import type { CatalogTool } from './types';
58
71
  import type { ViewerController, ColorTuple } from './PlaygroundViewer';
@@ -248,6 +261,149 @@ function formatColorTuple(c: ColorTuple): string {
248
261
  */
249
262
  const PROXIED_BSDD = new BsddNamespace({ apiBase: '/api/bsdd' });
250
263
 
264
+ // ── Clash detection (in-browser meshing → TS clash engine) ─────────────────
265
+ // Mirrors `packages/mcp/src/tools/clash.ts`: the whole model is meshed once
266
+ // (headless WASM pipeline, same as the inline viewer) and cached by model id,
267
+ // then the representation-agnostic clash engine runs against TYPE selectors.
268
+
269
+ /** Cap on clashes returned in a tool result. The dropped count is reported. */
270
+ const CLASH_DISPLAY_CAP = 50;
271
+
272
+ /**
273
+ * Runaway guardrail for the TS clash engine: the whole-run candidate-pair
274
+ * budget (broad-phase AABB-overlap survivors across every rule). The engine
275
+ * yields between chunks so it never hard-freezes the tab, but an agent can
276
+ * fire `clash_check` (all-vs-all) on an arbitrarily large uploaded model
277
+ * unattended — this ceiling bounds the narrow-phase work and is reported via
278
+ * `result.truncated` (never silent). Generous on purpose: real building models
279
+ * stay well under it, so totals match the viewer's (unbounded) ClashPanel; only
280
+ * pathological models get bounded.
281
+ */
282
+ const CLASH_MAX_CANDIDATE_PAIRS = 5_000_000;
283
+
284
+ /** Max distinct models whose meshes we keep cached at once (LRU). The playground
285
+ * is effectively single-model, so a small bound is plenty and stops a long
286
+ * session of re-uploads from pinning every model's meshes in memory. */
287
+ const CLASH_MESH_CACHE_MAX = 3;
288
+
289
+ /**
290
+ * Module-level mesh cache so repeated clash calls on the same model don't
291
+ * re-run the (expensive) headless tessellation. Keyed by `id:fileSize`, NOT id
292
+ * alone: the playground reuses a filename-slug id, so an edited re-upload would
293
+ * otherwise hit a stale mesh — folding in the byte length forces a re-mesh when
294
+ * the bytes change. Bounded to CLASH_MESH_CACHE_MAX entries with LRU eviction.
295
+ */
296
+ const clashMeshCache = new Map<string, MeshData[]>();
297
+
298
+ function meshCacheKey(m: LoadedPlaygroundModel): string {
299
+ return `${m.id}:${m.fileSize}`;
300
+ }
301
+
302
+ /** LRU get: a hit refreshes recency so the active model survives eviction. */
303
+ function getCachedMeshes(key: string): MeshData[] | undefined {
304
+ const hit = clashMeshCache.get(key);
305
+ if (hit) {
306
+ clashMeshCache.delete(key);
307
+ clashMeshCache.set(key, hit);
308
+ }
309
+ return hit;
310
+ }
311
+
312
+ /** LRU set: insert then evict the least-recently-used entries past the bound. */
313
+ function setCachedMeshes(key: string, meshes: MeshData[]): void {
314
+ clashMeshCache.set(key, meshes);
315
+ while (clashMeshCache.size > CLASH_MESH_CACHE_MAX) {
316
+ const oldest = clashMeshCache.keys().next().value;
317
+ if (oldest === undefined) break;
318
+ clashMeshCache.delete(oldest);
319
+ }
320
+ }
321
+
322
+ /** Mesh the whole model once (in-browser, same path as PlaygroundViewer) and
323
+ * cache it. Throws UNSUPPORTED_OPERATION when the model carries no drawable
324
+ * geometry — clash needs tessellated solids, not quantity sets. */
325
+ async function meshForClash(m: LoadedPlaygroundModel): Promise<MeshData[]> {
326
+ const key = meshCacheKey(m);
327
+ const cached = getCachedMeshes(key);
328
+ if (cached) return cached;
329
+
330
+ const processor = new GeometryProcessor({ preferNative: false });
331
+ await processor.init();
332
+ // Use our owning byte snapshot — store.source can be a detached sub-view.
333
+ const result = await processor.process(
334
+ m.bytes,
335
+ m.store.entityIndex.byId as unknown as Map<number, unknown>,
336
+ );
337
+ const meshes = result.meshes ?? [];
338
+ if (meshes.length === 0) {
339
+ throw new ToolExecutionError({
340
+ code: ToolErrorCode.UNSUPPORTED_OPERATION,
341
+ message: 'No mesh geometry could be produced for this model; clash detection needs tessellated solids.',
342
+ hint: 'Confirm the model carries explicit geometry (not schema/quantity-only data).',
343
+ });
344
+ }
345
+ setCachedMeshes(key, meshes);
346
+ return meshes;
347
+ }
348
+
349
+ /**
350
+ * The most recent clash result, so `clash_bcf_export` can turn the last run into
351
+ * a rich BCF without re-clashing (mirrors the viewer, where the ClashPanel holds
352
+ * the result the export dialog reads). Keyed by the SAME `id:fileSize` identity
353
+ * as the mesh cache — keying by `m.id` alone would serve a stale result after an
354
+ * edited re-upload (same filename slug) even though the meshes re-compute.
355
+ */
356
+ const lastClashResult = new Map<string, ClashResult>();
357
+
358
+ /** Run a rule set against a model's meshes, returning (and caching) the result. */
359
+ async function runClashRules(m: LoadedPlaygroundModel, rules: ClashRule[]): Promise<ClashResult> {
360
+ const meshes = await meshForClash(m);
361
+ const { elements, exclusions } = elementsFromStep({ store: m.store, meshes, modelId: m.id });
362
+ const engine = createClashEngine({ backend: 'ts' });
363
+ const result = await engine.run(elements, rules, { exclusions, maxCandidatePairs: CLASH_MAX_CANDIDATE_PAIRS });
364
+ lastClashResult.set(meshCacheKey(m), result);
365
+ return result;
366
+ }
367
+
368
+ /** When the candidate-pair guardrail bit, say so in the human-readable text so
369
+ * totals are never silently a lower bound. Empty string when the run was
370
+ * complete. */
371
+ function clashCapNote(result: ClashResult): string {
372
+ if (!result.truncated) return '';
373
+ return ` Note: the ${CLASH_MAX_CANDIDATE_PAIRS.toLocaleString()}-candidate-pair guardrail was hit`
374
+ + ` (${result.truncated.droppedPairs.toLocaleString()} pairs not evaluated) — totals are a lower bound.`;
375
+ }
376
+
377
+ /**
378
+ * Top clashes by signed distance (deepest penetration / smallest gap first),
379
+ * capped for display. Sort by RAW distance ascending, not |distance|: hard
380
+ * clashes carry a negative penetration depth, so most-negative-first surfaces
381
+ * the DEEPEST penetrations (the worst, most actionable rows) instead of
382
+ * burying them past the cap; clearance gaps are positive, so the same order
383
+ * surfaces the tightest gaps first.
384
+ */
385
+ function topClashRows(clashes: Clash[], cap: number): {
386
+ rows: Record<string, unknown>[];
387
+ truncated: { shown: number; dropped: number; total: number } | null;
388
+ } {
389
+ const sorted = [...clashes].sort((x, y) => x.distance - y.distance);
390
+ const shown = sorted.slice(0, cap);
391
+ const rows = shown.map((c) => ({
392
+ id: c.id,
393
+ rule: c.rule,
394
+ status: c.status,
395
+ severity: c.severity,
396
+ distance: c.distance,
397
+ point: c.point,
398
+ a: { key: c.a.key, ref: c.a.ref, tag: c.a.tag, name: c.a.name },
399
+ b: { key: c.b.key, ref: c.b.ref, tag: c.b.tag, name: c.b.name },
400
+ }));
401
+ const truncated = sorted.length > cap
402
+ ? { shown: shown.length, dropped: sorted.length - shown.length, total: sorted.length }
403
+ : null;
404
+ return { rows, truncated };
405
+ }
406
+
251
407
  const IMPLS: Record<string, ToolImpl> = {
252
408
  // ── Discovery ───────────────────────────────────────────────────────────
253
409
  async model_info(m) {
@@ -523,6 +679,127 @@ const IMPLS: Record<string, ToolImpl> = {
523
679
  return { text: area == null ? 'No Area quantity present.' : `Area = ${area.toFixed(3)} m².`, structured: { area } };
524
680
  },
525
681
 
682
+ // ── Clash detection ───────────────────────────────────────────────────────
683
+ async clash_check(m, args) {
684
+ const a = (args.a as string | undefined) ?? '*';
685
+ const b = args.b as string | undefined;
686
+ const mode = (args.mode as ClashMode | undefined) ?? 'hard';
687
+ const tolerance = args.tolerance as number | undefined;
688
+ const clearance = args.clearance as number | undefined;
689
+ // No `b` => self-clash within A (every element vs every other in the
690
+ // group); with the default a="*" that is "all clashes in the model".
691
+ const label = b ? `${a} vs ${b}` : a === '*' ? 'all elements (self-clash)' : `${a} (self-clash)`;
692
+
693
+ const rule: ClashRule = {
694
+ id: 'clash_check',
695
+ name: label,
696
+ a,
697
+ ...(b != null ? { b } : {}),
698
+ mode,
699
+ ...(tolerance != null ? { tolerance } : {}),
700
+ ...(clearance != null ? { clearance } : {}),
701
+ };
702
+
703
+ const result = await runClashRules(m, [rule]);
704
+ const { rows, truncated } = topClashRows(result.clashes, CLASH_DISPLAY_CAP);
705
+ const capNote = truncated
706
+ ? ` Showing top ${truncated.shown} by penetration depth; ${truncated.dropped} more not shown.`
707
+ : '';
708
+ return {
709
+ text: `Found ${result.summary.total} clash(es) for ${label} (mode=${mode}).${capNote}${clashCapNote(result)}`,
710
+ structured: {
711
+ summary: result.summary,
712
+ settings: { a, b: b ?? null, mode, tolerance: tolerance ?? null, clearance: clearance ?? null },
713
+ engineSettings: result.settings,
714
+ truncated: result.truncated ?? null,
715
+ clashes: rows,
716
+ clashesTruncated: truncated,
717
+ },
718
+ };
719
+ },
720
+ async clash_matrix(m, args) {
721
+ const mode = (args.mode as ClashMode | undefined) ?? 'hard';
722
+ const clearance = args.clearance as number | undefined;
723
+ const rules = disciplineMatrixRules(mode, clearance);
724
+
725
+ const result = await runClashRules(m, rules);
726
+ const { rows, truncated } = topClashRows(result.clashes, CLASH_DISPLAY_CAP);
727
+ const capNote = truncated
728
+ ? ` Sampling top ${truncated.shown} by penetration depth; ${truncated.dropped} more not shown.`
729
+ : '';
730
+ return {
731
+ text: `Discipline matrix (mode=${mode}, ${rules.length} rules): ${result.summary.total} clash(es).${capNote}${clashCapNote(result)}`,
732
+ structured: {
733
+ mode,
734
+ ruleCount: rules.length,
735
+ byRule: result.summary.byRule,
736
+ bySeverity: result.summary.bySeverity,
737
+ byTypePair: result.summary.byTypePair,
738
+ summary: result.summary,
739
+ engineSettings: result.settings,
740
+ truncated: result.truncated ?? null,
741
+ sampleClashes: rows,
742
+ sampleTruncated: truncated,
743
+ },
744
+ };
745
+ },
746
+ async clash_bcf_export(m, args) {
747
+ const groupBy = (args.group_by as GroupOptions['by'] | undefined) ?? 'cluster';
748
+ const epsilon = args.cluster_epsilon as number | undefined;
749
+ const status = args.status as string | undefined;
750
+ const maxTopics = args.max_topics as number | undefined;
751
+
752
+ // Reuse the last clash run for this model; if there is none, run a default
753
+ // all-vs-all hard self-clash so the tool works standalone (and caches it).
754
+ let result = lastClashResult.get(meshCacheKey(m));
755
+ if (!result) {
756
+ result = await runClashRules(m, [{ id: 'clash_check', name: 'all elements (self-clash)', a: '*', mode: 'hard' }]);
757
+ }
758
+ if (result.summary.total === 0) {
759
+ return {
760
+ text: 'No clashes to export — the last clash run found 0. Run clash_check first (omit a and b for every element vs every other).',
761
+ structured: { topics: 0, clashes: 0 },
762
+ };
763
+ }
764
+
765
+ // One BCF topic per clash group, each carrying a framed viewpoint (camera +
766
+ // the clashing elements as components) and severity/status/distance
767
+ // metadata — the same bridge the viewer's clash→BCF export uses. Snapshots
768
+ // are omitted: the inline ViewerController can't render frames headlessly,
769
+ // and BCF viewpoints are valid without an embedded image.
770
+ const groups = groupClashes(result, { by: groupBy, ...(epsilon != null ? { epsilon } : {}) });
771
+ const project = await createBCFFromClashResult(result, groups, {
772
+ author: 'clash@ifc-lite',
773
+ projectName: 'Clash report',
774
+ ...(status ? { status } : {}),
775
+ ...(maxTopics != null ? { maxTopics } : {}),
776
+ });
777
+
778
+ const filename = coerceFilename(args.file_path as string | undefined, 'bcfzip', 'clashes');
779
+ const blob = await writeBCF(project);
780
+ const file = playgroundFiles.add({
781
+ filename,
782
+ mimeType: 'application/zip',
783
+ size: blob.size,
784
+ blob,
785
+ source: 'clash_bcf_export',
786
+ description: `${project.topics.size} clash topic(s), grouped by ${groupBy}`,
787
+ });
788
+ return {
789
+ text: `Bundled ${filename}: ${project.topics.size} BCF topic(s) from ${result.summary.total} clash(es), grouped by ${groupBy}`
790
+ + ` — each with a framed viewpoint + clashing components + severity/distance metadata. (Snapshots omitted: the inline viewer can't capture frames headlessly.)`,
791
+ structured: {
792
+ fileId: file.id,
793
+ filename,
794
+ bytes: blob.size,
795
+ topics: project.topics.size,
796
+ groupBy,
797
+ clashes: result.summary.total,
798
+ },
799
+ download: { fileId: file.id, filename, mimeType: 'application/zip', size: blob.size, label: 'Get .bcfzip' },
800
+ };
801
+ },
802
+
526
803
  // ── Validation ──────────────────────────────────────────────────────────
527
804
  async model_audit(m) {
528
805
  let issues = 0;
@@ -22,7 +22,8 @@ export type ToolCategory =
22
22
  | 'bSDD'
23
23
  | 'Diff'
24
24
  | 'Export'
25
- | 'Viewer';
25
+ | 'Viewer'
26
+ | 'Clash';
26
27
 
27
28
  export interface CatalogTool {
28
29
  name: string;
@@ -0,0 +1,163 @@
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
+ * ComboInput — a free-text input that opens a suggestion dropdown on focus
7
+ * and filters it as you type. Pick a suggestion or keep typing anything;
8
+ * the value is never restricted to the options. Used by the filter chip
9
+ * editors to surface real model values (materials, classifications,
10
+ * property values, pset/qto names) without hiding them behind a tiny chevron.
11
+ *
12
+ * The list is portaled to `document.body` and fixed-positioned under the
13
+ * input so it's never clipped by the modal's scroll container, and it
14
+ * follows the input on scroll / resize.
15
+ */
16
+
17
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
18
+ import { createPortal } from 'react-dom';
19
+ import { Input } from '@/components/ui/input';
20
+ import { cn } from '@/lib/utils';
21
+
22
+ export interface ComboInputProps {
23
+ value: string;
24
+ onChange: (next: string) => void;
25
+ options: ReadonlyArray<string>;
26
+ placeholder?: string;
27
+ className?: string;
28
+ /** Cap rendered suggestions (filtering still scans all options). */
29
+ maxRendered?: number;
30
+ 'aria-label'?: string;
31
+ }
32
+
33
+ interface Anchor { left: number; top: number; width: number }
34
+
35
+ export function ComboInput({
36
+ value,
37
+ onChange,
38
+ options,
39
+ placeholder,
40
+ className,
41
+ maxRendered = 50,
42
+ 'aria-label': ariaLabel,
43
+ }: ComboInputProps) {
44
+ const [open, setOpen] = useState(false);
45
+ const [highlight, setHighlight] = useState(0);
46
+ const [anchor, setAnchor] = useState<Anchor | null>(null);
47
+ const inputRef = useRef<HTMLInputElement>(null);
48
+ const listRef = useRef<HTMLDivElement>(null);
49
+
50
+ const filtered = useMemo(() => {
51
+ const q = value.trim().toLowerCase();
52
+ const matches = q ? options.filter((o) => o.toLowerCase().includes(q)) : options;
53
+ return matches.slice(0, maxRendered);
54
+ }, [options, value, maxRendered]);
55
+
56
+ useEffect(() => { setHighlight(0); }, [filtered]);
57
+
58
+ const reposition = useCallback(() => {
59
+ const el = inputRef.current;
60
+ if (!el) return;
61
+ const r = el.getBoundingClientRect();
62
+ setAnchor({ left: r.left, top: r.bottom, width: r.width });
63
+ }, []);
64
+
65
+ // Track the input's position while open (capture = also catch ancestor
66
+ // scrolls inside the modal), and close on outside pointer-down / Escape.
67
+ useLayoutEffect(() => {
68
+ if (!open) return;
69
+ reposition();
70
+ const onScroll = () => reposition();
71
+ window.addEventListener('scroll', onScroll, true);
72
+ window.addEventListener('resize', onScroll);
73
+ const onDown = (e: MouseEvent) => {
74
+ const t = e.target as Node;
75
+ if (inputRef.current?.contains(t) || listRef.current?.contains(t)) return;
76
+ setOpen(false);
77
+ };
78
+ window.addEventListener('mousedown', onDown);
79
+ return () => {
80
+ window.removeEventListener('scroll', onScroll, true);
81
+ window.removeEventListener('resize', onScroll);
82
+ window.removeEventListener('mousedown', onDown);
83
+ };
84
+ }, [open, reposition]);
85
+
86
+ const showList = open && filtered.length > 0 && anchor !== null;
87
+
88
+ const commit = (v: string) => {
89
+ onChange(v);
90
+ setOpen(false);
91
+ };
92
+
93
+ return (
94
+ <>
95
+ <Input
96
+ ref={inputRef}
97
+ value={value}
98
+ placeholder={placeholder}
99
+ onChange={(e) => { onChange(e.target.value); setOpen(true); }}
100
+ onFocus={() => setOpen(true)}
101
+ onClick={() => setOpen(true)}
102
+ onKeyDown={(e) => {
103
+ if (e.key === 'ArrowDown') {
104
+ e.preventDefault();
105
+ setOpen(true);
106
+ setHighlight((h) => Math.min(h + 1, filtered.length - 1));
107
+ } else if (e.key === 'ArrowUp') {
108
+ e.preventDefault();
109
+ setHighlight((h) => Math.max(h - 1, 0));
110
+ } else if (e.key === 'Enter') {
111
+ if (showList && filtered[highlight] !== undefined) {
112
+ e.preventDefault();
113
+ commit(filtered[highlight]);
114
+ }
115
+ } else if (e.key === 'Escape') {
116
+ if (open) { e.stopPropagation(); setOpen(false); }
117
+ }
118
+ }}
119
+ className={className}
120
+ autoComplete="off"
121
+ role="combobox"
122
+ aria-expanded={showList}
123
+ aria-label={ariaLabel}
124
+ />
125
+ {showList && createPortal(
126
+ <div
127
+ ref={listRef}
128
+ role="listbox"
129
+ // Portaled to <body>, which sits OUTSIDE the Radix Dialog. Radix's
130
+ // scroll-lock disables pointer events on everything outside the
131
+ // dialog, so re-enable them here or mouse clicks/scroll are dead.
132
+ // Stop pointerdown from bubbling to the dialog's dismissable layer
133
+ // so selecting a value doesn't also close the whole modal.
134
+ style={{ position: 'fixed', left: anchor.left, top: anchor.top + 4, minWidth: anchor.width, pointerEvents: 'auto' }}
135
+ onPointerDown={(e) => e.stopPropagation()}
136
+ className="z-[120] max-h-60 w-max max-w-[20rem] overflow-y-auto rounded-md border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-950"
137
+ >
138
+ {filtered.map((o, i) => (
139
+ <button
140
+ key={o}
141
+ type="button"
142
+ role="option"
143
+ aria-selected={i === highlight}
144
+ // mousedown (not click) so the input doesn't blur-close first.
145
+ onMouseDown={(e) => { e.preventDefault(); commit(o); }}
146
+ onMouseEnter={() => setHighlight(i)}
147
+ className={cn(
148
+ 'block w-full truncate px-2 py-1 text-left text-xs font-mono',
149
+ i === highlight
150
+ ? 'bg-zinc-100 dark:bg-zinc-800'
151
+ : 'hover:bg-zinc-50 dark:hover:bg-zinc-900',
152
+ )}
153
+ title={o}
154
+ >
155
+ {o}
156
+ </button>
157
+ ))}
158
+ </div>,
159
+ document.body,
160
+ )}
161
+ </>
162
+ );
163
+ }
@@ -30,7 +30,7 @@ const TabsTrigger = React.forwardRef<
30
30
  <TabsPrimitive.Trigger
31
31
  ref={ref}
32
32
  className={cn(
33
- 'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
33
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
34
34
  className
35
35
  )}
36
36
  {...props}