@ifc-lite/viewer 1.15.0 → 1.17.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 (62) hide show
  1. package/.turbo/turbo-build.log +46 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/CHANGELOG.md +35 -0
  4. package/dist/assets/{Arrow.dom-OVBBPqOB.js → Arrow.dom-CcoDLP6E.js} +1 -1
  5. package/dist/assets/{basketViewActivator-Bx6QU4ma.js → basketViewActivator-FtbS__bG.js} +1 -1
  6. package/dist/assets/{browser-BMqEoJw4.js → browser-CXd3z0DO.js} +1 -1
  7. package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
  8. package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
  9. package/dist/assets/index-Ba4eoTe7.css +1 -0
  10. package/dist/assets/{index-DZY6uD8A.js → index-D99fzcwI.js} +32109 -28671
  11. package/dist/assets/{index-DsX-NCtx.js → index-DqNiuQep.js} +4 -4
  12. package/dist/assets/{native-bridge-D6tKFqGO.js → native-bridge-DjDj2M6p.js} +1 -1
  13. package/dist/assets/{wasm-bridge-D4kvZVDw.js → wasm-bridge-CDTF4ZQc.js} +1 -1
  14. package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
  15. package/dist/index.html +7 -2
  16. package/index.html +5 -0
  17. package/package.json +15 -14
  18. package/src/components/viewer/BCFPanel.tsx +12 -0
  19. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  20. package/src/components/viewer/CommandPalette.tsx +0 -6
  21. package/src/components/viewer/DataConnector.tsx +489 -284
  22. package/src/components/viewer/ExportDialog.tsx +66 -6
  23. package/src/components/viewer/KeyboardShortcutsDialog.tsx +227 -82
  24. package/src/components/viewer/MainToolbar.tsx +1 -5
  25. package/src/components/viewer/Viewport.tsx +42 -56
  26. package/src/components/viewer/ViewportContainer.tsx +3 -0
  27. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  28. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  29. package/src/components/viewer/hierarchy/HierarchyNode.tsx +26 -20
  30. package/src/components/viewer/hierarchy/ifc-icons.ts +90 -0
  31. package/src/components/viewer/lists/ListPanel.tsx +0 -21
  32. package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
  33. package/src/components/viewer/measureHandlers.ts +558 -0
  34. package/src/components/viewer/mouseHandlerTypes.ts +108 -0
  35. package/src/components/viewer/selectionHandlers.ts +86 -0
  36. package/src/components/viewer/useAnimationLoop.ts +116 -44
  37. package/src/components/viewer/useGeometryStreaming.ts +155 -367
  38. package/src/components/viewer/useKeyboardControls.ts +30 -46
  39. package/src/components/viewer/useMouseControls.ts +169 -695
  40. package/src/components/viewer/useRenderUpdates.ts +9 -59
  41. package/src/components/viewer/useTouchControls.ts +55 -40
  42. package/src/hooks/bcfIdLookup.ts +70 -0
  43. package/src/hooks/useBCF.ts +12 -31
  44. package/src/hooks/useIfcCache.ts +11 -29
  45. package/src/hooks/useIfcFederation.ts +5 -11
  46. package/src/hooks/useIfcLoader.ts +47 -56
  47. package/src/hooks/useIfcServer.ts +9 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +28 -12
  49. package/src/hooks/useLatestRef.ts +24 -0
  50. package/src/sdk/adapters/export-adapter.ts +2 -2
  51. package/src/sdk/adapters/model-adapter.ts +1 -0
  52. package/src/sdk/local-backend.ts +2 -0
  53. package/src/store/basketVisibleSet.ts +12 -0
  54. package/src/store/slices/bcfSlice.ts +9 -0
  55. package/src/store/slices/pinboardSlice.ts +46 -45
  56. package/src/utils/loadingUtils.ts +46 -0
  57. package/src/utils/serverDataModel.ts +4 -3
  58. package/src/utils/spatialHierarchy.ts +1 -1
  59. package/src/vite-env.d.ts +6 -2
  60. package/vite.config.ts +75 -23
  61. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  62. package/dist/assets/index-CJr7Itua.css +0 -1
@@ -14,7 +14,7 @@
14
14
  * - IFC5 → .ifcx
15
15
  */
16
16
 
17
- import { useState, useCallback, useMemo, useEffect } from 'react';
17
+ import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
18
18
  import {
19
19
  Download,
20
20
  AlertCircle,
@@ -48,10 +48,11 @@ import {
48
48
  AlertDescription,
49
49
  AlertTitle,
50
50
  } from '@/components/ui/alert';
51
+ import { Progress } from '@/components/ui/progress';
51
52
  import { useViewerStore } from '@/store';
52
53
  import { configureMutationView } from '@/utils/configureMutationView';
53
54
  import { toast } from '@/components/ui/toast';
54
- import { StepExporter, MergedExporter, Ifc5Exporter, IFC5_KNOWN_PROP_NAMES, type MergeModelInput } from '@ifc-lite/export';
55
+ import { StepExporter, MergedExporter, Ifc5Exporter, IFC5_KNOWN_PROP_NAMES, type MergeModelInput, type ExportProgress, type StepExportProgress } from '@ifc-lite/export';
55
56
  import { MutablePropertyView } from '@ifc-lite/mutations';
56
57
  import type { IfcDataStore } from '@ifc-lite/parser';
57
58
 
@@ -87,6 +88,27 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
87
88
  const [onlyKnownProperties, setOnlyKnownProperties] = useState(true);
88
89
  const [isExporting, setIsExporting] = useState(false);
89
90
  const [exportResult, setExportResult] = useState<{ success: boolean; message: string } | null>(null);
91
+ const [exportProgress, setExportProgress] = useState<{
92
+ phase: string;
93
+ percent: number;
94
+ entitiesProcessed: number;
95
+ entitiesTotal: number;
96
+ currentModel?: string;
97
+ } | null>(null);
98
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
99
+ const prevProgressRef = useRef<typeof exportProgress>(null);
100
+
101
+ const scrollToBottom = useCallback(() => {
102
+ if (scrollAreaRef.current) {
103
+ scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
104
+ }
105
+ }, []);
106
+
107
+ // Auto-scroll when progress first appears
108
+ useEffect(() => {
109
+ if (exportProgress && !prevProgressRef.current) scrollToBottom();
110
+ prevProgressRef.current = exportProgress;
111
+ }, [exportProgress, scrollToBottom]);
90
112
 
91
113
  // Derived: is this an IFC5/IFCX export?
92
114
  const isIfc5 = schema === 'IFC5';
@@ -286,6 +308,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
286
308
 
287
309
  setIsExporting(true);
288
310
  setExportResult(null);
311
+ setExportProgress(null);
289
312
 
290
313
  try {
291
314
  // Handle merged export of all models (STEP only, not IFC5)
@@ -308,7 +331,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
308
331
  }
309
332
  }
310
333
 
311
- const result = mergedExporter.export({
334
+ const result = await mergedExporter.exportAsync({
312
335
  schema,
313
336
  projectStrategy: 'keep-first',
314
337
  visibleOnly,
@@ -316,8 +339,19 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
316
339
  isolatedEntityIdsByModel: isolatedByModel,
317
340
  description: `Merged export of ${mergeInputs.length} models from ifc-lite`,
318
341
  application: 'ifc-lite',
342
+ onProgress: (p: ExportProgress) => setExportProgress({
343
+ phase: p.phase === 'preparing' ? 'Preparing models...'
344
+ : p.phase === 'entities' ? `Processing entities${p.currentModel ? ` (${p.currentModel})` : ''}...`
345
+ : 'Assembling file...',
346
+ percent: p.percent,
347
+ entitiesProcessed: p.entitiesProcessed,
348
+ entitiesTotal: p.entitiesTotal,
349
+ currentModel: p.currentModel,
350
+ }),
319
351
  });
320
352
 
353
+ setExportProgress(null);
354
+
321
355
  const blob = new Blob([result.content], { type: 'text/plain' });
322
356
  const url = URL.createObjectURL(blob);
323
357
  const a = document.createElement('a');
@@ -328,7 +362,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
328
362
  document.body.removeChild(a);
329
363
  URL.revokeObjectURL(url);
330
364
 
331
- const msg = `Merged ${result.stats.modelCount} models, ${result.stats.totalEntityCount} entities`;
365
+ const msg = `Merged ${result.stats.modelCount} models, ${result.stats.totalEntityCount.toLocaleString()} entities`;
332
366
  setExportResult({ success: true, message: msg });
333
367
  toast.success(msg);
334
368
  return;
@@ -431,7 +465,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
431
465
  const localHidden = visibleOnly ? getLocalHiddenIds(selectedModelId) : undefined;
432
466
  const localIsolated = visibleOnly ? getLocalIsolatedIds(selectedModelId) : undefined;
433
467
 
434
- const result = exporter.export({
468
+ const result = await exporter.exportAsync({
435
469
  schema,
436
470
  includeGeometry,
437
471
  applyMutations,
@@ -440,8 +474,18 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
440
474
  isolatedEntityIds: localIsolated,
441
475
  description: `Exported from ifc-lite with ${modifiedCount} modifications`,
442
476
  application: 'ifc-lite',
477
+ onProgress: (p: StepExportProgress) => setExportProgress({
478
+ phase: p.phase === 'preparing' ? 'Preparing export...'
479
+ : p.phase === 'entities' ? 'Processing entities...'
480
+ : 'Assembling file...',
481
+ percent: p.percent,
482
+ entitiesProcessed: p.entitiesProcessed,
483
+ entitiesTotal: p.entitiesTotal,
484
+ }),
443
485
  });
444
486
 
487
+ setExportProgress(null);
488
+
445
489
  const blob = new Blob([result.content], { type: 'text/plain' });
446
490
  const url = URL.createObjectURL(blob);
447
491
  const a = document.createElement('a');
@@ -488,7 +532,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
488
532
  </DialogDescription>
489
533
  </DialogHeader>
490
534
 
491
- <div className="grid gap-4 py-4">
535
+ <div ref={scrollAreaRef} className="grid gap-4 py-4 max-h-[60vh] overflow-y-auto">
492
536
  {/* Scope selector (only for STEP schemas with multiple models) */}
493
537
  {!isIfc5 && !changesOnly && modelList.length > 1 && (
494
538
  <div className="flex items-center gap-4">
@@ -632,6 +676,22 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
632
676
  </Alert>
633
677
  )}
634
678
 
679
+ {/* Export Progress */}
680
+ {isExporting && exportProgress && (
681
+ <div className="space-y-2">
682
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
683
+ <span className="flex items-center gap-2">
684
+ <Loader2 className="h-4 w-4 animate-spin" />
685
+ {exportProgress.phase}
686
+ </span>
687
+ <span>
688
+ {exportProgress.entitiesProcessed.toLocaleString()} / {exportProgress.entitiesTotal.toLocaleString()} entities
689
+ </span>
690
+ </div>
691
+ <Progress value={exportProgress.percent * 100} />
692
+ </div>
693
+ )}
694
+
635
695
  {/* Export result */}
636
696
  {exportResult && (
637
697
  <Alert variant={exportResult.success ? 'default' : 'destructive'}>
@@ -3,13 +3,12 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  import { useState, useEffect, useCallback, useMemo } from 'react';
6
- import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, Zap, Wrench, Plus } from 'lucide-react';
6
+ import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, ChevronRight, Zap, Wrench, Plus, Package, ShieldCheck } from 'lucide-react';
7
7
  import { Button } from '@/components/ui/button';
8
8
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
9
9
  import { KEYBOARD_SHORTCUTS } from '@/hooks/useKeyboardShortcuts';
10
10
 
11
11
  const GITHUB_URL = 'https://github.com/louistrue/ifc-lite';
12
- const INITIAL_RELEASE_COUNT = 5;
13
12
 
14
13
  interface InfoDialogProps {
15
14
  open: boolean;
@@ -34,86 +33,216 @@ const TYPE_CONFIG = {
34
33
  perf: { icon: Zap, className: 'text-blue-500' },
35
34
  } as const;
36
35
 
36
+ function PrivacyBanner() {
37
+ const [expanded, setExpanded] = useState(false);
38
+
39
+ return (
40
+ <div className="pt-2 border-t">
41
+ <button
42
+ onClick={() => setExpanded(!expanded)}
43
+ className="flex items-center gap-2 w-full rounded-md bg-emerald-500/10 px-2.5 py-1.5 text-left transition-colors hover:bg-emerald-500/15"
44
+ >
45
+ <ShieldCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
46
+ <span className="text-xs font-medium">Your IFC data never leaves your device.</span>
47
+ {expanded ? (
48
+ <ChevronDown className="h-3 w-3 ml-auto shrink-0 text-muted-foreground" />
49
+ ) : (
50
+ <ChevronRight className="h-3 w-3 ml-auto shrink-0 text-muted-foreground" />
51
+ )}
52
+ </button>
53
+ {expanded && (
54
+ <div className="mt-1.5 ml-1 space-y-1 text-xs text-muted-foreground">
55
+ <p>
56
+ All files are processed locally in the browser with{' '}
57
+ <a
58
+ href="https://webassembly.org/"
59
+ target="_blank"
60
+ rel="noopener noreferrer"
61
+ className="underline hover:text-foreground transition-colors"
62
+ >
63
+ WebAssembly (WASM)
64
+ </a>
65
+ {' '}&ndash; no server upload, near-native speed.
66
+ </p>
67
+ <p className="text-[11px] italic">
68
+ Verify: press <kbd className="px-1 py-0.5 bg-muted rounded border font-mono text-[10px]">F12</kbd> &rarr; Network tab &rarr; no IFC data transmitted.
69
+ </p>
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ }
75
+
37
76
  function AboutTab() {
77
+ const [showPackages, setShowPackages] = useState(false);
78
+ const packageVersions = __PACKAGE_VERSIONS__;
79
+
38
80
  return (
39
- <div className="space-y-4">
81
+ <div className="space-y-3">
40
82
  {/* Header */}
41
- <div className="text-center pb-4 border-b">
83
+ <div className="text-center pb-2 border-b">
42
84
  <h3 className="text-xl font-bold">ifc-lite</h3>
43
- <p className="text-sm text-muted-foreground mt-1">
44
- Version {__APP_VERSION__}
45
- </p>
46
85
  <p className="text-xs text-muted-foreground mt-0.5">
47
- Built {formatBuildDate(__BUILD_DATE__)}
48
- </p>
49
- </div>
50
-
51
- {/* Description */}
52
- <div className="space-y-2">
53
- <p className="text-sm">
54
- A high-performance IFC viewer for BIM models, built with WebGPU.
86
+ v{__APP_VERSION__} &middot; {formatBuildDate(__BUILD_DATE__)}
55
87
  </p>
56
88
  </div>
57
89
 
58
- {/* Features */}
59
- <div className="space-y-2">
60
- <h4 className="text-sm font-medium">Features</h4>
61
- <ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
62
- <li>WebGPU-accelerated 3D rendering</li>
63
- <li>IFC4 and IFC5/IFCX format support</li>
64
- <li>Multi-model federation</li>
65
- <li>Spatial hierarchy navigation</li>
66
- <li>Section planes and measurements</li>
67
- <li>Property inspection</li>
68
- </ul>
69
- </div>
70
-
71
90
  {/* Links */}
72
- <div className="pt-4 border-t space-y-2">
91
+ <div className="flex items-center justify-center gap-4 text-xs">
73
92
  <a
74
93
  href={GITHUB_URL}
75
94
  target="_blank"
76
95
  rel="noopener noreferrer"
77
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
96
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
78
97
  >
79
- <Github className="h-4 w-4" />
80
- <span>View on GitHub</span>
81
- <ExternalLink className="h-3 w-3" />
98
+ <Github className="h-3.5 w-3.5" />
99
+ GitHub
82
100
  </a>
83
101
  <a
84
102
  href={`${GITHUB_URL}/issues`}
85
103
  target="_blank"
86
104
  rel="noopener noreferrer"
87
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
105
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
88
106
  >
89
- <span className="w-4 text-center">🐛</span>
90
- <span>Report an issue</span>
107
+ Report issue
91
108
  <ExternalLink className="h-3 w-3" />
92
109
  </a>
110
+ <span className="text-muted-foreground">MPL-2.0</span>
93
111
  </div>
94
112
 
95
- {/* License */}
96
- <div className="pt-4 border-t">
97
- <p className="text-xs text-muted-foreground text-center">
98
- Licensed under Mozilla Public License 2.0
99
- </p>
113
+ {/* Feature chips */}
114
+ <div className="flex flex-wrap gap-1 justify-center pt-2 border-t">
115
+ {[
116
+ 'WebGPU', 'IFC2x3', 'IFC4', 'IFC4X3', 'IFC5/IFCX',
117
+ 'Federation', 'Measurements', 'Sections',
118
+ 'Properties', 'Data tables', 'Lens rules', 'IDS',
119
+ '2D drawings', 'BCF', 'Scripting', 'AI assistant',
120
+ 'glTF export', 'CSV', 'Parquet',
121
+ ].map((tag) => (
122
+ <span
123
+ key={tag}
124
+ className="px-2 py-0.5 text-[11px] rounded-full bg-muted/60 text-muted-foreground"
125
+ >
126
+ {tag}
127
+ </span>
128
+ ))}
100
129
  </div>
130
+
131
+ {/* Privacy & Security */}
132
+ <PrivacyBanner />
133
+
134
+ {/* Package Versions */}
135
+ {packageVersions.length > 0 && (
136
+ <div className="pt-2 border-t">
137
+ <button
138
+ onClick={() => setShowPackages(!showPackages)}
139
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
140
+ >
141
+ {showPackages ? (
142
+ <ChevronDown className="h-3 w-3" />
143
+ ) : (
144
+ <ChevronRight className="h-3 w-3" />
145
+ )}
146
+ <Package className="h-3 w-3" />
147
+ {packageVersions.length} packages
148
+ </button>
149
+ {showPackages && (
150
+ <div className="rounded-md border bg-muted/30 p-2 mt-1.5 max-h-48 overflow-y-auto">
151
+ <div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
152
+ {packageVersions.map((pkg) => (
153
+ <div
154
+ key={pkg.name}
155
+ className="flex items-center justify-between text-xs py-0.5 px-1 min-w-0"
156
+ >
157
+ <span className="text-muted-foreground font-mono truncate mr-2">
158
+ {pkg.name.replace('@ifc-lite/', '')}
159
+ </span>
160
+ <span className="font-mono shrink-0 tabular-nums">{pkg.version}</span>
161
+ </div>
162
+ ))}
163
+ </div>
164
+ </div>
165
+ )}
166
+ </div>
167
+ )}
101
168
  </div>
102
169
  );
103
170
  }
104
171
 
172
+ function formatPkgName(name: string): string {
173
+ return name.replace('@ifc-lite/', '');
174
+ }
175
+
176
+ type TimelineEntry = {
177
+ version: string;
178
+ isViewerVersion: boolean;
179
+ entries: Array<{ pkg: string; highlights: typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'] }>;
180
+ };
181
+
182
+ const compareSemver = (a: string, b: string) => {
183
+ const pa = a.split('.').map(Number);
184
+ const pb = b.split('.').map(Number);
185
+ for (let i = 0; i < 3; i++) {
186
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
187
+ }
188
+ return 0;
189
+ };
190
+
191
+ /** Merge all per-package changelogs into a unified timeline grouped by version. */
192
+ function buildTimeline(
193
+ packageChangelogs: typeof __RELEASE_HISTORY__,
194
+ viewerVersion: string
195
+ ): TimelineEntry[] {
196
+ type Highlights = typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'];
197
+ const versionMap = new Map<string, Map<string, Highlights>>();
198
+
199
+ for (const pkg of packageChangelogs) {
200
+ for (const release of pkg.releases) {
201
+ if (!versionMap.has(release.version)) {
202
+ versionMap.set(release.version, new Map());
203
+ }
204
+ versionMap.get(release.version)!.set(pkg.name, release.highlights);
205
+ }
206
+ }
207
+
208
+ return Array.from(versionMap.entries())
209
+ .sort(([a], [b]) => compareSemver(a, b))
210
+ .map(([version, pkgMap]) => ({
211
+ version,
212
+ isViewerVersion: version === viewerVersion,
213
+ entries: Array.from(pkgMap.entries())
214
+ .sort(([a], [b]) => a.localeCompare(b))
215
+ .map(([pkg, highlights]) => ({ pkg, highlights })),
216
+ }));
217
+ }
218
+
105
219
  function WhatsNewTab() {
106
- const [showAll, setShowAll] = useState(false);
107
- const releases = __RELEASE_HISTORY__;
220
+ const packageChangelogs = __RELEASE_HISTORY__;
221
+ const viewerVersion = __APP_VERSION__;
222
+ const [expandedVersions, setExpandedVersions] = useState<Set<string>>(() => new Set());
108
223
 
109
- const visibleReleases = useMemo(
110
- () => (showAll ? releases : releases.slice(0, INITIAL_RELEASE_COUNT)),
111
- [releases, showAll]
224
+ const timeline = useMemo(
225
+ () => buildTimeline(packageChangelogs, viewerVersion),
226
+ [packageChangelogs, viewerVersion]
112
227
  );
113
228
 
114
- const hasMore = releases.length > INITIAL_RELEASE_COUNT;
229
+ // Auto-expand the first version with actual changes
230
+ useEffect(() => {
231
+ if (timeline.length > 0 && expandedVersions.size === 0) {
232
+ setExpandedVersions(new Set([timeline[0].version]));
233
+ }
234
+ }, [timeline]);
235
+
236
+ const toggleVersion = useCallback((version: string) => {
237
+ setExpandedVersions((prev) => {
238
+ const next = new Set(prev);
239
+ if (next.has(version)) next.delete(version);
240
+ else next.add(version);
241
+ return next;
242
+ });
243
+ }, []);
115
244
 
116
- if (releases.length === 0) {
245
+ if (timeline.length === 0) {
117
246
  return (
118
247
  <div className="text-center py-8 text-sm text-muted-foreground">
119
248
  No release history available.
@@ -122,43 +251,59 @@ function WhatsNewTab() {
122
251
  }
123
252
 
124
253
  return (
125
- <div className="space-y-4">
126
- {visibleReleases.map((release, i) => (
127
- <div key={release.version}>
128
- <div className="flex items-center gap-2 mb-1.5">
129
- <span className="text-sm font-semibold">v{release.version}</span>
130
- {i === 0 && (
131
- <span className="px-1.5 py-0.5 text-[10px] font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 rounded">
132
- latest
254
+ <div className="space-y-1">
255
+ {timeline.map((release) => {
256
+ const isExpanded = expandedVersions.has(release.version);
257
+ const totalHighlights = release.entries.reduce((s, e) => s + e.highlights.length, 0);
258
+ return (
259
+ <div key={release.version}>
260
+ <button
261
+ onClick={() => toggleVersion(release.version)}
262
+ className="flex items-center gap-2 w-full py-1.5 px-1 text-left hover:bg-muted/40 transition-colors rounded"
263
+ >
264
+ {isExpanded ? (
265
+ <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
266
+ ) : (
267
+ <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
268
+ )}
269
+ <span className="text-sm font-semibold">v{release.version}</span>
270
+ {release.isViewerVersion && (
271
+ <span className="px-1.5 py-0.5 text-[10px] font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 rounded">
272
+ viewer
273
+ </span>
274
+ )}
275
+ <span className="text-xs text-muted-foreground ml-auto">
276
+ {totalHighlights} change{totalHighlights !== 1 ? 's' : ''}
133
277
  </span>
278
+ </button>
279
+ {isExpanded && (
280
+ <div className="ml-5 pb-2 space-y-2">
281
+ {release.entries.map(({ pkg, highlights }) => (
282
+ <div key={pkg}>
283
+ <span className="text-xs font-medium font-mono text-muted-foreground">
284
+ {formatPkgName(pkg)}
285
+ </span>
286
+ <ul className="space-y-0.5 mt-0.5">
287
+ {highlights.map((h) => {
288
+ const { icon: Icon, className } = TYPE_CONFIG[h.type];
289
+ return (
290
+ <li
291
+ key={h.text}
292
+ className="flex items-start gap-1.5 text-sm text-muted-foreground"
293
+ >
294
+ <Icon className={`h-3 w-3 mt-0.5 shrink-0 ${className}`} />
295
+ <span>{h.text}</span>
296
+ </li>
297
+ );
298
+ })}
299
+ </ul>
300
+ </div>
301
+ ))}
302
+ </div>
134
303
  )}
135
304
  </div>
136
- <ul className="space-y-1 ml-0.5">
137
- {release.highlights.map((h) => {
138
- const { icon: Icon, className } = TYPE_CONFIG[h.type];
139
- return (
140
- <li key={h.text} className="flex items-start gap-2 text-sm text-muted-foreground">
141
- <Icon className={`h-3.5 w-3.5 mt-0.5 shrink-0 ${className}`} />
142
- <span>{h.text}</span>
143
- </li>
144
- );
145
- })}
146
- </ul>
147
- {i < visibleReleases.length - 1 && (
148
- <div className="border-b mt-3" />
149
- )}
150
- </div>
151
- ))}
152
-
153
- {hasMore && !showAll && (
154
- <button
155
- onClick={() => setShowAll(true)}
156
- className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mx-auto"
157
- >
158
- <ChevronDown className="h-3.5 w-3.5" />
159
- Show all {releases.length} releases
160
- </button>
161
- )}
305
+ );
306
+ })}
162
307
 
163
308
  {/* Legend */}
164
309
  <div className="pt-3 border-t flex items-center justify-center gap-4 text-[11px] text-muted-foreground">
@@ -7,8 +7,6 @@ import {
7
7
  FolderOpen,
8
8
  Download,
9
9
  MousePointer2,
10
- Hand,
11
- Rotate3d,
12
10
  PersonStanding,
13
11
  Ruler,
14
12
  Scissors,
@@ -71,7 +69,7 @@ import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
71
69
  import { ThemeSwitch } from './ThemeSwitch';
72
70
  import { toast } from '@/components/ui/toast';
73
71
 
74
- type Tool = 'select' | 'pan' | 'orbit' | 'walk' | 'measure' | 'section';
72
+ type Tool = 'select' | 'walk' | 'measure' | 'section';
75
73
  type WorkspacePanel = 'script' | 'list' | 'bcf' | 'ids' | 'lens';
76
74
 
77
75
  // #region FIX: Move ToolButton OUTSIDE MainToolbar to prevent recreation on every render
@@ -741,8 +739,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
741
739
 
742
740
  {/* ── Navigation Tools ── */}
743
741
  <ToolButton tool="select" icon={MousePointer2} label="Select" shortcut="V" activeTool={activeTool} onToolChange={setActiveTool} />
744
- <ToolButton tool="pan" icon={Hand} label="Pan" shortcut="P" activeTool={activeTool} onToolChange={setActiveTool} />
745
- <ToolButton tool="orbit" icon={Rotate3d} label="Orbit" shortcut="O" activeTool={activeTool} onToolChange={setActiveTool} />
746
742
  <ToolButton tool="walk" icon={PersonStanding} label="Walk Mode" shortcut="C" activeTool={activeTool} onToolChange={setActiveTool} />
747
743
 
748
744
  <Separator orientation="vertical" className="h-6 mx-1" />