@semiont/react-ui 0.5.19 → 0.5.20
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/dist/{PdfAnnotationCanvas.client-75GY2EDR.js → PdfAnnotationCanvas.client-V7Q3BEEQ.js} +42 -45
- package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +1 -0
- package/dist/index.d.ts +231 -55
- package/dist/index.js +2115 -2161
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/AssistProgress.tsx +170 -0
- package/src/components/__tests__/AssistProgress.test.tsx +164 -0
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +23 -54
- package/src/components/pdf-annotation/__tests__/rects-for-page.test.ts +96 -0
- package/src/components/pdf-annotation/rects-for-page.ts +47 -0
- package/src/components/resource/ResourceViewer.tsx +23 -55
- package/src/components/resource/panels/AssessmentPanel.tsx +1 -1
- package/src/components/resource/panels/AssistSection.tsx +111 -180
- package/src/components/resource/panels/AssistShell.tsx +77 -0
- package/src/components/resource/panels/CommentsPanel.tsx +1 -1
- package/src/components/resource/panels/HighlightPanel.tsx +1 -1
- package/src/components/resource/panels/ReferenceEntry.tsx +8 -1
- package/src/components/resource/panels/ReferencesPanel.tsx +101 -132
- package/src/components/resource/panels/TaggingPanel.tsx +23 -86
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +6 -14
- package/src/components/resource/panels/__tests__/AssistShell.test.tsx +59 -0
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +20 -0
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +0 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +14 -20
- package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +30 -0
- package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -1
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +24 -64
- package/src/styles/features/compose.css +7 -0
- package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +0 -1
- package/src/components/AnnotateReferencesProgressWidget.tsx +0 -125
- package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +0 -101
|
@@ -159,6 +159,7 @@ export function ResourceComposePage({
|
|
|
159
159
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
|
160
160
|
const [fileMimeType, setFileMimeType] = useState<string>('text/markdown');
|
|
161
161
|
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null);
|
|
162
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
162
163
|
|
|
163
164
|
// Format selection for manual content entry
|
|
164
165
|
const [selectedFormat, setSelectedFormat] = useState<string>('text/markdown');
|
|
@@ -192,7 +193,13 @@ export function ResourceComposePage({
|
|
|
192
193
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
193
194
|
const file = e.target.files?.[0];
|
|
194
195
|
if (!file) return;
|
|
196
|
+
applyUploadedFile(file);
|
|
197
|
+
};
|
|
195
198
|
|
|
199
|
+
// One application path for both entries into the dropzone: the file-input
|
|
200
|
+
// change (click) and a real drag-and-drop (the input is display:none, so
|
|
201
|
+
// native input drops can never land — the label owns the drop).
|
|
202
|
+
const applyUploadedFile = (file: File) => {
|
|
196
203
|
const detectedMediaType = detectUploadMediaType(file);
|
|
197
204
|
setUploadedFile(file);
|
|
198
205
|
setFileMimeType(detectedMediaType);
|
|
@@ -548,7 +555,18 @@ export function ResourceComposePage({
|
|
|
548
555
|
<div className="semiont-form__upload-section">
|
|
549
556
|
<div>
|
|
550
557
|
<div className="semiont-form__upload-container">
|
|
551
|
-
<label
|
|
558
|
+
<label
|
|
559
|
+
className="semiont-form__upload-dropzone"
|
|
560
|
+
data-drag-over={isDragOver ? 'true' : 'false'}
|
|
561
|
+
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
|
562
|
+
onDragLeave={() => setIsDragOver(false)}
|
|
563
|
+
onDrop={(e) => {
|
|
564
|
+
e.preventDefault();
|
|
565
|
+
setIsDragOver(false);
|
|
566
|
+
const file = e.dataTransfer.files?.[0];
|
|
567
|
+
if (file && !isCreating) applyUploadedFile(file);
|
|
568
|
+
}}
|
|
569
|
+
>
|
|
552
570
|
<div className="semiont-form__upload-area">
|
|
553
571
|
<input
|
|
554
572
|
type="file"
|
|
@@ -24,6 +24,7 @@ import { useObservable } from '@semiont/react-ui';
|
|
|
24
24
|
import { useResourceContent } from '../../../hooks/useResourceContent';
|
|
25
25
|
import { useMediaToken } from '../../../hooks/useMediaToken';
|
|
26
26
|
import { useToast } from '../../../components/Toast';
|
|
27
|
+
import { useOutcomeToasts } from '../../../hooks/useOutcomeToasts';
|
|
27
28
|
import { useTheme } from '../../../contexts/ThemeContext';
|
|
28
29
|
import { useLineNumbers } from '../../../hooks/useLineNumbers';
|
|
29
30
|
import { useHoverDelay } from '../../../hooks/useHoverDelay';
|
|
@@ -39,7 +40,7 @@ import { useShellStateUnit } from '../../../hooks/useShellStateUnit';
|
|
|
39
40
|
import { useTranslations } from '../../../contexts/TranslationContext';
|
|
40
41
|
import { ReferenceWizardModal } from '../../../components/modals/ReferenceWizardModal';
|
|
41
42
|
import { ResourceGenerateModal } from '../../../components/modals/ResourceGenerateModal';
|
|
42
|
-
import {
|
|
43
|
+
import { AssistProgress } from '../../../components/AssistProgress';
|
|
43
44
|
import type { GenerationConfig } from '../../../components/modals/ConfigureGenerationStep';
|
|
44
45
|
|
|
45
46
|
type SemiontResource = ResourceDescriptor;
|
|
@@ -105,20 +106,15 @@ export interface ResourceViewerPageProps {
|
|
|
105
106
|
* @subscribes mark:unarchive - Unarchive the current resource
|
|
106
107
|
* @subscribes yield:clone - Clone the current resource
|
|
107
108
|
* @subscribes beckon:sparkle - Trigger sparkle animation
|
|
108
|
-
* @subscribes mark:added - Annotation was created
|
|
109
|
-
* @subscribes mark:removed - Annotation was deleted
|
|
110
|
-
* @subscribes mark:create-failed - Annotation creation failed
|
|
111
|
-
* @subscribes mark:delete-failed - Annotation deletion failed
|
|
112
|
-
* @subscribes mark:body-updated - Annotation body was updated
|
|
113
|
-
* @subscribes annotate:body-update-failed - Annotation body update failed
|
|
109
|
+
* @subscribes mark:added - Annotation was created (sparkle)
|
|
114
110
|
* @subscribes settings:theme-changed - UI theme changed
|
|
115
111
|
* @subscribes settings:line-numbers-toggled - Line numbers display toggled
|
|
116
|
-
* @subscribes detection:complete - Detection completed
|
|
117
|
-
* @subscribes detection:failed - Detection failed
|
|
118
|
-
* @subscribes generation:complete - Generation completed
|
|
119
|
-
* @subscribes generation:failed - Generation failed
|
|
120
112
|
* @subscribes browse:reference-navigate - Navigate to a referenced document
|
|
121
113
|
* @subscribes browse:entity-type-clicked - Navigate filtered by entity type
|
|
114
|
+
*
|
|
115
|
+
* Outcome-notification channels (mark:create-error, mark:delete-error,
|
|
116
|
+
* bind:body-error, job:complete, job:fail, mark:assist-timeout) are
|
|
117
|
+
* subscribed by useOutcomeToasts.
|
|
122
118
|
*/
|
|
123
119
|
export function ResourceViewerPage({
|
|
124
120
|
resource,
|
|
@@ -151,7 +147,7 @@ export function ResourceViewerPage({
|
|
|
151
147
|
}, [browser]);
|
|
152
148
|
|
|
153
149
|
// UI state hooks
|
|
154
|
-
const { showError, showSuccess
|
|
150
|
+
const { showError, showSuccess } = useToast();
|
|
155
151
|
const { theme, setTheme } = useTheme();
|
|
156
152
|
const { showLineNumbers, toggleLineNumbers } = useLineNumbers();
|
|
157
153
|
const { hoverDelayMs } = useHoverDelay();
|
|
@@ -288,7 +284,7 @@ export function ResourceViewerPage({
|
|
|
288
284
|
path: `/know/compose?${params.toString()}`,
|
|
289
285
|
reason: 'compose-from-wizard',
|
|
290
286
|
});
|
|
291
|
-
}, [
|
|
287
|
+
}, [browser]);
|
|
292
288
|
|
|
293
289
|
// Add resource to open tabs when it loads
|
|
294
290
|
useEffect(() => {
|
|
@@ -349,7 +345,7 @@ export function ResourceViewerPage({
|
|
|
349
345
|
console.error('Failed to generate clone token:', err);
|
|
350
346
|
showError('Failed to generate clone link');
|
|
351
347
|
}
|
|
352
|
-
}, [semiont, rUri, showError,
|
|
348
|
+
}, [semiont, rUri, showError, browser]);
|
|
353
349
|
|
|
354
350
|
const handleAnnotationSparkle = useCallback(({ annotationId }: { annotationId: string }) => {
|
|
355
351
|
triggerSparkleAnimation(annotationId);
|
|
@@ -359,75 +355,39 @@ export function ResourceViewerPage({
|
|
|
359
355
|
triggerSparkleAnimation(stored.payload.annotation.id);
|
|
360
356
|
}, [triggerSparkleAnimation]);
|
|
361
357
|
|
|
362
|
-
const handleAnnotationCreateFailed = useCallback(({ message }: { message?: string }) =>
|
|
363
|
-
showError(`Failed to create annotation: ${message || 'unknown error'}`), [showError]);
|
|
364
|
-
const handleAnnotationDeleteFailed = useCallback(({ message }: { message?: string }) =>
|
|
365
|
-
showError(`Failed to delete annotation: ${message || 'unknown error'}`), [showError]);
|
|
366
|
-
const handleAnnotateBodyUpdated = useCallback(() => {
|
|
367
|
-
// Success - optimistic update already applied via EventBus
|
|
368
|
-
}, []);
|
|
369
|
-
const handleAnnotateBodyUpdateFailed = useCallback(({ message }: { message: string }) =>
|
|
370
|
-
showError(`Failed to update reference: ${message}`), [showError]);
|
|
371
|
-
|
|
372
358
|
const handleSettingsThemeChanged = useCallback(({ theme }: { theme: any }) => setTheme(theme), [setTheme]);
|
|
373
359
|
|
|
374
|
-
// Unified job lifecycle handlers. `job:complete` / `job:fail` fire
|
|
375
|
-
// for every job type (annotation + generation); we dispatch on
|
|
376
|
-
// jobType and filter to this resource. `annotationId` is present on
|
|
377
|
-
// jobs attached to a specific annotation (today: generation from a
|
|
378
|
-
// reference); it's what UI consumers lower down in the tree use to
|
|
379
|
-
// attach per-annotation visual feedback.
|
|
380
|
-
const handleJobComplete = useCallback((event: components['schemas']['JobCompleteCommand']) => {
|
|
381
|
-
if (event.resourceId !== (resource.id as string)) return;
|
|
382
|
-
if (event.jobType === 'generation') {
|
|
383
|
-
const result = event.result as components['schemas']['JobGenerationResult'] | undefined;
|
|
384
|
-
const name = result?.resourceName;
|
|
385
|
-
showSuccess(name
|
|
386
|
-
? `Resource "${name}" created successfully!`
|
|
387
|
-
: 'Resource created successfully!');
|
|
388
|
-
} else {
|
|
389
|
-
showSuccess('Annotation complete');
|
|
390
|
-
}
|
|
391
|
-
}, [resource.id, showSuccess]);
|
|
392
|
-
const handleJobFailed = useCallback((event: components['schemas']['JobFailCommand']) => {
|
|
393
|
-
if (event.resourceId !== (resource.id as string)) return;
|
|
394
|
-
if (event.jobType === 'generation') {
|
|
395
|
-
showError(`Resource generation failed: ${event.error}`);
|
|
396
|
-
} else {
|
|
397
|
-
showError(event.error || 'Annotation failed');
|
|
398
|
-
}
|
|
399
|
-
}, [resource.id, showError]);
|
|
400
|
-
|
|
401
360
|
const handleReferenceNavigate = useCallback(({ resourceId }: { resourceId: string }) => {
|
|
402
361
|
if (routes.resourceDetail) {
|
|
403
362
|
const path = routes.resourceDetail(resourceId);
|
|
404
363
|
browser.emit('nav:push', { path, reason: 'reference-link' });
|
|
405
364
|
}
|
|
406
|
-
}, [routes.resourceDetail,
|
|
365
|
+
}, [routes.resourceDetail, browser]);
|
|
407
366
|
|
|
408
367
|
const handleEntityTypeClicked = useCallback(({ entityType }: { entityType: string }) => {
|
|
409
368
|
if (routes.know) {
|
|
410
369
|
const path = `${routes.know}?entityType=${encodeURIComponent(entityType)}`;
|
|
411
370
|
browser.emit('nav:push', { path, reason: 'entity-type-filter' });
|
|
412
371
|
}
|
|
413
|
-
}, [routes.know,
|
|
372
|
+
}, [routes.know, browser]);
|
|
373
|
+
|
|
374
|
+
// Outcome notifications (annotation CRUD failures, job success/decline/fail,
|
|
375
|
+
// assist timed-out) live in useOutcomeToasts — they need only the resource id
|
|
376
|
+
// and the toast surface. The registration below keeps the handlers that need
|
|
377
|
+
// page-local dependencies (SDK actions, sparkles, settings, navigation).
|
|
378
|
+
useOutcomeToasts(resource.id as string);
|
|
414
379
|
|
|
415
|
-
//
|
|
380
|
+
// Single useEventSubscriptions call per file (enforced by
|
|
381
|
+
// scripts/compliance/audit-hooks-ordering.ts); hooks like useOutcomeToasts
|
|
382
|
+
// own their channels in their own files.
|
|
416
383
|
useEventSubscriptions({
|
|
417
384
|
'mark:archive': handleResourceArchive,
|
|
418
385
|
'mark:unarchive': handleResourceUnarchive,
|
|
419
386
|
'yield:clone': handleResourceClone,
|
|
420
387
|
'beckon:sparkle': handleAnnotationSparkle,
|
|
421
388
|
'mark:added': handleAnnotationAdded,
|
|
422
|
-
'mark:create-failed': handleAnnotationCreateFailed,
|
|
423
|
-
'mark:delete-failed': handleAnnotationDeleteFailed,
|
|
424
|
-
'mark:body-updated': handleAnnotateBodyUpdated,
|
|
425
|
-
'bind:body-update-failed': handleAnnotateBodyUpdateFailed,
|
|
426
389
|
'settings:theme-changed': handleSettingsThemeChanged,
|
|
427
390
|
'settings:line-numbers-toggled': toggleLineNumbers,
|
|
428
|
-
'job:complete': handleJobComplete,
|
|
429
|
-
'job:fail': handleJobFailed,
|
|
430
|
-
'mark:assist-cancelled': () => showInfo('Annotation cancelled'),
|
|
431
391
|
'browse:reference-navigate': handleReferenceNavigate,
|
|
432
392
|
'browse:entity-type-clicked': handleEntityTypeClicked,
|
|
433
393
|
});
|
|
@@ -486,10 +446,10 @@ export function ResourceViewerPage({
|
|
|
486
446
|
</div>
|
|
487
447
|
{/* Resource-generation progress (GENERATE-FROM-BUTTON P7) — no annotationId ⇒ a resource-gen job */}
|
|
488
448
|
{generationProgress && !generationProgress.annotationId && (
|
|
489
|
-
<
|
|
449
|
+
<AssistProgress
|
|
490
450
|
progress={generationProgress}
|
|
491
|
-
|
|
492
|
-
|
|
451
|
+
dataType="generation"
|
|
452
|
+
onCancel={() => session?.client.job.cancelRequest('generation')}
|
|
493
453
|
translations={{
|
|
494
454
|
title: tg('progressTitle'),
|
|
495
455
|
cancel: tg('progressCancel'),
|
|
@@ -114,6 +114,13 @@
|
|
|
114
114
|
display: none;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/* The label owns drag-and-drop (the input above can't receive native drops);
|
|
118
|
+
highlight while a drag hovers. */
|
|
119
|
+
.semiont-form__upload-dropzone[data-drag-over="true"] {
|
|
120
|
+
border-color: var(--semiont-color-primary-500);
|
|
121
|
+
background: var(--semiont-color-primary-50, rgba(59, 130, 246, 0.06));
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
.semiont-form__upload-text {
|
|
118
125
|
font-size: 0.875rem;
|
|
119
126
|
color: var(--semiont-color-gray-600);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/components/pdf-annotation/PdfAnnotationCanvas.tsx","../src/lib/pdf-coordinates.ts"],"sourcesContent":["'use client';\n\nimport React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';\nimport type { Annotation, AnchorRect } from '@semiont/core';\nimport { resourceId as toResourceId } from '@semiont/core';\nimport { toViewportAnchorRect } from '../../lib/anchor-rect';\nimport {\n getTargetSelector,\n createFragmentSelector,\n parseFragmentSelector,\n getPageFromFragment,\n} from '@semiont/core';\nimport { createHoverHandlers, type SemiontSession } from '@semiont/sdk';\nimport type { SelectionMotivation } from '../annotation/AnnotateToolbar';\nimport {\n canvasToPdfCoordinates,\n pdfToCanvasCoordinates,\n type CanvasRectangle\n} from '../../lib/pdf-coordinates';\nimport {\n loadPdfDocument,\n renderPdfPageToDataUrl,\n type PDFDocumentProxy\n} from '../../lib/browser-pdfjs';\nimport './PdfAnnotationCanvas.css';\n\nexport type DrawingMode = 'rectangle' | 'circle' | 'polygon' | null;\n\n/**\n * Get color for annotation based on motivation\n */\nfunction getMotivationColor(motivation: SelectionMotivation | null): { stroke: string; fill: string } {\n if (!motivation) {\n return { stroke: 'rgb(156, 163, 175)', fill: 'rgba(156, 163, 175, 0.2)' };\n }\n\n switch (motivation) {\n case 'highlighting':\n return { stroke: 'rgb(250, 204, 21)', fill: 'rgba(250, 204, 21, 0.3)' };\n case 'linking':\n return { stroke: 'rgb(59, 130, 246)', fill: 'rgba(59, 130, 246, 0.2)' };\n case 'assessing':\n return { stroke: 'rgb(239, 68, 68)', fill: 'rgba(239, 68, 68, 0.2)' };\n case 'commenting':\n return { stroke: 'rgb(255, 255, 255)', fill: 'rgba(255, 255, 255, 0.2)' };\n default:\n return { stroke: 'rgb(156, 163, 175)', fill: 'rgba(156, 163, 175, 0.2)' };\n }\n}\n\ninterface PdfAnnotationCanvasProps {\n pdfUrl: string;\n /** The '@id' of the annotated resource — stamped as `source` on mark:requested (multi-viewer routing). */\n resourceUri: string;\n existingAnnotations?: Annotation[];\n drawingMode: DrawingMode;\n selectedMotivation?: SelectionMotivation | null;\n session?: SemiontSession | null | undefined;\n hoveredAnnotationId?: string | null;\n selectedAnnotationId?: string | null;\n hoverDelayMs?: number;\n}\n\n/**\n * PDF annotation canvas with page navigation and rectangle drawing\n *\n * @emits browse:click - Annotation clicked on PDF. Payload: { annotationId: string, motivation: Motivation }\n * @emits mark:requested - New annotation drawn on PDF. Payload: { selector: FragmentSelector, motivation: SelectionMotivation }\n * @emits beckon:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }\n */\nexport function PdfAnnotationCanvas({\n pdfUrl,\n resourceUri,\n existingAnnotations = [],\n drawingMode,\n selectedMotivation,\n session,\n hoveredAnnotationId,\n selectedAnnotationId,\n hoverDelayMs = 150\n}: PdfAnnotationCanvasProps) {\n // PDF state\n const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);\n const [numPages, setNumPages] = useState<number>(0);\n const [pageNumber, setPageNumber] = useState(1);\n const [pageImageUrl, setPageImageUrl] = useState<string | null>(null);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [pageDimensions, setPageDimensions] = useState<{ width: number; height: number } | null>(null);\n const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);\n const [scale] = useState(1.5); // Fixed scale for better quality\n\n // Drawing state\n const [isDrawing, setIsDrawing] = useState(false);\n const [selection, setSelection] = useState<CanvasRectangle | null>(null);\n\n const containerRef = useRef<HTMLDivElement>(null);\n const imageRef = useRef<HTMLImageElement>(null);\n\n // Load PDF document on mount\n useEffect(() => {\n let cancelled = false;\n\n async function loadPdf() {\n try {\n setIsLoading(true);\n setError(null);\n\n const doc = await loadPdfDocument(pdfUrl);\n\n if (cancelled) return;\n\n setPdfDoc(doc);\n setNumPages(doc.numPages);\n setIsLoading(false);\n } catch (err) {\n if (cancelled) return;\n\n console.error('Error loading PDF:', err);\n setError('Failed to load PDF');\n setIsLoading(false);\n }\n }\n\n loadPdf();\n\n return () => {\n cancelled = true;\n };\n }, [pdfUrl]);\n\n // Load current page when page number changes\n useEffect(() => {\n if (!pdfDoc) return;\n\n let cancelled = false;\n const doc = pdfDoc;\n\n async function loadPage() {\n try {\n const page = await doc.getPage(pageNumber);\n\n if (cancelled) return;\n\n // Get page dimensions (at scale 1.0)\n const viewport = page.getViewport({ scale: 1.0 });\n setPageDimensions({\n width: viewport.width,\n height: viewport.height\n });\n\n // Render page to image\n const { dataUrl } = await renderPdfPageToDataUrl(page, scale);\n\n if (cancelled) return;\n\n setPageImageUrl(dataUrl);\n } catch (err) {\n if (cancelled) return;\n\n console.error('Error loading page:', err);\n setError('Failed to load page');\n }\n }\n\n loadPage();\n\n return () => {\n cancelled = true;\n };\n }, [pdfDoc, pageNumber, scale]);\n\n // Update display dimensions on resize\n useEffect(() => {\n const updateDisplayDimensions = () => {\n if (imageRef.current) {\n setDisplayDimensions({\n width: imageRef.current.clientWidth,\n height: imageRef.current.clientHeight\n });\n }\n };\n\n updateDisplayDimensions();\n\n // Use ResizeObserver to detect image element size changes\n // This catches: sidebar open/close, window resize, font size changes, etc.\n let resizeObserver: ResizeObserver | null = null;\n\n try {\n resizeObserver = new ResizeObserver(updateDisplayDimensions);\n if (imageRef.current) {\n resizeObserver.observe(imageRef.current);\n }\n } catch (error) {\n // Fallback for browsers without ResizeObserver support\n console.warn('ResizeObserver not supported, falling back to window resize listener');\n window.addEventListener('resize', updateDisplayDimensions);\n }\n\n return () => {\n if (resizeObserver) {\n resizeObserver.disconnect();\n } else {\n window.removeEventListener('resize', updateDisplayDimensions);\n }\n };\n }, [pageImageUrl]);\n\n // Mouse event handlers for drawing\n const handleMouseDown = useCallback((e: React.MouseEvent) => {\n if (!drawingMode) return;\n if (!imageRef.current) return;\n\n const rect = imageRef.current.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n\n // Clear any previous selection when starting new drawing\n setIsDrawing(true);\n setSelection({\n startX: x,\n startY: y,\n endX: x,\n endY: y\n });\n }, [drawingMode]);\n\n const handleMouseMove = useCallback((e: React.MouseEvent) => {\n if (!isDrawing || !selection || !imageRef.current) return;\n\n const rect = imageRef.current.getBoundingClientRect();\n\n setSelection({\n ...selection,\n endX: e.clientX - rect.left,\n endY: e.clientY - rect.top\n });\n }, [isDrawing, selection]);\n\n const handleMouseUp = useCallback(() => {\n if (!isDrawing || !selection || !pageDimensions || !displayDimensions || !session) {\n setIsDrawing(false);\n setSelection(null);\n return;\n }\n\n // Calculate drag distance\n const dragDistance = Math.sqrt(\n Math.pow(selection.endX - selection.startX, 2) +\n Math.pow(selection.endY - selection.startY, 2)\n );\n\n // Minimum drag threshold in pixels (10px)\n const MIN_DRAG_DISTANCE = 10;\n\n if (dragDistance < MIN_DRAG_DISTANCE) {\n // This was a click, not a drag - check if we clicked an existing annotation\n if (existingAnnotations.length > 0) {\n // The hit-test owns the coordinate transform — capture the hit\n // annotation's viewport rect for the emission below (A1 anchor).\n let hitRect: AnchorRect | undefined;\n const clickedAnnotation = pageAnnotations.find(ann => {\n const fragmentSel = getFragmentSelector(ann.target);\n if (!fragmentSel) return false;\n\n const pdfCoord = parseFragmentSelector(fragmentSel.value);\n if (!pdfCoord) return false;\n\n const rect = pdfToCanvasCoordinates(pdfCoord, pageDimensions.height, 1.0);\n\n // Scale to display coordinates\n const scaleX = displayDimensions.width / pageDimensions.width;\n const scaleY = displayDimensions.height / pageDimensions.height;\n\n const displayX = rect.x * scaleX;\n const displayY = rect.y * scaleY;\n const displayWidth = rect.width * scaleX;\n const displayHeight = rect.height * scaleY;\n\n const hit = (\n selection.endX >= displayX &&\n selection.endX <= displayX + displayWidth &&\n selection.endY >= displayY &&\n selection.endY <= displayY + displayHeight\n );\n if (hit && imageRef.current) {\n hitRect = toViewportAnchorRect(imageRef.current.getBoundingClientRect(), displayX, displayY, displayWidth, displayHeight);\n }\n return hit;\n });\n\n if (clickedAnnotation) {\n session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation, hitRect);\n setIsDrawing(false);\n setSelection(null);\n return;\n }\n }\n\n // Click on empty space - do nothing\n setIsDrawing(false);\n setSelection(null);\n return;\n }\n\n // This was a drag - create new annotation\n // Scale selection from display coordinates to native page coordinates\n const scaleX = pageDimensions.width / displayDimensions.width;\n const scaleY = pageDimensions.height / displayDimensions.height;\n\n const nativeSelection: CanvasRectangle = {\n startX: selection.startX * scaleX,\n startY: selection.startY * scaleY,\n endX: selection.endX * scaleX,\n endY: selection.endY * scaleY\n };\n\n // Convert canvas coordinates to PDF coordinates\n const pdfCoord = canvasToPdfCoordinates(\n nativeSelection,\n pageNumber,\n pageDimensions.width,\n pageDimensions.height,\n 1.0 // Use scale 1.0 since we already scaled to native coords\n );\n\n // Create FragmentSelector\n const fragmentSelector = createFragmentSelector(pdfCoord);\n\n // Emit annotation:requested event with FragmentSelector\n if (selectedMotivation) {\n session.client.mark.request(\n toResourceId(resourceUri),\n {\n type: 'FragmentSelector',\n conformsTo: 'http://tools.ietf.org/rfc/rfc3778',\n value: fragmentSelector,\n },\n selectedMotivation,\n );\n }\n\n // Keep drawing state active to show preview until annotation is persisted\n // The parent component should clear this by changing drawingMode after save\n setIsDrawing(false);\n // Note: We keep selection so the preview remains visible\n // It will be cleared when drawingMode changes or user starts new selection\n }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations]);\n\n // Helper to get FragmentSelector from annotation target\n const getFragmentSelector = (target: Annotation['target']) => {\n const selector = getTargetSelector(target);\n if (!selector) return null;\n const selectors = Array.isArray(selector) ? selector : [selector];\n\n const found = selectors.find(s => s.type === 'FragmentSelector');\n if (!found || found.type !== 'FragmentSelector') return null;\n return found as { type: 'FragmentSelector'; value: string; conformsTo?: string };\n };\n\n // Filter annotations for current page\n const pageAnnotations = existingAnnotations.filter(ann => {\n const fragmentSel = getFragmentSelector(ann.target);\n if (!fragmentSel) return false;\n const page = getPageFromFragment(fragmentSel.value);\n return page === pageNumber;\n });\n\n // Hover handlers with currentHover guard and dwell delay\n const { handleMouseEnter, handleMouseLeave } = useMemo(\n () => createHoverHandlers((id) => session?.client.beckon.hover(id), hoverDelayMs),\n [session, hoverDelayMs]\n );\n\n // Calculate motivation color\n const { stroke, fill } = getMotivationColor(selectedMotivation ?? null);\n\n if (error) {\n return <div className=\"semiont-pdf-annotation-canvas__error\">{error}</div>;\n }\n\n return (\n <div className=\"semiont-pdf-annotation-canvas\">\n {isLoading && <div className=\"semiont-pdf-annotation-canvas__loading\">Loading PDF...</div>}\n\n <div\n ref={containerRef}\n className=\"semiont-pdf-annotation-canvas__container\"\n style={{ display: isLoading ? 'none' : undefined }}\n onMouseDown={handleMouseDown}\n onMouseMove={handleMouseMove}\n onMouseUp={handleMouseUp}\n onMouseLeave={() => {\n if (isDrawing) {\n setIsDrawing(false);\n setSelection(null);\n }\n }}\n data-drawing-mode={drawingMode || 'none'}\n >\n {/* PDF page rendered as image */}\n {pageImageUrl && (\n <img\n ref={imageRef}\n src={pageImageUrl}\n alt={`PDF page ${pageNumber}`}\n className=\"semiont-pdf-annotation-canvas__image\"\n draggable={false}\n style={{ pointerEvents: 'none' }}\n onLoad={() => {\n // Use double RAF to ensure layout is complete even in onLoad\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n if (imageRef.current) {\n setDisplayDimensions({\n width: imageRef.current.clientWidth,\n height: imageRef.current.clientHeight\n });\n }\n });\n });\n }}\n />\n )}\n\n {/* SVG overlay for annotations */}\n {displayDimensions && pageDimensions && (\n <div className=\"semiont-pdf-annotation-canvas__overlay-container\">\n <div className=\"semiont-pdf-annotation-canvas__overlay\">\n <svg\n className=\"semiont-pdf-annotation-canvas__svg\"\n width={displayDimensions.width}\n height={displayDimensions.height}\n >\n {/* Render existing annotations for this page */}\n {pageAnnotations.map(ann => {\n const fragmentSel = getFragmentSelector(ann.target);\n if (!fragmentSel) return null;\n\n const pdfCoord = parseFragmentSelector(fragmentSel.value);\n if (!pdfCoord) return null;\n\n const rect = pdfToCanvasCoordinates(pdfCoord, pageDimensions.height, 1.0);\n\n // Scale to display coordinates\n const scaleX = displayDimensions.width / pageDimensions.width;\n const scaleY = displayDimensions.height / pageDimensions.height;\n\n const isHovered = ann.id === hoveredAnnotationId;\n const isSelected = ann.id === selectedAnnotationId;\n\n // Get color for this annotation's motivation (not the selected motivation)\n const annMotivation = ann.motivation as SelectionMotivation | null;\n const { stroke: annStroke, fill: annFill } = getMotivationColor(annMotivation);\n\n return (\n <rect\n key={ann.id}\n x={rect.x * scaleX}\n y={rect.y * scaleY}\n width={rect.width * scaleX}\n height={rect.height * scaleY}\n stroke={annStroke}\n strokeWidth={isSelected ? 4 : isHovered ? 3 : 2}\n fill={annFill}\n style={{\n pointerEvents: 'auto',\n cursor: 'pointer',\n opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7\n }}\n onClick={(e) => session?.client.browse.click(ann.id, ann.motivation, e.currentTarget.getBoundingClientRect())}\n onMouseEnter={() => handleMouseEnter(ann.id)}\n onMouseLeave={handleMouseLeave}\n />\n );\n })}\n\n {/* Render current selection while drawing or awaiting save */}\n {selection && (() => {\n const rectX = Math.min(selection.startX, selection.endX);\n const rectY = Math.min(selection.startY, selection.endY);\n const rectWidth = Math.abs(selection.endX - selection.startX);\n const rectHeight = Math.abs(selection.endY - selection.startY);\n\n // PDF only supports rectangle shapes (FragmentSelector with viewrect)\n // Circle/polygon are disabled in the UI for PDF media types\n return (\n <rect\n x={rectX}\n y={rectY}\n width={rectWidth}\n height={rectHeight}\n stroke={stroke}\n strokeWidth={2}\n strokeDasharray=\"5,5\"\n fill={fill}\n pointerEvents=\"none\"\n />\n );\n })()}\n </svg>\n </div>\n </div>\n )}\n </div>\n\n {/* Page navigation controls */}\n {numPages > 0 && (\n <div className=\"semiont-pdf-annotation-canvas__controls\">\n <button\n disabled={pageNumber <= 1}\n onClick={() => setPageNumber(pageNumber - 1)}\n className=\"semiont-pdf-annotation-canvas__button\"\n >\n Previous\n </button>\n <span className=\"semiont-pdf-annotation-canvas__page-info\">\n Page {pageNumber} of {numPages}\n </span>\n <button\n disabled={pageNumber >= numPages}\n onClick={() => setPageNumber(pageNumber + 1)}\n className=\"semiont-pdf-annotation-canvas__button\"\n >\n Next\n </button>\n </div>\n )}\n </div>\n );\n}\n","/**\n * PDF Canvas Coordinate Transforms\n *\n * Converts between canvas space (pixels, top-left origin, Y increases downward)\n * and PDF space (points, bottom-left origin, Y increases upward) — the Y-flip and\n * scale. UI-only: the server has no canvas.\n *\n * `PdfCoordinate` and the viewrect FragmentSelector codec live in `@semiont/core`.\n *\n * Based on RFC 3778 PDF Fragment Identifiers:\n * https://tools.ietf.org/html/rfc3778\n */\n\nimport type { PdfCoordinate } from '@semiont/core';\n\nexport interface Rectangle {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport interface CanvasRectangle {\n startX: number;\n startY: number;\n endX: number;\n endY: number;\n}\n\n/**\n * Convert canvas coordinates to PDF coordinates\n *\n * Canvas: Origin at top-left, Y increases downward\n * PDF: Origin at bottom-left, Y increases upward\n *\n * @param canvasRect - Rectangle in canvas pixel coordinates\n * @param page - PDF page number (1-indexed)\n * @param pageWidth - PDF page width in points\n * @param pageHeight - PDF page height in points\n * @param scale - Current canvas scale factor\n */\nexport function canvasToPdfCoordinates(\n canvasRect: CanvasRectangle,\n page: number,\n _pageWidth: number,\n pageHeight: number,\n scale: number = 1\n): PdfCoordinate {\n // Normalize rectangle (handle drag in any direction)\n const x1 = Math.min(canvasRect.startX, canvasRect.endX);\n const y1 = Math.min(canvasRect.startY, canvasRect.endY);\n const x2 = Math.max(canvasRect.startX, canvasRect.endX);\n const y2 = Math.max(canvasRect.startY, canvasRect.endY);\n\n // Convert from canvas pixels to PDF points\n const pdfX = x1 / scale;\n const pdfWidth = (x2 - x1) / scale;\n\n // Flip Y coordinate (canvas top-left to PDF bottom-left)\n const pdfY = pageHeight - (y2 / scale);\n const pdfHeight = (y2 - y1) / scale;\n\n return {\n page,\n x: Math.round(pdfX),\n y: Math.round(pdfY),\n width: Math.round(pdfWidth),\n height: Math.round(pdfHeight)\n };\n}\n\n/**\n * Convert PDF coordinates to canvas coordinates\n *\n * @param pdfCoord - Coordinates in PDF space\n * @param pageHeight - PDF page height in points\n * @param scale - Current canvas scale factor\n */\nexport function pdfToCanvasCoordinates(\n pdfCoord: PdfCoordinate,\n pageHeight: number,\n scale: number = 1\n): Rectangle {\n // Convert from PDF points to canvas pixels\n const canvasX = pdfCoord.x * scale;\n const canvasWidth = pdfCoord.width * scale;\n\n // Flip Y coordinate (PDF bottom-left to canvas top-left)\n const canvasY = (pageHeight - pdfCoord.y - pdfCoord.height) * scale;\n const canvasHeight = pdfCoord.height * scale;\n\n return {\n x: canvasX,\n y: canvasY,\n width: canvasWidth,\n height: canvasHeight\n };\n}\n"],"mappings":"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","names":["error","pdfCoord","scaleX","scaleY"]}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useSemiont } from '../session/SemiontProvider';
|
|
4
|
-
import { useObservable } from '../hooks/useObservable';
|
|
5
|
-
import type { components } from '@semiont/core';
|
|
6
|
-
|
|
7
|
-
type Motivation = components['schemas']['Motivation'];
|
|
8
|
-
type JobProgress = components['schemas']['JobProgress'];
|
|
9
|
-
|
|
10
|
-
export interface JobProgressWidgetTranslations {
|
|
11
|
-
/** Header title (e.g. "Annotating Entity References" / "Generating Resource"). */
|
|
12
|
-
title: string;
|
|
13
|
-
/** Cancel-button title attribute. */
|
|
14
|
-
cancel: string;
|
|
15
|
-
/** Default in-progress status message (used when the job sends no `message`). */
|
|
16
|
-
inProgress: string;
|
|
17
|
-
complete: string;
|
|
18
|
-
failed: string;
|
|
19
|
-
/** Completed entity-type log line (annotation flow only). */
|
|
20
|
-
found?: (count: number) => string;
|
|
21
|
-
/** Current entity-type status (annotation flow only). */
|
|
22
|
-
current?: (entityType: string) => string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface AnnotateReferencesProgressWidgetProps {
|
|
26
|
-
progress: JobProgress | null;
|
|
27
|
-
/** CSS `data-type` hook. */
|
|
28
|
-
annotationType?: Motivation | 'reference' | 'generation';
|
|
29
|
-
/** Job type the cancel button requests. */
|
|
30
|
-
cancelJobType: 'annotation' | 'generation';
|
|
31
|
-
translations: JobProgressWidgetTranslations;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Job-progress widget (header + cancel + status). Shared by the annotation
|
|
36
|
-
* (reference) flow and the resource-generate flow — the title, status copy, and
|
|
37
|
-
* cancel job type are supplied by the caller so neither flow's wording leaks into
|
|
38
|
-
* the other.
|
|
39
|
-
*
|
|
40
|
-
* @emits job:cancel-requested - User requested to cancel the job. Payload: { jobType: string }
|
|
41
|
-
*/
|
|
42
|
-
export function AnnotateReferencesProgressWidget({ progress, annotationType = 'reference', cancelJobType, translations: tr }: AnnotateReferencesProgressWidgetProps) {
|
|
43
|
-
const session = useObservable(useSemiont().activeSession$);
|
|
44
|
-
|
|
45
|
-
const handleCancel = () => {
|
|
46
|
-
session?.client.job.cancelRequest(cancelJobType);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
if (!progress) return null;
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div
|
|
53
|
-
className="semiont-annotation-progress"
|
|
54
|
-
data-status={progress.stage}
|
|
55
|
-
data-type={annotationType}
|
|
56
|
-
>
|
|
57
|
-
{/* Header with pulsing sparkle */}
|
|
58
|
-
<div className="semiont-annotation-header">
|
|
59
|
-
<h3 className="semiont-annotation-title">
|
|
60
|
-
<span className="semiont-annotation-sparkle">✨</span>
|
|
61
|
-
{tr.title}
|
|
62
|
-
</h3>
|
|
63
|
-
{progress.stage !== 'complete' && (
|
|
64
|
-
<button
|
|
65
|
-
onClick={handleCancel}
|
|
66
|
-
className="semiont-annotation-cancel"
|
|
67
|
-
title={tr.cancel}
|
|
68
|
-
>
|
|
69
|
-
✕
|
|
70
|
-
</button>
|
|
71
|
-
)}
|
|
72
|
-
</div>
|
|
73
|
-
|
|
74
|
-
{/* Request Parameters */}
|
|
75
|
-
{progress.requestParams && progress.requestParams.length > 0 && (
|
|
76
|
-
<div className="semiont-annotation-progress__params">
|
|
77
|
-
<div className="semiont-annotation-progress__params-title">Request Parameters:</div>
|
|
78
|
-
{progress.requestParams.map((param, idx) => (
|
|
79
|
-
<div key={idx} className="semiont-annotation-progress__param">
|
|
80
|
-
<span className="semiont-annotation-progress__param-label">{param.label}:</span> {param.value}
|
|
81
|
-
</div>
|
|
82
|
-
))}
|
|
83
|
-
</div>
|
|
84
|
-
)}
|
|
85
|
-
|
|
86
|
-
{/* Completed entity types log (annotation flow only) */}
|
|
87
|
-
{tr.found && progress.completedEntityTypes && progress.completedEntityTypes.length > 0 && (
|
|
88
|
-
<div className="semiont-annotation-log">
|
|
89
|
-
{progress.completedEntityTypes.map((item, index) => (
|
|
90
|
-
<div key={index} className="semiont-annotation-log-item">
|
|
91
|
-
<span className="semiont-annotation-check">✓</span>
|
|
92
|
-
<span className="semiont-annotation-entity-type">{item.entityType}:</span>
|
|
93
|
-
<span>{tr.found?.(item.foundCount)}</span>
|
|
94
|
-
</div>
|
|
95
|
-
))}
|
|
96
|
-
</div>
|
|
97
|
-
)}
|
|
98
|
-
|
|
99
|
-
{/* Status display with pulsing animation */}
|
|
100
|
-
<div className="semiont-annotation-progress__status">
|
|
101
|
-
{progress.stage === 'complete' ? (
|
|
102
|
-
<div className="semiont-annotation-progress__message">
|
|
103
|
-
<span className="semiont-annotation-progress__icon">✅</span>
|
|
104
|
-
<span>{tr.complete}</span>
|
|
105
|
-
</div>
|
|
106
|
-
) : progress.stage === 'error' ? (
|
|
107
|
-
<div className="semiont-annotation-progress__message">
|
|
108
|
-
<span className="semiont-annotation-progress__icon">❌</span>
|
|
109
|
-
<span>{progress.message || tr.failed}</span>
|
|
110
|
-
</div>
|
|
111
|
-
) : (
|
|
112
|
-
<div className="semiont-annotation-progress__message">
|
|
113
|
-
<span className="semiont-annotation-progress__icon">✨</span>
|
|
114
|
-
<span>{progress.message || (progress.currentEntityType && tr.current ? tr.current(progress.currentEntityType) : tr.inProgress)}</span>
|
|
115
|
-
</div>
|
|
116
|
-
)}
|
|
117
|
-
{progress.currentEntityType && progress.stage !== 'complete' && progress.stage !== 'error' && (
|
|
118
|
-
<div className="semiont-annotation-progress__details">
|
|
119
|
-
Processing: {progress.currentEntityType}
|
|
120
|
-
</div>
|
|
121
|
-
)}
|
|
122
|
-
</div>
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { screen, fireEvent } from '@testing-library/react';
|
|
3
|
-
import '@testing-library/jest-dom';
|
|
4
|
-
import { renderWithProviders } from '../../test-utils';
|
|
5
|
-
import { AnnotateReferencesProgressWidget } from '../AnnotateReferencesProgressWidget';
|
|
6
|
-
import type { components } from '@semiont/core';
|
|
7
|
-
|
|
8
|
-
type JobProgress = components['schemas']['JobProgress'];
|
|
9
|
-
|
|
10
|
-
// The widget no longer reads translations internally — the caller supplies the
|
|
11
|
-
// copy + cancel job type. These are the annotation-flow values for these tests.
|
|
12
|
-
const tr = {
|
|
13
|
-
title: 'Annotating',
|
|
14
|
-
cancel: 'Cancel',
|
|
15
|
-
inProgress: 'In progress',
|
|
16
|
-
complete: 'Complete',
|
|
17
|
-
failed: 'Failed',
|
|
18
|
-
found: (count: number) => `Found ${count}`,
|
|
19
|
-
current: (entityType: string) => `Current ${entityType}`,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
function renderWidget(progress: JobProgress | null, opts?: { returnEventBus?: boolean }) {
|
|
23
|
-
return renderWithProviders(
|
|
24
|
-
<AnnotateReferencesProgressWidget progress={progress} cancelJobType="annotation" translations={tr} />,
|
|
25
|
-
opts,
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe('AnnotateReferencesProgressWidget', () => {
|
|
30
|
-
it('returns null when progress is null', () => {
|
|
31
|
-
const { container } = renderWidget(null);
|
|
32
|
-
expect(container.firstChild).toBeNull();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('renders progress with stage message', () => {
|
|
36
|
-
const progress: JobProgress = { stage: 'in-progress', percentage: 50, message: 'Processing entities...' };
|
|
37
|
-
renderWidget(progress);
|
|
38
|
-
expect(screen.getByText('Processing entities...')).toBeInTheDocument();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('shows cancel button when not complete', () => {
|
|
42
|
-
const progress: JobProgress = { stage: 'in-progress', percentage: 30, message: 'Working...' };
|
|
43
|
-
renderWidget(progress);
|
|
44
|
-
expect(screen.getByTitle('Cancel')).toBeInTheDocument();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('hides cancel button when complete', () => {
|
|
48
|
-
const progress: JobProgress = { stage: 'complete', percentage: 100, message: '' };
|
|
49
|
-
renderWidget(progress);
|
|
50
|
-
expect(screen.queryByTitle('Cancel')).not.toBeInTheDocument();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('emits job:cancel-requested on cancel click', () => {
|
|
54
|
-
const handler = vi.fn();
|
|
55
|
-
const progress: JobProgress = { stage: 'in-progress', percentage: 40, message: 'Working...' };
|
|
56
|
-
const { eventBus } = renderWidget(progress, { returnEventBus: true });
|
|
57
|
-
|
|
58
|
-
const subscription = eventBus!.get('job:cancel-requested').subscribe(handler);
|
|
59
|
-
fireEvent.click(screen.getByTitle('Cancel'));
|
|
60
|
-
expect(handler).toHaveBeenCalledWith({ jobType: 'annotation' });
|
|
61
|
-
|
|
62
|
-
subscription.unsubscribe();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('renders completed entity types', () => {
|
|
66
|
-
const progress: JobProgress = {
|
|
67
|
-
stage: 'in-progress',
|
|
68
|
-
percentage: 60,
|
|
69
|
-
message: '',
|
|
70
|
-
completedEntityTypes: [
|
|
71
|
-
{ entityType: 'Person', foundCount: 5 },
|
|
72
|
-
{ entityType: 'Organization', foundCount: 3 },
|
|
73
|
-
],
|
|
74
|
-
};
|
|
75
|
-
renderWidget(progress);
|
|
76
|
-
expect(screen.getByText('Person:')).toBeInTheDocument();
|
|
77
|
-
expect(screen.getByText('Organization:')).toBeInTheDocument();
|
|
78
|
-
expect(screen.getByText('Found 5')).toBeInTheDocument();
|
|
79
|
-
expect(screen.getByText('Found 3')).toBeInTheDocument();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('shows complete icon for complete stage', () => {
|
|
83
|
-
const progress: JobProgress = { stage: 'complete', percentage: 100, message: '' };
|
|
84
|
-
const { container } = renderWidget(progress);
|
|
85
|
-
expect(container.querySelector('[data-status="complete"]')).toBeInTheDocument();
|
|
86
|
-
expect(screen.getByText('Complete')).toBeInTheDocument();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('shows error message for error stage', () => {
|
|
90
|
-
const progress: JobProgress = { stage: 'error', percentage: 0, message: 'Something went wrong' };
|
|
91
|
-
const { container } = renderWidget(progress);
|
|
92
|
-
expect(container.querySelector('[data-status="error"]')).toBeInTheDocument();
|
|
93
|
-
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('shows current entity type processing details', () => {
|
|
97
|
-
const progress: JobProgress = { stage: 'in-progress', percentage: 50, message: '', currentEntityType: 'Location' };
|
|
98
|
-
renderWidget(progress);
|
|
99
|
-
expect(screen.getByText(/Processing: Location/)).toBeInTheDocument();
|
|
100
|
-
});
|
|
101
|
-
});
|