@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.
- package/.env.example +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- 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
|
+
};
|