@ifc-lite/viewer 1.8.0 → 1.10.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 (75) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/assets/{Arrow.dom-CwcRxist.js → Arrow.dom-Bw5JMdDs.js} +1 -1
  3. package/dist/assets/browser-DdRf3aWl.js +694 -0
  4. package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
  5. package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
  6. package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
  7. package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
  8. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
  9. package/dist/assets/esbuild-COv63sf-.js +1 -0
  10. package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
  11. package/dist/assets/ffi-DlhRHxHv.js +1 -0
  12. package/dist/assets/ifc-lite_bg-C1-gLAHo.wasm +0 -0
  13. package/dist/assets/index-1ff6P0kc.js +100011 -0
  14. package/dist/assets/index-Bz7vHRxl.js +216 -0
  15. package/dist/assets/index-mvbV6NHd.css +1 -0
  16. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
  17. package/dist/assets/{native-bridge-5LbrYh3R.js → native-bridge-C5hD5vae.js} +1 -1
  18. package/dist/assets/{wasm-bridge-CgpLtj1h.js → wasm-bridge-CaNKXFGM.js} +1 -1
  19. package/dist/index.html +12 -3
  20. package/index.html +10 -1
  21. package/package.json +30 -21
  22. package/src/App.tsx +6 -1
  23. package/src/components/ui/dialog.tsx +8 -6
  24. package/src/components/viewer/CodeEditor.tsx +309 -0
  25. package/src/components/viewer/CommandPalette.tsx +597 -0
  26. package/src/components/viewer/MainToolbar.tsx +31 -3
  27. package/src/components/viewer/ScriptPanel.tsx +416 -0
  28. package/src/components/viewer/ViewerLayout.tsx +63 -11
  29. package/src/components/viewer/Viewport.tsx +58 -2
  30. package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
  31. package/src/components/viewer/useAnimationLoop.ts +4 -1
  32. package/src/components/viewer/useGeometryStreaming.ts +13 -1
  33. package/src/components/viewer/useRenderUpdates.ts +6 -1
  34. package/src/hooks/useKeyboardShortcuts.ts +1 -0
  35. package/src/hooks/useLens.ts +2 -1
  36. package/src/hooks/useSandbox.ts +113 -0
  37. package/src/hooks/useViewerSelectors.ts +22 -0
  38. package/src/index.css +6 -0
  39. package/src/lib/recent-files.ts +122 -0
  40. package/src/lib/scripts/persistence.ts +132 -0
  41. package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
  42. package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
  43. package/src/lib/scripts/templates/envelope-check.ts +164 -0
  44. package/src/lib/scripts/templates/federation-compare.ts +189 -0
  45. package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
  46. package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
  47. package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
  48. package/src/lib/scripts/templates/reset-view.ts +6 -0
  49. package/src/lib/scripts/templates/space-validation.ts +189 -0
  50. package/src/lib/scripts/templates/tsconfig.json +13 -0
  51. package/src/lib/scripts/templates.ts +86 -0
  52. package/src/sdk/BimProvider.tsx +50 -0
  53. package/src/sdk/adapters/export-adapter.ts +283 -0
  54. package/src/sdk/adapters/lens-adapter.ts +44 -0
  55. package/src/sdk/adapters/model-adapter.ts +32 -0
  56. package/src/sdk/adapters/model-compat.ts +80 -0
  57. package/src/sdk/adapters/mutate-adapter.ts +45 -0
  58. package/src/sdk/adapters/query-adapter.ts +241 -0
  59. package/src/sdk/adapters/selection-adapter.ts +29 -0
  60. package/src/sdk/adapters/spatial-adapter.ts +37 -0
  61. package/src/sdk/adapters/types.ts +11 -0
  62. package/src/sdk/adapters/viewer-adapter.ts +103 -0
  63. package/src/sdk/adapters/visibility-adapter.ts +61 -0
  64. package/src/sdk/local-backend.ts +144 -0
  65. package/src/sdk/useBimHost.ts +69 -0
  66. package/src/store/constants.ts +30 -2
  67. package/src/store/index.ts +24 -1
  68. package/src/store/slices/pinboardSlice.ts +37 -41
  69. package/src/store/slices/scriptSlice.ts +218 -0
  70. package/src/store/slices/uiSlice.ts +43 -0
  71. package/tsconfig.json +5 -2
  72. package/vite.config.ts +8 -0
  73. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  74. package/dist/assets/index-7WoQ-qVC.css +0 -1
  75. package/dist/assets/index-BSANf7-H.js +0 -78795
@@ -2,7 +2,7 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import React, { useRef, useCallback, useMemo } from 'react';
5
+ import React, { useRef, useCallback, useEffect, useMemo } from 'react';
6
6
  import {
7
7
  FolderOpen,
8
8
  Download,
@@ -66,6 +66,7 @@ import { BulkPropertyEditor } from './BulkPropertyEditor';
66
66
  import { DataConnector } from './DataConnector';
67
67
  import { ExportChangesButton } from './ExportChangesButton';
68
68
  import { useFloorplanView } from '@/hooks/useFloorplanView';
69
+ import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
69
70
 
70
71
  type Tool = 'select' | 'pan' | 'orbit' | 'walk' | 'measure' | 'section';
71
72
 
@@ -147,6 +148,16 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
147
148
  const addModelInputRef = useRef<HTMLInputElement>(null);
148
149
  const { loadFile, loading, progress, geometryResult, ifcDataStore, models, clearAllModels, loadFilesSequentially, loadFederatedIfcx, addIfcxOverlays, addModel } = useIfc();
149
150
 
151
+ // Listen for programmatic file-load requests (from command palette recent files)
152
+ useEffect(() => {
153
+ const handler = (e: Event) => {
154
+ const file = (e as CustomEvent<File>).detail;
155
+ if (file) loadFile(file);
156
+ };
157
+ window.addEventListener('ifc-lite:load-file', handler);
158
+ return () => window.removeEventListener('ifc-lite:load-file', handler);
159
+ }, [loadFile]);
160
+
150
161
  // Floorplan view
151
162
  const { availableStoreys, activateFloorplan } = useFloorplanView();
152
163
 
@@ -169,8 +180,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
169
180
  const resetViewerState = useViewerStore((state) => state.resetViewerState);
170
181
  const bcfPanelVisible = useViewerStore((state) => state.bcfPanelVisible);
171
182
  const toggleBcfPanel = useViewerStore((state) => state.toggleBcfPanel);
183
+ const setBcfPanelVisible = useViewerStore((state) => state.setBcfPanelVisible);
172
184
  const idsPanelVisible = useViewerStore((state) => state.idsPanelVisible);
173
185
  const toggleIdsPanel = useViewerStore((state) => state.toggleIdsPanel);
186
+ const setIdsPanelVisible = useViewerStore((state) => state.setIdsPanelVisible);
174
187
  const listPanelVisible = useViewerStore((state) => state.listPanelVisible);
175
188
  const toggleListPanel = useViewerStore((state) => state.toggleListPanel);
176
189
  const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
@@ -187,6 +200,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
187
200
  // Lens state
188
201
  const lensPanelVisible = useViewerStore((state) => state.lensPanelVisible);
189
202
  const toggleLensPanel = useViewerStore((state) => state.toggleLensPanel);
203
+ const setLensPanelVisible = useViewerStore((state) => state.setLensPanelVisible);
190
204
 
191
205
  // Check which type geometries exist across ALL loaded models (federation-aware)
192
206
  const typeGeometryExists = useMemo(() => {
@@ -231,6 +245,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
231
245
 
232
246
  if (supportedFiles.length === 0) return;
233
247
 
248
+ // Track recently opened files (metadata + blob cache for instant reload)
249
+ recordRecentFiles(supportedFiles.map(f => ({ name: f.name, size: f.size })));
250
+ cacheFileBlobs(supportedFiles);
251
+
234
252
  if (supportedFiles.length === 1) {
235
253
  // Single file - use loadFile (simpler single-model path)
236
254
  loadFile(supportedFiles[0]);
@@ -472,6 +490,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
472
490
  <div className="flex items-center gap-1 px-2 h-12 border-b bg-white dark:bg-black border-zinc-200 dark:border-zinc-800 relative z-50">
473
491
  {/* ── File Operations ── */}
474
492
  <input
493
+ id="file-input-open"
475
494
  ref={fileInputRef}
476
495
  type="file"
477
496
  accept=".ifc,.ifcx,.glb"
@@ -633,8 +652,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
633
652
  size="icon-sm"
634
653
  onClick={(e) => {
635
654
  (e.currentTarget as HTMLButtonElement).blur();
636
- // If BCF is being shown, also expand the right panel
637
655
  if (!bcfPanelVisible) {
656
+ // Close other right-panel content first, then expand
657
+ setIdsPanelVisible(false);
658
+ setLensPanelVisible(false);
638
659
  setRightPanelCollapsed(false);
639
660
  }
640
661
  toggleBcfPanel();
@@ -655,8 +676,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
655
676
  size="icon-sm"
656
677
  onClick={(e) => {
657
678
  (e.currentTarget as HTMLButtonElement).blur();
658
- // If IDS is being shown, also expand the right panel
659
679
  if (!idsPanelVisible) {
680
+ // Close other right-panel content first, then expand
681
+ setBcfPanelVisible(false);
682
+ setLensPanelVisible(false);
660
683
  setRightPanelCollapsed(false);
661
684
  }
662
685
  toggleIdsPanel();
@@ -677,6 +700,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
677
700
  size="icon-sm"
678
701
  onClick={(e) => {
679
702
  (e.currentTarget as HTMLButtonElement).blur();
703
+ // Close script panel (bottom-panel exclusivity)
704
+ useViewerStore.getState().setScriptPanelVisible(false);
680
705
  if (!listPanelVisible) {
681
706
  setRightPanelCollapsed(false);
682
707
  }
@@ -826,6 +851,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
826
851
  onClick={(e) => {
827
852
  (e.currentTarget as HTMLButtonElement).blur();
828
853
  if (!lensPanelVisible) {
854
+ // Close other right-panel content first, then expand
855
+ setBcfPanelVisible(false);
856
+ setIdsPanelVisible(false);
829
857
  setRightPanelCollapsed(false);
830
858
  }
831
859
  toggleLensPanel();
@@ -0,0 +1,416 @@
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
+ * ScriptPanel — Code editor + output console for BIM scripting.
7
+ *
8
+ * Uses CodeMirror 6 for the code editor with bim.* autocomplete.
9
+ * Connects to the QuickJS sandbox via useSandbox() and displays results
10
+ * in a log console.
11
+ */
12
+
13
+ import { useCallback, useMemo, useState, memo } from 'react';
14
+ import {
15
+ Play,
16
+ Save,
17
+ Plus,
18
+ Trash2,
19
+ X,
20
+ ChevronDown,
21
+ FileCode2,
22
+ RotateCcw,
23
+ AlertCircle,
24
+ CheckCircle2,
25
+ Info,
26
+ AlertTriangle,
27
+ } from 'lucide-react';
28
+ import { Button } from '@/components/ui/button';
29
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
30
+ import {
31
+ DropdownMenu,
32
+ DropdownMenuContent,
33
+ DropdownMenuItem,
34
+ DropdownMenuSeparator,
35
+ DropdownMenuTrigger,
36
+ } from '@/components/ui/dropdown-menu';
37
+ import {
38
+ Dialog,
39
+ DialogContent,
40
+ DialogHeader,
41
+ DialogFooter,
42
+ DialogTitle,
43
+ DialogDescription,
44
+ } from '@/components/ui/dialog';
45
+ import { ScrollArea } from '@/components/ui/scroll-area';
46
+ import { cn, formatDuration } from '@/lib/utils';
47
+ import { useViewerStore } from '@/store';
48
+ import { useSandbox } from '@/hooks/useSandbox';
49
+ import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates';
50
+ import { CodeEditor } from './CodeEditor';
51
+ import type { LogEntry } from '@/store/slices/scriptSlice';
52
+
53
+ interface ScriptPanelProps {
54
+ onClose?: () => void;
55
+ }
56
+
57
+ /** Consolidated script state selector — single subscription instead of 14 */
58
+ function useScriptState() {
59
+ const editorContent = useViewerStore((s) => s.scriptEditorContent);
60
+ const setEditorContent = useViewerStore((s) => s.setScriptEditorContent);
61
+ const executionState = useViewerStore((s) => s.scriptExecutionState);
62
+ const lastResult = useViewerStore((s) => s.scriptLastResult);
63
+ const lastError = useViewerStore((s) => s.scriptLastError);
64
+ const savedScripts = useViewerStore((s) => s.savedScripts);
65
+ const activeScriptId = useViewerStore((s) => s.activeScriptId);
66
+ const editorDirty = useViewerStore((s) => s.scriptEditorDirty);
67
+ const createScript = useViewerStore((s) => s.createScript);
68
+ const saveActiveScript = useViewerStore((s) => s.saveActiveScript);
69
+ const deleteScript = useViewerStore((s) => s.deleteScript);
70
+ const setActiveScriptId = useViewerStore((s) => s.setActiveScriptId);
71
+ const deleteConfirmId = useViewerStore((s) => s.scriptDeleteConfirmId);
72
+ const setDeleteConfirmId = useViewerStore((s) => s.setScriptDeleteConfirmId);
73
+
74
+ return {
75
+ editorContent,
76
+ setEditorContent,
77
+ executionState,
78
+ lastResult,
79
+ lastError,
80
+ savedScripts,
81
+ activeScriptId,
82
+ editorDirty,
83
+ createScript,
84
+ saveActiveScript,
85
+ deleteScript,
86
+ setActiveScriptId,
87
+ deleteConfirmId,
88
+ setDeleteConfirmId,
89
+ };
90
+ }
91
+
92
+ export function ScriptPanel({ onClose }: ScriptPanelProps) {
93
+ const {
94
+ editorContent,
95
+ setEditorContent,
96
+ executionState,
97
+ lastResult,
98
+ lastError,
99
+ savedScripts,
100
+ activeScriptId,
101
+ editorDirty,
102
+ createScript,
103
+ saveActiveScript,
104
+ deleteScript,
105
+ setActiveScriptId,
106
+ deleteConfirmId,
107
+ setDeleteConfirmId,
108
+ } = useScriptState();
109
+
110
+ const { execute, reset } = useSandbox();
111
+ const [outputCollapsed, setOutputCollapsed] = useState(false);
112
+
113
+ const activeScript = useMemo(
114
+ () => savedScripts.find((s) => s.id === activeScriptId),
115
+ [savedScripts, activeScriptId],
116
+ );
117
+
118
+ const deleteConfirmScript = useMemo(
119
+ () => (deleteConfirmId ? savedScripts.find((s) => s.id === deleteConfirmId) : null),
120
+ [savedScripts, deleteConfirmId],
121
+ );
122
+
123
+ const handleRun = useCallback(async () => {
124
+ if (executionState === 'running') return;
125
+ await execute(editorContent);
126
+ }, [execute, editorContent, executionState]);
127
+
128
+ const handleSave = useCallback(() => {
129
+ if (activeScriptId) {
130
+ saveActiveScript();
131
+ } else {
132
+ createScript('Untitled Script');
133
+ }
134
+ }, [activeScriptId, saveActiveScript, createScript]);
135
+
136
+ const handleNew = useCallback((name: string, code?: string) => {
137
+ createScript(name, code);
138
+ }, [createScript]);
139
+
140
+ const handleDeleteConfirm = useCallback(() => {
141
+ if (deleteConfirmId) {
142
+ deleteScript(deleteConfirmId);
143
+ }
144
+ }, [deleteConfirmId, deleteScript]);
145
+
146
+ return (
147
+ <div className="h-full flex flex-col bg-background">
148
+ {/* Header */}
149
+ <div className="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
150
+ <FileCode2 className="h-4 w-4 text-muted-foreground shrink-0" />
151
+ <span className="text-sm font-medium truncate">
152
+ {activeScript ? activeScript.name : 'Script Editor'}
153
+ {editorDirty && <span className="text-muted-foreground ml-1">*</span>}
154
+ </span>
155
+ <div className="flex-1" />
156
+
157
+ {/* Script selector dropdown */}
158
+ {savedScripts.length > 0 && (
159
+ <DropdownMenu>
160
+ <DropdownMenuTrigger asChild>
161
+ <Button variant="ghost" size="icon-xs">
162
+ <ChevronDown className="h-3.5 w-3.5" />
163
+ </Button>
164
+ </DropdownMenuTrigger>
165
+ <DropdownMenuContent align="end">
166
+ {savedScripts.map((s) => (
167
+ <DropdownMenuItem
168
+ key={s.id}
169
+ onClick={() => setActiveScriptId(s.id)}
170
+ className={cn(s.id === activeScriptId && 'bg-accent')}
171
+ >
172
+ <FileCode2 className="h-3.5 w-3.5 mr-2" />
173
+ {s.name}
174
+ </DropdownMenuItem>
175
+ ))}
176
+ <DropdownMenuSeparator />
177
+ {activeScriptId && (
178
+ <DropdownMenuItem
179
+ onClick={() => setDeleteConfirmId(activeScriptId)}
180
+ className="text-destructive"
181
+ >
182
+ <Trash2 className="h-3.5 w-3.5 mr-2" />
183
+ Delete
184
+ </DropdownMenuItem>
185
+ )}
186
+ </DropdownMenuContent>
187
+ </DropdownMenu>
188
+ )}
189
+
190
+ {onClose && (
191
+ <Button variant="ghost" size="icon-xs" onClick={onClose}>
192
+ <X className="h-3.5 w-3.5" />
193
+ </Button>
194
+ )}
195
+ </div>
196
+
197
+ {/* Toolbar */}
198
+ <div className="flex items-center gap-1 px-2 py-1 border-b shrink-0">
199
+ <Tooltip>
200
+ <TooltipTrigger asChild>
201
+ <Button
202
+ variant="default"
203
+ size="sm"
204
+ onClick={handleRun}
205
+ disabled={executionState === 'running'}
206
+ className="gap-1"
207
+ >
208
+ <Play className="h-3.5 w-3.5" />
209
+ Run
210
+ </Button>
211
+ </TooltipTrigger>
212
+ <TooltipContent>Run script (Ctrl+Enter)</TooltipContent>
213
+ </Tooltip>
214
+
215
+ <Tooltip>
216
+ <TooltipTrigger asChild>
217
+ <Button variant="ghost" size="icon-xs" onClick={handleSave}>
218
+ <Save className="h-3.5 w-3.5" />
219
+ </Button>
220
+ </TooltipTrigger>
221
+ <TooltipContent>Save (Ctrl+S)</TooltipContent>
222
+ </Tooltip>
223
+
224
+ {/* New script dropdown with templates */}
225
+ <DropdownMenu>
226
+ <Tooltip>
227
+ <TooltipTrigger asChild>
228
+ <DropdownMenuTrigger asChild>
229
+ <Button variant="ghost" size="icon-xs">
230
+ <Plus className="h-3.5 w-3.5" />
231
+ </Button>
232
+ </DropdownMenuTrigger>
233
+ </TooltipTrigger>
234
+ <TooltipContent>New script</TooltipContent>
235
+ </Tooltip>
236
+ <DropdownMenuContent align="start">
237
+ <DropdownMenuItem onClick={() => handleNew('Untitled Script')}>
238
+ <FileCode2 className="h-3.5 w-3.5 mr-2" />
239
+ Blank Script
240
+ </DropdownMenuItem>
241
+ <DropdownMenuSeparator />
242
+ {SCRIPT_TEMPLATES.map((t) => (
243
+ <DropdownMenuItem key={t.name} onClick={() => handleNew(t.name, t.code)}>
244
+ <FileCode2 className="h-3.5 w-3.5 mr-2" />
245
+ {t.name}
246
+ </DropdownMenuItem>
247
+ ))}
248
+ </DropdownMenuContent>
249
+ </DropdownMenu>
250
+
251
+ <Tooltip>
252
+ <TooltipTrigger asChild>
253
+ <Button variant="ghost" size="icon-xs" onClick={reset}>
254
+ <RotateCcw className="h-3.5 w-3.5" />
255
+ </Button>
256
+ </TooltipTrigger>
257
+ <TooltipContent>Reset sandbox</TooltipContent>
258
+ </Tooltip>
259
+
260
+ {/* Status indicator */}
261
+ <div className="flex-1" />
262
+ {executionState === 'running' && (
263
+ <span className="text-xs text-muted-foreground animate-pulse">Running...</span>
264
+ )}
265
+ {executionState === 'success' && lastResult && (
266
+ <span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
267
+ <CheckCircle2 className="h-3 w-3" />
268
+ {formatDuration(lastResult.durationMs)}
269
+ </span>
270
+ )}
271
+ {executionState === 'error' && (
272
+ <span className="text-xs text-destructive flex items-center gap-1">
273
+ <AlertCircle className="h-3 w-3" />
274
+ Error
275
+ </span>
276
+ )}
277
+ </div>
278
+
279
+ {/* Code Editor */}
280
+ <div className="flex-1 min-h-0 overflow-hidden">
281
+ <CodeEditor
282
+ value={editorContent}
283
+ onChange={setEditorContent}
284
+ onRun={handleRun}
285
+ onSave={handleSave}
286
+ className="h-full"
287
+ />
288
+ </div>
289
+
290
+ {/* Output Console */}
291
+ <div className="shrink-0 border-t">
292
+ {/* Output header */}
293
+ <button
294
+ className="flex items-center gap-1.5 px-2 py-1 w-full hover:bg-muted/50 transition-colors text-left"
295
+ onClick={() => setOutputCollapsed(!outputCollapsed)}
296
+ >
297
+ <ChevronDown
298
+ className={cn('h-3 w-3 transition-transform', outputCollapsed && '-rotate-90')}
299
+ />
300
+ <span className="text-xs font-medium text-muted-foreground">Output</span>
301
+ {lastResult && lastResult.logs.length > 0 && (
302
+ <span className="text-xs text-muted-foreground">({lastResult.logs.length})</span>
303
+ )}
304
+ </button>
305
+
306
+ {!outputCollapsed && (
307
+ <ScrollArea className="h-[140px]">
308
+ <div className="px-2 pb-2 font-mono text-xs space-y-0.5">
309
+ {/* Error message */}
310
+ {lastError && (
311
+ <div className="flex items-start gap-1.5 text-destructive">
312
+ <AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />
313
+ <span className="whitespace-pre-wrap break-all">{lastError}</span>
314
+ </div>
315
+ )}
316
+
317
+ {/* Log entries */}
318
+ {lastResult?.logs.map((log, i) => (
319
+ <MemoizedLogLine key={i} log={log} />
320
+ ))}
321
+
322
+ {/* Return value */}
323
+ {lastResult && lastResult.value !== undefined && lastResult.value !== null && (
324
+ <div className="text-muted-foreground mt-1 pt-1 border-t border-border/50">
325
+ <span className="opacity-60">Return: </span>
326
+ <span className="text-foreground">
327
+ {typeof lastResult.value === 'object'
328
+ ? JSON.stringify(lastResult.value, null, 2)
329
+ : String(lastResult.value)}
330
+ </span>
331
+ </div>
332
+ )}
333
+
334
+ {/* Empty state */}
335
+ {!lastError && !lastResult && (
336
+ <div className="text-muted-foreground py-2 text-center">
337
+ Press Run or Ctrl+Enter to execute
338
+ </div>
339
+ )}
340
+ </div>
341
+ </ScrollArea>
342
+ )}
343
+ </div>
344
+
345
+ {/* Delete confirmation dialog */}
346
+ <Dialog open={deleteConfirmId !== null} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null); }}>
347
+ <DialogContent className="sm:max-w-[400px]">
348
+ <DialogHeader>
349
+ <DialogTitle>Delete Script</DialogTitle>
350
+ <DialogDescription>
351
+ Are you sure you want to delete &ldquo;{deleteConfirmScript?.name ?? 'this script'}&rdquo;?
352
+ This action cannot be undone.
353
+ </DialogDescription>
354
+ </DialogHeader>
355
+ <DialogFooter>
356
+ <Button variant="ghost" onClick={() => setDeleteConfirmId(null)}>
357
+ Cancel
358
+ </Button>
359
+ <Button variant="destructive" onClick={handleDeleteConfirm}>
360
+ Delete
361
+ </Button>
362
+ </DialogFooter>
363
+ </DialogContent>
364
+ </Dialog>
365
+ </div>
366
+ );
367
+ }
368
+
369
+ /** Format a log entry's args into a display string */
370
+ function formatLogArgs(args: unknown[]): string {
371
+ return args.map((a) => {
372
+ if (typeof a === 'object' && a !== null) {
373
+ try {
374
+ return JSON.stringify(a, null, 2);
375
+ } catch {
376
+ return String(a);
377
+ }
378
+ }
379
+ return String(a);
380
+ }).join(' ');
381
+ }
382
+
383
+ /** Render a single log entry with appropriate icon and color — memoized */
384
+ const MemoizedLogLine = memo(function LogLine({ log }: { log: LogEntry }) {
385
+ const formatted = useMemo(() => formatLogArgs(log.args), [log.args]);
386
+
387
+ switch (log.level) {
388
+ case 'error':
389
+ return (
390
+ <div className="flex items-start gap-1.5 text-destructive">
391
+ <AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />
392
+ <span className="whitespace-pre-wrap break-all">{formatted}</span>
393
+ </div>
394
+ );
395
+ case 'warn':
396
+ return (
397
+ <div className="flex items-start gap-1.5 text-yellow-600 dark:text-yellow-400">
398
+ <AlertTriangle className="h-3 w-3 mt-0.5 shrink-0" />
399
+ <span className="whitespace-pre-wrap break-all">{formatted}</span>
400
+ </div>
401
+ );
402
+ case 'info':
403
+ return (
404
+ <div className="flex items-start gap-1.5 text-blue-600 dark:text-blue-400">
405
+ <Info className="h-3 w-3 mt-0.5 shrink-0" />
406
+ <span className="whitespace-pre-wrap break-all">{formatted}</span>
407
+ </div>
408
+ );
409
+ default:
410
+ return (
411
+ <div className="flex items-start gap-1.5">
412
+ <span className="whitespace-pre-wrap break-all">{formatted}</span>
413
+ </div>
414
+ );
415
+ }
416
+ });
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { useCallback, useEffect, useRef, useState } from 'react';
6
6
  import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels';
7
+ import type { PanelImperativeHandle } from 'react-resizable-panels';
7
8
  import { TooltipProvider } from '@/components/ui/tooltip';
8
9
  import { MainToolbar } from './MainToolbar';
9
10
  import { HierarchyPanel } from './HierarchyPanel';
@@ -19,6 +20,8 @@ import { BCFPanel } from './BCFPanel';
19
20
  import { IDSPanel } from './IDSPanel';
20
21
  import { LensPanel } from './LensPanel';
21
22
  import { ListPanel } from './lists/ListPanel';
23
+ import { ScriptPanel } from './ScriptPanel';
24
+ import { CommandPalette } from './CommandPalette';
22
25
 
23
26
  const BOTTOM_PANEL_MIN_HEIGHT = 120;
24
27
  const BOTTOM_PANEL_DEFAULT_HEIGHT = 300;
@@ -29,6 +32,21 @@ export function ViewerLayout() {
29
32
  useKeyboardShortcuts();
30
33
  const shortcutsDialog = useKeyboardShortcutsDialog();
31
34
 
35
+ // Command palette state
36
+ const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
37
+
38
+ // Ctrl+K / Cmd+K to open command palette
39
+ useEffect(() => {
40
+ const handler = (e: globalThis.KeyboardEvent) => {
41
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
42
+ e.preventDefault();
43
+ setCommandPaletteOpen((prev) => !prev);
44
+ }
45
+ };
46
+ window.addEventListener('keydown', handler);
47
+ return () => window.removeEventListener('keydown', handler);
48
+ }, []);
49
+
32
50
  // Initialize theme on mount
33
51
  const theme = useViewerStore((s) => s.theme);
34
52
  const isMobile = useViewerStore((s) => s.isMobile);
@@ -45,6 +63,27 @@ export function ViewerLayout() {
45
63
  const setListPanelVisible = useViewerStore((s) => s.setListPanelVisible);
46
64
  const lensPanelVisible = useViewerStore((s) => s.lensPanelVisible);
47
65
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
66
+ const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
67
+ const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
68
+
69
+ // Panel refs for programmatic collapse/expand (command palette, keyboard shortcuts)
70
+ const leftPanelRef = useRef<PanelImperativeHandle>(null);
71
+ const rightPanelRef = useRef<PanelImperativeHandle>(null);
72
+
73
+ // Sync store state → Panel collapse/expand on desktop
74
+ useEffect(() => {
75
+ const panel = leftPanelRef.current;
76
+ if (!panel) return;
77
+ if (leftPanelCollapsed && !panel.isCollapsed()) panel.collapse();
78
+ else if (!leftPanelCollapsed && panel.isCollapsed()) panel.expand();
79
+ }, [leftPanelCollapsed]);
80
+
81
+ useEffect(() => {
82
+ const panel = rightPanelRef.current;
83
+ if (!panel) return;
84
+ if (rightPanelCollapsed && !panel.isCollapsed()) panel.collapse();
85
+ else if (!rightPanelCollapsed && panel.isCollapsed()) panel.expand();
86
+ }, [rightPanelCollapsed]);
48
87
 
49
88
  // Bottom panel resize state (pixel height, persisted in ref to avoid re-renders during drag)
50
89
  const [bottomHeight, setBottomHeight] = useState(BOTTOM_PANEL_DEFAULT_HEIGHT);
@@ -113,12 +152,7 @@ export function ViewerLayout() {
113
152
  return () => window.removeEventListener('resize', checkMobile);
114
153
  }, [setIsMobile, setLeftPanelCollapsed, setRightPanelCollapsed]);
115
154
 
116
- // Initialize theme on mount and sync with store
117
- useEffect(() => {
118
- const currentTheme = useViewerStore.getState().theme;
119
- document.documentElement.classList.toggle('dark', currentTheme === 'dark');
120
- }, []);
121
-
155
+ // Keep DOM class in sync when theme changes (initial class is set by inline script in index.html)
122
156
  useEffect(() => {
123
157
  document.documentElement.classList.toggle('dark', theme === 'dark');
124
158
  }, [theme]);
@@ -133,6 +167,7 @@ export function ViewerLayout() {
133
167
  {/* Global Overlays */}
134
168
  <EntityContextMenu />
135
169
  <HoverTooltip />
170
+ <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
136
171
 
137
172
  {/* Main Toolbar */}
138
173
  <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
@@ -150,6 +185,11 @@ export function ViewerLayout() {
150
185
  minSize={10}
151
186
  collapsible
152
187
  collapsedSize={0}
188
+ panelRef={leftPanelRef}
189
+ onResize={() => {
190
+ const collapsed = leftPanelRef.current?.isCollapsed() ?? false;
191
+ if (collapsed !== leftPanelCollapsed) setLeftPanelCollapsed(collapsed);
192
+ }}
153
193
  >
154
194
  <div className="h-full w-full overflow-hidden">
155
195
  <HierarchyPanel />
@@ -174,6 +214,11 @@ export function ViewerLayout() {
174
214
  minSize={15}
175
215
  collapsible
176
216
  collapsedSize={0}
217
+ panelRef={rightPanelRef}
218
+ onResize={() => {
219
+ const collapsed = rightPanelRef.current?.isCollapsed() ?? false;
220
+ if (collapsed !== rightPanelCollapsed) setRightPanelCollapsed(collapsed);
221
+ }}
177
222
  >
178
223
  <div className="h-full w-full overflow-hidden">
179
224
  {lensPanelVisible ? (
@@ -190,8 +235,8 @@ export function ViewerLayout() {
190
235
  </PanelGroup>
191
236
  </div>
192
237
 
193
- {/* Bottom Panel - Lists (custom resizable, outside PanelGroup) */}
194
- {listPanelVisible && (
238
+ {/* Bottom Panel - Lists or Script (custom resizable, outside PanelGroup) */}
239
+ {(listPanelVisible || scriptPanelVisible) && (
195
240
  <div style={{ height: bottomHeight, flexShrink: 0 }} className="relative">
196
241
  {/* Drag handle */}
197
242
  <div
@@ -199,7 +244,11 @@ export function ViewerLayout() {
199
244
  onMouseDown={handleResizeStart}
200
245
  />
201
246
  <div className="h-full w-full overflow-hidden border-t pt-1.5">
202
- <ListPanel onClose={() => setListPanelVisible(false)} />
247
+ {scriptPanelVisible ? (
248
+ <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
249
+ ) : (
250
+ <ListPanel onClose={() => setListPanelVisible(false)} />
251
+ )}
203
252
  </div>
204
253
  </div>
205
254
  )}
@@ -240,12 +289,13 @@ export function ViewerLayout() {
240
289
  <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
241
290
  <div className="flex items-center justify-between p-2 border-b">
242
291
  <span className="font-medium text-sm">
243
- {listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
292
+ {scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
244
293
  </span>
245
294
  <button
246
295
  className="p-1 hover:bg-muted rounded"
247
296
  onClick={() => {
248
297
  setRightPanelCollapsed(true);
298
+ if (scriptPanelVisible) setScriptPanelVisible(false);
249
299
  if (listPanelVisible) setListPanelVisible(false);
250
300
  if (bcfPanelVisible) setBcfPanelVisible(false);
251
301
  if (lensPanelVisible) setLensPanelVisible(false);
@@ -259,7 +309,9 @@ export function ViewerLayout() {
259
309
  </button>
260
310
  </div>
261
311
  <div className="h-[calc(50vh-48px)] overflow-auto">
262
- {listPanelVisible ? (
312
+ {scriptPanelVisible ? (
313
+ <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
314
+ ) : listPanelVisible ? (
263
315
  <ListPanel onClose={() => setListPanelVisible(false)} />
264
316
  ) : lensPanelVisible ? (
265
317
  <LensPanel onClose={() => setLensPanelVisible(false)} />