@ifc-lite/viewer 1.17.6 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +69 -10
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +70 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -9,19 +9,23 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
|
|
9
9
|
import { MainToolbar } from './MainToolbar';
|
|
10
10
|
import { HierarchyPanel } from './HierarchyPanel';
|
|
11
11
|
import { PropertiesPanel } from './PropertiesPanel';
|
|
12
|
+
import { AddElementPanel } from './AddElementPanel';
|
|
12
13
|
import { StatusBar } from './StatusBar';
|
|
13
14
|
import { ViewportContainer } from './ViewportContainer';
|
|
14
15
|
import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
|
|
15
16
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
|
16
17
|
import { useViewerStore } from '@/store';
|
|
17
18
|
import { EntityContextMenu } from './EntityContextMenu';
|
|
19
|
+
import { useDuplicateShortcut } from './useDuplicateShortcut';
|
|
18
20
|
import { HoverTooltip } from './HoverTooltip';
|
|
19
21
|
import { BCFPanel } from './BCFPanel';
|
|
20
22
|
import { IDSPanel } from './IDSPanel';
|
|
21
23
|
import { LensPanel } from './LensPanel';
|
|
22
24
|
import { ListPanel } from './lists/ListPanel';
|
|
23
25
|
import { ScriptPanel } from './ScriptPanel';
|
|
26
|
+
import { GanttPanel } from './schedule/GanttPanel';
|
|
24
27
|
import { CommandPalette } from './CommandPalette';
|
|
28
|
+
import { SearchModal } from './SearchModal';
|
|
25
29
|
import { DesktopEntitlementBanner } from './DesktopEntitlementBanner';
|
|
26
30
|
import {
|
|
27
31
|
closeActiveAnalysisExtension,
|
|
@@ -37,6 +41,8 @@ const BOTTOM_PANEL_MAX_RATIO = 0.7; // max 70% of container
|
|
|
37
41
|
export function ViewerLayout() {
|
|
38
42
|
// Initialize keyboard shortcuts
|
|
39
43
|
useKeyboardShortcuts();
|
|
44
|
+
// ⌘D / Ctrl+D to duplicate the current selection.
|
|
45
|
+
useDuplicateShortcut();
|
|
40
46
|
const shortcutsDialog = useKeyboardShortcutsDialog();
|
|
41
47
|
|
|
42
48
|
// Command palette state
|
|
@@ -76,6 +82,8 @@ export function ViewerLayout() {
|
|
|
76
82
|
const setRightPanelCollapsed = useViewerStore((s) => s.setRightPanelCollapsed);
|
|
77
83
|
const bcfPanelVisible = useViewerStore((s) => s.bcfPanelVisible);
|
|
78
84
|
const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
|
|
85
|
+
const activeTool = useViewerStore((s) => s.activeTool);
|
|
86
|
+
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
79
87
|
const idsPanelVisible = useViewerStore((s) => s.idsPanelVisible);
|
|
80
88
|
const setIdsPanelVisible = useViewerStore((s) => s.setIdsPanelVisible);
|
|
81
89
|
const listPanelVisible = useViewerStore((s) => s.listPanelVisible);
|
|
@@ -84,6 +92,8 @@ export function ViewerLayout() {
|
|
|
84
92
|
const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
|
|
85
93
|
const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
|
|
86
94
|
const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
|
|
95
|
+
const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
|
|
96
|
+
const setGanttPanelVisible = useViewerStore((s) => s.setGanttPanelVisible);
|
|
87
97
|
const analysisExtensionState = useSyncExternalStore(
|
|
88
98
|
subscribeAnalysisExtensions,
|
|
89
99
|
getAnalysisExtensionsSnapshot,
|
|
@@ -200,6 +210,7 @@ export function ViewerLayout() {
|
|
|
200
210
|
<EntityContextMenu />
|
|
201
211
|
<HoverTooltip />
|
|
202
212
|
<CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
|
|
213
|
+
<SearchModal />
|
|
203
214
|
|
|
204
215
|
{/* Main Toolbar */}
|
|
205
216
|
<MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
|
|
@@ -256,6 +267,8 @@ export function ViewerLayout() {
|
|
|
256
267
|
<div className="h-full w-full overflow-hidden panel-container">
|
|
257
268
|
{activeRightAnalysisExtension ? (
|
|
258
269
|
activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
270
|
+
) : activeTool === 'addElement' ? (
|
|
271
|
+
<AddElementPanel onClose={() => setActiveTool('select')} />
|
|
259
272
|
) : lensPanelVisible ? (
|
|
260
273
|
<LensPanel onClose={() => setLensPanelVisible(false)} />
|
|
261
274
|
) : idsPanelVisible ? (
|
|
@@ -270,8 +283,8 @@ export function ViewerLayout() {
|
|
|
270
283
|
</PanelGroup>
|
|
271
284
|
</div>
|
|
272
285
|
|
|
273
|
-
{/* Bottom Panel - Lists
|
|
274
|
-
{(listPanelVisible || scriptPanelVisible || !!activeBottomAnalysisExtension) && (
|
|
286
|
+
{/* Bottom Panel - Lists / Script / Gantt / analysis ext (custom resizable) */}
|
|
287
|
+
{(listPanelVisible || scriptPanelVisible || ganttPanelVisible || !!activeBottomAnalysisExtension) && (
|
|
275
288
|
<div style={{ height: bottomHeight, flexShrink: 0 }} className="relative">
|
|
276
289
|
{/* Drag handle */}
|
|
277
290
|
<div
|
|
@@ -281,6 +294,8 @@ export function ViewerLayout() {
|
|
|
281
294
|
<div className="h-full w-full overflow-hidden border-t pt-1.5">
|
|
282
295
|
{activeBottomAnalysisExtension ? (
|
|
283
296
|
activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
297
|
+
) : ganttPanelVisible ? (
|
|
298
|
+
<GanttPanel onClose={() => setGanttPanelVisible(false)} />
|
|
284
299
|
) : scriptPanelVisible ? (
|
|
285
300
|
<ScriptPanel onClose={() => setScriptPanelVisible(false)} />
|
|
286
301
|
) : (
|
|
@@ -326,7 +341,7 @@ export function ViewerLayout() {
|
|
|
326
341
|
<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">
|
|
327
342
|
<div className="flex items-center justify-between p-2 border-b">
|
|
328
343
|
<span className="font-medium text-sm">
|
|
329
|
-
{activeAnalysisExtension ? activeAnalysisExtension.label : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : '
|
|
344
|
+
{activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Inspector'}
|
|
330
345
|
</span>
|
|
331
346
|
<button
|
|
332
347
|
className="p-1 hover:bg-muted rounded"
|
|
@@ -334,6 +349,7 @@ export function ViewerLayout() {
|
|
|
334
349
|
setRightPanelCollapsed(true);
|
|
335
350
|
if (scriptPanelVisible) setScriptPanelVisible(false);
|
|
336
351
|
if (listPanelVisible) setListPanelVisible(false);
|
|
352
|
+
if (ganttPanelVisible) setGanttPanelVisible(false);
|
|
337
353
|
if (bcfPanelVisible) setBcfPanelVisible(false);
|
|
338
354
|
if (lensPanelVisible) setLensPanelVisible(false);
|
|
339
355
|
if (idsPanelVisible) setIdsPanelVisible(false);
|
|
@@ -351,10 +367,14 @@ export function ViewerLayout() {
|
|
|
351
367
|
activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
352
368
|
) : activeRightAnalysisExtension ? (
|
|
353
369
|
activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
|
|
370
|
+
) : ganttPanelVisible ? (
|
|
371
|
+
<GanttPanel onClose={() => setGanttPanelVisible(false)} />
|
|
354
372
|
) : scriptPanelVisible ? (
|
|
355
373
|
<ScriptPanel onClose={() => setScriptPanelVisible(false)} />
|
|
356
374
|
) : listPanelVisible ? (
|
|
357
375
|
<ListPanel onClose={() => setListPanelVisible(false)} />
|
|
376
|
+
) : activeTool === 'addElement' ? (
|
|
377
|
+
<AddElementPanel onClose={() => setActiveTool('select')} />
|
|
358
378
|
) : lensPanelVisible ? (
|
|
359
379
|
<LensPanel onClose={() => setLensPanelVisible(false)} />
|
|
360
380
|
) : idsPanelVisible ? (
|
|
@@ -386,7 +406,7 @@ export function ViewerLayout() {
|
|
|
386
406
|
setRightPanelCollapsed(!rightPanelCollapsed);
|
|
387
407
|
}}
|
|
388
408
|
>
|
|
389
|
-
|
|
409
|
+
Inspector
|
|
390
410
|
</button>
|
|
391
411
|
</div>
|
|
392
412
|
</div>
|
|
@@ -456,11 +456,21 @@ export function Viewport({
|
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
// Set cursor based on active tool
|
|
459
|
-
if (activeTool === 'measure') {
|
|
459
|
+
if (activeTool === 'measure' || activeTool === 'annotate' || activeTool === 'addElement') {
|
|
460
460
|
canvas.style.cursor = 'crosshair';
|
|
461
461
|
} else {
|
|
462
462
|
canvas.style.cursor = 'default';
|
|
463
463
|
}
|
|
464
|
+
|
|
465
|
+
// Clear add-element pending state + hover point when leaving the
|
|
466
|
+
// tool so the SVG overlay doesn't paint stale geometry from a
|
|
467
|
+
// previous session.
|
|
468
|
+
if (activeTool !== 'addElement') {
|
|
469
|
+
const state = useViewerStore.getState();
|
|
470
|
+
if (state.addElementPendingPoints.length > 0 || state.addElementHoverPoint !== null) {
|
|
471
|
+
state.clearAddElementPending();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
464
474
|
}, [activeTool, activeMeasurement, cancelMeasurement]);
|
|
465
475
|
|
|
466
476
|
// Helper: calculate scale bar value (world-space size for 96px scale bar)
|
|
@@ -6,6 +6,7 @@ import { useMemo, useRef, useState, useCallback, useEffect, useSyncExternalStore
|
|
|
6
6
|
import { Viewport } from './Viewport';
|
|
7
7
|
import { ViewportOverlays } from './ViewportOverlays';
|
|
8
8
|
import { ToolOverlays } from './ToolOverlays';
|
|
9
|
+
import { AnnotationLayer } from './annotations/AnnotationLayer';
|
|
9
10
|
import { Section2DPanel } from './Section2DPanel';
|
|
10
11
|
import { BasketPresentationDock } from './BasketPresentationDock';
|
|
11
12
|
import { BCFOverlay } from './bcf/BCFOverlay';
|
|
@@ -851,6 +852,7 @@ export function ViewportContainer() {
|
|
|
851
852
|
releaseGeometryAfterStream={false}
|
|
852
853
|
onGeometryReleased={releaseGeometryMemory}
|
|
853
854
|
/>
|
|
855
|
+
<AnnotationLayer />
|
|
854
856
|
{bcfOverlayVisible && <BCFOverlay />}
|
|
855
857
|
<ViewportOverlays />
|
|
856
858
|
<ToolOverlays />
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
* The inline note input that appears at the click site when the user
|
|
7
|
+
* drops a fresh pin with the Annotate tool. Shape mirrors the popover's
|
|
8
|
+
* edit mode so muscle memory carries over, but the chrome is lighter
|
|
9
|
+
* (a guiding label, no entity-context header) since this is a
|
|
10
|
+
* commit-or-cancel surface.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
+
import { Check, X } from 'lucide-react';
|
|
15
|
+
import { Button } from '@/components/ui/button';
|
|
16
|
+
import { cn } from '@/lib/utils';
|
|
17
|
+
|
|
18
|
+
const MAX_NOTE_LEN = 2000;
|
|
19
|
+
const SOFT_NOTE_LIMIT = 200;
|
|
20
|
+
const INPUT_WIDTH = 280;
|
|
21
|
+
const INPUT_OFFSET_X = 16;
|
|
22
|
+
|
|
23
|
+
export interface AnnotationDropInputProps {
|
|
24
|
+
anchorX: number;
|
|
25
|
+
anchorY: number;
|
|
26
|
+
canvasWidth: number;
|
|
27
|
+
canvasHeight: number;
|
|
28
|
+
/** Resolved entity type when the drop landed on a known mesh. */
|
|
29
|
+
entityType?: string | null;
|
|
30
|
+
entityExpressId?: number | null;
|
|
31
|
+
onSave: (note: string) => void;
|
|
32
|
+
onCancel: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function AnnotationDropInput({
|
|
36
|
+
anchorX,
|
|
37
|
+
anchorY,
|
|
38
|
+
canvasWidth,
|
|
39
|
+
canvasHeight,
|
|
40
|
+
entityType,
|
|
41
|
+
entityExpressId,
|
|
42
|
+
onSave,
|
|
43
|
+
onCancel,
|
|
44
|
+
}: AnnotationDropInputProps) {
|
|
45
|
+
const [draft, setDraft] = useState('');
|
|
46
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
47
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
textareaRef.current?.focus();
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
// Cancel on outside click, but defer registration so the click that
|
|
54
|
+
// dropped the pin doesn't immediately close the input.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const handler = (e: MouseEvent) => {
|
|
57
|
+
const node = containerRef.current;
|
|
58
|
+
if (!node) return;
|
|
59
|
+
if (node.contains(e.target as Node)) return;
|
|
60
|
+
// Empty draft on outside-click → silent cancel; non-empty
|
|
61
|
+
// → commit the draft (matches "blur to save" feel without
|
|
62
|
+
// destroying typed content). An over-limit draft is rejected
|
|
63
|
+
// consistently with the disabled save button.
|
|
64
|
+
if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
|
|
65
|
+
onCancel();
|
|
66
|
+
} else {
|
|
67
|
+
onSave(draft);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const id = window.setTimeout(() => {
|
|
71
|
+
document.addEventListener('mousedown', handler);
|
|
72
|
+
}, 0);
|
|
73
|
+
return () => {
|
|
74
|
+
window.clearTimeout(id);
|
|
75
|
+
document.removeEventListener('mousedown', handler);
|
|
76
|
+
};
|
|
77
|
+
}, [draft, onSave, onCancel]);
|
|
78
|
+
|
|
79
|
+
const handleKeyDown = useCallback(
|
|
80
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
81
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
|
|
84
|
+
// Over-limit Enter does nothing — match the disabled button.
|
|
85
|
+
if (draft.trim().length === 0) onCancel();
|
|
86
|
+
} else {
|
|
87
|
+
onSave(draft);
|
|
88
|
+
}
|
|
89
|
+
} else if (e.key === 'Escape') {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
onCancel();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
[draft, onSave, onCancel],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const wantsLeft = anchorX + INPUT_OFFSET_X + INPUT_WIDTH > canvasWidth;
|
|
98
|
+
const left = wantsLeft
|
|
99
|
+
? Math.max(8, anchorX - INPUT_OFFSET_X - INPUT_WIDTH)
|
|
100
|
+
: Math.min(anchorX + INPUT_OFFSET_X, canvasWidth - INPUT_WIDTH - 8);
|
|
101
|
+
const top = Math.min(Math.max(8, anchorY - 8), canvasHeight - 140);
|
|
102
|
+
|
|
103
|
+
const charCountVisible = draft.length >= SOFT_NOTE_LIMIT;
|
|
104
|
+
const overSoftLimit = draft.length > SOFT_NOTE_LIMIT;
|
|
105
|
+
const overHardLimit = draft.length > MAX_NOTE_LEN;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
ref={containerRef}
|
|
110
|
+
role="dialog"
|
|
111
|
+
aria-label="New annotation"
|
|
112
|
+
style={{ left, top, width: INPUT_WIDTH }}
|
|
113
|
+
className={cn(
|
|
114
|
+
'absolute z-[60] pointer-events-auto',
|
|
115
|
+
'rounded-md border border-amber-400/70 dark:border-amber-600/40',
|
|
116
|
+
'bg-white/95 dark:bg-zinc-950/95 backdrop-blur-md',
|
|
117
|
+
'shadow-[0_8px_32px_rgba(0,0,0,0.18)]',
|
|
118
|
+
'overflow-hidden',
|
|
119
|
+
'animate-in fade-in-0 zoom-in-95 duration-150',
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
{/* Guiding label — explicit so the user knows what to type and
|
|
123
|
+
establishes "this is for capturing intent, not chat". */}
|
|
124
|
+
<div className="px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20">
|
|
125
|
+
<span className="font-mono text-[10px] uppercase tracking-wider text-amber-700 dark:text-amber-300">
|
|
126
|
+
What's worth noting?
|
|
127
|
+
{entityType && (
|
|
128
|
+
<span className="ml-1.5 text-zinc-500 dark:text-zinc-400">
|
|
129
|
+
· {entityType}
|
|
130
|
+
{entityExpressId !== null && entityExpressId !== undefined && ` #${entityExpressId}`}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="px-3 py-2.5">
|
|
137
|
+
<textarea
|
|
138
|
+
ref={textareaRef}
|
|
139
|
+
value={draft}
|
|
140
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
141
|
+
onKeyDown={handleKeyDown}
|
|
142
|
+
placeholder="A short note — flag a defect, ask a question, leave context…"
|
|
143
|
+
rows={3}
|
|
144
|
+
maxLength={MAX_NOTE_LEN + 100}
|
|
145
|
+
className={cn(
|
|
146
|
+
'w-full resize-none font-mono text-[11px] leading-relaxed',
|
|
147
|
+
'bg-zinc-50 dark:bg-zinc-900/60 text-zinc-800 dark:text-zinc-200',
|
|
148
|
+
'border border-zinc-200 dark:border-zinc-800 rounded-sm',
|
|
149
|
+
'px-2 py-1.5 outline-none focus:ring-1',
|
|
150
|
+
overHardLimit
|
|
151
|
+
? 'focus:ring-red-400 border-red-300 dark:border-red-700/60'
|
|
152
|
+
: 'focus:ring-amber-400/50 focus:border-amber-300/60',
|
|
153
|
+
)}
|
|
154
|
+
spellCheck
|
|
155
|
+
autoCorrect="on"
|
|
156
|
+
/>
|
|
157
|
+
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-mono">
|
|
158
|
+
<span className="text-zinc-400 dark:text-zinc-500">
|
|
159
|
+
⏎ save · ⇧⏎ newline · esc cancel
|
|
160
|
+
</span>
|
|
161
|
+
{charCountVisible && (
|
|
162
|
+
<span
|
|
163
|
+
className={cn(
|
|
164
|
+
'tabular-nums',
|
|
165
|
+
overHardLimit
|
|
166
|
+
? 'text-red-500'
|
|
167
|
+
: overSoftLimit
|
|
168
|
+
? 'text-amber-600 dark:text-amber-400'
|
|
169
|
+
: 'text-zinc-400',
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
{draft.length}/{MAX_NOTE_LEN}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
<div className="mt-2 flex items-center justify-end gap-1">
|
|
177
|
+
<Button
|
|
178
|
+
variant="ghost"
|
|
179
|
+
size="sm"
|
|
180
|
+
className="h-7 px-2 text-[11px]"
|
|
181
|
+
onClick={onCancel}
|
|
182
|
+
>
|
|
183
|
+
<X className="h-3 w-3 mr-1" />
|
|
184
|
+
Cancel
|
|
185
|
+
</Button>
|
|
186
|
+
<Button
|
|
187
|
+
size="sm"
|
|
188
|
+
className="h-7 px-2 text-[11px] bg-amber-500 hover:bg-amber-500/90 text-white"
|
|
189
|
+
onClick={() => {
|
|
190
|
+
if (overHardLimit) return;
|
|
191
|
+
if (draft.trim().length === 0) onCancel();
|
|
192
|
+
else onSave(draft);
|
|
193
|
+
}}
|
|
194
|
+
disabled={overHardLimit}
|
|
195
|
+
>
|
|
196
|
+
<Check className="h-3 w-3 mr-1" />
|
|
197
|
+
Drop pin
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
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
|
+
* DOM-billboard overlay for annotation pins.
|
|
7
|
+
*
|
|
8
|
+
* Sits on top of the WebGPU canvas and re-projects every pin's world
|
|
9
|
+
* position to screen space each frame via the camera callbacks.
|
|
10
|
+
* Uses a single rAF loop driven by camera/canvas events (we listen
|
|
11
|
+
* to a per-frame tick exposed by the camera) so the loop pauses when
|
|
12
|
+
* nothing's moving — the runtime cost when idle is zero.
|
|
13
|
+
*
|
|
14
|
+
* Key invariants:
|
|
15
|
+
* • The layer is `pointer-events: none` by default. Each pin and
|
|
16
|
+
* popover opts into `pointer-events: auto` so 3D interactions
|
|
17
|
+
* (orbit, pan, pick) still pass through the empty space between
|
|
18
|
+
* pins.
|
|
19
|
+
* • Only one popover or drop-input is visible at a time. They
|
|
20
|
+
* anchor to the pin's last projected position and re-anchor as
|
|
21
|
+
* the camera moves.
|
|
22
|
+
* • Persistence happens on commit/edit/delete via the slice's
|
|
23
|
+
* localStorage write — this layer never touches storage directly.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
27
|
+
import { useViewerStore } from '@/store';
|
|
28
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
29
|
+
import type { AnnotationPosition } from '@/store/slices/annotationsSlice';
|
|
30
|
+
import { AnnotationPin } from './AnnotationPin';
|
|
31
|
+
import { AnnotationPopover } from './AnnotationPopover';
|
|
32
|
+
import { AnnotationDropInput } from './AnnotationDropInput';
|
|
33
|
+
|
|
34
|
+
interface ProjectedPin {
|
|
35
|
+
id: string;
|
|
36
|
+
index: number;
|
|
37
|
+
/** Screen-space position relative to the canvas. Null when behind the camera. */
|
|
38
|
+
screen: { x: number; y: number } | null;
|
|
39
|
+
preview: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makePreview(note: string, maxLen = 60): string {
|
|
43
|
+
const trimmed = note.trim();
|
|
44
|
+
if (trimmed.length === 0) return '(empty note)';
|
|
45
|
+
return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pins live in the canvas's coordinate space. The wrapping <div>
|
|
50
|
+
* matches the canvas's bounding rect; pins are positioned absolutely
|
|
51
|
+
* within it. We mirror the canvas geometry via a ResizeObserver +
|
|
52
|
+
* a per-frame projection tick.
|
|
53
|
+
*/
|
|
54
|
+
export function AnnotationLayer() {
|
|
55
|
+
const annotations = useViewerStore((s) => s.annotations);
|
|
56
|
+
const draft = useViewerStore((s) => s.draft);
|
|
57
|
+
const selectedAnnotationId = useViewerStore((s) => s.selectedAnnotationId);
|
|
58
|
+
const selectAnnotation = useViewerStore((s) => s.selectAnnotation);
|
|
59
|
+
const updateAnnotation = useViewerStore((s) => s.updateAnnotation);
|
|
60
|
+
const removeAnnotation = useViewerStore((s) => s.removeAnnotation);
|
|
61
|
+
const commitDraft = useViewerStore((s) => s.commitDraft);
|
|
62
|
+
const cancelDraft = useViewerStore((s) => s.cancelDraft);
|
|
63
|
+
const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
|
|
64
|
+
const { ifcDataStore, models } = useIfc();
|
|
65
|
+
|
|
66
|
+
// Track canvas geometry so the overlay sits exactly on top.
|
|
67
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
68
|
+
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
|
69
|
+
|
|
70
|
+
useLayoutEffect(() => {
|
|
71
|
+
const container = containerRef.current;
|
|
72
|
+
if (!container) return;
|
|
73
|
+
const parent = container.parentElement;
|
|
74
|
+
if (!parent) return;
|
|
75
|
+
|
|
76
|
+
let observer: ResizeObserver | null = null;
|
|
77
|
+
|
|
78
|
+
const measure = (canvas: HTMLCanvasElement) => {
|
|
79
|
+
const rect = canvas.getBoundingClientRect();
|
|
80
|
+
setBounds({ width: rect.width, height: rect.height });
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const bind = (canvas: HTMLCanvasElement) => {
|
|
84
|
+
measure(canvas);
|
|
85
|
+
observer = new ResizeObserver(() => measure(canvas));
|
|
86
|
+
observer.observe(canvas);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const initialCanvas = parent.querySelector('canvas') as HTMLCanvasElement | null;
|
|
90
|
+
if (initialCanvas) {
|
|
91
|
+
bind(initialCanvas);
|
|
92
|
+
return () => observer?.disconnect();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Canvas not mounted yet (initial mount before viewport renders) —
|
|
96
|
+
// watch the parent for the canvas to appear, then bind once it does.
|
|
97
|
+
const mutationObserver = new MutationObserver(() => {
|
|
98
|
+
const canvas = parent.querySelector('canvas') as HTMLCanvasElement | null;
|
|
99
|
+
if (canvas) {
|
|
100
|
+
bind(canvas);
|
|
101
|
+
mutationObserver.disconnect();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
mutationObserver.observe(parent, { childList: true, subtree: true });
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
mutationObserver.disconnect();
|
|
108
|
+
observer?.disconnect();
|
|
109
|
+
};
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Stable list view so React doesn't churn when the Map identity
|
|
113
|
+
// changes but the entries are equal.
|
|
114
|
+
const annotationList = useMemo(() => Array.from(annotations.values()), [annotations]);
|
|
115
|
+
|
|
116
|
+
// Per-frame projection tick. We don't have a global "camera moved"
|
|
117
|
+
// event, so a rAF loop is the cheapest way to keep pins glued to
|
|
118
|
+
// the world. The loop is mostly idle — projection is < 10 µs per
|
|
119
|
+
// pin and the typical scene has < 20 pins.
|
|
120
|
+
const [projectedPins, setProjectedPins] = useState<ProjectedPin[]>([]);
|
|
121
|
+
const [draftScreen, setDraftScreen] = useState<{ x: number; y: number } | null>(null);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const project = cameraCallbacks.projectToScreen;
|
|
125
|
+
if (!project) {
|
|
126
|
+
setProjectedPins([]);
|
|
127
|
+
setDraftScreen(null);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let raf: number | null = null;
|
|
132
|
+
let lastSerialized = '';
|
|
133
|
+
|
|
134
|
+
const tick = () => {
|
|
135
|
+
const next: ProjectedPin[] = annotationList.map((ann, i) => ({
|
|
136
|
+
id: ann.id,
|
|
137
|
+
index: i + 1,
|
|
138
|
+
screen: project(ann.position),
|
|
139
|
+
preview: makePreview(ann.note),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
// Cheap deep-eq check: serialize the screen positions. Skip the
|
|
143
|
+
// setState when nothing moved, otherwise we re-render every
|
|
144
|
+
// frame even when the camera is still.
|
|
145
|
+
const serialized = next.map((p) => `${p.id}:${p.screen?.x ?? 'x'}:${p.screen?.y ?? 'y'}`).join(',');
|
|
146
|
+
if (serialized !== lastSerialized) {
|
|
147
|
+
lastSerialized = serialized;
|
|
148
|
+
setProjectedPins(next);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const draftPos = useViewerStore.getState().draft?.position ?? null;
|
|
152
|
+
const draftScreenNext = draftPos ? project(draftPos) : null;
|
|
153
|
+
setDraftScreen((prev) => {
|
|
154
|
+
if (prev === draftScreenNext) return prev;
|
|
155
|
+
if (prev && draftScreenNext && prev.x === draftScreenNext.x && prev.y === draftScreenNext.y) {
|
|
156
|
+
return prev;
|
|
157
|
+
}
|
|
158
|
+
return draftScreenNext;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
raf = requestAnimationFrame(tick);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
raf = requestAnimationFrame(tick);
|
|
165
|
+
return () => {
|
|
166
|
+
if (raf !== null) cancelAnimationFrame(raf);
|
|
167
|
+
};
|
|
168
|
+
// The list of annotations is captured per-render via annotationList;
|
|
169
|
+
// that closure is what the rAF tick reads. Pin position changes
|
|
170
|
+
// automatically pick up via the next render's loop replacement.
|
|
171
|
+
}, [cameraCallbacks, annotationList]);
|
|
172
|
+
|
|
173
|
+
const selectedAnnotation = selectedAnnotationId ? annotations.get(selectedAnnotationId) : null;
|
|
174
|
+
const selectedScreen = useMemo(() => {
|
|
175
|
+
if (!selectedAnnotation) return null;
|
|
176
|
+
return projectedPins.find((p) => p.id === selectedAnnotation.id)?.screen ?? null;
|
|
177
|
+
}, [selectedAnnotation, projectedPins]);
|
|
178
|
+
|
|
179
|
+
// Resolve entity type + id for the popover header. Cheap lookup
|
|
180
|
+
// against whichever data store the annotation was anchored to.
|
|
181
|
+
const resolveEntityType = (modelId: string | null, expressId: number | null): string | null => {
|
|
182
|
+
if (expressId === null) return null;
|
|
183
|
+
// Federation safety: when the annotation carries a modelId that
|
|
184
|
+
// isn't in the current `models` map, falling back to
|
|
185
|
+
// `ifcDataStore` would silently resolve `expressId` against the
|
|
186
|
+
// wrong model (the same id can exist in many federated models).
|
|
187
|
+
// The fallback is therefore restricted to single-model sessions.
|
|
188
|
+
let dataStore: typeof ifcDataStore | null;
|
|
189
|
+
if (!modelId) {
|
|
190
|
+
dataStore = ifcDataStore;
|
|
191
|
+
} else {
|
|
192
|
+
const scoped = models.get(modelId)?.ifcDataStore;
|
|
193
|
+
if (scoped) {
|
|
194
|
+
dataStore = scoped;
|
|
195
|
+
} else if (models.size <= 1) {
|
|
196
|
+
dataStore = ifcDataStore;
|
|
197
|
+
} else {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!dataStore?.entities) return null;
|
|
202
|
+
return dataStore.entities.getTypeName(expressId) || null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (!bounds) {
|
|
206
|
+
return <div ref={containerRef} className="absolute inset-0 pointer-events-none" />;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
ref={containerRef}
|
|
212
|
+
className="absolute inset-0 pointer-events-none overflow-hidden"
|
|
213
|
+
aria-label="Annotations layer"
|
|
214
|
+
>
|
|
215
|
+
{/* Pins */}
|
|
216
|
+
{projectedPins.map((pin) => {
|
|
217
|
+
if (!pin.screen) return null;
|
|
218
|
+
const annotation = annotations.get(pin.id);
|
|
219
|
+
if (!annotation) return null;
|
|
220
|
+
const isSelected = selectedAnnotationId === pin.id;
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
key={pin.id}
|
|
224
|
+
data-annotation-pin-id={pin.id}
|
|
225
|
+
className="absolute pointer-events-auto"
|
|
226
|
+
style={{
|
|
227
|
+
left: pin.screen.x,
|
|
228
|
+
top: pin.screen.y,
|
|
229
|
+
transform: 'translate(-50%, -50%)',
|
|
230
|
+
animationDelay: `${pin.index * 40}ms`,
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
<AnnotationPin
|
|
234
|
+
index={pin.index}
|
|
235
|
+
selected={isSelected}
|
|
236
|
+
preview={pin.preview}
|
|
237
|
+
onClick={() => selectAnnotation(isSelected ? null : pin.id)}
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
})}
|
|
242
|
+
|
|
243
|
+
{/* Popover for the selected pin */}
|
|
244
|
+
{selectedAnnotation && selectedScreen && (
|
|
245
|
+
<AnnotationPopover
|
|
246
|
+
annotation={selectedAnnotation}
|
|
247
|
+
anchorX={selectedScreen.x}
|
|
248
|
+
anchorY={selectedScreen.y}
|
|
249
|
+
canvasWidth={bounds.width}
|
|
250
|
+
canvasHeight={bounds.height}
|
|
251
|
+
entityType={resolveEntityType(selectedAnnotation.modelId, selectedAnnotation.entityExpressId)}
|
|
252
|
+
onSave={(note) => updateAnnotation(selectedAnnotation.id, note)}
|
|
253
|
+
onDelete={() => removeAnnotation(selectedAnnotation.id)}
|
|
254
|
+
onClose={() => selectAnnotation(null)}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Drop input + ghost pin while drafting */}
|
|
259
|
+
{draft && draftScreen && (
|
|
260
|
+
<>
|
|
261
|
+
<div
|
|
262
|
+
className="absolute pointer-events-none"
|
|
263
|
+
style={{
|
|
264
|
+
left: draftScreen.x,
|
|
265
|
+
top: draftScreen.y,
|
|
266
|
+
transform: 'translate(-50%, -50%)',
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
<AnnotationPin index={annotationList.length + 1} variant="draft" />
|
|
270
|
+
</div>
|
|
271
|
+
<AnnotationDropInput
|
|
272
|
+
anchorX={draftScreen.x}
|
|
273
|
+
anchorY={draftScreen.y}
|
|
274
|
+
canvasWidth={bounds.width}
|
|
275
|
+
canvasHeight={bounds.height}
|
|
276
|
+
entityType={resolveEntityType(draft.modelId, draft.entityExpressId)}
|
|
277
|
+
entityExpressId={draft.entityExpressId}
|
|
278
|
+
onSave={(note) => commitDraft(note)}
|
|
279
|
+
onCancel={cancelDraft}
|
|
280
|
+
/>
|
|
281
|
+
</>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type { AnnotationPosition };
|