@striae-org/striae 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. package/wrangler.toml.example +8 -0
@@ -0,0 +1,634 @@
1
+ import { useState, useCallback, useMemo, useRef, useEffect, useContext } from 'react';
2
+ import { BoxAnnotation } from '~/types';
3
+ import { AuthContext } from '~/contexts/auth.context';
4
+ import { auditService } from '~/services/audit.service';
5
+ import { resolveEarliestAnnotationTimestamp } from '~/utils/annotation-timestamp';
6
+ import styles from './box-annotations.module.css';
7
+
8
+ // Constants
9
+ const PRESET_COLOR_NAMES: Record<string, string> = {
10
+ '#ff0000': 'Red',
11
+ '#ff8000': 'Orange',
12
+ '#ffde21': 'Yellow',
13
+ '#00ff00': 'Green',
14
+ '#00ffff': 'Cyan',
15
+ '#0000ff': 'Blue',
16
+ '#8000ff': 'Purple',
17
+ '#ff00ff': 'Magenta',
18
+ '#000000': 'Black',
19
+ '#ffffff': 'White'
20
+ } as const;
21
+
22
+ const MIN_BOX_SIZE_PERCENT = 1; // Minimum box size as percentage of image
23
+ const DIALOG_OFFSET = 10; // Offset for dialog positioning
24
+
25
+ interface BoxAnnotationsProps {
26
+ imageRef: React.RefObject<HTMLImageElement | null>;
27
+ annotations: BoxAnnotation[];
28
+ onAnnotationsChange: (annotations: BoxAnnotation[]) => void;
29
+ isAnnotationMode: boolean;
30
+ annotationColor: string;
31
+ className?: string;
32
+ annotationData?: {
33
+ additionalNotes?: string;
34
+ earliestAnnotationTimestamp?: string;
35
+ };
36
+ onAnnotationDataChange?: (data: { additionalNotes?: string; boxAnnotations?: BoxAnnotation[]; earliestAnnotationTimestamp?: string }) => void;
37
+ isReadOnly?: boolean;
38
+ caseNumber: string; // Required for audit logging
39
+ imageFileId?: string;
40
+ originalImageFileName?: string;
41
+ }
42
+
43
+ interface DrawingState {
44
+ isDrawing: boolean;
45
+ startX: number;
46
+ startY: number;
47
+ currentX: number;
48
+ currentY: number;
49
+ }
50
+
51
+ interface LabelDialogState {
52
+ isVisible: boolean;
53
+ annotationId: string | null;
54
+ x: number;
55
+ y: number;
56
+ label: string;
57
+ }
58
+
59
+ export const BoxAnnotations = ({
60
+ imageRef,
61
+ annotations,
62
+ onAnnotationsChange,
63
+ isAnnotationMode,
64
+ annotationColor,
65
+ className,
66
+ annotationData,
67
+ onAnnotationDataChange,
68
+ isReadOnly = false,
69
+ caseNumber,
70
+ imageFileId,
71
+ originalImageFileName
72
+ }: BoxAnnotationsProps) => {
73
+ const { user } = useContext(AuthContext);
74
+ const [drawingState, setDrawingState] = useState<DrawingState>({
75
+ isDrawing: false,
76
+ startX: 0,
77
+ startY: 0,
78
+ currentX: 0,
79
+ currentY: 0
80
+ });
81
+
82
+ const [labelDialog, setLabelDialog] = useState<LabelDialogState>({
83
+ isVisible: false,
84
+ annotationId: null,
85
+ x: 0,
86
+ y: 0,
87
+ label: ''
88
+ });
89
+
90
+ // Ref to track if component is mounted to prevent state updates after unmount
91
+ const isMountedRef = useRef(true);
92
+
93
+ useEffect(() => {
94
+ return () => {
95
+ isMountedRef.current = false;
96
+ };
97
+ }, []);
98
+
99
+ // Memoized function to get relative coordinates (more stable reference)
100
+ const getRelativeCoordinates = useCallback((e: React.MouseEvent): { x: number; y: number } => {
101
+ const imageElement = imageRef.current;
102
+ if (!imageElement) return { x: 0, y: 0 };
103
+
104
+ try {
105
+ const rect = imageElement.getBoundingClientRect();
106
+ const x = ((e.clientX - rect.left) / rect.width) * 100;
107
+ const y = ((e.clientY - rect.top) / rect.height) * 100;
108
+
109
+ // Clamp values to valid range
110
+ return {
111
+ x: Math.max(0, Math.min(100, x)),
112
+ y: Math.max(0, Math.min(100, y))
113
+ };
114
+ } catch (error) {
115
+ return { x: 0, y: 0 };
116
+ }
117
+ }, [imageRef]);
118
+
119
+ // Helper function to generate unique annotation ID
120
+ const generateAnnotationId = useCallback(() => {
121
+ return `box-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
122
+ }, []);
123
+
124
+ // Helper function to calculate dialog position with viewport boundary checks
125
+ const calculateDialogPosition = useCallback((x: number, y: number): { x: number; y: number } => {
126
+ const imageElement = imageRef.current;
127
+ if (!imageElement) return { x: 0, y: 0 };
128
+
129
+ const rect = imageElement.getBoundingClientRect();
130
+ const dialogX = rect.left + (x / 100) * rect.width;
131
+ const dialogY = rect.top + (y / 100) * rect.height;
132
+
133
+ // Check viewport boundaries and adjust if necessary
134
+ const viewportWidth = window.innerWidth;
135
+ const viewportHeight = window.innerHeight;
136
+ const dialogWidth = 220; // Approximate dialog width
137
+ const dialogHeight = 120; // Approximate dialog height
138
+
139
+ const adjustedX = Math.min(dialogX, viewportWidth - dialogWidth - DIALOG_OFFSET);
140
+ const adjustedY = Math.min(dialogY, viewportHeight - dialogHeight - DIALOG_OFFSET);
141
+
142
+ return {
143
+ x: Math.max(DIALOG_OFFSET, adjustedX),
144
+ y: Math.max(DIALOG_OFFSET, adjustedY)
145
+ };
146
+ }, []);
147
+
148
+ // Handle mouse down - start drawing
149
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
150
+ // Don't allow drawing in read-only mode
151
+ if (isReadOnly) return;
152
+
153
+ // Only start drawing if in annotation mode and not clicking on an existing box
154
+ if (!isAnnotationMode || !imageRef.current) return;
155
+
156
+ // Check if clicking on an existing annotation
157
+ const target = e.target as HTMLElement;
158
+ if (target.classList.contains(styles.savedAnnotationBox)) {
159
+ return; // Don't start drawing if clicking on an existing box
160
+ }
161
+
162
+ e.preventDefault();
163
+ e.stopPropagation();
164
+
165
+ const { x, y } = getRelativeCoordinates(e);
166
+
167
+ if (isMountedRef.current) {
168
+ setDrawingState({
169
+ isDrawing: true,
170
+ startX: x,
171
+ startY: y,
172
+ currentX: x,
173
+ currentY: y
174
+ });
175
+ }
176
+ }, [isAnnotationMode, getRelativeCoordinates]);
177
+
178
+ // Handle mouse move - update current drawing box
179
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
180
+ if (!drawingState.isDrawing || !isAnnotationMode) return;
181
+
182
+ e.preventDefault();
183
+ const { x, y } = getRelativeCoordinates(e);
184
+
185
+ if (isMountedRef.current) {
186
+ setDrawingState(prev => ({
187
+ ...prev,
188
+ currentX: x,
189
+ currentY: y
190
+ }));
191
+ }
192
+ }, [drawingState.isDrawing, isAnnotationMode, getRelativeCoordinates]);
193
+
194
+ // Handle mouse up - complete drawing and save annotation
195
+ const handleMouseUp = useCallback(async () => {
196
+ if (!drawingState.isDrawing || !isAnnotationMode || !isMountedRef.current) return;
197
+
198
+ const { startX, startY, currentX, currentY } = drawingState;
199
+
200
+ // Calculate box dimensions
201
+ const x = Math.min(startX, currentX);
202
+ const y = Math.min(startY, currentY);
203
+ const width = Math.abs(currentX - startX);
204
+ const height = Math.abs(currentY - startY);
205
+
206
+ // Reset drawing state first
207
+ setDrawingState({
208
+ isDrawing: false,
209
+ startX: 0,
210
+ startY: 0,
211
+ currentX: 0,
212
+ currentY: 0
213
+ });
214
+
215
+ // Only save if box has meaningful size (at least MIN_BOX_SIZE_PERCENT of image)
216
+ if (width > MIN_BOX_SIZE_PERCENT && height > MIN_BOX_SIZE_PERCENT) {
217
+ const now = new Date().toISOString();
218
+ const newAnnotation: BoxAnnotation = {
219
+ id: generateAnnotationId(),
220
+ x,
221
+ y,
222
+ width,
223
+ height,
224
+ color: annotationColor,
225
+ timestamp: now
226
+ };
227
+
228
+ try {
229
+ // Save the annotation immediately
230
+ const updatedAnnotations = [...annotations, newAnnotation];
231
+ onAnnotationsChange(updatedAnnotations);
232
+
233
+ // Update annotation data with earliest timestamp if not already set
234
+ if (onAnnotationDataChange && annotationData) {
235
+ onAnnotationDataChange({
236
+ ...annotationData,
237
+ boxAnnotations: updatedAnnotations,
238
+ earliestAnnotationTimestamp: resolveEarliestAnnotationTimestamp(
239
+ annotationData.earliestAnnotationTimestamp,
240
+ undefined,
241
+ now
242
+ )
243
+ });
244
+ }
245
+
246
+ // Log annotation creation audit
247
+ if (user) {
248
+ await auditService.logAnnotationCreate(
249
+ user,
250
+ newAnnotation.id,
251
+ 'region',
252
+ {
253
+ position: { x, y, width, height },
254
+ annotationType: 'box',
255
+ color: annotationColor
256
+ },
257
+ caseNumber,
258
+ 'box-tool',
259
+ imageFileId,
260
+ originalImageFileName
261
+ );
262
+ }
263
+
264
+ // Show label dialog positioned near the annotation with boundary checks
265
+ const dialogPosition = calculateDialogPosition(x, y);
266
+ setLabelDialog({
267
+ isVisible: true,
268
+ annotationId: newAnnotation.id,
269
+ x: dialogPosition.x,
270
+ y: dialogPosition.y,
271
+ label: ''
272
+ });
273
+ } catch (error) {
274
+ console.error('Failed to create annotation or log audit:', error);
275
+ // Continue with UI flow even if audit logging fails
276
+ }
277
+ }
278
+ }, [
279
+ drawingState,
280
+ isAnnotationMode,
281
+ annotationColor,
282
+ annotations,
283
+ onAnnotationsChange,
284
+ generateAnnotationId,
285
+ calculateDialogPosition,
286
+ user
287
+ ]);
288
+
289
+ // Remove a box annotation with validation
290
+ const removeBoxAnnotation = useCallback(async (annotationId: string) => {
291
+ // Find the annotation being removed
292
+ const annotationToRemove = annotations.find(annotation => annotation.id === annotationId);
293
+
294
+ if (!annotationToRemove) {
295
+ console.warn('Attempted to remove non-existent annotation:', annotationId);
296
+ return;
297
+ }
298
+
299
+ try {
300
+ // Filter out the removed annotation
301
+ const updatedAnnotations = annotations.filter(annotation => annotation.id !== annotationId);
302
+
303
+ // Check if the removed annotation has a preset color and label that needs to be removed from Additional Notes
304
+ if (annotationToRemove?.label && annotationData && onAnnotationDataChange) {
305
+ const presetColorName = PRESET_COLOR_NAMES[annotationToRemove.color.toLowerCase()];
306
+
307
+ if (presetColorName) {
308
+ const labelEntry = `${presetColorName}: ${annotationToRemove.label}`;
309
+ const existingNotes = annotationData.additionalNotes || '';
310
+
311
+ // Remove the specific entry from Additional Notes
312
+ let updatedAdditionalNotes = existingNotes;
313
+
314
+ // Handle different positions of the entry (beginning, middle, end)
315
+ if (existingNotes.includes(labelEntry)) {
316
+ // Split by lines to find and remove the exact entry
317
+ const lines = existingNotes.split('\n');
318
+ const filteredLines = lines.filter(line => line !== labelEntry);
319
+ updatedAdditionalNotes = filteredLines.join('\n');
320
+
321
+ // Clean up any resulting empty lines at the beginning or end
322
+ updatedAdditionalNotes = updatedAdditionalNotes.replace(/^\n+|\n+$/g, '');
323
+ }
324
+
325
+ // Update both annotations and additional notes
326
+ onAnnotationDataChange({
327
+ ...annotationData,
328
+ additionalNotes: updatedAdditionalNotes,
329
+ boxAnnotations: updatedAnnotations,
330
+ earliestAnnotationTimestamp: annotationData.earliestAnnotationTimestamp // Preserve earliest timestamp
331
+ });
332
+ } else {
333
+ // No preset color, just update annotations
334
+ onAnnotationsChange(updatedAnnotations);
335
+ }
336
+ } else {
337
+ // No label or no annotation data callback, just update annotations
338
+ onAnnotationsChange(updatedAnnotations);
339
+ }
340
+
341
+ // Log annotation deletion audit
342
+ if (user) {
343
+ await auditService.logAnnotationDelete(
344
+ user,
345
+ annotationId,
346
+ {
347
+ position: {
348
+ x: annotationToRemove.x,
349
+ y: annotationToRemove.y,
350
+ width: annotationToRemove.width,
351
+ height: annotationToRemove.height
352
+ },
353
+ color: annotationToRemove.color,
354
+ label: annotationToRemove.label || 'Unlabeled',
355
+ deletedAt: new Date().toISOString()
356
+ },
357
+ caseNumber,
358
+ 'user-requested',
359
+ imageFileId,
360
+ originalImageFileName
361
+ );
362
+ }
363
+ } catch (error) {
364
+ console.error('Failed to remove annotation or log audit:', error);
365
+ // Continue with removal even if audit logging fails
366
+ }
367
+ }, [annotations, onAnnotationsChange, annotationData, onAnnotationDataChange, user]);
368
+
369
+ // Handle right-click to remove annotation
370
+ const handleAnnotationRightClick = useCallback((e: React.MouseEvent, annotationId: string) => {
371
+ e.preventDefault();
372
+ e.stopPropagation();
373
+ removeBoxAnnotation(annotationId);
374
+ }, [removeBoxAnnotation]);
375
+
376
+ // Handle label confirmation with improved error handling
377
+ const handleLabelConfirm = useCallback(async () => {
378
+ if (!labelDialog.annotationId || !isMountedRef.current) return;
379
+
380
+ // Find the annotation being labeled
381
+ const targetAnnotation = annotations.find(ann => ann.id === labelDialog.annotationId);
382
+ if (!targetAnnotation) {
383
+ // If annotation not found, just close dialog
384
+ setLabelDialog({ isVisible: false, annotationId: null, x: 0, y: 0, label: '' });
385
+ return;
386
+ }
387
+
388
+ const label = labelDialog.label.trim();
389
+ const previousLabel = targetAnnotation.label;
390
+
391
+ try {
392
+ // Always update the box annotation with the label (even if empty)
393
+ const updatedAnnotations = annotations.map(annotation =>
394
+ annotation.id === labelDialog.annotationId
395
+ ? { ...annotation, label: label || undefined }
396
+ : annotation
397
+ );
398
+
399
+ // Prepare additional notes update for preset colors
400
+ let updatedAdditionalNotes = annotationData?.additionalNotes;
401
+ const presetColorName = PRESET_COLOR_NAMES[targetAnnotation.color.toLowerCase()];
402
+
403
+ if (label && presetColorName && annotationData) {
404
+ const existingNotes = annotationData.additionalNotes || '';
405
+ const labelEntry = `${presetColorName}: ${label}`;
406
+
407
+ // Append to existing notes with proper formatting
408
+ updatedAdditionalNotes = existingNotes
409
+ ? `${existingNotes}\n${labelEntry}`
410
+ : labelEntry;
411
+ }
412
+
413
+ // Make a single combined update with both annotations and additional notes
414
+ if (onAnnotationDataChange && annotationData) {
415
+ onAnnotationDataChange({
416
+ ...annotationData,
417
+ additionalNotes: updatedAdditionalNotes,
418
+ boxAnnotations: updatedAnnotations,
419
+ earliestAnnotationTimestamp: annotationData.earliestAnnotationTimestamp // Preserve earliest timestamp
420
+ });
421
+ } else {
422
+ // Fallback to just updating annotations if no combined callback
423
+ onAnnotationsChange(updatedAnnotations);
424
+ }
425
+
426
+ // Log annotation edit audit (only if label actually changed)
427
+ if (user && label !== previousLabel) {
428
+ await auditService.logAnnotationEdit(
429
+ user,
430
+ labelDialog.annotationId,
431
+ {
432
+ position: {
433
+ x: targetAnnotation.x,
434
+ y: targetAnnotation.y,
435
+ width: targetAnnotation.width,
436
+ height: targetAnnotation.height
437
+ },
438
+ color: targetAnnotation.color,
439
+ label: previousLabel || 'Unlabeled'
440
+ },
441
+ {
442
+ position: {
443
+ x: targetAnnotation.x,
444
+ y: targetAnnotation.y,
445
+ width: targetAnnotation.width,
446
+ height: targetAnnotation.height
447
+ },
448
+ color: targetAnnotation.color,
449
+ label: label || 'Unlabeled'
450
+ },
451
+ caseNumber,
452
+ 'label-edit',
453
+ imageFileId,
454
+ originalImageFileName
455
+ );
456
+ }
457
+
458
+ setLabelDialog({ isVisible: false, annotationId: null, x: 0, y: 0, label: '' });
459
+ } catch (error) {
460
+ console.error('Error updating annotation data or logging audit:', error);
461
+ // Still try to close dialog even if update fails
462
+ setLabelDialog({ isVisible: false, annotationId: null, x: 0, y: 0, label: '' });
463
+ }
464
+ }, [labelDialog, annotations, onAnnotationsChange, annotationData, onAnnotationDataChange, user]);
465
+
466
+ // Handle label cancellation
467
+ const handleLabelCancel = useCallback(() => {
468
+ if (!isMountedRef.current) return;
469
+ setLabelDialog({ isVisible: false, annotationId: null, x: 0, y: 0, label: '' });
470
+ }, []);
471
+
472
+ // Handle label input change
473
+ const handleLabelChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
474
+ if (!isMountedRef.current) return;
475
+ setLabelDialog(prev => ({ ...prev, label: e.target.value }));
476
+ }, []);
477
+
478
+ // Handle Enter key in label input
479
+ const handleLabelKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
480
+ if (e.key === 'Enter') {
481
+ handleLabelConfirm();
482
+ } else if (e.key === 'Escape') {
483
+ handleLabelCancel();
484
+ }
485
+ }, [handleLabelConfirm, handleLabelCancel]);
486
+
487
+ // Memoized current drawing box to avoid unnecessary re-renders
488
+ const currentDrawingBox = useMemo(() => {
489
+ if (!drawingState.isDrawing) return null;
490
+
491
+ const { startX, startY, currentX, currentY } = drawingState;
492
+ const x = Math.min(startX, currentX);
493
+ const y = Math.min(startY, currentY);
494
+ const width = Math.abs(currentX - startX);
495
+ const height = Math.abs(currentY - startY);
496
+
497
+ return (
498
+ <div
499
+ className={styles.drawingBox}
500
+ style={{
501
+ left: `${x}%`,
502
+ top: `${y}%`,
503
+ width: `${width}%`,
504
+ height: `${height}%`,
505
+ border: `2px solid ${annotationColor}`,
506
+ backgroundColor: 'transparent'
507
+ }}
508
+ />
509
+ );
510
+ }, [drawingState, annotationColor]);
511
+
512
+ // Memoized saved annotations to avoid unnecessary re-renders
513
+ const savedAnnotations = useMemo(() => {
514
+ // Always show existing box annotations (for viewing purposes)
515
+ // But only allow interactions when not in read-only mode and annotation mode is active
516
+
517
+ return annotations.map((annotation) => (
518
+ <div
519
+ key={annotation.id}
520
+ className={`${styles.savedAnnotationBox} ${isReadOnly ? styles.readOnlyAnnotation : ''}`}
521
+ style={{
522
+ left: `${annotation.x}%`,
523
+ top: `${annotation.y}%`,
524
+ width: `${annotation.width}%`,
525
+ height: `${annotation.height}%`,
526
+ border: `2px solid ${annotation.color}`,
527
+ backgroundColor: 'transparent',
528
+ pointerEvents: isReadOnly ? 'none' : 'auto', // Disable interactions in read-only mode
529
+ opacity: isReadOnly ? 0.8 : 1 // Slightly transparent in read-only mode
530
+ }}
531
+ title={isReadOnly
532
+ ? undefined
533
+ : 'Double-click or right-click to delete'
534
+ }
535
+ onDoubleClick={(e) => {
536
+ if (isReadOnly) return;
537
+ e.stopPropagation();
538
+ removeBoxAnnotation(annotation.id);
539
+ }}
540
+ onContextMenu={(e) => {
541
+ if (isReadOnly) return;
542
+ handleAnnotationRightClick(e, annotation.id);
543
+ }}
544
+ >
545
+ {annotation.label && (
546
+ <div className={styles.annotationLabel}>
547
+ {annotation.label}
548
+ </div>
549
+ )}
550
+ </div>
551
+ ));
552
+ }, [annotations, removeBoxAnnotation, handleAnnotationRightClick, isReadOnly]);
553
+
554
+ // Memoized label dialog to avoid unnecessary re-renders
555
+ const labelDialogElement = useMemo(() => {
556
+ if (!labelDialog.isVisible) return null;
557
+
558
+ // Check if current annotation uses a preset color
559
+ const targetAnnotation = annotations.find(ann => ann.id === labelDialog.annotationId);
560
+ const isPresetColor = targetAnnotation && PRESET_COLOR_NAMES[targetAnnotation.color.toLowerCase()];
561
+
562
+ return (
563
+ <div
564
+ className={styles.labelDialog}
565
+ style={{
566
+ position: 'fixed',
567
+ left: `${labelDialog.x}px`,
568
+ top: `${labelDialog.y}px`,
569
+ zIndex: 1000
570
+ }}
571
+ >
572
+ <div className={styles.labelDialogContent}>
573
+ <div className={styles.labelDialogTitle}>Add Label (Optional)</div>
574
+ <div className={styles.labelDialogNote}>
575
+ {isPresetColor
576
+ ? `Note: Labels for preset colors will appear in PDF reports under Additional Notes.`
577
+ : `Note: Labels for custom colors are for organization only and will not appear in PDF reports.`
578
+ }
579
+ </div>
580
+ <input
581
+ type="text"
582
+ value={labelDialog.label}
583
+ onChange={handleLabelChange}
584
+ onKeyDown={handleLabelKeyDown}
585
+ placeholder="Enter label..."
586
+ className={styles.labelInput}
587
+ autoFocus
588
+ />
589
+ <div className={styles.labelDialogButtons}>
590
+ <button
591
+ onClick={handleLabelConfirm}
592
+ className={styles.labelConfirmButton}
593
+ >
594
+ Confirm
595
+ </button>
596
+ <button
597
+ onClick={handleLabelCancel}
598
+ className={styles.labelCancelButton}
599
+ >
600
+ Cancel
601
+ </button>
602
+ </div>
603
+ </div>
604
+ </div>
605
+ );
606
+ }, [
607
+ labelDialog,
608
+ annotations,
609
+ handleLabelChange,
610
+ handleLabelKeyDown,
611
+ handleLabelConfirm,
612
+ handleLabelCancel
613
+ ]);
614
+
615
+ return (
616
+ <>
617
+ <div
618
+ className={`${styles.boxAnnotationsOverlay} ${className || ''}`}
619
+ onMouseDown={handleMouseDown}
620
+ onMouseMove={handleMouseMove}
621
+ onMouseUp={handleMouseUp}
622
+ onMouseLeave={handleMouseUp}
623
+ style={{
624
+ cursor: isAnnotationMode && !isReadOnly ? 'crosshair' : 'default',
625
+ pointerEvents: 'auto' // Always allow pointer events for viewing annotations
626
+ }}
627
+ >
628
+ {savedAnnotations}
629
+ {currentDrawingBox}
630
+ </div>
631
+ {labelDialogElement}
632
+ </>
633
+ );
634
+ };