@ifc-lite/viewer 1.17.4 → 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 +20 -17
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +630 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +13 -13
- package/src/App.tsx +16 -2
- 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/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +259 -93
- 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 -13
- package/src/components/viewer/PropertiesPanel.tsx +237 -23
- 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/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +25 -4
- package/src/components/viewer/Viewport.tsx +25 -3
- package/src/components/viewer/ViewportContainer.tsx +51 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -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 +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- 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/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +23 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +550 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -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 +20 -2
- 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/main.tsx +1 -10
- 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/api-keys.ts +73 -0
- 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/constants.ts +20 -2
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +82 -6
- 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/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- 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/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/store.ts +14 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- package/src/lib/llm/clerk-auth.ts +0 -62
|
@@ -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 };
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
* Single annotation pin — a 14px circle anchored to a screen point.
|
|
7
|
+
*
|
|
8
|
+
* The dot itself is small; we wrap it in a 24×24 invisible hit-target
|
|
9
|
+
* so it stays comfortable on touch and doesn't fight pointer events
|
|
10
|
+
* on the surrounding canvas. The pin sits in the canvas overlay layer
|
|
11
|
+
* (`AnnotationLayer`) which positions it absolutely each frame.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { forwardRef } from 'react';
|
|
15
|
+
import { cn } from '@/lib/utils';
|
|
16
|
+
|
|
17
|
+
export interface AnnotationPinProps {
|
|
18
|
+
/** Index in the rendered list — 1-based. Shown inside the dot when ≤ 9. */
|
|
19
|
+
index: number;
|
|
20
|
+
/** Highlights the dot with the emerald ring used for selection across the viewer. */
|
|
21
|
+
selected?: boolean;
|
|
22
|
+
/** Tooltip preview when the user hovers (author + first ~40 chars of note). */
|
|
23
|
+
preview?: string;
|
|
24
|
+
/** Called when the dot is clicked. */
|
|
25
|
+
onClick?: () => void;
|
|
26
|
+
/** Called when the dot is right-clicked — used by the layer for "delete via menu". */
|
|
27
|
+
onContextMenu?: (e: React.MouseEvent) => void;
|
|
28
|
+
/** Visual variant. `draft` is a slightly washed-out pin used while the note input is open. */
|
|
29
|
+
variant?: 'idle' | 'draft';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const AnnotationPin = forwardRef<HTMLButtonElement, AnnotationPinProps>(
|
|
33
|
+
function AnnotationPin({ index, selected, preview, onClick, onContextMenu, variant = 'idle' }, ref) {
|
|
34
|
+
// 1-character glyph: the index for ≤ 9, ellipsis otherwise. Keeps
|
|
35
|
+
// the pin readable at 14px without feeling crowded.
|
|
36
|
+
const glyph = index <= 9 ? String(index) : '·';
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<button
|
|
40
|
+
ref={ref}
|
|
41
|
+
type="button"
|
|
42
|
+
title={preview}
|
|
43
|
+
aria-label={preview ? `Annotation ${index}: ${preview}` : `Annotation ${index}`}
|
|
44
|
+
onClick={onClick}
|
|
45
|
+
onContextMenu={onContextMenu}
|
|
46
|
+
className={cn(
|
|
47
|
+
// 24×24 invisible hit-target around a 14px dot — touch comfort
|
|
48
|
+
// without bloating the visual.
|
|
49
|
+
'group relative inline-flex h-6 w-6 items-center justify-center',
|
|
50
|
+
// Keyboard focus ring uses the same emerald accent as selection.
|
|
51
|
+
'cursor-pointer outline-none rounded-full',
|
|
52
|
+
'focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<span
|
|
56
|
+
aria-hidden
|
|
57
|
+
className={cn(
|
|
58
|
+
// Inner dot: 14px disc, amber accent, white glyph centered.
|
|
59
|
+
// Drop shadow grounds it against the rendered scene; without
|
|
60
|
+
// it the pin floats and reads as a UI bug.
|
|
61
|
+
'flex h-3.5 w-3.5 items-center justify-center rounded-full',
|
|
62
|
+
'text-[8px] font-mono font-bold leading-none text-white tabular-nums',
|
|
63
|
+
'shadow-[0_1px_4px_rgba(0,0,0,0.35),0_0_0_1px_rgba(0,0,0,0.15)]',
|
|
64
|
+
'transition-transform duration-150 ease-out',
|
|
65
|
+
'group-hover:scale-[1.18]',
|
|
66
|
+
// Idle pulse on first paint — drawn from the layer's
|
|
67
|
+
// animation-delay so a freshly committed pin announces
|
|
68
|
+
// itself once and then settles.
|
|
69
|
+
variant === 'idle' && 'annotation-pin-idle',
|
|
70
|
+
variant === 'draft' && 'opacity-70',
|
|
71
|
+
)}
|
|
72
|
+
style={{
|
|
73
|
+
backgroundColor: variant === 'draft' ? '#fbbf24' : '#f59e0b',
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{glyph}
|
|
77
|
+
</span>
|
|
78
|
+
{selected && (
|
|
79
|
+
<span
|
|
80
|
+
aria-hidden
|
|
81
|
+
// Selection ring — emerald, matches the existing
|
|
82
|
+
// "constructive" accent (Raw STEP nav, duplicate path).
|
|
83
|
+
// Sits one pixel outside the dot via `ring-offset`.
|
|
84
|
+
className="pointer-events-none absolute inset-[5px] rounded-full ring-2 ring-emerald-500 ring-offset-1 ring-offset-transparent"
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
@@ -0,0 +1,296 @@
|
|
|
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 popover — appears next to a pin when the user clicks
|
|
7
|
+
* an existing annotation. Read mode shows the note + relative time
|
|
8
|
+
* + entity context; edit mode swaps in a textarea with Enter-to-save
|
|
9
|
+
* / Shift+Enter-newline / Esc-cancel semantics.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import { Pencil, Trash2, X, Check } from 'lucide-react';
|
|
14
|
+
import { Button } from '@/components/ui/button';
|
|
15
|
+
import { cn } from '@/lib/utils';
|
|
16
|
+
import type { Annotation } from '@/store/slices/annotationsSlice';
|
|
17
|
+
|
|
18
|
+
const MAX_NOTE_LEN = 2000;
|
|
19
|
+
const SOFT_NOTE_LIMIT = 200;
|
|
20
|
+
|
|
21
|
+
export interface AnnotationPopoverProps {
|
|
22
|
+
annotation: Annotation;
|
|
23
|
+
/** Anchor in canvas-relative pixel coordinates. */
|
|
24
|
+
anchorX: number;
|
|
25
|
+
anchorY: number;
|
|
26
|
+
/** Canvas dimensions for edge clamping (so the popover never falls off-screen). */
|
|
27
|
+
canvasWidth: number;
|
|
28
|
+
canvasHeight: number;
|
|
29
|
+
/** Resolved entity type, when the pin is anchored to a known IfcRoot. */
|
|
30
|
+
entityType?: string | null;
|
|
31
|
+
onSave: (note: string) => void;
|
|
32
|
+
onDelete: () => void;
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const POPOVER_WIDTH = 280;
|
|
37
|
+
const POPOVER_OFFSET_X = 16;
|
|
38
|
+
|
|
39
|
+
function formatRelativeTime(timestamp: number): string {
|
|
40
|
+
const diff = Date.now() - timestamp;
|
|
41
|
+
const minute = 60_000;
|
|
42
|
+
const hour = 60 * minute;
|
|
43
|
+
const day = 24 * hour;
|
|
44
|
+
const week = 7 * day;
|
|
45
|
+
if (diff < minute) return 'just now';
|
|
46
|
+
if (diff < hour) return `${Math.floor(diff / minute)}m ago`;
|
|
47
|
+
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
|
|
48
|
+
if (diff < week) return `${Math.floor(diff / day)}d ago`;
|
|
49
|
+
return new Date(timestamp).toLocaleDateString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function AnnotationPopover({
|
|
53
|
+
annotation,
|
|
54
|
+
anchorX,
|
|
55
|
+
anchorY,
|
|
56
|
+
canvasWidth,
|
|
57
|
+
canvasHeight,
|
|
58
|
+
entityType,
|
|
59
|
+
onSave,
|
|
60
|
+
onDelete,
|
|
61
|
+
onClose,
|
|
62
|
+
}: AnnotationPopoverProps) {
|
|
63
|
+
const [editing, setEditing] = useState(annotation.note.length === 0);
|
|
64
|
+
const [draft, setDraft] = useState(annotation.note);
|
|
65
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
66
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
67
|
+
|
|
68
|
+
// Reset editor state when the popover is reused for a different
|
|
69
|
+
// annotation. Without this, switching pins would carry the previous
|
|
70
|
+
// pin's draft into the new popover.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setEditing(annotation.note.length === 0);
|
|
73
|
+
setDraft(annotation.note);
|
|
74
|
+
}, [annotation.id, annotation.note]);
|
|
75
|
+
|
|
76
|
+
// When the user enters edit mode, focus + select the textarea so
|
|
77
|
+
// typing replaces the existing body cleanly.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (editing && textareaRef.current) {
|
|
80
|
+
textareaRef.current.focus();
|
|
81
|
+
textareaRef.current.select();
|
|
82
|
+
}
|
|
83
|
+
}, [editing]);
|
|
84
|
+
|
|
85
|
+
// Close on outside click. Listening at the document level keeps
|
|
86
|
+
// the popover predictable when the user mouses anywhere else.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const handler = (e: MouseEvent) => {
|
|
89
|
+
const node = containerRef.current;
|
|
90
|
+
if (!node) return;
|
|
91
|
+
if (node.contains(e.target as Node)) return;
|
|
92
|
+
// Don't close when the click landed on the same pin — the
|
|
93
|
+
// pin's onClick handler controls open/close itself.
|
|
94
|
+
const closestPin = (e.target as HTMLElement).closest?.('[data-annotation-pin-id]');
|
|
95
|
+
if (closestPin?.getAttribute('data-annotation-pin-id') === annotation.id) return;
|
|
96
|
+
onClose();
|
|
97
|
+
};
|
|
98
|
+
// Defer registration to next tick so the click that opened the
|
|
99
|
+
// popover doesn't immediately close it.
|
|
100
|
+
const id = window.setTimeout(() => {
|
|
101
|
+
document.addEventListener('mousedown', handler);
|
|
102
|
+
}, 0);
|
|
103
|
+
return () => {
|
|
104
|
+
window.clearTimeout(id);
|
|
105
|
+
document.removeEventListener('mousedown', handler);
|
|
106
|
+
};
|
|
107
|
+
}, [annotation.id, onClose]);
|
|
108
|
+
|
|
109
|
+
const handleSave = useCallback(() => {
|
|
110
|
+
onSave(draft);
|
|
111
|
+
setEditing(false);
|
|
112
|
+
}, [draft, onSave]);
|
|
113
|
+
|
|
114
|
+
const handleCancel = useCallback(() => {
|
|
115
|
+
setDraft(annotation.note);
|
|
116
|
+
setEditing(false);
|
|
117
|
+
if (annotation.note.length === 0) {
|
|
118
|
+
// No saved body — user backed out of an edit on a freshly
|
|
119
|
+
// committed pin with no body. Close the popover entirely.
|
|
120
|
+
onClose();
|
|
121
|
+
}
|
|
122
|
+
}, [annotation.note, onClose]);
|
|
123
|
+
|
|
124
|
+
const handleKeyDown = useCallback(
|
|
125
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
126
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
handleSave();
|
|
129
|
+
} else if (e.key === 'Escape') {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
handleCancel();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[handleSave, handleCancel],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Edge clamp the popover. Default: anchor to the right of the pin
|
|
138
|
+
// with a 16px gap; flip left when the right edge would clip.
|
|
139
|
+
const wantsLeft = anchorX + POPOVER_OFFSET_X + POPOVER_WIDTH > canvasWidth;
|
|
140
|
+
const left = wantsLeft
|
|
141
|
+
? Math.max(8, anchorX - POPOVER_OFFSET_X - POPOVER_WIDTH)
|
|
142
|
+
: Math.min(anchorX + POPOVER_OFFSET_X, canvasWidth - POPOVER_WIDTH - 8);
|
|
143
|
+
const top = Math.min(Math.max(8, anchorY - 12), canvasHeight - 100);
|
|
144
|
+
|
|
145
|
+
const charCountVisible = editing && draft.length >= SOFT_NOTE_LIMIT;
|
|
146
|
+
const overSoftLimit = draft.length > SOFT_NOTE_LIMIT;
|
|
147
|
+
const overHardLimit = draft.length > MAX_NOTE_LEN;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
ref={containerRef}
|
|
152
|
+
role="dialog"
|
|
153
|
+
aria-label="Annotation"
|
|
154
|
+
style={{ left, top, width: POPOVER_WIDTH }}
|
|
155
|
+
className={cn(
|
|
156
|
+
'absolute z-[60] pointer-events-auto',
|
|
157
|
+
'rounded-md border border-amber-300/60 dark:border-amber-700/40',
|
|
158
|
+
'bg-white/95 dark:bg-zinc-950/95 backdrop-blur-md',
|
|
159
|
+
'shadow-[0_8px_32px_rgba(0,0,0,0.18)]',
|
|
160
|
+
'overflow-hidden',
|
|
161
|
+
'animate-in fade-in-0 zoom-in-95 duration-150',
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{/* Header — entity context + close. Amber accent strip on the
|
|
165
|
+
left signals this is an annotation surface. */}
|
|
166
|
+
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20">
|
|
167
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
168
|
+
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" aria-hidden />
|
|
169
|
+
<span className="font-mono text-[10px] uppercase tracking-wider text-amber-700 dark:text-amber-300 truncate">
|
|
170
|
+
{entityType ? entityType : 'Annotation'}
|
|
171
|
+
{annotation.entityExpressId !== null && (
|
|
172
|
+
<span className="ml-1 text-zinc-400 dark:text-zinc-500">
|
|
173
|
+
#{annotation.entityExpressId}
|
|
174
|
+
</span>
|
|
175
|
+
)}
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
<Button
|
|
179
|
+
variant="ghost"
|
|
180
|
+
size="icon"
|
|
181
|
+
className="h-5 w-5 p-0 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200"
|
|
182
|
+
onClick={onClose}
|
|
183
|
+
title="Close"
|
|
184
|
+
>
|
|
185
|
+
<X className="h-3 w-3" />
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{/* Body */}
|
|
190
|
+
<div className="px-3 py-2.5">
|
|
191
|
+
{editing ? (
|
|
192
|
+
<>
|
|
193
|
+
<textarea
|
|
194
|
+
ref={textareaRef}
|
|
195
|
+
value={draft}
|
|
196
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
197
|
+
onKeyDown={handleKeyDown}
|
|
198
|
+
placeholder="Note about this point…"
|
|
199
|
+
rows={4}
|
|
200
|
+
maxLength={MAX_NOTE_LEN + 100}
|
|
201
|
+
className={cn(
|
|
202
|
+
'w-full resize-none font-mono text-[11px] leading-relaxed',
|
|
203
|
+
'bg-zinc-50 dark:bg-zinc-900/60 text-zinc-800 dark:text-zinc-200',
|
|
204
|
+
'border border-zinc-200 dark:border-zinc-800 rounded-sm',
|
|
205
|
+
'px-2 py-1.5 outline-none focus:ring-1',
|
|
206
|
+
overHardLimit
|
|
207
|
+
? 'focus:ring-red-400 border-red-300 dark:border-red-700/60'
|
|
208
|
+
: 'focus:ring-amber-400/50 focus:border-amber-300/60',
|
|
209
|
+
)}
|
|
210
|
+
spellCheck
|
|
211
|
+
autoCorrect="on"
|
|
212
|
+
/>
|
|
213
|
+
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-mono">
|
|
214
|
+
<span className="text-zinc-400 dark:text-zinc-500">
|
|
215
|
+
⏎ save · ⇧⏎ newline · esc cancel
|
|
216
|
+
</span>
|
|
217
|
+
{charCountVisible && (
|
|
218
|
+
<span
|
|
219
|
+
className={cn(
|
|
220
|
+
'tabular-nums',
|
|
221
|
+
overHardLimit
|
|
222
|
+
? 'text-red-500'
|
|
223
|
+
: overSoftLimit
|
|
224
|
+
? 'text-amber-600 dark:text-amber-400'
|
|
225
|
+
: 'text-zinc-400',
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
{draft.length}/{MAX_NOTE_LEN}
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
<div className="mt-2 flex items-center justify-end gap-1">
|
|
233
|
+
<Button
|
|
234
|
+
variant="ghost"
|
|
235
|
+
size="sm"
|
|
236
|
+
className="h-7 px-2 text-[11px]"
|
|
237
|
+
onClick={handleCancel}
|
|
238
|
+
>
|
|
239
|
+
Cancel
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
size="sm"
|
|
243
|
+
className="h-7 px-2 text-[11px] bg-amber-500 hover:bg-amber-500/90 text-white"
|
|
244
|
+
onClick={handleSave}
|
|
245
|
+
disabled={overHardLimit}
|
|
246
|
+
>
|
|
247
|
+
<Check className="h-3 w-3 mr-1" />
|
|
248
|
+
Save
|
|
249
|
+
</Button>
|
|
250
|
+
</div>
|
|
251
|
+
</>
|
|
252
|
+
) : (
|
|
253
|
+
<>
|
|
254
|
+
{annotation.note ? (
|
|
255
|
+
<p className="font-mono text-[11px] leading-relaxed text-zinc-800 dark:text-zinc-200 whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
|
256
|
+
{annotation.note}
|
|
257
|
+
</p>
|
|
258
|
+
) : (
|
|
259
|
+
<p className="font-mono text-[11px] italic text-zinc-400 dark:text-zinc-500">
|
|
260
|
+
(no note — click the pen icon to add one)
|
|
261
|
+
</p>
|
|
262
|
+
)}
|
|
263
|
+
<div className="mt-2 pt-2 border-t border-zinc-200/60 dark:border-zinc-800/60 flex items-center justify-between gap-2">
|
|
264
|
+
<span className="text-[9.5px] font-mono uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
|
|
265
|
+
{formatRelativeTime(annotation.updatedAt)}
|
|
266
|
+
{annotation.updatedAt !== annotation.createdAt && (
|
|
267
|
+
<span className="ml-1">· edited</span>
|
|
268
|
+
)}
|
|
269
|
+
</span>
|
|
270
|
+
<div className="flex items-center gap-0.5">
|
|
271
|
+
<Button
|
|
272
|
+
variant="ghost"
|
|
273
|
+
size="icon"
|
|
274
|
+
className="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200"
|
|
275
|
+
onClick={() => setEditing(true)}
|
|
276
|
+
title="Edit note"
|
|
277
|
+
>
|
|
278
|
+
<Pencil className="h-3 w-3" />
|
|
279
|
+
</Button>
|
|
280
|
+
<Button
|
|
281
|
+
variant="ghost"
|
|
282
|
+
size="icon"
|
|
283
|
+
className="h-6 w-6 p-0 text-zinc-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30"
|
|
284
|
+
onClick={onDelete}
|
|
285
|
+
title="Delete annotation"
|
|
286
|
+
>
|
|
287
|
+
<Trash2 className="h-3 w-3" />
|
|
288
|
+
</Button>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|