@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.
- package/CHANGELOG.md +77 -0
- package/dist/assets/{Arrow.dom-CwcRxist.js → Arrow.dom-Bw5JMdDs.js} +1 -1
- package/dist/assets/browser-DdRf3aWl.js +694 -0
- package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
- package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
- package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
- package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
- package/dist/assets/esbuild-COv63sf-.js +1 -0
- package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
- package/dist/assets/ffi-DlhRHxHv.js +1 -0
- package/dist/assets/ifc-lite_bg-C1-gLAHo.wasm +0 -0
- package/dist/assets/index-1ff6P0kc.js +100011 -0
- package/dist/assets/index-Bz7vHRxl.js +216 -0
- package/dist/assets/index-mvbV6NHd.css +1 -0
- package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
- package/dist/assets/{native-bridge-5LbrYh3R.js → native-bridge-C5hD5vae.js} +1 -1
- package/dist/assets/{wasm-bridge-CgpLtj1h.js → wasm-bridge-CaNKXFGM.js} +1 -1
- package/dist/index.html +12 -3
- package/index.html +10 -1
- package/package.json +30 -21
- package/src/App.tsx +6 -1
- package/src/components/ui/dialog.tsx +8 -6
- package/src/components/viewer/CodeEditor.tsx +309 -0
- package/src/components/viewer/CommandPalette.tsx +597 -0
- package/src/components/viewer/MainToolbar.tsx +31 -3
- package/src/components/viewer/ScriptPanel.tsx +416 -0
- package/src/components/viewer/ViewerLayout.tsx +63 -11
- package/src/components/viewer/Viewport.tsx +58 -2
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
- package/src/components/viewer/useAnimationLoop.ts +4 -1
- package/src/components/viewer/useGeometryStreaming.ts +13 -1
- package/src/components/viewer/useRenderUpdates.ts +6 -1
- package/src/hooks/useKeyboardShortcuts.ts +1 -0
- package/src/hooks/useLens.ts +2 -1
- package/src/hooks/useSandbox.ts +113 -0
- package/src/hooks/useViewerSelectors.ts +22 -0
- package/src/index.css +6 -0
- package/src/lib/recent-files.ts +122 -0
- package/src/lib/scripts/persistence.ts +132 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
- package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
- package/src/lib/scripts/templates/envelope-check.ts +164 -0
- package/src/lib/scripts/templates/federation-compare.ts +189 -0
- package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
- package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
- package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
- package/src/lib/scripts/templates/reset-view.ts +6 -0
- package/src/lib/scripts/templates/space-validation.ts +189 -0
- package/src/lib/scripts/templates/tsconfig.json +13 -0
- package/src/lib/scripts/templates.ts +86 -0
- package/src/sdk/BimProvider.tsx +50 -0
- package/src/sdk/adapters/export-adapter.ts +283 -0
- package/src/sdk/adapters/lens-adapter.ts +44 -0
- package/src/sdk/adapters/model-adapter.ts +32 -0
- package/src/sdk/adapters/model-compat.ts +80 -0
- package/src/sdk/adapters/mutate-adapter.ts +45 -0
- package/src/sdk/adapters/query-adapter.ts +241 -0
- package/src/sdk/adapters/selection-adapter.ts +29 -0
- package/src/sdk/adapters/spatial-adapter.ts +37 -0
- package/src/sdk/adapters/types.ts +11 -0
- package/src/sdk/adapters/viewer-adapter.ts +103 -0
- package/src/sdk/adapters/visibility-adapter.ts +61 -0
- package/src/sdk/local-backend.ts +144 -0
- package/src/sdk/useBimHost.ts +69 -0
- package/src/store/constants.ts +30 -2
- package/src/store/index.ts +24 -1
- package/src/store/slices/pinboardSlice.ts +37 -41
- package/src/store/slices/scriptSlice.ts +218 -0
- package/src/store/slices/uiSlice.ts +43 -0
- package/tsconfig.json +5 -2
- package/vite.config.ts +8 -0
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/index-7WoQ-qVC.css +0 -1
- 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 “{deleteConfirmScript?.name ?? 'this script'}”?
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
{
|
|
312
|
+
{scriptPanelVisible ? (
|
|
313
|
+
<ScriptPanel onClose={() => setScriptPanelVisible(false)} />
|
|
314
|
+
) : listPanelVisible ? (
|
|
263
315
|
<ListPanel onClose={() => setListPanelVisible(false)} />
|
|
264
316
|
) : lensPanelVisible ? (
|
|
265
317
|
<LensPanel onClose={() => setLensPanelVisible(false)} />
|