@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "engines": {
5
5
  "node": ">=24.0.0"
6
6
  },
@@ -122,9 +122,9 @@
122
122
  "directory": "packages/react-ui"
123
123
  },
124
124
  "dependencies": {
125
- "@semiont/core": "0.5.18",
126
- "@semiont/http-transport": "0.5.18",
127
- "@semiont/sdk": "0.5.18",
125
+ "@semiont/core": "0.5.20",
126
+ "@semiont/http-transport": "0.5.20",
127
+ "@semiont/sdk": "0.5.20",
128
128
  "pdfjs-dist": "^6.1.200",
129
129
  "react-error-boundary": "^6.1.2",
130
130
  "react-markdown": "^10.1.0",
@@ -0,0 +1,170 @@
1
+ 'use client';
2
+
3
+ import type { components } from '@semiont/core';
4
+
5
+ type JobProgress = components['schemas']['JobProgress'];
6
+
7
+ export interface AssistProgressTranslations {
8
+ /** Header title (e.g. "Annotating Entity References" / "Generating Resource"). Omit for the headerless inline style. */
9
+ title?: string;
10
+ /** Cancel-button title attribute. */
11
+ cancel?: string;
12
+ /** Default in-progress status message (used when the job sends no `message`). */
13
+ inProgress?: string;
14
+ /** Status copy for the terminal 'complete' stage. */
15
+ complete?: string;
16
+ /** Fallback status copy for the terminal 'error' stage. */
17
+ failed?: string;
18
+ /** Completed entity-type log line (reference flow). */
19
+ found?: (count: number) => string;
20
+ /** Current-work detail line (reference flow). */
21
+ current?: (label: string) => string;
22
+ /** Dismiss-button label. */
23
+ close?: string;
24
+ }
25
+
26
+ export interface AssistProgressProps {
27
+ progress: JobProgress;
28
+ /** CSS `data-type` hook ('highlight' | 'comment' | … | 'reference' | 'tag' | 'generation'). */
29
+ dataType: string;
30
+ /** Cancel the underlying job — rendered while running when provided. Caller wires `client.job.cancelRequest(...)`. */
31
+ onCancel?: () => void;
32
+ /**
33
+ * Dismiss the display — rendered whenever provided. WHEN dismissal is
34
+ * offered is the caller's policy (AssistShell withholds the callback while
35
+ * the assist is still running). Caller wires `client.mark.dismissProgress()`.
36
+ */
37
+ onDismiss?: () => void;
38
+ /** Render the percentage bar (tag flow's visual; percentage itself comes from `progress`). */
39
+ showPercentBar?: boolean;
40
+ translations?: AssistProgressTranslations;
41
+ }
42
+
43
+ /**
44
+ * The one job-progress renderer (#7) — unifies the three previous shapes
45
+ * (AssistSection's inline block, the reference/generation widget, TaggingPanel's
46
+ * inline block). Presentational and provider-free: no session, no context —
47
+ * cancel/dismiss arrive as callbacks, so it renders identically on the page and
48
+ * in embeddable (bring-your-own-session) hosts. Feature blocks are
49
+ * data-presence-driven: each call site keeps its established visuals by passing
50
+ * the data and translations it always had.
51
+ */
52
+ export function AssistProgress({
53
+ progress,
54
+ dataType,
55
+ onCancel,
56
+ onDismiss,
57
+ showPercentBar = false,
58
+ translations: tr = {},
59
+ }: AssistProgressProps) {
60
+ const terminal = progress.stage === 'complete' || progress.stage === 'error';
61
+
62
+ return (
63
+ <div className="semiont-annotation-progress" data-status={progress.stage} data-type={dataType}>
64
+ {/* Header (title + cancel) — reference/generation style; omitted inline */}
65
+ {tr.title && (
66
+ <div className="semiont-annotation-header">
67
+ <h3 className="semiont-annotation-title">
68
+ <span className="semiont-annotation-sparkle">✨</span>
69
+ {tr.title}
70
+ </h3>
71
+ {onCancel && !terminal && (
72
+ <button
73
+ onClick={onCancel}
74
+ className="semiont-annotation-cancel"
75
+ title={tr.cancel || 'Cancel'}
76
+ aria-label={tr.cancel || 'Cancel'}
77
+ type="button"
78
+ >
79
+
80
+ </button>
81
+ )}
82
+ </div>
83
+ )}
84
+
85
+ {/* Request parameters */}
86
+ {progress.requestParams && progress.requestParams.length > 0 && (
87
+ <div className="semiont-annotation-progress__params" data-type={dataType}>
88
+ <div className="semiont-annotation-progress__params-title">Request Parameters:</div>
89
+ {progress.requestParams.map((param, idx) => (
90
+ <div key={idx} className="semiont-annotation-progress__param">
91
+ <span className="semiont-annotation-progress__param-label">{param.label}:</span> <span>{param.value}</span>
92
+ </div>
93
+ ))}
94
+ </div>
95
+ )}
96
+
97
+ {/* Completed entity-type log (reference flow) */}
98
+ {tr.found && progress.completedEntityTypes && progress.completedEntityTypes.length > 0 && (
99
+ <div className="semiont-annotation-log">
100
+ {progress.completedEntityTypes.map((item, index) => (
101
+ <div key={index} className="semiont-annotation-log-item">
102
+ <span className="semiont-annotation-check">✓</span>
103
+ <span className="semiont-annotation-entity-type">{item.entityType}:</span>
104
+ <span>{tr.found!(item.foundCount)}</span>
105
+ </div>
106
+ ))}
107
+ </div>
108
+ )}
109
+
110
+ {/* Status line with stage branching */}
111
+ <div className="semiont-annotation-progress__status">
112
+ {progress.stage === 'complete' ? (
113
+ <div className="semiont-annotation-progress__message">
114
+ <span className="semiont-annotation-progress__icon">✅</span>
115
+ {/* JobProgress.message is required but may be '' — never blank a terminal line. */}
116
+ <span>{tr.complete || progress.message || 'Complete'}</span>
117
+ </div>
118
+ ) : progress.stage === 'error' ? (
119
+ <div className="semiont-annotation-progress__message">
120
+ <span className="semiont-annotation-progress__icon">❌</span>
121
+ <span>{progress.message || tr.failed || 'Failed'}</span>
122
+ </div>
123
+ ) : (
124
+ <div className="semiont-annotation-progress__message">
125
+ <span className="semiont-annotation-progress__icon">✨</span>
126
+ <span>
127
+ {progress.message
128
+ || (progress.currentEntityType && tr.current ? tr.current(progress.currentEntityType) : tr.inProgress)}
129
+ </span>
130
+ </div>
131
+ )}
132
+
133
+ {/* Current-work detail while running */}
134
+ {!terminal && progress.currentEntityType && (
135
+ <div className="semiont-annotation-progress__details">
136
+ {tr.current ? tr.current(progress.currentEntityType) : `Processing: ${progress.currentEntityType}`}
137
+ </div>
138
+ )}
139
+ {!terminal && progress.currentCategory && (
140
+ <div className="semiont-annotation-progress__details">
141
+ Processing: {progress.currentCategory}
142
+ {progress.processedCategories !== undefined && progress.totalCategories !== undefined && (
143
+ <> ({progress.processedCategories}/{progress.totalCategories})</>
144
+ )}
145
+ </div>
146
+ )}
147
+
148
+ {/* Dismiss — rendered whenever the caller offers it */}
149
+ {onDismiss && (
150
+ <button
151
+ onClick={onDismiss}
152
+ className="semiont-annotation-progress__close"
153
+ aria-label={tr.close || 'Dismiss'}
154
+ title={tr.close || 'Dismiss'}
155
+ type="button"
156
+ >
157
+ ×
158
+ </button>
159
+ )}
160
+ </div>
161
+
162
+ {/* Percentage bar (tag flow) */}
163
+ {showPercentBar && progress.percentage !== undefined && (
164
+ <div className="semiont-progress-bar">
165
+ <div className="semiont-progress-bar__fill" data-type={dataType} style={{ width: `${progress.percentage}%` }} />
166
+ </div>
167
+ )}
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * AssistProgress (#7) — the ONE job-progress renderer, unifying the three
3
+ * previous shapes (AssistSection's inline block, AnnotateReferencesProgressWidget,
4
+ * TaggingPanel's inline block) plus the resource-generate flow.
5
+ *
6
+ * Contract: presentational and provider-free — no SemiontProvider, no session;
7
+ * cancel/dismiss arrive as callbacks the caller wires (job.cancelRequest /
8
+ * mark.dismissProgress). Feature blocks are data-presence-driven so each call
9
+ * site keeps its current visuals by passing what it always had.
10
+ */
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { render, screen } from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import '@testing-library/jest-dom';
15
+ import type { components } from '@semiont/core';
16
+ import { AssistProgress } from '../AssistProgress';
17
+
18
+ type JobProgress = components['schemas']['JobProgress'];
19
+
20
+ const running = (over: Partial<JobProgress> = {}): JobProgress => ({
21
+ stage: 'analyzing', percentage: 40, message: 'working on it', ...over,
22
+ });
23
+
24
+ describe('AssistProgress', () => {
25
+ it('renders provider-free: message, params, and data hooks', () => {
26
+ const { container } = render(
27
+ <AssistProgress
28
+ progress={running({ requestParams: [{ label: 'Density', value: '5' }] })}
29
+ dataType="comment"
30
+ />,
31
+ );
32
+ expect(screen.getByText('working on it')).toBeInTheDocument();
33
+ expect(screen.getByText(/Density/)).toBeInTheDocument();
34
+ expect(screen.getByText('5')).toBeInTheDocument();
35
+ const root = container.querySelector('.semiont-annotation-progress');
36
+ expect(root).toHaveAttribute('data-type', 'comment');
37
+ expect(root).toHaveAttribute('data-status', 'analyzing');
38
+ });
39
+
40
+ it('renders a title header only when given one', () => {
41
+ const { rerender } = render(
42
+ <AssistProgress progress={running()} dataType="reference" translations={{ title: 'Annotating Entity References' }} />,
43
+ );
44
+ expect(screen.getByText('Annotating Entity References')).toBeInTheDocument();
45
+ rerender(<AssistProgress progress={running()} dataType="comment" />);
46
+ expect(screen.queryByText('Annotating Entity References')).not.toBeInTheDocument();
47
+ });
48
+
49
+ it('shows cancel in the header while running, hides it once complete', async () => {
50
+ // Cancel lives in the title header — both flows that offer cancel
51
+ // (reference detection, generation) render the titled profile.
52
+ const onCancel = vi.fn();
53
+ const tr = { title: 'Generating Resource', cancel: 'Cancel Job' };
54
+ const { rerender } = render(
55
+ <AssistProgress progress={running()} dataType="generation" onCancel={onCancel} translations={tr} />,
56
+ );
57
+ await userEvent.click(screen.getByTitle('Cancel Job'));
58
+ expect(onCancel).toHaveBeenCalledOnce();
59
+ rerender(
60
+ <AssistProgress progress={running({ stage: 'complete' })} dataType="generation" onCancel={onCancel} translations={tr} />,
61
+ );
62
+ expect(screen.queryByTitle('Cancel Job')).not.toBeInTheDocument();
63
+ // Error is terminal too — offering cancel on a dead job is misleading and
64
+ // invites redundant cancel requests.
65
+ rerender(
66
+ <AssistProgress progress={running({ stage: 'error', message: 'it broke' })} dataType="generation" onCancel={onCancel} translations={tr} />,
67
+ );
68
+ expect(screen.queryByTitle('Cancel Job')).not.toBeInTheDocument();
69
+ });
70
+
71
+ it('stage branching: complete shows ✅ + complete copy, error shows ❌ + message', () => {
72
+ const { rerender } = render(
73
+ <AssistProgress progress={running({ stage: 'complete' })} dataType="reference" translations={{ complete: 'All done!' }} />,
74
+ );
75
+ expect(screen.getByText('All done!')).toBeInTheDocument();
76
+ rerender(
77
+ <AssistProgress progress={running({ stage: 'error', message: 'it broke' })} dataType="reference" translations={{ failed: 'Failed' }} />,
78
+ );
79
+ expect(screen.getByText('it broke')).toBeInTheDocument();
80
+ });
81
+
82
+ it('renders the completed entity-type log when data + formatter are present', () => {
83
+ render(
84
+ <AssistProgress
85
+ progress={running({ completedEntityTypes: [{ entityType: 'Person', foundCount: 3 }] })}
86
+ dataType="reference" translations={{ found: (n) => `Found ${n}` }}
87
+ />,
88
+ );
89
+ expect(screen.getByText('Person:')).toBeInTheDocument();
90
+ expect(screen.getByText('Found 3')).toBeInTheDocument();
91
+ });
92
+
93
+ it('renders the current-work detail line: entity type via formatter, category with counts', () => {
94
+ const { rerender } = render(
95
+ <AssistProgress
96
+ progress={running({ currentEntityType: 'Location' })}
97
+ dataType="reference" translations={{ current: (l) => `Processing: ${l}` }}
98
+ />,
99
+ );
100
+ expect(screen.getByText('Processing: Location')).toBeInTheDocument();
101
+ rerender(
102
+ <AssistProgress
103
+ progress={running({ currentCategory: 'Rule', processedCategories: 2, totalCategories: 5 })}
104
+ dataType="tag" />,
105
+ );
106
+ expect(screen.getByText(/Rule/)).toBeInTheDocument();
107
+ expect(screen.getByText(/2\/5/)).toBeInTheDocument();
108
+ });
109
+
110
+ it('renders the percentage bar only when opted in', () => {
111
+ const { container, rerender } = render(
112
+ <AssistProgress progress={running({ percentage: 40 })} dataType="tag" showPercentBar />,
113
+ );
114
+ const fill = container.querySelector('.semiont-progress-bar__fill');
115
+ expect(fill).toBeInTheDocument();
116
+ expect(fill).toHaveStyle({ width: '40%' });
117
+ rerender(
118
+ <AssistProgress progress={running({ percentage: 40 })} dataType="reference" />,
119
+ );
120
+ expect(container.querySelector('.semiont-progress-bar__fill')).not.toBeInTheDocument();
121
+ });
122
+
123
+ it('cancel and dismiss carry accessible names, with safe fallbacks when untranslated', () => {
124
+ // ✕ / × glyphs alone are meaningless to screen readers; the controls must
125
+ // have an accessible name even when a caller forgets the translations.
126
+ render(
127
+ <AssistProgress progress={running()} dataType="generation"
128
+ onCancel={vi.fn()} onDismiss={vi.fn()}
129
+ translations={{ title: 'Generating' }} />,
130
+ );
131
+ expect(screen.getByLabelText('Cancel')).toBeInTheDocument();
132
+ expect(screen.getByLabelText('Dismiss')).toBeInTheDocument();
133
+ });
134
+
135
+ it('terminal stages never render a blank status line', () => {
136
+ // JobProgress.message is required but can be '' — a terminal display with
137
+ // no text at all is worse than a generic word.
138
+ const { rerender } = render(
139
+ <AssistProgress progress={running({ stage: 'complete', message: '' })} dataType="reference" />,
140
+ );
141
+ expect(screen.getByText('Complete')).toBeInTheDocument();
142
+ rerender(
143
+ <AssistProgress progress={running({ stage: 'error', message: '' })} dataType="reference" />,
144
+ );
145
+ expect(screen.getByText('Failed')).toBeInTheDocument();
146
+ });
147
+
148
+ it('renders dismiss whenever the caller offers it (WHEN is the caller\'s policy)', async () => {
149
+ // AssistShell withholds onDismiss while the assist is running — that
150
+ // gate is pinned in AssistShell.test; here the contract is just
151
+ // "callback present → affordance rendered".
152
+ const onDismiss = vi.fn();
153
+ const { rerender } = render(
154
+ <AssistProgress progress={running()} dataType="highlight" translations={{ close: 'Close' }} />,
155
+ );
156
+ expect(screen.queryByLabelText('Close')).not.toBeInTheDocument();
157
+ rerender(
158
+ <AssistProgress progress={running()} dataType="highlight"
159
+ onDismiss={onDismiss} translations={{ close: 'Close' }} />,
160
+ );
161
+ await userEvent.click(screen.getByLabelText('Close'));
162
+ expect(onDismiss).toHaveBeenCalledOnce();
163
+ });
164
+ });
@@ -4,12 +4,8 @@ import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react'
4
4
  import type { Annotation, AnchorRect } from '@semiont/core';
5
5
  import { resourceId as toResourceId } from '@semiont/core';
6
6
  import { toViewportAnchorRect } from '../../lib/anchor-rect';
7
- import {
8
- getTargetSelector,
9
- createFragmentSelector,
10
- parseFragmentSelector,
11
- getPageFromFragment,
12
- } from '@semiont/core';
7
+ import { createFragmentSelector } from '@semiont/core';
8
+ import { rectsForPage } from './rects-for-page';
13
9
  import { createHoverHandlers, type SemiontSession } from '@semiont/sdk';
14
10
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
15
11
  import {
@@ -260,14 +256,8 @@ export function PdfAnnotationCanvas({
260
256
  // The hit-test owns the coordinate transform — capture the hit
261
257
  // annotation's viewport rect for the emission below (A1 anchor).
262
258
  let hitRect: AnchorRect | undefined;
263
- const clickedAnnotation = pageAnnotations.find(ann => {
264
- const fragmentSel = getFragmentSelector(ann.target);
265
- if (!fragmentSel) return false;
266
-
267
- const pdfCoord = parseFragmentSelector(fragmentSel.value);
268
- if (!pdfCoord) return false;
269
-
270
- const rect = pdfToCanvasCoordinates(pdfCoord, pageDimensions.height, 1.0);
259
+ const hit = rectsForPage(existingAnnotations, pageNumber).find(r => {
260
+ const rect = pdfToCanvasCoordinates(r.coord, pageDimensions.height, 1.0);
271
261
 
272
262
  // Scale to display coordinates
273
263
  const scaleX = displayDimensions.width / pageDimensions.width;
@@ -278,20 +268,20 @@ export function PdfAnnotationCanvas({
278
268
  const displayWidth = rect.width * scaleX;
279
269
  const displayHeight = rect.height * scaleY;
280
270
 
281
- const hit = (
271
+ const inside = (
282
272
  selection.endX >= displayX &&
283
273
  selection.endX <= displayX + displayWidth &&
284
274
  selection.endY >= displayY &&
285
275
  selection.endY <= displayY + displayHeight
286
276
  );
287
- if (hit && imageRef.current) {
277
+ if (inside && imageRef.current) {
288
278
  hitRect = toViewportAnchorRect(imageRef.current.getBoundingClientRect(), displayX, displayY, displayWidth, displayHeight);
289
279
  }
290
- return hit;
280
+ return inside;
291
281
  });
292
282
 
293
- if (clickedAnnotation) {
294
- session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation, hitRect);
283
+ if (hit) {
284
+ session?.client.browse.click(hit.annId, hit.annotation.motivation, hitRect);
295
285
  setIsDrawing(false);
296
286
  setSelection(null);
297
287
  return;
@@ -346,26 +336,11 @@ export function PdfAnnotationCanvas({
346
336
  setIsDrawing(false);
347
337
  // Note: We keep selection so the preview remains visible
348
338
  // It will be cleared when drawingMode changes or user starts new selection
349
- }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations]);
350
-
351
- // Helper to get FragmentSelector from annotation target
352
- const getFragmentSelector = (target: Annotation['target']) => {
353
- const selector = getTargetSelector(target);
354
- if (!selector) return null;
355
- const selectors = Array.isArray(selector) ? selector : [selector];
356
-
357
- const found = selectors.find(s => s.type === 'FragmentSelector');
358
- if (!found || found.type !== 'FragmentSelector') return null;
359
- return found as { type: 'FragmentSelector'; value: string; conformsTo?: string };
360
- };
361
-
362
- // Filter annotations for current page
363
- const pageAnnotations = existingAnnotations.filter(ann => {
364
- const fragmentSel = getFragmentSelector(ann.target);
365
- if (!fragmentSel) return false;
366
- const page = getPageFromFragment(fragmentSel.value);
367
- return page === pageNumber;
368
- });
339
+ }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri]);
340
+
341
+ // Every FragmentSelector rect on the current page — one per line for a
342
+ // multi-line (multi-selector) annotation, exactly one for a manual annotation.
343
+ const pageRects = rectsForPage(existingAnnotations, pageNumber);
369
344
 
370
345
  // Hover handlers with currentHover guard and dwell delay
371
346
  const { handleMouseEnter, handleMouseLeave } = useMemo(
@@ -434,29 +409,23 @@ export function PdfAnnotationCanvas({
434
409
  height={displayDimensions.height}
435
410
  >
436
411
  {/* Render existing annotations for this page */}
437
- {pageAnnotations.map(ann => {
438
- const fragmentSel = getFragmentSelector(ann.target);
439
- if (!fragmentSel) return null;
440
-
441
- const pdfCoord = parseFragmentSelector(fragmentSel.value);
442
- if (!pdfCoord) return null;
443
-
444
- const rect = pdfToCanvasCoordinates(pdfCoord, pageDimensions.height, 1.0);
412
+ {pageRects.map(r => {
413
+ const rect = pdfToCanvasCoordinates(r.coord, pageDimensions.height, 1.0);
445
414
 
446
415
  // Scale to display coordinates
447
416
  const scaleX = displayDimensions.width / pageDimensions.width;
448
417
  const scaleY = displayDimensions.height / pageDimensions.height;
449
418
 
450
- const isHovered = ann.id === hoveredAnnotationId;
451
- const isSelected = ann.id === selectedAnnotationId;
419
+ const isHovered = r.annId === hoveredAnnotationId;
420
+ const isSelected = r.annId === selectedAnnotationId;
452
421
 
453
- // Get color for this annotation's motivation (not the selected motivation)
454
- const annMotivation = ann.motivation as SelectionMotivation | null;
422
+ // Colour by the annotation's own motivation (not the toolbar's).
423
+ const annMotivation = r.annotation.motivation as SelectionMotivation | null;
455
424
  const { stroke: annStroke, fill: annFill } = getMotivationColor(annMotivation);
456
425
 
457
426
  return (
458
427
  <rect
459
- key={ann.id}
428
+ key={`${r.annId}:${r.selectorIndex}`}
460
429
  x={rect.x * scaleX}
461
430
  y={rect.y * scaleY}
462
431
  width={rect.width * scaleX}
@@ -469,8 +438,8 @@ export function PdfAnnotationCanvas({
469
438
  cursor: 'pointer',
470
439
  opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7
471
440
  }}
472
- onClick={(e) => session?.client.browse.click(ann.id, ann.motivation, e.currentTarget.getBoundingClientRect())}
473
- onMouseEnter={() => handleMouseEnter(ann.id)}
441
+ onClick={(e) => session?.client.browse.click(r.annId, r.annotation.motivation, e.currentTarget.getBoundingClientRect())}
442
+ onMouseEnter={() => handleMouseEnter(r.annId)}
474
443
  onMouseLeave={handleMouseLeave}
475
444
  />
476
445
  );
@@ -0,0 +1,96 @@
1
+ /**
2
+ * rectsForPage axioms (#735) — the pure partition that distributes an annotation's
3
+ * FragmentSelectors into per-page rects. Geometry (each coord → canvas pixels) is
4
+ * covered separately by the pdf-coordinates transform axioms + the core codec axioms.
5
+ *
6
+ * The only producer of multi-selector PDF annotations is AI detection (#736), which
7
+ * doesn't exist yet — so these synthetic fixtures stand in for it.
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import * as fc from 'fast-check';
11
+ import { annotationId, resourceId, type Annotation } from '@semiont/core';
12
+ import { rectsForPage } from '../rects-for-page';
13
+
14
+ const PAGES = 5;
15
+
16
+ type Sel = { page: number; x: number; y: number; w: number; h: number };
17
+
18
+ const selArb: fc.Arbitrary<Sel> = fc.record({
19
+ page: fc.integer({ min: 1, max: PAGES }),
20
+ x: fc.integer({ min: 0, max: 800 }),
21
+ y: fc.integer({ min: 0, max: 800 }),
22
+ w: fc.integer({ min: 1, max: 200 }),
23
+ h: fc.integer({ min: 1, max: 60 }),
24
+ });
25
+ // A "document" is a list of annotations, each a list of 0..4 selectors.
26
+ const docArb = fc.array(fc.array(selArb, { maxLength: 4 }), { maxLength: 6 });
27
+
28
+ function build(doc: ReadonlyArray<ReadonlyArray<Sel>>): Annotation[] {
29
+ return doc.map((sels, i): Annotation => ({
30
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
31
+ type: 'Annotation',
32
+ id: annotationId(`ann-${i}`),
33
+ target: {
34
+ source: resourceId('res-1'),
35
+ selector: sels.map(s => ({
36
+ type: 'FragmentSelector' as const,
37
+ value: `page=${s.page}&viewrect=${s.x},${s.y},${s.w},${s.h}`,
38
+ conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
39
+ })),
40
+ },
41
+ motivation: 'highlighting',
42
+ created: '2026-01-01T00:00:00.000Z',
43
+ }));
44
+ }
45
+
46
+ const keysOn = (anns: Annotation[], page: number): string[] =>
47
+ rectsForPage(anns, page).map(r => `${r.annId}:${r.selectorIndex}`);
48
+
49
+ describe('rectsForPage (#735 multi-rect partition)', () => {
50
+ it('completeness: every FragmentSelector renders exactly once across all pages', () => {
51
+ fc.assert(fc.property(docArb, doc => {
52
+ const anns = build(doc);
53
+ const total = doc.reduce((n, sels) => n + sels.length, 0);
54
+ let rendered = 0;
55
+ for (let p = 1; p <= PAGES; p++) rendered += rectsForPage(anns, p).length;
56
+ expect(rendered).toBe(total);
57
+ }));
58
+ });
59
+
60
+ it('page-locality: every rect on page p came from a selector with page === p', () => {
61
+ fc.assert(fc.property(docArb, fc.integer({ min: 1, max: PAGES }), (doc, p) => {
62
+ for (const r of rectsForPage(build(doc), p)) expect(r.coord.page).toBe(p);
63
+ }));
64
+ });
65
+
66
+ it('key uniqueness: `${annId}:${selectorIndex}` is pairwise unique on any page', () => {
67
+ fc.assert(fc.property(docArb, fc.integer({ min: 1, max: PAGES }), (doc, p) => {
68
+ const keys = keysOn(build(doc), p);
69
+ expect(new Set(keys).size).toBe(keys.length);
70
+ }));
71
+ });
72
+
73
+ it('order independence: reversing the annotation list preserves the rendered set per page', () => {
74
+ fc.assert(fc.property(docArb, fc.integer({ min: 1, max: PAGES }), (doc, p) => {
75
+ const anns = build(doc);
76
+ expect([...keysOn([...anns].reverse(), p)].sort()).toEqual([...keysOn(anns, p)].sort());
77
+ }));
78
+ });
79
+
80
+ it('single-selector invariance: a manual (one-selector) annotation → exactly one rect on its page', () => {
81
+ const anns = build([[{ page: 2, x: 72, y: 720, w: 150, h: 12 }]]);
82
+ const onPage2 = rectsForPage(anns, 2);
83
+ expect(onPage2).toHaveLength(1);
84
+ expect(onPage2[0].selectorIndex).toBe(0);
85
+ expect(rectsForPage(anns, 1)).toHaveLength(0);
86
+ });
87
+
88
+ it('multi-line: a 3-selector annotation on one page → 3 distinctly-keyed rects', () => {
89
+ const anns = build([[
90
+ { page: 2, x: 72, y: 720, w: 150, h: 12 },
91
+ { page: 2, x: 72, y: 700, w: 140, h: 12 },
92
+ { page: 2, x: 72, y: 680, w: 90, h: 12 },
93
+ ]]);
94
+ expect(keysOn(anns, 2)).toEqual(['ann-0:0', 'ann-0:1', 'ann-0:2']);
95
+ });
96
+ });
@@ -0,0 +1,47 @@
1
+ import type { Annotation, PdfCoordinate } from '@semiont/core';
2
+ import { getTargetSelector, parseFragmentSelector } from '@semiont/core';
3
+
4
+ /** One FragmentSelector rectangle to paint on a PDF page. */
5
+ export interface PageRect {
6
+ /** Owning annotation id — shared by every rect of a multi-line annotation (hover/click routing). */
7
+ annId: Annotation['id'];
8
+ /** Index within the annotation's FragmentSelectors — the stable half of the React key. */
9
+ selectorIndex: number;
10
+ /** PDF-point geometry for this rect. */
11
+ coord: PdfCoordinate;
12
+ /** Owning annotation (motivation colour, etc.). */
13
+ annotation: Annotation;
14
+ }
15
+
16
+ /** Every FragmentSelector on a target, in order (`target.selector` may be one or an array). */
17
+ function fragmentSelectors(target: Annotation['target']): { value: string }[] {
18
+ const selector = getTargetSelector(target);
19
+ if (!selector) return [];
20
+ const selectors = Array.isArray(selector) ? selector : [selector];
21
+ return selectors
22
+ .filter(s => s.type === 'FragmentSelector')
23
+ .map(s => s as { type: 'FragmentSelector'; value: string });
24
+ }
25
+
26
+ /**
27
+ * The rectangles to paint on `pageNumber`: one entry per FragmentSelector whose
28
+ * viewrect page matches. A multi-line (multi-selector) annotation therefore yields
29
+ * one rect per line; a single-selector (manual) annotation yields exactly one.
30
+ *
31
+ * Pure — no React/DOM. `PdfAnnotationCanvas` maps this to `<rect>` keyed
32
+ * `${annId}:${selectorIndex}`, and the rects-for-page axioms exercise it directly.
33
+ * Geometry stays deferred: each `coord` still goes through `pdfToCanvasCoordinates`
34
+ * at paint time (itself covered by the coordinate-transform axioms).
35
+ */
36
+ export function rectsForPage(annotations: Annotation[], pageNumber: number): PageRect[] {
37
+ const rects: PageRect[] = [];
38
+ for (const annotation of annotations) {
39
+ fragmentSelectors(annotation.target).forEach((sel, selectorIndex) => {
40
+ const coord = parseFragmentSelector(sel.value);
41
+ if (coord && coord.page === pageNumber) {
42
+ rects.push({ annId: annotation.id, selectorIndex, coord, annotation });
43
+ }
44
+ });
45
+ }
46
+ return rects;
47
+ }