@ifc-lite/viewer 1.17.6 → 1.19.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 +20 -15
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +949 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/sandbox-Baez7n-t.js +9682 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +11 -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 +73 -12
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- 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 +29 -2
- package/src/components/viewer/ViewportContainer.tsx +45 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- 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 +581 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +89 -13
- 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 +8 -3
- 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 +79 -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/pointCloudSlice.ts +102 -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/types.ts +7 -0
- package/src/store.ts +14 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -0,0 +1,275 @@
|
|
|
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
|
+
* Add-element tool state — drives the right-side AddElementPanel and
|
|
7
|
+
* the viewport's click-to-place state machine. The actual STEP work
|
|
8
|
+
* runs through `mutationSlice` actions (`addWall` / `addSlab` /
|
|
9
|
+
* `addBeam` / `addColumn`); this slice holds:
|
|
10
|
+
*
|
|
11
|
+
* - the panel form state (selected type, per-type dimensions,
|
|
12
|
+
* target storey, target federated model)
|
|
13
|
+
* - the in-progress click-placement state (pendingPoints,
|
|
14
|
+
* hoverPoint, slabMode for rectangle vs polygon)
|
|
15
|
+
*
|
|
16
|
+
* Defaults match the IfcCreator builders' construction-standard
|
|
17
|
+
* conventions: wall thickness 0.2m, floor height 3m, slab 5×5×0.3m,
|
|
18
|
+
* column 0.4×0.4×3m, beam 0.3×0.5×3m.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { type StateCreator } from 'zustand';
|
|
22
|
+
|
|
23
|
+
export type AddElementType =
|
|
24
|
+
| 'wall'
|
|
25
|
+
| 'slab'
|
|
26
|
+
| 'beam'
|
|
27
|
+
| 'column'
|
|
28
|
+
| 'door'
|
|
29
|
+
| 'window'
|
|
30
|
+
| 'space'
|
|
31
|
+
| 'roof'
|
|
32
|
+
| 'plate'
|
|
33
|
+
| 'member';
|
|
34
|
+
export type AddElementSlabMode = 'rectangle' | 'polygon';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A single accumulated 3D click point in **renderer-frame** Y-up world
|
|
38
|
+
* coordinates (the same space the camera projects from). The IFC
|
|
39
|
+
* conversion happens at builder dispatch time so the live preview can
|
|
40
|
+
* project each pending point to screen without needing to know the
|
|
41
|
+
* target storey's elevation.
|
|
42
|
+
*/
|
|
43
|
+
export interface AddElementVec3 {
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
z: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AddElementWallParams {
|
|
50
|
+
Thickness: number;
|
|
51
|
+
Height: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AddElementSlabParams {
|
|
55
|
+
Width: number;
|
|
56
|
+
Depth: number;
|
|
57
|
+
Thickness: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AddElementBeamParams {
|
|
61
|
+
Width: number;
|
|
62
|
+
Height: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AddElementColumnParams {
|
|
66
|
+
Width: number;
|
|
67
|
+
Depth: number;
|
|
68
|
+
Height: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AddElementDoorParams {
|
|
72
|
+
Width: number;
|
|
73
|
+
Height: number;
|
|
74
|
+
FrameThickness: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AddElementWindowParams {
|
|
78
|
+
Width: number;
|
|
79
|
+
Height: number;
|
|
80
|
+
FrameThickness: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AddElementSpaceParams {
|
|
84
|
+
Width: number;
|
|
85
|
+
Depth: number;
|
|
86
|
+
Height: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AddElementRoofParams {
|
|
90
|
+
Width: number;
|
|
91
|
+
Depth: number;
|
|
92
|
+
Thickness: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AddElementPlateParams {
|
|
96
|
+
Width: number;
|
|
97
|
+
Depth: number;
|
|
98
|
+
Thickness: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AddElementMemberParams {
|
|
102
|
+
Width: number;
|
|
103
|
+
Height: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Auto-space generation settings — ties into `generateSpacesFromWalls`.
|
|
108
|
+
* Lives here so the panel form survives type-switches.
|
|
109
|
+
*/
|
|
110
|
+
export interface AddElementAutoSpaceParams {
|
|
111
|
+
/** Wall-end snap tolerance in metres (collapses tiny gaps). */
|
|
112
|
+
SnapTolerance: number;
|
|
113
|
+
/** Drop detected regions below this area (m²). */
|
|
114
|
+
MinArea: number;
|
|
115
|
+
/** IfcSpace extrusion height (m). */
|
|
116
|
+
Height: number;
|
|
117
|
+
/** Naming pattern; `{n}` = 1-based index. */
|
|
118
|
+
NamePattern: string;
|
|
119
|
+
/** IfcSpaceTypeEnum value (without dots). */
|
|
120
|
+
PredefinedType: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Live preview from the most recent dry-run detection (cleared on commit). */
|
|
124
|
+
export interface AddElementAutoSpacePreview {
|
|
125
|
+
storeyExpressId: number;
|
|
126
|
+
/** CCW outlines in IFC storey-local 2D (X/Y, m). */
|
|
127
|
+
outlines: Array<Array<[number, number]>>;
|
|
128
|
+
/** Per-region metadata for the panel summary. */
|
|
129
|
+
regions: Array<{ area: number }>;
|
|
130
|
+
wallsConsidered: number;
|
|
131
|
+
wallsContributing: number;
|
|
132
|
+
/**
|
|
133
|
+
* Diagnostic counts from the planar-graph pipeline. Surfaced
|
|
134
|
+
* verbatim in the Auto Spaces panel so users can spot pipeline
|
|
135
|
+
* failures (e.g. zero edges after intersect-split → walls don't
|
|
136
|
+
* connect).
|
|
137
|
+
*/
|
|
138
|
+
diagnostics?: {
|
|
139
|
+
vertices: number;
|
|
140
|
+
edgesAfterSplit: number;
|
|
141
|
+
facesTotal: number;
|
|
142
|
+
outerFacesDropped: number;
|
|
143
|
+
belowMinAreaDropped: number;
|
|
144
|
+
largestArea: number;
|
|
145
|
+
skipReasons: Record<string, number>;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface AddElementSlice {
|
|
150
|
+
addElementType: AddElementType;
|
|
151
|
+
/** Target storey expressId; `null` ⇒ auto-pick first storey on click. */
|
|
152
|
+
addElementStoreyId: number | null;
|
|
153
|
+
/** Target model id; `null` ⇒ auto-pick the active model on click. */
|
|
154
|
+
addElementModelId: string | null;
|
|
155
|
+
addElementWallParams: AddElementWallParams;
|
|
156
|
+
addElementSlabParams: AddElementSlabParams;
|
|
157
|
+
addElementBeamParams: AddElementBeamParams;
|
|
158
|
+
addElementColumnParams: AddElementColumnParams;
|
|
159
|
+
addElementDoorParams: AddElementDoorParams;
|
|
160
|
+
addElementWindowParams: AddElementWindowParams;
|
|
161
|
+
addElementSpaceParams: AddElementSpaceParams;
|
|
162
|
+
addElementRoofParams: AddElementRoofParams;
|
|
163
|
+
addElementPlateParams: AddElementPlateParams;
|
|
164
|
+
addElementMemberParams: AddElementMemberParams;
|
|
165
|
+
addElementAutoSpaceParams: AddElementAutoSpaceParams;
|
|
166
|
+
addElementAutoSpacePreview: AddElementAutoSpacePreview | null;
|
|
167
|
+
|
|
168
|
+
/** Rectangle (2 clicks) or polygon (N clicks + Enter to close). */
|
|
169
|
+
addElementSlabMode: AddElementSlabMode;
|
|
170
|
+
/** In-progress click points. Cleared on tool exit, type change, or Esc. */
|
|
171
|
+
addElementPendingPoints: AddElementVec3[];
|
|
172
|
+
/** Live preview point under the cursor (snap-aware). */
|
|
173
|
+
addElementHoverPoint: AddElementVec3 | null;
|
|
174
|
+
|
|
175
|
+
setAddElementType: (t: AddElementType) => void;
|
|
176
|
+
setAddElementStoreyId: (id: number | null) => void;
|
|
177
|
+
setAddElementModelId: (id: string | null) => void;
|
|
178
|
+
setAddElementWallParams: (p: Partial<AddElementWallParams>) => void;
|
|
179
|
+
setAddElementSlabParams: (p: Partial<AddElementSlabParams>) => void;
|
|
180
|
+
setAddElementBeamParams: (p: Partial<AddElementBeamParams>) => void;
|
|
181
|
+
setAddElementColumnParams: (p: Partial<AddElementColumnParams>) => void;
|
|
182
|
+
setAddElementDoorParams: (p: Partial<AddElementDoorParams>) => void;
|
|
183
|
+
setAddElementWindowParams: (p: Partial<AddElementWindowParams>) => void;
|
|
184
|
+
setAddElementSpaceParams: (p: Partial<AddElementSpaceParams>) => void;
|
|
185
|
+
setAddElementRoofParams: (p: Partial<AddElementRoofParams>) => void;
|
|
186
|
+
setAddElementPlateParams: (p: Partial<AddElementPlateParams>) => void;
|
|
187
|
+
setAddElementMemberParams: (p: Partial<AddElementMemberParams>) => void;
|
|
188
|
+
setAddElementAutoSpaceParams: (p: Partial<AddElementAutoSpaceParams>) => void;
|
|
189
|
+
setAddElementAutoSpacePreview: (preview: AddElementAutoSpacePreview | null) => void;
|
|
190
|
+
setAddElementSlabMode: (m: AddElementSlabMode) => void;
|
|
191
|
+
appendAddElementPendingPoint: (p: AddElementVec3) => void;
|
|
192
|
+
setAddElementHoverPoint: (p: AddElementVec3 | null) => void;
|
|
193
|
+
clearAddElementPending: () => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ADD_ELEMENT_DEFAULTS = {
|
|
197
|
+
type: 'wall' as AddElementType,
|
|
198
|
+
wall: { Thickness: 0.2, Height: 3 } as AddElementWallParams,
|
|
199
|
+
slab: { Width: 5, Depth: 5, Thickness: 0.3 } as AddElementSlabParams,
|
|
200
|
+
beam: { Width: 0.3, Height: 0.5 } as AddElementBeamParams,
|
|
201
|
+
column: { Width: 0.4, Depth: 0.4, Height: 3 } as AddElementColumnParams,
|
|
202
|
+
door: { Width: 0.9, Height: 2.1, FrameThickness: 0.05 } as AddElementDoorParams,
|
|
203
|
+
window: { Width: 1.2, Height: 1.5, FrameThickness: 0.05 } as AddElementWindowParams,
|
|
204
|
+
space: { Width: 4, Depth: 4, Height: 3 } as AddElementSpaceParams,
|
|
205
|
+
roof: { Width: 8, Depth: 8, Thickness: 0.3 } as AddElementRoofParams,
|
|
206
|
+
plate: { Width: 1, Depth: 1, Thickness: 0.02 } as AddElementPlateParams,
|
|
207
|
+
member: { Width: 0.1, Height: 0.1 } as AddElementMemberParams,
|
|
208
|
+
autoSpace: {
|
|
209
|
+
SnapTolerance: 0.1,
|
|
210
|
+
MinArea: 0.5,
|
|
211
|
+
Height: 3,
|
|
212
|
+
NamePattern: 'Space {n}',
|
|
213
|
+
PredefinedType: 'INTERNAL',
|
|
214
|
+
} as AddElementAutoSpaceParams,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const createAddElementSlice: StateCreator<AddElementSlice, [], [], AddElementSlice> = (set) => ({
|
|
218
|
+
addElementType: ADD_ELEMENT_DEFAULTS.type,
|
|
219
|
+
addElementStoreyId: null,
|
|
220
|
+
addElementModelId: null,
|
|
221
|
+
addElementWallParams: { ...ADD_ELEMENT_DEFAULTS.wall },
|
|
222
|
+
addElementSlabParams: { ...ADD_ELEMENT_DEFAULTS.slab },
|
|
223
|
+
addElementBeamParams: { ...ADD_ELEMENT_DEFAULTS.beam },
|
|
224
|
+
addElementColumnParams: { ...ADD_ELEMENT_DEFAULTS.column },
|
|
225
|
+
addElementDoorParams: { ...ADD_ELEMENT_DEFAULTS.door },
|
|
226
|
+
addElementWindowParams: { ...ADD_ELEMENT_DEFAULTS.window },
|
|
227
|
+
addElementSpaceParams: { ...ADD_ELEMENT_DEFAULTS.space },
|
|
228
|
+
addElementRoofParams: { ...ADD_ELEMENT_DEFAULTS.roof },
|
|
229
|
+
addElementPlateParams: { ...ADD_ELEMENT_DEFAULTS.plate },
|
|
230
|
+
addElementMemberParams: { ...ADD_ELEMENT_DEFAULTS.member },
|
|
231
|
+
addElementAutoSpaceParams: { ...ADD_ELEMENT_DEFAULTS.autoSpace },
|
|
232
|
+
addElementAutoSpacePreview: null,
|
|
233
|
+
addElementSlabMode: 'rectangle',
|
|
234
|
+
addElementPendingPoints: [],
|
|
235
|
+
addElementHoverPoint: null,
|
|
236
|
+
|
|
237
|
+
setAddElementType: (addElementType) =>
|
|
238
|
+
// Switching types resets the pending-click queue — a wall's start
|
|
239
|
+
// doesn't make sense as a slab's first corner. Hover is cleared
|
|
240
|
+
// alongside so a stale preview doesn't flash with the new shape.
|
|
241
|
+
set({ addElementType, addElementPendingPoints: [], addElementHoverPoint: null }),
|
|
242
|
+
setAddElementStoreyId: (addElementStoreyId) => set({ addElementStoreyId }),
|
|
243
|
+
setAddElementModelId: (addElementModelId) => set({ addElementModelId }),
|
|
244
|
+
setAddElementWallParams: (p) =>
|
|
245
|
+
set((s) => ({ addElementWallParams: { ...s.addElementWallParams, ...p } })),
|
|
246
|
+
setAddElementSlabParams: (p) =>
|
|
247
|
+
set((s) => ({ addElementSlabParams: { ...s.addElementSlabParams, ...p } })),
|
|
248
|
+
setAddElementBeamParams: (p) =>
|
|
249
|
+
set((s) => ({ addElementBeamParams: { ...s.addElementBeamParams, ...p } })),
|
|
250
|
+
setAddElementColumnParams: (p) =>
|
|
251
|
+
set((s) => ({ addElementColumnParams: { ...s.addElementColumnParams, ...p } })),
|
|
252
|
+
setAddElementDoorParams: (p) =>
|
|
253
|
+
set((s) => ({ addElementDoorParams: { ...s.addElementDoorParams, ...p } })),
|
|
254
|
+
setAddElementWindowParams: (p) =>
|
|
255
|
+
set((s) => ({ addElementWindowParams: { ...s.addElementWindowParams, ...p } })),
|
|
256
|
+
setAddElementSpaceParams: (p) =>
|
|
257
|
+
set((s) => ({ addElementSpaceParams: { ...s.addElementSpaceParams, ...p } })),
|
|
258
|
+
setAddElementRoofParams: (p) =>
|
|
259
|
+
set((s) => ({ addElementRoofParams: { ...s.addElementRoofParams, ...p } })),
|
|
260
|
+
setAddElementPlateParams: (p) =>
|
|
261
|
+
set((s) => ({ addElementPlateParams: { ...s.addElementPlateParams, ...p } })),
|
|
262
|
+
setAddElementMemberParams: (p) =>
|
|
263
|
+
set((s) => ({ addElementMemberParams: { ...s.addElementMemberParams, ...p } })),
|
|
264
|
+
setAddElementAutoSpaceParams: (p) =>
|
|
265
|
+
set((s) => ({ addElementAutoSpaceParams: { ...s.addElementAutoSpaceParams, ...p } })),
|
|
266
|
+
setAddElementAutoSpacePreview: (preview) =>
|
|
267
|
+
set({ addElementAutoSpacePreview: preview }),
|
|
268
|
+
setAddElementSlabMode: (addElementSlabMode) =>
|
|
269
|
+
set({ addElementSlabMode, addElementPendingPoints: [], addElementHoverPoint: null }),
|
|
270
|
+
appendAddElementPendingPoint: (p) =>
|
|
271
|
+
set((s) => ({ addElementPendingPoints: [...s.addElementPendingPoints, p] })),
|
|
272
|
+
setAddElementHoverPoint: (addElementHoverPoint) => set({ addElementHoverPoint }),
|
|
273
|
+
clearAddElementPending: () =>
|
|
274
|
+
set({ addElementPendingPoints: [], addElementHoverPoint: null }),
|
|
275
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { createAnnotationsSlice, type AnnotationsSlice } from './annotationsSlice.js';
|
|
8
|
+
|
|
9
|
+
// Stub localStorage so the slice can read/write without browser env.
|
|
10
|
+
function installStubStorage(): { wipe: () => void } {
|
|
11
|
+
const data = new Map<string, string>();
|
|
12
|
+
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
|
13
|
+
getItem: (k: string) => data.get(k) ?? null,
|
|
14
|
+
setItem: (k: string, v: string) => { data.set(k, v); },
|
|
15
|
+
removeItem: (k: string) => { data.delete(k); },
|
|
16
|
+
clear: () => data.clear(),
|
|
17
|
+
key: (i: number) => Array.from(data.keys())[i] ?? null,
|
|
18
|
+
get length() { return data.size; },
|
|
19
|
+
} as Storage;
|
|
20
|
+
return { wipe: () => data.clear() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('AnnotationsSlice', () => {
|
|
24
|
+
let state: AnnotationsSlice;
|
|
25
|
+
let setState: (partial: Partial<AnnotationsSlice> | ((s: AnnotationsSlice) => Partial<AnnotationsSlice>)) => void;
|
|
26
|
+
let storage: { wipe: () => void };
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
storage = installStubStorage();
|
|
30
|
+
storage.wipe();
|
|
31
|
+
|
|
32
|
+
setState = (partial) => {
|
|
33
|
+
const next = typeof partial === 'function' ? partial(state) : partial;
|
|
34
|
+
state = { ...state, ...next };
|
|
35
|
+
};
|
|
36
|
+
state = createAnnotationsSlice(setState as never, () => state, {} as never);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('initial state', () => {
|
|
40
|
+
it('starts with no annotations and no draft', () => {
|
|
41
|
+
assert.strictEqual(state.annotations.size, 0);
|
|
42
|
+
assert.strictEqual(state.draft, null);
|
|
43
|
+
assert.strictEqual(state.selectedAnnotationId, null);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('beginDraft + commitDraft', () => {
|
|
48
|
+
it('opens a draft at the given world position', () => {
|
|
49
|
+
state.beginDraft({ x: 1, y: 2, z: 3 }, 42, 'arch');
|
|
50
|
+
assert.ok(state.draft);
|
|
51
|
+
assert.deepStrictEqual(state.draft!.position, { x: 1, y: 2, z: 3 });
|
|
52
|
+
assert.strictEqual(state.draft!.entityExpressId, 42);
|
|
53
|
+
assert.strictEqual(state.draft!.modelId, 'arch');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('commits the draft into a new annotation', () => {
|
|
57
|
+
state.beginDraft({ x: 1, y: 2, z: 3 }, 42, 'arch');
|
|
58
|
+
const id = state.commitDraft('Defect: chip in the corner');
|
|
59
|
+
assert.ok(id);
|
|
60
|
+
assert.strictEqual(state.annotations.size, 1);
|
|
61
|
+
const ann = state.annotations.get(id!);
|
|
62
|
+
assert.strictEqual(ann?.note, 'Defect: chip in the corner');
|
|
63
|
+
assert.strictEqual(ann?.entityExpressId, 42);
|
|
64
|
+
assert.strictEqual(state.draft, null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('drops the draft silently when committed with an empty note', () => {
|
|
68
|
+
state.beginDraft({ x: 0, y: 0, z: 0 }, null, null);
|
|
69
|
+
const id = state.commitDraft(' ');
|
|
70
|
+
assert.strictEqual(id, null);
|
|
71
|
+
assert.strictEqual(state.annotations.size, 0);
|
|
72
|
+
assert.strictEqual(state.draft, null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('clears the selected pin when a draft begins', () => {
|
|
76
|
+
state.beginDraft({ x: 0, y: 0, z: 0 }, null, null);
|
|
77
|
+
const id = state.commitDraft('first');
|
|
78
|
+
state.selectAnnotation(id!);
|
|
79
|
+
assert.strictEqual(state.selectedAnnotationId, id);
|
|
80
|
+
state.beginDraft({ x: 1, y: 1, z: 1 }, null, null);
|
|
81
|
+
assert.strictEqual(state.selectedAnnotationId, null);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('updateAnnotation', () => {
|
|
86
|
+
it('updates the note and bumps updatedAt', () => {
|
|
87
|
+
state.beginDraft({ x: 0, y: 0, z: 0 }, null, null);
|
|
88
|
+
const id = state.commitDraft('original')!;
|
|
89
|
+
const original = state.annotations.get(id)!;
|
|
90
|
+
// Force a measurable time delta even when the test runner is fast.
|
|
91
|
+
const before = original.updatedAt;
|
|
92
|
+
state.updateAnnotation(id, 'revised');
|
|
93
|
+
const after = state.annotations.get(id)!;
|
|
94
|
+
assert.strictEqual(after.note, 'revised');
|
|
95
|
+
assert.ok(after.updatedAt >= before);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('keeps the annotation when the note is wiped (does not auto-delete)', () => {
|
|
99
|
+
state.beginDraft({ x: 0, y: 0, z: 0 }, null, null);
|
|
100
|
+
const id = state.commitDraft('something')!;
|
|
101
|
+
state.updateAnnotation(id, '');
|
|
102
|
+
const ann = state.annotations.get(id);
|
|
103
|
+
assert.ok(ann);
|
|
104
|
+
assert.strictEqual(ann!.note, '');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('removeAnnotation', () => {
|
|
109
|
+
it('removes the entry and clears the selection if it was selected', () => {
|
|
110
|
+
state.beginDraft({ x: 0, y: 0, z: 0 }, null, null);
|
|
111
|
+
const id = state.commitDraft('to-delete')!;
|
|
112
|
+
state.selectAnnotation(id);
|
|
113
|
+
state.removeAnnotation(id);
|
|
114
|
+
assert.strictEqual(state.annotations.size, 0);
|
|
115
|
+
assert.strictEqual(state.selectedAnnotationId, null);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('persistence', () => {
|
|
120
|
+
it('survives a fresh slice instantiation by round-tripping localStorage', () => {
|
|
121
|
+
state.beginDraft({ x: 7, y: 8, z: 9 }, 1, 'm1');
|
|
122
|
+
const id = state.commitDraft('persistent')!;
|
|
123
|
+
|
|
124
|
+
// Spin up a brand-new slice — it should pick up the saved entry
|
|
125
|
+
// without us threading state through ourselves.
|
|
126
|
+
let s2: AnnotationsSlice;
|
|
127
|
+
const setState2: (p: never) => void = () => {};
|
|
128
|
+
s2 = createAnnotationsSlice(setState2 as never, () => s2, {} as never);
|
|
129
|
+
assert.strictEqual(s2.annotations.size, 1);
|
|
130
|
+
assert.strictEqual(s2.annotations.get(id)?.note, 'persistent');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
* Annotation slice — pins anchored to world points on the 3D scene.
|
|
7
|
+
*
|
|
8
|
+
* Each pin holds a short note and persists across reloads via
|
|
9
|
+
* localStorage (scoped per browser, no server). Pins are NOT IFC
|
|
10
|
+
* entities — they live alongside the model as an authoring overlay.
|
|
11
|
+
* Future PRs will add BCF round-trip and IfcAnnotation export.
|
|
12
|
+
*
|
|
13
|
+
* Coordinate frame: world positions are stored in the renderer's
|
|
14
|
+
* local Y-up coordinate space (the same one the camera projects
|
|
15
|
+
* from). Pins are placed by raycasting the scene under the cursor;
|
|
16
|
+
* the raycast intersection is already in this frame so no conversion
|
|
17
|
+
* is needed at write time.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type StateCreator } from 'zustand';
|
|
21
|
+
|
|
22
|
+
const STORAGE_KEY = 'ifc-lite:annotations:v1';
|
|
23
|
+
const MAX_NOTE_LEN = 2000;
|
|
24
|
+
|
|
25
|
+
export interface AnnotationPosition {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
z: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Annotation {
|
|
32
|
+
id: string;
|
|
33
|
+
/** World-space position in the renderer's local Y-up frame. */
|
|
34
|
+
position: AnnotationPosition;
|
|
35
|
+
/** Plain-text body (Markdown rendering deliberately punted to v2). */
|
|
36
|
+
note: string;
|
|
37
|
+
/** Express id of the entity the user clicked, when one was hit. */
|
|
38
|
+
entityExpressId: number | null;
|
|
39
|
+
/** Federated model id when the click landed on a model mesh; null for empty-space clicks. */
|
|
40
|
+
modelId: string | null;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
updatedAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AnnotationDraft {
|
|
46
|
+
/** Floating ID used by the inline input UI before the annotation is committed. */
|
|
47
|
+
draftId: string;
|
|
48
|
+
position: AnnotationPosition;
|
|
49
|
+
entityExpressId: number | null;
|
|
50
|
+
modelId: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AnnotationsSlice {
|
|
54
|
+
// State
|
|
55
|
+
annotations: Map<string, Annotation>;
|
|
56
|
+
/** Pending pin awaiting a note — drives the inline drop input. */
|
|
57
|
+
draft: AnnotationDraft | null;
|
|
58
|
+
/** Currently expanded pin (popover open). */
|
|
59
|
+
selectedAnnotationId: string | null;
|
|
60
|
+
|
|
61
|
+
// Actions
|
|
62
|
+
/** Open the inline drop input at a world position. */
|
|
63
|
+
beginDraft: (position: AnnotationPosition, entityExpressId: number | null, modelId: string | null) => void;
|
|
64
|
+
/** Commit the draft into a new annotation. Empty notes drop the draft silently. */
|
|
65
|
+
commitDraft: (note: string) => string | null;
|
|
66
|
+
/** Cancel the draft. */
|
|
67
|
+
cancelDraft: () => void;
|
|
68
|
+
/** Update an existing annotation's note. */
|
|
69
|
+
updateAnnotation: (id: string, note: string) => void;
|
|
70
|
+
/** Delete an annotation. */
|
|
71
|
+
removeAnnotation: (id: string) => void;
|
|
72
|
+
/** Open the popover for an existing pin. */
|
|
73
|
+
selectAnnotation: (id: string | null) => void;
|
|
74
|
+
/** Wipe all annotations across all models. Used by tests / "reset". */
|
|
75
|
+
clearAllAnnotations: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function generateId(prefix: 'ann' | 'draft'): string {
|
|
79
|
+
const rnd = Math.random().toString(36).slice(2, 9);
|
|
80
|
+
return `${prefix}_${Date.now().toString(36)}_${rnd}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clampNote(note: string): string {
|
|
84
|
+
const trimmed = note.trim();
|
|
85
|
+
return trimmed.length > MAX_NOTE_LEN ? trimmed.slice(0, MAX_NOTE_LEN) : trimmed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Persistence ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function isFiniteNumber(v: unknown): v is number {
|
|
91
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isValidPosition(v: unknown): v is AnnotationPosition {
|
|
95
|
+
if (!v || typeof v !== 'object') return false;
|
|
96
|
+
const p = v as Record<string, unknown>;
|
|
97
|
+
return isFiniteNumber(p.x) && isFiniteNumber(p.y) && isFiniteNumber(p.z);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isValidAnnotation(v: unknown): v is Annotation {
|
|
101
|
+
if (!v || typeof v !== 'object') return false;
|
|
102
|
+
const a = v as Record<string, unknown>;
|
|
103
|
+
if (typeof a.id !== 'string' || a.id.length === 0) return false;
|
|
104
|
+
if (typeof a.note !== 'string') return false;
|
|
105
|
+
if (!isValidPosition(a.position)) return false;
|
|
106
|
+
if (a.entityExpressId !== null && !isFiniteNumber(a.entityExpressId)) return false;
|
|
107
|
+
if (a.modelId !== null && typeof a.modelId !== 'string') return false;
|
|
108
|
+
if (!isFiniteNumber(a.createdAt) || !isFiniteNumber(a.updatedAt)) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function loadFromStorage(): Map<string, Annotation> {
|
|
113
|
+
try {
|
|
114
|
+
if (typeof localStorage === 'undefined') return new Map();
|
|
115
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
116
|
+
if (!raw) return new Map();
|
|
117
|
+
const parsed = JSON.parse(raw);
|
|
118
|
+
if (!Array.isArray(parsed)) return new Map();
|
|
119
|
+
const map = new Map<string, Annotation>();
|
|
120
|
+
for (const item of parsed) {
|
|
121
|
+
if (!isValidAnnotation(item)) {
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.warn(`[annotations] skipping malformed entry from ${STORAGE_KEY}`, item);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
map.set(item.id, item);
|
|
127
|
+
}
|
|
128
|
+
return map;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// eslint-disable-next-line no-console
|
|
131
|
+
console.warn(`[annotations] failed to load from ${STORAGE_KEY}`, err);
|
|
132
|
+
return new Map();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function saveToStorage(annotations: Map<string, Annotation>): void {
|
|
137
|
+
try {
|
|
138
|
+
if (typeof localStorage === 'undefined') return;
|
|
139
|
+
const arr = Array.from(annotations.values());
|
|
140
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Quota exceeded / private mode — annotations stay in memory but
|
|
143
|
+
// the warning makes the failure debuggable.
|
|
144
|
+
// eslint-disable-next-line no-console
|
|
145
|
+
console.warn(`[annotations] failed to persist to ${STORAGE_KEY}`, err);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Slice ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export const createAnnotationsSlice: StateCreator<AnnotationsSlice, [], [], AnnotationsSlice> = (
|
|
152
|
+
set,
|
|
153
|
+
get,
|
|
154
|
+
) => ({
|
|
155
|
+
annotations: loadFromStorage(),
|
|
156
|
+
draft: null,
|
|
157
|
+
selectedAnnotationId: null,
|
|
158
|
+
|
|
159
|
+
beginDraft: (position, entityExpressId, modelId) => {
|
|
160
|
+
set({
|
|
161
|
+
draft: {
|
|
162
|
+
draftId: generateId('draft'),
|
|
163
|
+
position,
|
|
164
|
+
entityExpressId,
|
|
165
|
+
modelId,
|
|
166
|
+
},
|
|
167
|
+
// Drafting opens its own input — close any open popover first
|
|
168
|
+
// so the two pieces of UI don't fight for focus.
|
|
169
|
+
selectedAnnotationId: null,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
commitDraft: (note) => {
|
|
174
|
+
const draft = get().draft;
|
|
175
|
+
if (!draft) return null;
|
|
176
|
+
const clamped = clampNote(note);
|
|
177
|
+
if (clamped.length === 0) {
|
|
178
|
+
set({ draft: null });
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const id = generateId('ann');
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const annotation: Annotation = {
|
|
184
|
+
id,
|
|
185
|
+
position: draft.position,
|
|
186
|
+
note: clamped,
|
|
187
|
+
entityExpressId: draft.entityExpressId,
|
|
188
|
+
modelId: draft.modelId,
|
|
189
|
+
createdAt: now,
|
|
190
|
+
updatedAt: now,
|
|
191
|
+
};
|
|
192
|
+
set((state) => {
|
|
193
|
+
const next = new Map(state.annotations);
|
|
194
|
+
next.set(id, annotation);
|
|
195
|
+
saveToStorage(next);
|
|
196
|
+
return {
|
|
197
|
+
annotations: next,
|
|
198
|
+
draft: null,
|
|
199
|
+
selectedAnnotationId: null,
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
return id;
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
cancelDraft: () => {
|
|
206
|
+
set({ draft: null });
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
updateAnnotation: (id, note) => {
|
|
210
|
+
set((state) => {
|
|
211
|
+
const existing = state.annotations.get(id);
|
|
212
|
+
if (!existing) return {};
|
|
213
|
+
const clamped = clampNote(note);
|
|
214
|
+
if (clamped.length === 0) {
|
|
215
|
+
// Deleting via empty note feels surprising — keep the
|
|
216
|
+
// annotation but with an empty body so the user can choose
|
|
217
|
+
// to delete via the trash icon explicitly.
|
|
218
|
+
const next = new Map(state.annotations);
|
|
219
|
+
next.set(id, { ...existing, note: '', updatedAt: Date.now() });
|
|
220
|
+
saveToStorage(next);
|
|
221
|
+
return { annotations: next };
|
|
222
|
+
}
|
|
223
|
+
const next = new Map(state.annotations);
|
|
224
|
+
next.set(id, { ...existing, note: clamped, updatedAt: Date.now() });
|
|
225
|
+
saveToStorage(next);
|
|
226
|
+
return { annotations: next };
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
removeAnnotation: (id) => {
|
|
231
|
+
set((state) => {
|
|
232
|
+
if (!state.annotations.has(id)) return {};
|
|
233
|
+
const next = new Map(state.annotations);
|
|
234
|
+
next.delete(id);
|
|
235
|
+
saveToStorage(next);
|
|
236
|
+
return {
|
|
237
|
+
annotations: next,
|
|
238
|
+
selectedAnnotationId: state.selectedAnnotationId === id ? null : state.selectedAnnotationId,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
selectAnnotation: (id) => {
|
|
244
|
+
set({ selectedAnnotationId: id });
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
clearAllAnnotations: () => {
|
|
248
|
+
saveToStorage(new Map());
|
|
249
|
+
set({ annotations: new Map(), draft: null, selectedAnnotationId: null });
|
|
250
|
+
},
|
|
251
|
+
});
|