@semiont/react-ui 0.5.18 → 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.
Files changed (33) hide show
  1. package/README.md +4 -0
  2. package/dist/{PdfAnnotationCanvas.client-75GY2EDR.js → PdfAnnotationCanvas.client-V7Q3BEEQ.js} +42 -45
  3. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +1 -0
  4. package/dist/index.d.ts +231 -55
  5. package/dist/index.js +2115 -2161
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/AssistProgress.tsx +170 -0
  9. package/src/components/__tests__/AssistProgress.test.tsx +164 -0
  10. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +23 -54
  11. package/src/components/pdf-annotation/__tests__/rects-for-page.test.ts +96 -0
  12. package/src/components/pdf-annotation/rects-for-page.ts +47 -0
  13. package/src/components/resource/ResourceViewer.tsx +23 -55
  14. package/src/components/resource/panels/AssessmentPanel.tsx +1 -1
  15. package/src/components/resource/panels/AssistSection.tsx +111 -180
  16. package/src/components/resource/panels/AssistShell.tsx +77 -0
  17. package/src/components/resource/panels/CommentsPanel.tsx +1 -1
  18. package/src/components/resource/panels/HighlightPanel.tsx +1 -1
  19. package/src/components/resource/panels/ReferenceEntry.tsx +8 -1
  20. package/src/components/resource/panels/ReferencesPanel.tsx +101 -132
  21. package/src/components/resource/panels/TaggingPanel.tsx +23 -86
  22. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +6 -14
  23. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +59 -0
  24. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +20 -0
  25. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +0 -1
  26. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +14 -20
  27. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +30 -0
  28. package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -1
  29. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +24 -64
  30. package/src/styles/features/compose.css +7 -0
  31. package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +0 -1
  32. package/src/components/AnnotateReferencesProgressWidget.tsx +0 -125
  33. 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 className="semiont-form__upload-dropzone">
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 { AnnotateReferencesProgressWidget } from '../../../components/AnnotateReferencesProgressWidget';
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, showInfo } = useToast();
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
- }, [session]);
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, session]);
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, session]);
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, session]);
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
- // Event bus subscriptions (combined into single useEventSubscriptions call to prevent hook ordering issues)
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
- <AnnotateReferencesProgressWidget
449
+ <AssistProgress
490
450
  progress={generationProgress}
491
- annotationType="generation"
492
- cancelJobType="generation"
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
- });