@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.
- package/README.md +4 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@semiont/react-ui",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
126
|
-
"@semiont/http-transport": "0.5.
|
|
127
|
-
"@semiont/sdk": "0.5.
|
|
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
|
-
|
|
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
|
|
264
|
-
const
|
|
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
|
|
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 (
|
|
277
|
+
if (inside && imageRef.current) {
|
|
288
278
|
hitRect = toViewportAnchorRect(imageRef.current.getBoundingClientRect(), displayX, displayY, displayWidth, displayHeight);
|
|
289
279
|
}
|
|
290
|
-
return
|
|
280
|
+
return inside;
|
|
291
281
|
});
|
|
292
282
|
|
|
293
|
-
if (
|
|
294
|
-
session?.client.browse.click(
|
|
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
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
{
|
|
438
|
-
const
|
|
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 =
|
|
451
|
-
const isSelected =
|
|
419
|
+
const isHovered = r.annId === hoveredAnnotationId;
|
|
420
|
+
const isSelected = r.annId === selectedAnnotationId;
|
|
452
421
|
|
|
453
|
-
//
|
|
454
|
-
const annMotivation =
|
|
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={
|
|
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(
|
|
473
|
-
onMouseEnter={() => handleMouseEnter(
|
|
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
|
+
}
|