@semiont/react-ui 0.5.24 → 0.5.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{PdfAnnotationCanvas.client-V7Q3BEEQ.js → PdfAnnotationCanvas.client-EHQWF6BR.js} +45 -10
- package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js.map +1 -0
- package/dist/{ar-JRTKCMTF.js → ar-CULRHLNG.js} +20 -6
- package/dist/ar-CULRHLNG.js.map +1 -0
- package/dist/{bn-LO6EDSCC.js → bn-HYR7CEL2.js} +20 -6
- package/dist/bn-HYR7CEL2.js.map +1 -0
- package/dist/{chunk-5Y5JDCKC.js → chunk-NH6L2GIL.js} +20 -6
- package/dist/chunk-NH6L2GIL.js.map +1 -0
- package/dist/{chunk-4QMTEPQ3.js → chunk-TG2XQEC4.js} +31 -31
- package/dist/{cs-LCHHOR4O.js → cs-YBRWY7NH.js} +20 -6
- package/dist/cs-YBRWY7NH.js.map +1 -0
- package/dist/{da-TARGPMJR.js → da-PHDEOBV3.js} +20 -6
- package/dist/da-PHDEOBV3.js.map +1 -0
- package/dist/{de-SBH4QBPJ.js → de-O4UCCVLJ.js} +20 -6
- package/dist/de-O4UCCVLJ.js.map +1 -0
- package/dist/{el-2D2OLGWB.js → el-MVGO6WZV.js} +20 -6
- package/dist/el-MVGO6WZV.js.map +1 -0
- package/dist/{en-QFIEG3CA.js → en-2KQILFKQ.js} +4 -2
- package/dist/{es-U6YK2LX6.js → es-USSLXQY7.js} +20 -6
- package/dist/es-USSLXQY7.js.map +1 -0
- package/dist/{fa-S7SZE37M.js → fa-WMAREFWY.js} +20 -6
- package/dist/fa-WMAREFWY.js.map +1 -0
- package/dist/{fi-CBL3NIXN.js → fi-B3NBNOTS.js} +20 -6
- package/dist/fi-B3NBNOTS.js.map +1 -0
- package/dist/{fr-EABUHV6S.js → fr-TXYK5T5W.js} +20 -6
- package/dist/fr-TXYK5T5W.js.map +1 -0
- package/dist/{he-THWD244Y.js → he-JCJACW5D.js} +20 -6
- package/dist/he-JCJACW5D.js.map +1 -0
- package/dist/{hi-GHGFZJ44.js → hi-VOQLZNJU.js} +20 -6
- package/dist/hi-VOQLZNJU.js.map +1 -0
- package/dist/{id-POZOUMDW.js → id-BFN5RZNZ.js} +20 -6
- package/dist/id-BFN5RZNZ.js.map +1 -0
- package/dist/index.css +0 -36
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +85 -24
- package/dist/index.js +1763 -1690
- package/dist/index.js.map +1 -1
- package/dist/{it-KDYJADSH.js → it-TWWDHKKM.js} +20 -6
- package/dist/it-TWWDHKKM.js.map +1 -0
- package/dist/{ja-Z5Z6ZF3P.js → ja-YRQLEBFM.js} +20 -6
- package/dist/ja-YRQLEBFM.js.map +1 -0
- package/dist/{ko-7X3XUXRN.js → ko-PG6B3CRB.js} +20 -6
- package/dist/ko-PG6B3CRB.js.map +1 -0
- package/dist/{ms-BTWEIW43.js → ms-7EZECVKM.js} +20 -6
- package/dist/ms-7EZECVKM.js.map +1 -0
- package/dist/{nl-IS2CGF5I.js → nl-HP753YAP.js} +20 -6
- package/dist/nl-HP753YAP.js.map +1 -0
- package/dist/{no-AAMEVA26.js → no-JUD5WX3N.js} +20 -6
- package/dist/no-JUD5WX3N.js.map +1 -0
- package/dist/{pl-PVXUQYFT.js → pl-RVUL4CBM.js} +20 -6
- package/dist/pl-RVUL4CBM.js.map +1 -0
- package/dist/{pt-3ITQ76TL.js → pt-RPOVD4QM.js} +20 -6
- package/dist/pt-RPOVD4QM.js.map +1 -0
- package/dist/{ro-FRB4IIVS.js → ro-DMYWQTRD.js} +20 -6
- package/dist/ro-DMYWQTRD.js.map +1 -0
- package/dist/{sv-DS3RO24M.js → sv-DMZ4FGXD.js} +20 -6
- package/dist/sv-DMZ4FGXD.js.map +1 -0
- package/dist/test-utils.js +2 -2
- package/dist/{th-AX66MWWB.js → th-4HVBJBQ2.js} +20 -6
- package/dist/th-4HVBJBQ2.js.map +1 -0
- package/dist/{tr-7KTB67TT.js → tr-6BDPU5ML.js} +20 -6
- package/dist/tr-6BDPU5ML.js.map +1 -0
- package/dist/{uk-SMQS2MAQ.js → uk-TBZBT2TL.js} +20 -6
- package/dist/uk-TBZBT2TL.js.map +1 -0
- package/dist/{vi-D3JXL2EZ.js → vi-UMKZKZL3.js} +20 -6
- package/dist/vi-UMKZKZL3.js.map +1 -0
- package/dist/{zh-S754AWFS.js → zh-YBJWJJYB.js} +20 -6
- package/dist/zh-YBJWJJYB.js.map +1 -0
- package/package.json +8 -8
- package/src/components/AssistProgress.tsx +42 -30
- package/src/components/EntityFoundLog.tsx +38 -0
- package/src/components/__tests__/AssistProgress.test.tsx +86 -28
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +112 -12
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +247 -1
- package/src/components/resource/AnnotateView.tsx +14 -7
- package/src/components/resource/BrowseView.tsx +2 -7
- package/src/components/resource/DownloadFileLink.tsx +49 -0
- package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +58 -0
- package/src/components/resource/__tests__/download-link.test.tsx +129 -0
- package/src/components/resource/panels/AssistSection.css +0 -43
- package/src/components/resource/panels/AssistSection.tsx +11 -2
- package/src/components/resource/panels/AssistShell.tsx +5 -1
- package/src/components/resource/panels/ReferencesPanel.tsx +12 -13
- package/src/components/resource/panels/TaggingPanel.tsx +21 -1
- package/src/components/resource/panels/__tests__/AssistSection.test.tsx +2 -2
- package/src/components/resource/panels/__tests__/AssistShell.test.tsx +9 -4
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +1 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +26 -0
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +32 -3
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +17 -6
- package/translations/ar.json +17 -5
- package/translations/bn.json +17 -5
- package/translations/cs.json +17 -5
- package/translations/da.json +17 -5
- package/translations/de.json +17 -5
- package/translations/el.json +17 -5
- package/translations/en.json +17 -5
- package/translations/es.json +17 -5
- package/translations/fa.json +17 -5
- package/translations/fi.json +17 -5
- package/translations/fr.json +17 -5
- package/translations/he.json +17 -5
- package/translations/hi.json +17 -5
- package/translations/id.json +17 -5
- package/translations/it.json +17 -5
- package/translations/ja.json +17 -5
- package/translations/ko.json +17 -5
- package/translations/ms.json +17 -5
- package/translations/nl.json +17 -5
- package/translations/no.json +17 -5
- package/translations/pl.json +17 -5
- package/translations/pt.json +17 -5
- package/translations/ro.json +17 -5
- package/translations/sv.json +17 -5
- package/translations/th.json +17 -5
- package/translations/tr.json +17 -5
- package/translations/uk.json +17 -5
- package/translations/vi.json +17 -5
- package/translations/zh.json +17 -5
- package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +0 -1
- package/dist/ar-JRTKCMTF.js.map +0 -1
- package/dist/bn-LO6EDSCC.js.map +0 -1
- package/dist/chunk-5Y5JDCKC.js.map +0 -1
- package/dist/cs-LCHHOR4O.js.map +0 -1
- package/dist/da-TARGPMJR.js.map +0 -1
- package/dist/de-SBH4QBPJ.js.map +0 -1
- package/dist/el-2D2OLGWB.js.map +0 -1
- package/dist/es-U6YK2LX6.js.map +0 -1
- package/dist/fa-S7SZE37M.js.map +0 -1
- package/dist/fi-CBL3NIXN.js.map +0 -1
- package/dist/fr-EABUHV6S.js.map +0 -1
- package/dist/he-THWD244Y.js.map +0 -1
- package/dist/hi-GHGFZJ44.js.map +0 -1
- package/dist/id-POZOUMDW.js.map +0 -1
- package/dist/it-KDYJADSH.js.map +0 -1
- package/dist/ja-Z5Z6ZF3P.js.map +0 -1
- package/dist/ko-7X3XUXRN.js.map +0 -1
- package/dist/ms-BTWEIW43.js.map +0 -1
- package/dist/nl-IS2CGF5I.js.map +0 -1
- package/dist/no-AAMEVA26.js.map +0 -1
- package/dist/pl-PVXUQYFT.js.map +0 -1
- package/dist/pt-3ITQ76TL.js.map +0 -1
- package/dist/ro-FRB4IIVS.js.map +0 -1
- package/dist/sv-DS3RO24M.js.map +0 -1
- package/dist/th-AX66MWWB.js.map +0 -1
- package/dist/tr-7KTB67TT.js.map +0 -1
- package/dist/uk-SMQS2MAQ.js.map +0 -1
- package/dist/vi-D3JXL2EZ.js.map +0 -1
- package/dist/zh-S754AWFS.js.map +0 -1
- /package/dist/{chunk-4QMTEPQ3.js.map → chunk-TG2XQEC4.js.map} +0 -0
- /package/dist/{en-QFIEG3CA.js.map → en-2KQILFKQ.js.map} +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
|
4
|
-
import type { Annotation, AnchorRect } from '@semiont/core';
|
|
4
|
+
import type { Annotation, AnchorRect, AnchoredText } from '@semiont/core';
|
|
5
5
|
import { resourceId as toResourceId } from '@semiont/core';
|
|
6
6
|
import { toViewportAnchorRect } from '../../lib/anchor-rect';
|
|
7
|
-
import { createFragmentSelector } from '@semiont/core';
|
|
7
|
+
import { createFragmentSelector, anchorRuns, isTextRun, textUnder } from '@semiont/core';
|
|
8
8
|
import { rectsForPage } from './rects-for-page';
|
|
9
9
|
import { createHoverHandlers, type SemiontSession } from '@semiont/sdk';
|
|
10
10
|
import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
|
|
@@ -61,7 +61,7 @@ interface PdfAnnotationCanvasProps {
|
|
|
61
61
|
* PDF annotation canvas with page navigation and rectangle drawing
|
|
62
62
|
*
|
|
63
63
|
* @emits browse:click - Annotation clicked on PDF. Payload: { annotationId: string, motivation: Motivation }
|
|
64
|
-
* @emits mark:requested - New annotation drawn on PDF. Payload: { selector: FragmentSelector, motivation: SelectionMotivation }
|
|
64
|
+
* @emits mark:requested - New annotation drawn on PDF. Payload: { selector: [FragmentSelector, TextQuoteSelector?], motivation: SelectionMotivation } — the quote is the text under the rectangle, omitted when the page has no text layer
|
|
65
65
|
* @emits beckon:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }
|
|
66
66
|
*/
|
|
67
67
|
export function PdfAnnotationCanvas({
|
|
@@ -83,6 +83,13 @@ export function PdfAnnotationCanvas({
|
|
|
83
83
|
const [isLoading, setIsLoading] = useState(true);
|
|
84
84
|
const [error, setError] = useState<string | null>(null);
|
|
85
85
|
const [pageDimensions, setPageDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
86
|
+
/**
|
|
87
|
+
* The current page's text and per-run geometry, read once when the page
|
|
88
|
+
* loads so a drag can be quoted without a round trip. Null while the page
|
|
89
|
+
* is loading; `items` is empty on a scanned page, which has no text layer
|
|
90
|
+
* for the browser to read.
|
|
91
|
+
*/
|
|
92
|
+
const [pageAnchored, setPageAnchored] = useState<AnchoredText | null>(null);
|
|
86
93
|
const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
87
94
|
const [scale] = useState(1.5); // Fixed scale for better quality
|
|
88
95
|
|
|
@@ -125,6 +132,36 @@ export function PdfAnnotationCanvas({
|
|
|
125
132
|
};
|
|
126
133
|
}, [pdfUrl]);
|
|
127
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The server-derived map, fetched once per document rather than once per
|
|
137
|
+
* page-load effect (PERSIST-ANCHORS P4). The map is WHOLE-RESOURCE — one
|
|
138
|
+
* artifact covering every page — so re-reading it from the per-page effect
|
|
139
|
+
* meant a full refetch and re-decode on every page turn; on a 400-page scan
|
|
140
|
+
* that is one decode per interaction instead of one per document.
|
|
141
|
+
*
|
|
142
|
+
* The cache holds the in-flight promise so concurrent page loads share one
|
|
143
|
+
* fetch. Answers cache — including "no map" and a stored decline, which are
|
|
144
|
+
* definitive — but a transport failure clears the entry, so the next page
|
|
145
|
+
* load retries instead of pinning the whole document to geometry-only.
|
|
146
|
+
*/
|
|
147
|
+
const resourceAnchoredRef = useRef<{ uri: string; outcome: Promise<AnchoredText | null> } | null>(null);
|
|
148
|
+
const fetchResourceAnchored = useCallback((): Promise<AnchoredText | null> => {
|
|
149
|
+
if (!session) return Promise.resolve(null); // no session yet — don't cache its absence
|
|
150
|
+
const cached = resourceAnchoredRef.current;
|
|
151
|
+
if (cached && cached.uri === resourceUri) return cached.outcome;
|
|
152
|
+
|
|
153
|
+
const uri = resourceUri;
|
|
154
|
+
const outcome = session.client.browse.resourceAnchoredText(toResourceId(uri)).then(
|
|
155
|
+
(served) => (served && !('declined' in served) ? served : null),
|
|
156
|
+
() => {
|
|
157
|
+
if (resourceAnchoredRef.current?.uri === uri) resourceAnchoredRef.current = null;
|
|
158
|
+
return null;
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
resourceAnchoredRef.current = { uri, outcome };
|
|
162
|
+
return outcome;
|
|
163
|
+
}, [session, resourceUri]);
|
|
164
|
+
|
|
128
165
|
// Load current page when page number changes
|
|
129
166
|
useEffect(() => {
|
|
130
167
|
if (!pdfDoc) return;
|
|
@@ -132,6 +169,49 @@ export function PdfAnnotationCanvas({
|
|
|
132
169
|
let cancelled = false;
|
|
133
170
|
const doc = pdfDoc;
|
|
134
171
|
|
|
172
|
+
// Never quote the previous page's text under this page's rectangles.
|
|
173
|
+
setPageAnchored(null);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The map a rectangle on this page quotes from, or null when there is
|
|
177
|
+
* none to be had.
|
|
178
|
+
*
|
|
179
|
+
* Never rejects, which is what lets the render run alongside it: quoting
|
|
180
|
+
* is the optional half of loading a page, so a failure here degrades to
|
|
181
|
+
* geometry-only rather than reaching the caller's error path. It is also
|
|
182
|
+
* the only reason `Promise.all` below is safe — a rejection from either
|
|
183
|
+
* side would leave the other promise dangling, and only the render can
|
|
184
|
+
* reject.
|
|
185
|
+
*/
|
|
186
|
+
async function resolveAnchored(page: Awaited<ReturnType<typeof doc.getPage>>): Promise<AnchoredText | null> {
|
|
187
|
+
try {
|
|
188
|
+
// The page's text layer, read once here rather than at drag time —
|
|
189
|
+
// `handleMouseUp` stays synchronous, and a native page costs nothing
|
|
190
|
+
// extra since pdf.js already parsed it to draw the page.
|
|
191
|
+
const runs = (await page.getTextContent()).items.filter(isTextRun);
|
|
192
|
+
if (runs.length > 0) return anchorRuns(runs, pageNumber);
|
|
193
|
+
|
|
194
|
+
// No runs means a scanned page: the characters exist only as pixels
|
|
195
|
+
// and pdf.js has nothing to give. The server derived a map at ingest,
|
|
196
|
+
// so ask for it rather than leaving the annotation anonymous.
|
|
197
|
+
// Whole-resource — served once per document via the cache above —
|
|
198
|
+
// and `textUnder` filters by page: the same shape the native branch
|
|
199
|
+
// produces, so nothing downstream branches.
|
|
200
|
+
//
|
|
201
|
+
// `null` is the ordinary answer for a document that has no map and
|
|
202
|
+
// never will; a failure is equally non-fatal. Either way the
|
|
203
|
+
// annotation ships with geometry only, which is what shipped before
|
|
204
|
+
// this existed. The served record is the full extraction outcome
|
|
205
|
+
// (PERSIST-ANCHORS D1); a stored decline means extraction ran and
|
|
206
|
+
// found nothing to anchor — for this canvas the same degradation as
|
|
207
|
+
// no map at all. A success outcome IS the anchoring shape, plus
|
|
208
|
+
// provenance this canvas does not read.
|
|
209
|
+
return await fetchResourceAnchored();
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
135
215
|
async function loadPage() {
|
|
136
216
|
try {
|
|
137
217
|
const page = await doc.getPage(pageNumber);
|
|
@@ -145,11 +225,21 @@ export function PdfAnnotationCanvas({
|
|
|
145
225
|
height: viewport.height
|
|
146
226
|
});
|
|
147
227
|
|
|
148
|
-
//
|
|
149
|
-
|
|
228
|
+
// Anchoring and rendering are independent, and only one of them the
|
|
229
|
+
// reader is waiting on. Sequencing them put a network round-trip in
|
|
230
|
+
// front of the pixels on exactly the documents that need it most: a
|
|
231
|
+
// scanned page fetches its map from the server, and rendering behind
|
|
232
|
+
// that await is how "failing to quote it must not fail to show it"
|
|
233
|
+
// became true of errors but not of latency. Started together, the
|
|
234
|
+
// page appears on its own schedule.
|
|
235
|
+
const [anchored, { dataUrl }] = await Promise.all([
|
|
236
|
+
resolveAnchored(page),
|
|
237
|
+
renderPdfPageToDataUrl(page, scale),
|
|
238
|
+
]);
|
|
150
239
|
|
|
151
240
|
if (cancelled) return;
|
|
152
241
|
|
|
242
|
+
setPageAnchored(anchored);
|
|
153
243
|
setPageImageUrl(dataUrl);
|
|
154
244
|
} catch (err) {
|
|
155
245
|
if (cancelled) return;
|
|
@@ -164,7 +254,7 @@ export function PdfAnnotationCanvas({
|
|
|
164
254
|
return () => {
|
|
165
255
|
cancelled = true;
|
|
166
256
|
};
|
|
167
|
-
}, [pdfDoc, pageNumber, scale]);
|
|
257
|
+
}, [pdfDoc, pageNumber, scale, fetchResourceAnchored]);
|
|
168
258
|
|
|
169
259
|
// Update display dimensions on resize
|
|
170
260
|
useEffect(() => {
|
|
@@ -318,15 +408,25 @@ export function PdfAnnotationCanvas({
|
|
|
318
408
|
// Create FragmentSelector
|
|
319
409
|
const fragmentSelector = createFragmentSelector(pdfCoord);
|
|
320
410
|
|
|
411
|
+
// What the box was drawn around. Without it the annotation is a rectangle
|
|
412
|
+
// with no memory of its own content: the panel entry is blank, search over
|
|
413
|
+
// annotation text misses it, and an export has nothing to print. Empty on a
|
|
414
|
+
// scanned page or over an image — emit no quote at all rather than an empty
|
|
415
|
+
// one, which would assert the box was drawn around nothing.
|
|
416
|
+
const quoted = pageAnchored ? textUnder(pageAnchored, pdfCoord) : '';
|
|
417
|
+
|
|
321
418
|
// Emit annotation:requested event with FragmentSelector
|
|
322
419
|
if (selectedMotivation) {
|
|
323
420
|
session.client.mark.request(
|
|
324
421
|
toResourceId(resourceUri),
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
422
|
+
[
|
|
423
|
+
{
|
|
424
|
+
type: 'FragmentSelector',
|
|
425
|
+
conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
|
|
426
|
+
value: fragmentSelector,
|
|
427
|
+
},
|
|
428
|
+
...(quoted ? [{ type: 'TextQuoteSelector' as const, exact: quoted }] : []),
|
|
429
|
+
],
|
|
330
430
|
selectedMotivation,
|
|
331
431
|
);
|
|
332
432
|
}
|
|
@@ -336,7 +436,7 @@ export function PdfAnnotationCanvas({
|
|
|
336
436
|
setIsDrawing(false);
|
|
337
437
|
// Note: We keep selection so the preview remains visible
|
|
338
438
|
// It will be cleared when drawingMode changes or user starts new selection
|
|
339
|
-
}, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri]);
|
|
439
|
+
}, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri, pageAnchored]);
|
|
340
440
|
|
|
341
441
|
// Every FragmentSelector rect on the current page — one per line for a
|
|
342
442
|
// multi-line (multi-selector) annotation, exactly one for a manual annotation.
|
|
@@ -13,9 +13,35 @@ import userEvent from '@testing-library/user-event';
|
|
|
13
13
|
import { PdfAnnotationCanvas } from '../PdfAnnotationCanvas';
|
|
14
14
|
import { resourceId, annotationId, parseFragmentSelector } from '@semiont/core';
|
|
15
15
|
import { pdfToCanvasCoordinates } from '../../../lib/pdf-coordinates';
|
|
16
|
+
import { loadPdfDocument, renderPdfPageToDataUrl } from '../../../lib/browser-pdfjs';
|
|
16
17
|
|
|
17
18
|
import type { Annotation } from '@semiont/core';
|
|
18
19
|
|
|
20
|
+
// The page's text layer, as pdf.js reports it. Geometry is PDF points,
|
|
21
|
+
// bottom-left origin: "Hello world" sits on one line at y=700, "elsewhere" a
|
|
22
|
+
// hundred points below it.
|
|
23
|
+
// vi.hoisted: vi.mock's factory is lifted above these declarations.
|
|
24
|
+
const { MOCK_TEXT_ITEMS } = vi.hoisted(() => ({
|
|
25
|
+
MOCK_TEXT_ITEMS: [
|
|
26
|
+
{ str: 'Hello', transform: [1, 0, 0, 1, 72, 700], width: 30, height: 12, hasEOL: false },
|
|
27
|
+
{ str: 'world', transform: [1, 0, 0, 1, 106, 700], width: 32, height: 12, hasEOL: true },
|
|
28
|
+
{ str: 'elsewhere', transform: [1, 0, 0, 1, 72, 600], width: 54, height: 12, hasEOL: true },
|
|
29
|
+
],
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const mockPage = (textItems: unknown[]) => ({
|
|
33
|
+
getViewport: vi.fn().mockReturnValue({
|
|
34
|
+
width: 612,
|
|
35
|
+
height: 792,
|
|
36
|
+
scale: 1.0,
|
|
37
|
+
rotation: 0
|
|
38
|
+
}),
|
|
39
|
+
render: vi.fn().mockReturnValue({
|
|
40
|
+
promise: Promise.resolve()
|
|
41
|
+
}),
|
|
42
|
+
getTextContent: vi.fn().mockResolvedValue({ items: textItems })
|
|
43
|
+
});
|
|
44
|
+
|
|
19
45
|
// Mock browser-pdfjs module
|
|
20
46
|
vi.mock('../../../lib/browser-pdfjs', () => ({
|
|
21
47
|
loadPdfDocument: vi.fn().mockResolvedValue({
|
|
@@ -29,7 +55,8 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
|
|
|
29
55
|
}),
|
|
30
56
|
render: vi.fn().mockReturnValue({
|
|
31
57
|
promise: Promise.resolve()
|
|
32
|
-
})
|
|
58
|
+
}),
|
|
59
|
+
getTextContent: vi.fn().mockResolvedValue({ items: MOCK_TEXT_ITEMS })
|
|
33
60
|
})
|
|
34
61
|
}),
|
|
35
62
|
renderPdfPageToDataUrl: vi.fn().mockResolvedValue({
|
|
@@ -313,6 +340,225 @@ describe('PdfAnnotationCanvas', () => {
|
|
|
313
340
|
});
|
|
314
341
|
});
|
|
315
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Drives the drag → mark.request path far enough to inspect the selector.
|
|
345
|
+
* `drag` is in canvas pixels; at scale 1 with a 612×792 page these are also
|
|
346
|
+
* display coordinates, since jsdom's getBoundingClientRect returns zeros.
|
|
347
|
+
*/
|
|
348
|
+
async function drawRectangle(
|
|
349
|
+
drag: { fromX: number; fromY: number; toX: number; toY: number },
|
|
350
|
+
) {
|
|
351
|
+
const request = vi.fn();
|
|
352
|
+
const session = {
|
|
353
|
+
client: { mark: { request }, beckon: { hover: vi.fn() } },
|
|
354
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
355
|
+
|
|
356
|
+
render(
|
|
357
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
358
|
+
pdfUrl={mockPdfUrl}
|
|
359
|
+
drawingMode="rectangle"
|
|
360
|
+
selectedMotivation="highlighting"
|
|
361
|
+
session={session}
|
|
362
|
+
/>
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
await waitFor(() => {
|
|
366
|
+
const img = document.querySelector('.semiont-pdf-annotation-canvas__image');
|
|
367
|
+
expect(img).toBeInTheDocument();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
|
|
371
|
+
Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
|
|
372
|
+
Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
|
|
373
|
+
fireEvent.load(img);
|
|
374
|
+
|
|
375
|
+
const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
|
|
376
|
+
fireEvent.mouseDown(container, { clientX: drag.fromX, clientY: drag.fromY });
|
|
377
|
+
fireEvent.mouseMove(container, { clientX: drag.toX, clientY: drag.toY });
|
|
378
|
+
fireEvent.mouseUp(container, { clientX: drag.toX, clientY: drag.toY });
|
|
379
|
+
|
|
380
|
+
return request;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// The manual-annotation capture gap (.plans/PDF-MANUAL-ANNOTATION-TEXT.md):
|
|
384
|
+
// a hand-drawn rectangle stored geometry and nothing else, so every panel
|
|
385
|
+
// that quotes an annotation showed it blank.
|
|
386
|
+
test('a drawn rectangle carries the text it was drawn around', async () => {
|
|
387
|
+
// The "Hello world" line in canvas space: PDF y=700..712 flips to
|
|
388
|
+
// canvas y=80..92 on a 792pt page. Drag a box a little larger than it.
|
|
389
|
+
const line = pdfToCanvasCoordinates(
|
|
390
|
+
{ page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
|
|
391
|
+
);
|
|
392
|
+
const request = await drawRectangle({
|
|
393
|
+
fromX: line.x - 2,
|
|
394
|
+
fromY: line.y - 2,
|
|
395
|
+
toX: line.x + line.width + 8,
|
|
396
|
+
toY: line.y + line.height + 4,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
400
|
+
|
|
401
|
+
const [source, selector, motivation] = request.mock.calls[0];
|
|
402
|
+
expect(source).toBe(resourceId('res-1'));
|
|
403
|
+
expect(motivation).toBe('highlighting');
|
|
404
|
+
expect(selector).toEqual([
|
|
405
|
+
{
|
|
406
|
+
type: 'FragmentSelector',
|
|
407
|
+
conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
|
|
408
|
+
value: expect.stringContaining('page=1&viewrect='),
|
|
409
|
+
},
|
|
410
|
+
{ type: 'TextQuoteSelector', exact: 'Hello world' },
|
|
411
|
+
]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('a rectangle over blank space carries geometry only', async () => {
|
|
415
|
+
// Between the two lines of text — nothing under the box. An empty-string
|
|
416
|
+
// quote would assert the box was drawn around nothing, so emit no quote.
|
|
417
|
+
const request = await drawRectangle({ fromX: 40, fromY: 120, toX: 200, toY: 150 });
|
|
418
|
+
|
|
419
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
420
|
+
|
|
421
|
+
const selector = request.mock.calls[0][1];
|
|
422
|
+
expect(selector).toHaveLength(1);
|
|
423
|
+
expect(selector[0].type).toBe('FragmentSelector');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Phase 2 of .plans/PDF-MANUAL-ANNOTATION-TEXT.md. A scanned page has no text
|
|
427
|
+
// in the browser, but the server derived one at ingest and serves it through
|
|
428
|
+
// `browse.resourceAnchoredText`. Same `AnchoredText` shape either way, so
|
|
429
|
+
// `textUnder` and the drag handler do not branch — only the source does.
|
|
430
|
+
test('a scanned page quotes from the map the server derived', async () => {
|
|
431
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
432
|
+
numPages: 3,
|
|
433
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
434
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
435
|
+
|
|
436
|
+
const request = vi.fn();
|
|
437
|
+
const session = {
|
|
438
|
+
client: {
|
|
439
|
+
mark: { request },
|
|
440
|
+
beckon: { hover: vi.fn() },
|
|
441
|
+
browse: {
|
|
442
|
+
resourceAnchoredText: vi.fn().mockResolvedValue({
|
|
443
|
+
text: 'Hello world again',
|
|
444
|
+
items: [
|
|
445
|
+
{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
|
|
446
|
+
{ start: 6, end: 11, page: 1, x: 106, y: 700, width: 32, height: 12 },
|
|
447
|
+
],
|
|
448
|
+
}),
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
452
|
+
|
|
453
|
+
render(
|
|
454
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
455
|
+
pdfUrl={mockPdfUrl}
|
|
456
|
+
drawingMode="rectangle"
|
|
457
|
+
selectedMotivation="highlighting"
|
|
458
|
+
session={session}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
await waitFor(() => {
|
|
463
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__image')).toBeInTheDocument();
|
|
464
|
+
});
|
|
465
|
+
const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
|
|
466
|
+
Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
|
|
467
|
+
Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
|
|
468
|
+
fireEvent.load(img);
|
|
469
|
+
|
|
470
|
+
const line = pdfToCanvasCoordinates({ page: 1, x: 72, y: 700, width: 66, height: 12 }, 792, 1.0);
|
|
471
|
+
const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
|
|
472
|
+
fireEvent.mouseDown(container, { clientX: line.x - 2, clientY: line.y - 2 });
|
|
473
|
+
fireEvent.mouseMove(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
|
|
474
|
+
fireEvent.mouseUp(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
|
|
475
|
+
|
|
476
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
477
|
+
expect(request.mock.calls[0][1]).toEqual([
|
|
478
|
+
expect.objectContaining({ type: 'FragmentSelector' }),
|
|
479
|
+
{ type: 'TextQuoteSelector', exact: 'Hello world' },
|
|
480
|
+
]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// PERSIST-ANCHORS P4. The server map is WHOLE-RESOURCE — one artifact
|
|
484
|
+
// covering every page — so fetching it from inside the per-page load
|
|
485
|
+
// effect refetched and re-decoded the entire document's geometry on every
|
|
486
|
+
// page turn. On a 400-page scan that is the difference between one decode
|
|
487
|
+
// and one per interaction.
|
|
488
|
+
test('the whole-resource map is fetched once per document, not once per page-turn', async () => {
|
|
489
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
490
|
+
numPages: 3,
|
|
491
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
492
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
493
|
+
|
|
494
|
+
const resourceAnchoredText = vi.fn().mockResolvedValue({
|
|
495
|
+
text: 'Hello world again',
|
|
496
|
+
items: [
|
|
497
|
+
{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
|
|
498
|
+
{ start: 6, end: 11, page: 2, x: 106, y: 700, width: 32, height: 12 },
|
|
499
|
+
],
|
|
500
|
+
});
|
|
501
|
+
const session = {
|
|
502
|
+
client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
|
|
503
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
504
|
+
|
|
505
|
+
render(
|
|
506
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
507
|
+
pdfUrl={mockPdfUrl}
|
|
508
|
+
drawingMode={null}
|
|
509
|
+
session={session}
|
|
510
|
+
/>
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const user = userEvent.setup();
|
|
514
|
+
await waitFor(() => {
|
|
515
|
+
expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await user.click(screen.getByRole('button', { name: /next/i }));
|
|
519
|
+
await waitFor(() => {
|
|
520
|
+
expect(screen.getByText(/page 2 of 3/i)).toBeInTheDocument();
|
|
521
|
+
});
|
|
522
|
+
await user.click(screen.getByRole('button', { name: /next/i }));
|
|
523
|
+
await waitFor(() => {
|
|
524
|
+
expect(screen.getByText(/page 3 of 3/i)).toBeInTheDocument();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// The render half runs once per page load and starts alongside the
|
|
528
|
+
// anchoring half (Promise.all), so three render calls prove all three
|
|
529
|
+
// page-load effects ran to the point of resolving their map.
|
|
530
|
+
await waitFor(() => {
|
|
531
|
+
expect(vi.mocked(renderPdfPageToDataUrl)).toHaveBeenCalledTimes(3);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test('a scanned page with no server map carries geometry only', async () => {
|
|
538
|
+
// Class B: pdf.js returns no runs, so the browser cannot do the job and
|
|
539
|
+
// the annotation stays geometry-only pending async enrichment (Phase 2).
|
|
540
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
541
|
+
numPages: 3,
|
|
542
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
543
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
544
|
+
|
|
545
|
+
const line = pdfToCanvasCoordinates(
|
|
546
|
+
{ page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
|
|
547
|
+
);
|
|
548
|
+
const request = await drawRectangle({
|
|
549
|
+
fromX: line.x - 2,
|
|
550
|
+
fromY: line.y - 2,
|
|
551
|
+
toX: line.x + line.width + 8,
|
|
552
|
+
toY: line.y + line.height + 4,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
556
|
+
|
|
557
|
+
const selector = request.mock.calls[0][1];
|
|
558
|
+
expect(selector).toHaveLength(1);
|
|
559
|
+
expect(selector[0].type).toBe('FragmentSelector');
|
|
560
|
+
});
|
|
561
|
+
|
|
316
562
|
test('accepts a drawing gesture without throwing when drawing mode is active', async () => {
|
|
317
563
|
render(
|
|
318
564
|
<PdfAnnotationCanvas resourceUri="res-1"
|
|
@@ -5,9 +5,11 @@ import { capabilitiesOf, resourceId as toResourceId } from '@semiont/core';
|
|
|
5
5
|
import { ANNOTATORS } from '../../lib/annotation-registry';
|
|
6
6
|
import { buildTextSelectors, fallbackTextPosition } from '../../lib/text-selection-handler';
|
|
7
7
|
import { defaultAnnotateRenderers, type AnnotateMediaRenderers } from './annotate-renderers';
|
|
8
|
+
import { DownloadFileLink } from './DownloadFileLink';
|
|
8
9
|
import type { EditorView } from '@codemirror/view';
|
|
9
10
|
import type { SemiontSession } from '@semiont/sdk';
|
|
10
11
|
import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
|
|
12
|
+
import { scrollAnnotationIntoView } from '../../lib/scroll-utils';
|
|
11
13
|
|
|
12
14
|
// Type augmentation for custom DOM properties
|
|
13
15
|
interface EnrichedHTMLElement extends HTMLElement {
|
|
@@ -51,6 +53,7 @@ interface Props {
|
|
|
51
53
|
*
|
|
52
54
|
* @emits mark:requested - User requested to create annotation. Payload: { selector: Selector | Selector[], motivation: SelectionMotivation }
|
|
53
55
|
* @subscribes beckon:hover - Annotation hovered. Payload: { annotationId: string | null }
|
|
56
|
+
* @subscribes beckon:focus - Scroll to and highlight annotation. Payload: { annotationId: string }
|
|
54
57
|
*/
|
|
55
58
|
export function AnnotateView({
|
|
56
59
|
content,
|
|
@@ -104,10 +107,20 @@ export function AnnotateView({
|
|
|
104
107
|
onUIStateChangeRef.current?.({ hoveredAnnotationId: annotationId });
|
|
105
108
|
}, []);
|
|
106
109
|
|
|
110
|
+
// "Scroll to and highlight this annotation" — the same contract BrowseView
|
|
111
|
+
// serves, so the behaviour no longer depends on which view mode is active.
|
|
112
|
+
// The `scrollToAnnotationId` prop path (uiState → renderer) stays as the
|
|
113
|
+
// host-facing capability it is; this is the in-app producer's route.
|
|
114
|
+
const handleAnnotationFocus = useCallback(({ annotationId }: { annotationId?: string | null }) => {
|
|
115
|
+
if (!containerRef.current) return;
|
|
116
|
+
scrollAnnotationIntoView(annotationId ?? null, containerRef.current, { pulse: true });
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
107
119
|
// Annotation hover (session-scoped). Toolbar preference changes flow through
|
|
108
120
|
// props/callbacks, not the bus (TOOLBAR-PREFS-AS-PROPS).
|
|
109
121
|
useSessionEventSubscriptions(session, {
|
|
110
122
|
'beckon:hover': handleAnnotationHover,
|
|
123
|
+
'beckon:focus': handleAnnotationFocus,
|
|
111
124
|
});
|
|
112
125
|
|
|
113
126
|
// Handle text annotation with sparkle or immediate creation
|
|
@@ -210,13 +223,7 @@ export function AnnotateView({
|
|
|
210
223
|
<p className="semiont-annotate-view__empty-message">
|
|
211
224
|
Annotation not supported for {mimeType}
|
|
212
225
|
</p>
|
|
213
|
-
<
|
|
214
|
-
href={`/api/resources/${resourceUri}`}
|
|
215
|
-
download
|
|
216
|
-
className="semiont-button semiont-button--primary"
|
|
217
|
-
>
|
|
218
|
-
Download File
|
|
219
|
-
</a>
|
|
226
|
+
<DownloadFileLink resourceUri={resourceUri} session={session} />
|
|
220
227
|
</div>
|
|
221
228
|
</div>
|
|
222
229
|
);
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import type { SemiontSession } from '@semiont/sdk';
|
|
22
22
|
import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
|
|
23
23
|
import { defaultBrowseRenderers, type BrowseMediaRenderers } from './browse-renderers';
|
|
24
|
+
import { DownloadFileLink } from './DownloadFileLink';
|
|
24
25
|
|
|
25
26
|
interface Props {
|
|
26
27
|
content: string;
|
|
@@ -295,13 +296,7 @@ export const BrowseView = memo(function BrowseView({
|
|
|
295
296
|
<p className="semiont-browse-view__empty-message">
|
|
296
297
|
Preview not available for {mimeType}
|
|
297
298
|
</p>
|
|
298
|
-
<
|
|
299
|
-
href={`/api/resources/${resourceUri}`}
|
|
300
|
-
download
|
|
301
|
-
className="semiont-button semiont-button--primary"
|
|
302
|
-
>
|
|
303
|
-
Download File
|
|
304
|
-
</a>
|
|
299
|
+
<DownloadFileLink resourceUri={resourceUri} session={session} />
|
|
305
300
|
</div>
|
|
306
301
|
</div>
|
|
307
302
|
);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { resourceId as toResourceId } from '@semiont/core';
|
|
4
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
5
|
+
import { useMediaToken } from '../../hooks/useMediaToken';
|
|
6
|
+
import { mediaUrl } from '../../lib/media-url';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** The '@id' of the resource whose bytes to offer. */
|
|
10
|
+
resourceUri: string;
|
|
11
|
+
/** Session for that resource — its client mints the media token and supplies the backend origin. */
|
|
12
|
+
session: SemiontSession | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The "Download File" affordance on the unsupported-media fallback, shared by
|
|
17
|
+
* the browse and annotate views.
|
|
18
|
+
*
|
|
19
|
+
* Its own component rather than inline markup for two reasons. The token is a
|
|
20
|
+
* hook, and this fallback is a branch — mounting the component only in that
|
|
21
|
+
* branch keeps every text/image/PDF view from minting a media token it will
|
|
22
|
+
* never use. And the URL rule (absolute + `?token=`) lives in `mediaUrl`,
|
|
23
|
+
* where both views and the viewer page read the same one.
|
|
24
|
+
*
|
|
25
|
+
* Before the token lands the anchor renders as a disabled button: a tokenless
|
|
26
|
+
* URL is a guaranteed 401, and a link that silently fails is worse than one
|
|
27
|
+
* visibly not ready yet. A client with no `auth` namespace (a host on a bare
|
|
28
|
+
* transport) stays in that state permanently, which is the honest answer —
|
|
29
|
+
* it cannot mint tokens at all.
|
|
30
|
+
*/
|
|
31
|
+
export function DownloadFileLink({ resourceUri, session }: Props) {
|
|
32
|
+
const client = session?.client ?? null;
|
|
33
|
+
const { token } = useMediaToken(client, toResourceId(resourceUri));
|
|
34
|
+
const href = mediaUrl(client, toResourceId(resourceUri), token);
|
|
35
|
+
|
|
36
|
+
if (!href) {
|
|
37
|
+
return (
|
|
38
|
+
<button type="button" disabled className="semiont-button semiont-button--primary semiont-button--disabled">
|
|
39
|
+
Download File
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<a href={href} download className="semiont-button semiont-button--primary">
|
|
46
|
+
Download File
|
|
47
|
+
</a>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASSIST-SURFACE-WARTS Lane D — in-content scroll on `beckon:focus`.
|
|
3
|
+
*
|
|
4
|
+
* `beckon:focus` is the established "scroll to and highlight this annotation"
|
|
5
|
+
* contract; BrowseView has subscribed to it all along. AnnotateView did not,
|
|
6
|
+
* so the same event scrolled the content in browse mode and did nothing in
|
|
7
|
+
* annotate mode. With the history panel now producing the event, that asymmetry
|
|
8
|
+
* becomes user-visible: the same click works or doesn't depending on the mode.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
import { render } from '@testing-library/react';
|
|
12
|
+
import '@testing-library/jest-dom';
|
|
13
|
+
import type { AnnotationUIState } from '../../../types/annotation-props';
|
|
14
|
+
import { createTestSemiontWrapper } from '../../../test-utils';
|
|
15
|
+
|
|
16
|
+
const scrollSpy = vi.hoisted(() => vi.fn());
|
|
17
|
+
vi.mock('../../../lib/scroll-utils', () => ({
|
|
18
|
+
scrollAnnotationIntoView: scrollSpy,
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('../../CodeMirrorRenderer', () => ({
|
|
21
|
+
CodeMirrorRenderer: () => <div data-annotation-id="ann-7">cm-mock</div>,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { AnnotateView } from '../AnnotateView';
|
|
25
|
+
|
|
26
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
27
|
+
const uiState: AnnotationUIState = {
|
|
28
|
+
selectedMotivation: 'linking',
|
|
29
|
+
selectedClick: 'detail',
|
|
30
|
+
selectedShape: 'rectangle',
|
|
31
|
+
hoveredAnnotationId: null,
|
|
32
|
+
scrollToAnnotationId: null,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('AnnotateView — beckon:focus scrolls the content', () => {
|
|
36
|
+
beforeEach(() => { scrollSpy.mockClear(); });
|
|
37
|
+
|
|
38
|
+
it('scrolls to the annotation when the session emits beckon:focus', () => {
|
|
39
|
+
const { session, client } = createTestSemiontWrapper();
|
|
40
|
+
|
|
41
|
+
render(
|
|
42
|
+
<AnnotateView
|
|
43
|
+
content="hello world"
|
|
44
|
+
mimeType="text/plain"
|
|
45
|
+
resourceUri="res-1"
|
|
46
|
+
annotations={emptyAnnotations}
|
|
47
|
+
uiState={uiState}
|
|
48
|
+
session={session}
|
|
49
|
+
annotateMode
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
client.bus.get('beckon:focus').next({ annotationId: 'ann-7' });
|
|
54
|
+
|
|
55
|
+
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(scrollSpy.mock.calls[0]?.[0]).toBe('ann-7');
|
|
57
|
+
});
|
|
58
|
+
});
|