@semiont/react-ui 0.5.24 → 0.5.26
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-6ZSF6ZTJ.js +677 -0
- package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js.map +1 -0
- package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css +180 -0
- package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css.map +1 -0
- package/dist/{ar-JRTKCMTF.js → ar-YTUUGK2O.js} +32 -6
- package/dist/ar-YTUUGK2O.js.map +1 -0
- package/dist/{bn-LO6EDSCC.js → bn-ASGNGPMD.js} +32 -6
- package/dist/bn-ASGNGPMD.js.map +1 -0
- package/dist/{chunk-4QMTEPQ3.js → chunk-BTUG3VJJ.js} +18 -298
- package/dist/chunk-BTUG3VJJ.js.map +1 -0
- package/dist/{chunk-5Y5JDCKC.js → chunk-L3WKRWP3.js} +32 -6
- package/dist/chunk-L3WKRWP3.js.map +1 -0
- package/dist/chunk-OBQOZCJE.js +287 -0
- package/dist/chunk-OBQOZCJE.js.map +1 -0
- package/dist/{cs-LCHHOR4O.js → cs-WMYVAOZY.js} +32 -6
- package/dist/cs-WMYVAOZY.js.map +1 -0
- package/dist/{da-TARGPMJR.js → da-FSHHN4D6.js} +32 -6
- package/dist/da-FSHHN4D6.js.map +1 -0
- package/dist/{de-SBH4QBPJ.js → de-FJFO3TPT.js} +32 -6
- package/dist/de-FJFO3TPT.js.map +1 -0
- package/dist/{el-2D2OLGWB.js → el-XOH4QIAG.js} +32 -6
- package/dist/el-XOH4QIAG.js.map +1 -0
- package/dist/{en-QFIEG3CA.js → en-3FCBW3YD.js} +6 -2
- package/dist/{es-U6YK2LX6.js → es-HDX4QI7E.js} +32 -6
- package/dist/es-HDX4QI7E.js.map +1 -0
- package/dist/{fa-S7SZE37M.js → fa-DJ6WTFTJ.js} +32 -6
- package/dist/fa-DJ6WTFTJ.js.map +1 -0
- package/dist/{fi-CBL3NIXN.js → fi-4S7H7W7U.js} +32 -6
- package/dist/fi-4S7H7W7U.js.map +1 -0
- package/dist/{fr-EABUHV6S.js → fr-6UJSYHXC.js} +32 -6
- package/dist/fr-6UJSYHXC.js.map +1 -0
- package/dist/{he-THWD244Y.js → he-ZZJOHJX2.js} +32 -6
- package/dist/he-ZZJOHJX2.js.map +1 -0
- package/dist/{hi-GHGFZJ44.js → hi-HZJTAW7U.js} +32 -6
- package/dist/hi-HZJTAW7U.js.map +1 -0
- package/dist/{id-POZOUMDW.js → id-E3I6ILEM.js} +32 -6
- package/dist/id-E3I6ILEM.js.map +1 -0
- package/dist/index.css +140 -94
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +85 -24
- package/dist/index.js +1782 -1697
- package/dist/index.js.map +1 -1
- package/dist/{it-KDYJADSH.js → it-ULAPAD7U.js} +32 -6
- package/dist/it-ULAPAD7U.js.map +1 -0
- package/dist/{ja-Z5Z6ZF3P.js → ja-SAHZQ4CZ.js} +32 -6
- package/dist/ja-SAHZQ4CZ.js.map +1 -0
- package/dist/{ko-7X3XUXRN.js → ko-DFU2ADQ4.js} +32 -6
- package/dist/ko-DFU2ADQ4.js.map +1 -0
- package/dist/{ms-BTWEIW43.js → ms-NWL76XNT.js} +32 -6
- package/dist/ms-NWL76XNT.js.map +1 -0
- package/dist/{nl-IS2CGF5I.js → nl-ATCM76WN.js} +32 -6
- package/dist/nl-ATCM76WN.js.map +1 -0
- package/dist/{no-AAMEVA26.js → no-UIAQ5RJF.js} +32 -6
- package/dist/no-UIAQ5RJF.js.map +1 -0
- package/dist/{pl-PVXUQYFT.js → pl-NOECQDXD.js} +32 -6
- package/dist/pl-NOECQDXD.js.map +1 -0
- package/dist/{pt-3ITQ76TL.js → pt-SVWIIUPC.js} +32 -6
- package/dist/pt-SVWIIUPC.js.map +1 -0
- package/dist/{ro-FRB4IIVS.js → ro-NR2DRTZG.js} +32 -6
- package/dist/ro-NR2DRTZG.js.map +1 -0
- package/dist/{sv-DS3RO24M.js → sv-STVJ37JP.js} +32 -6
- package/dist/sv-STVJ37JP.js.map +1 -0
- package/dist/test-utils.js +5 -3
- package/dist/test-utils.js.map +1 -1
- package/dist/{th-AX66MWWB.js → th-UWQR4K54.js} +32 -6
- package/dist/th-UWQR4K54.js.map +1 -0
- package/dist/{tr-7KTB67TT.js → tr-WNXZSD27.js} +32 -6
- package/dist/tr-WNXZSD27.js.map +1 -0
- package/dist/{uk-SMQS2MAQ.js → uk-SJRMB47Y.js} +32 -6
- package/dist/uk-SJRMB47Y.js.map +1 -0
- package/dist/{vi-D3JXL2EZ.js → vi-TMRQFES2.js} +32 -6
- package/dist/vi-TMRQFES2.js.map +1 -0
- package/dist/{zh-S754AWFS.js → zh-QOL3LYEM.js} +32 -6
- package/dist/zh-QOL3LYEM.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/StatusDisplay.css +35 -35
- package/src/components/__tests__/AssistProgress.test.tsx +86 -28
- package/src/components/annotation/annotations.css +1 -1
- package/src/components/modals/SearchModal.css +5 -5
- package/src/components/pdf-annotation/PdfAnnotationCanvas.css +143 -6
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +689 -198
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +1008 -3
- package/src/components/pdf-annotation/__tests__/estimate-slot-height.test.ts +53 -0
- package/src/components/pdf-annotation/estimate-slot-height.ts +31 -0
- 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__/browse-renderers.dispatch.test.tsx +33 -0
- package/src/components/resource/__tests__/download-link.test.tsx +129 -0
- package/src/components/resource/annotate-renderers.tsx +10 -1
- package/src/components/resource/browse-renderers.tsx +10 -1
- 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/auth/auth.css +10 -10
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +58 -3
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +18 -7
- package/src/styles/core/inputs.css +1 -1
- package/src/styles/features/compose.css +1 -1
- package/src/styles/features/resource-viewer.css +1 -1
- package/src/styles/features/static-pages.css +11 -11
- package/src/styles/motivations/motivation-comment.css +2 -2
- package/src/styles/motivations/motivation-reference.css +2 -2
- package/src/styles/patterns/panels-base.css +4 -4
- package/src/styles/utilities/motion.css +15 -15
- package/src/styles/variables.css +29 -1
- package/translations/ar.json +27 -5
- package/translations/bn.json +27 -5
- package/translations/cs.json +27 -5
- package/translations/da.json +27 -5
- package/translations/de.json +27 -5
- package/translations/el.json +27 -5
- package/translations/en.json +27 -5
- package/translations/es.json +27 -5
- package/translations/fa.json +27 -5
- package/translations/fi.json +27 -5
- package/translations/fr.json +27 -5
- package/translations/he.json +27 -5
- package/translations/hi.json +27 -5
- package/translations/id.json +27 -5
- package/translations/it.json +27 -5
- package/translations/ja.json +27 -5
- package/translations/ko.json +27 -5
- package/translations/ms.json +27 -5
- package/translations/nl.json +27 -5
- package/translations/no.json +27 -5
- package/translations/pl.json +27 -5
- package/translations/pt.json +27 -5
- package/translations/ro.json +27 -5
- package/translations/sv.json +27 -5
- package/translations/th.json +27 -5
- package/translations/tr.json +27 -5
- package/translations/uk.json +27 -5
- package/translations/vi.json +27 -5
- package/translations/zh.json +27 -5
- package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css +0 -98
- package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css.map +0 -1
- package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js +0 -427
- 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-4QMTEPQ3.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/{en-QFIEG3CA.js.map → en-3FCBW3YD.js.map} +0 -0
|
@@ -7,15 +7,43 @@
|
|
|
7
7
|
* - Annotation display
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
|
11
|
-
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
10
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
|
12
12
|
import userEvent from '@testing-library/user-event';
|
|
13
13
|
import { PdfAnnotationCanvas } from '../PdfAnnotationCanvas';
|
|
14
|
+
import { TranslationProvider } from '../../../contexts/TranslationContext';
|
|
15
|
+
import { defaultMocks } from '../../../test-utils';
|
|
14
16
|
import { resourceId, annotationId, parseFragmentSelector } from '@semiont/core';
|
|
15
17
|
import { pdfToCanvasCoordinates } from '../../../lib/pdf-coordinates';
|
|
18
|
+
import { loadPdfDocument, renderPdfPageToDataUrl } from '../../../lib/browser-pdfjs';
|
|
16
19
|
|
|
17
20
|
import type { Annotation } from '@semiont/core';
|
|
18
21
|
|
|
22
|
+
// The page's text layer, as pdf.js reports it. Geometry is PDF points,
|
|
23
|
+
// bottom-left origin: "Hello world" sits on one line at y=700, "elsewhere" a
|
|
24
|
+
// hundred points below it.
|
|
25
|
+
// vi.hoisted: vi.mock's factory is lifted above these declarations.
|
|
26
|
+
const { MOCK_TEXT_ITEMS } = vi.hoisted(() => ({
|
|
27
|
+
MOCK_TEXT_ITEMS: [
|
|
28
|
+
{ str: 'Hello', transform: [1, 0, 0, 1, 72, 700], width: 30, height: 12, hasEOL: false },
|
|
29
|
+
{ str: 'world', transform: [1, 0, 0, 1, 106, 700], width: 32, height: 12, hasEOL: true },
|
|
30
|
+
{ str: 'elsewhere', transform: [1, 0, 0, 1, 72, 600], width: 54, height: 12, hasEOL: true },
|
|
31
|
+
],
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const mockPage = (textItems: unknown[]) => ({
|
|
35
|
+
getViewport: vi.fn().mockReturnValue({
|
|
36
|
+
width: 612,
|
|
37
|
+
height: 792,
|
|
38
|
+
scale: 1.0,
|
|
39
|
+
rotation: 0
|
|
40
|
+
}),
|
|
41
|
+
render: vi.fn().mockReturnValue({
|
|
42
|
+
promise: Promise.resolve()
|
|
43
|
+
}),
|
|
44
|
+
getTextContent: vi.fn().mockResolvedValue({ items: textItems })
|
|
45
|
+
});
|
|
46
|
+
|
|
19
47
|
// Mock browser-pdfjs module
|
|
20
48
|
vi.mock('../../../lib/browser-pdfjs', () => ({
|
|
21
49
|
loadPdfDocument: vi.fn().mockResolvedValue({
|
|
@@ -29,7 +57,8 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
|
|
|
29
57
|
}),
|
|
30
58
|
render: vi.fn().mockReturnValue({
|
|
31
59
|
promise: Promise.resolve()
|
|
32
|
-
})
|
|
60
|
+
}),
|
|
61
|
+
getTextContent: vi.fn().mockResolvedValue({ items: MOCK_TEXT_ITEMS })
|
|
33
62
|
})
|
|
34
63
|
}),
|
|
35
64
|
renderPdfPageToDataUrl: vi.fn().mockResolvedValue({
|
|
@@ -39,10 +68,78 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
|
|
|
39
68
|
})
|
|
40
69
|
}));
|
|
41
70
|
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* jsdom has no IntersectionObserver. This stub records what the component
|
|
74
|
+
* observes and hands the test the callback, so a "scroll" is an explicit
|
|
75
|
+
* fire() rather than a simulated layout — jsdom has no layout either.
|
|
76
|
+
*/
|
|
77
|
+
function stubIntersectionObserver() {
|
|
78
|
+
// (element, callback) PAIRS, not a map keyed by element: the component runs
|
|
79
|
+
// two observers over the same slots — one widened for preloading, one bare
|
|
80
|
+
// for "what is on screen" — and a map would keep only the last registered.
|
|
81
|
+
const observed: Array<{ el: Element; cb: IntersectionObserverCallback }> = [];
|
|
82
|
+
const margins = new Map<IntersectionObserverCallback, string>();
|
|
83
|
+
class FakeIO {
|
|
84
|
+
constructor(private cb: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
|
85
|
+
margins.set(cb, options?.rootMargin ?? '0px');
|
|
86
|
+
}
|
|
87
|
+
observe(el: Element) { observed.push({ el, cb: this.cb }); }
|
|
88
|
+
unobserve(el: Element) {
|
|
89
|
+
for (let i = observed.length - 1; i >= 0; i--) {
|
|
90
|
+
if (observed[i]!.el === el && observed[i]!.cb === this.cb) observed.splice(i, 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
disconnect() {
|
|
94
|
+
for (let i = observed.length - 1; i >= 0; i--) {
|
|
95
|
+
if (observed[i]!.cb === this.cb) observed.splice(i, 1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
takeRecords() { return []; }
|
|
99
|
+
}
|
|
100
|
+
vi.stubGlobal('IntersectionObserver', FakeIO);
|
|
101
|
+
|
|
102
|
+
/** Fire only the observer that has no preload margin — the one that decides
|
|
103
|
+
* which page the reader is actually looking at. */
|
|
104
|
+
function fireFor(visible: number[], match: (rootMargin: string) => boolean) {
|
|
105
|
+
const byCb = new Map<IntersectionObserverCallback, IntersectionObserverEntry[]>();
|
|
106
|
+
for (const { el, cb } of observed) {
|
|
107
|
+
if (!match(margins.get(cb) ?? '0px')) continue;
|
|
108
|
+
const page = Number((el as HTMLElement).dataset.page);
|
|
109
|
+
byCb.set(cb, [...(byCb.get(cb) ?? []), {
|
|
110
|
+
target: el, isIntersecting: visible.includes(page),
|
|
111
|
+
} as unknown as IntersectionObserverEntry]);
|
|
112
|
+
}
|
|
113
|
+
act(() => { for (const [cb, entries] of byCb) cb(entries, {} as IntersectionObserver); });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Only the bare observer — what the reader can actually see. */
|
|
117
|
+
function fireOnscreen(visible: number[]) {
|
|
118
|
+
fireFor(visible, (m) => m === '0px');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
fireOnscreen,
|
|
123
|
+
/**
|
|
124
|
+
* Fire both observers: these pages are mounted AND on screen — the
|
|
125
|
+
* ordinary case. A test that needs the two to differ (a page preloaded
|
|
126
|
+
* but not yet visible) calls `fireOnscreen` afterwards to narrow it.
|
|
127
|
+
*/
|
|
128
|
+
fire(visible: number[]) {
|
|
129
|
+
fireFor(visible, () => true);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
42
134
|
describe('PdfAnnotationCanvas', () => {
|
|
43
135
|
const mockResourceId = resourceId('123');
|
|
44
136
|
const mockPdfUrl = 'https://example.com/resources/123.pdf';
|
|
45
137
|
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
// Stubs must not leak into other files sharing this Vitest environment.
|
|
140
|
+
vi.unstubAllGlobals();
|
|
141
|
+
});
|
|
142
|
+
|
|
46
143
|
beforeEach(() => {
|
|
47
144
|
vi.clearAllMocks();
|
|
48
145
|
|
|
@@ -313,6 +410,225 @@ describe('PdfAnnotationCanvas', () => {
|
|
|
313
410
|
});
|
|
314
411
|
});
|
|
315
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Drives the drag → mark.request path far enough to inspect the selector.
|
|
415
|
+
* `drag` is in canvas pixels; at scale 1 with a 612×792 page these are also
|
|
416
|
+
* display coordinates, since jsdom's getBoundingClientRect returns zeros.
|
|
417
|
+
*/
|
|
418
|
+
async function drawRectangle(
|
|
419
|
+
drag: { fromX: number; fromY: number; toX: number; toY: number },
|
|
420
|
+
) {
|
|
421
|
+
const request = vi.fn();
|
|
422
|
+
const session = {
|
|
423
|
+
client: { mark: { request }, beckon: { hover: vi.fn() } },
|
|
424
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
425
|
+
|
|
426
|
+
render(
|
|
427
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
428
|
+
pdfUrl={mockPdfUrl}
|
|
429
|
+
drawingMode="rectangle"
|
|
430
|
+
selectedMotivation="highlighting"
|
|
431
|
+
session={session}
|
|
432
|
+
/>
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
await waitFor(() => {
|
|
436
|
+
const img = document.querySelector('.semiont-pdf-annotation-canvas__image');
|
|
437
|
+
expect(img).toBeInTheDocument();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
|
|
441
|
+
Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
|
|
442
|
+
Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
|
|
443
|
+
fireEvent.load(img);
|
|
444
|
+
|
|
445
|
+
const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
|
|
446
|
+
fireEvent.mouseDown(container, { clientX: drag.fromX, clientY: drag.fromY });
|
|
447
|
+
fireEvent.mouseMove(container, { clientX: drag.toX, clientY: drag.toY });
|
|
448
|
+
fireEvent.mouseUp(container, { clientX: drag.toX, clientY: drag.toY });
|
|
449
|
+
|
|
450
|
+
return request;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The manual-annotation capture gap (.plans/PDF-MANUAL-ANNOTATION-TEXT.md):
|
|
454
|
+
// a hand-drawn rectangle stored geometry and nothing else, so every panel
|
|
455
|
+
// that quotes an annotation showed it blank.
|
|
456
|
+
test('a drawn rectangle carries the text it was drawn around', async () => {
|
|
457
|
+
// The "Hello world" line in canvas space: PDF y=700..712 flips to
|
|
458
|
+
// canvas y=80..92 on a 792pt page. Drag a box a little larger than it.
|
|
459
|
+
const line = pdfToCanvasCoordinates(
|
|
460
|
+
{ page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
|
|
461
|
+
);
|
|
462
|
+
const request = await drawRectangle({
|
|
463
|
+
fromX: line.x - 2,
|
|
464
|
+
fromY: line.y - 2,
|
|
465
|
+
toX: line.x + line.width + 8,
|
|
466
|
+
toY: line.y + line.height + 4,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
470
|
+
|
|
471
|
+
const [source, selector, motivation] = request.mock.calls[0];
|
|
472
|
+
expect(source).toBe(resourceId('res-1'));
|
|
473
|
+
expect(motivation).toBe('highlighting');
|
|
474
|
+
expect(selector).toEqual([
|
|
475
|
+
{
|
|
476
|
+
type: 'FragmentSelector',
|
|
477
|
+
conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
|
|
478
|
+
value: expect.stringContaining('page=1&viewrect='),
|
|
479
|
+
},
|
|
480
|
+
{ type: 'TextQuoteSelector', exact: 'Hello world' },
|
|
481
|
+
]);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('a rectangle over blank space carries geometry only', async () => {
|
|
485
|
+
// Between the two lines of text — nothing under the box. An empty-string
|
|
486
|
+
// quote would assert the box was drawn around nothing, so emit no quote.
|
|
487
|
+
const request = await drawRectangle({ fromX: 40, fromY: 120, toX: 200, toY: 150 });
|
|
488
|
+
|
|
489
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
490
|
+
|
|
491
|
+
const selector = request.mock.calls[0][1];
|
|
492
|
+
expect(selector).toHaveLength(1);
|
|
493
|
+
expect(selector[0].type).toBe('FragmentSelector');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Phase 2 of .plans/PDF-MANUAL-ANNOTATION-TEXT.md. A scanned page has no text
|
|
497
|
+
// in the browser, but the server derived one at ingest and serves it through
|
|
498
|
+
// `browse.resourceAnchoredText`. Same `AnchoredText` shape either way, so
|
|
499
|
+
// `textUnder` and the drag handler do not branch — only the source does.
|
|
500
|
+
test('a scanned page quotes from the map the server derived', async () => {
|
|
501
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
502
|
+
numPages: 3,
|
|
503
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
504
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
505
|
+
|
|
506
|
+
const request = vi.fn();
|
|
507
|
+
const session = {
|
|
508
|
+
client: {
|
|
509
|
+
mark: { request },
|
|
510
|
+
beckon: { hover: vi.fn() },
|
|
511
|
+
browse: {
|
|
512
|
+
resourceAnchoredText: vi.fn().mockResolvedValue({
|
|
513
|
+
text: 'Hello world again',
|
|
514
|
+
items: [
|
|
515
|
+
{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
|
|
516
|
+
{ start: 6, end: 11, page: 1, x: 106, y: 700, width: 32, height: 12 },
|
|
517
|
+
],
|
|
518
|
+
}),
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
522
|
+
|
|
523
|
+
render(
|
|
524
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
525
|
+
pdfUrl={mockPdfUrl}
|
|
526
|
+
drawingMode="rectangle"
|
|
527
|
+
selectedMotivation="highlighting"
|
|
528
|
+
session={session}
|
|
529
|
+
/>
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
await waitFor(() => {
|
|
533
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__image')).toBeInTheDocument();
|
|
534
|
+
});
|
|
535
|
+
const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
|
|
536
|
+
Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
|
|
537
|
+
Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
|
|
538
|
+
fireEvent.load(img);
|
|
539
|
+
|
|
540
|
+
const line = pdfToCanvasCoordinates({ page: 1, x: 72, y: 700, width: 66, height: 12 }, 792, 1.0);
|
|
541
|
+
const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
|
|
542
|
+
fireEvent.mouseDown(container, { clientX: line.x - 2, clientY: line.y - 2 });
|
|
543
|
+
fireEvent.mouseMove(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
|
|
544
|
+
fireEvent.mouseUp(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
|
|
545
|
+
|
|
546
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
547
|
+
expect(request.mock.calls[0][1]).toEqual([
|
|
548
|
+
expect.objectContaining({ type: 'FragmentSelector' }),
|
|
549
|
+
{ type: 'TextQuoteSelector', exact: 'Hello world' },
|
|
550
|
+
]);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// PERSIST-ANCHORS P4. The server map is WHOLE-RESOURCE — one artifact
|
|
554
|
+
// covering every page — so fetching it from inside the per-page load
|
|
555
|
+
// effect refetched and re-decoded the entire document's geometry on every
|
|
556
|
+
// page turn. On a 400-page scan that is the difference between one decode
|
|
557
|
+
// and one per interaction.
|
|
558
|
+
test('the whole-resource map is fetched once per document, not once per page-turn', async () => {
|
|
559
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
560
|
+
numPages: 3,
|
|
561
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
562
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
563
|
+
|
|
564
|
+
const resourceAnchoredText = vi.fn().mockResolvedValue({
|
|
565
|
+
text: 'Hello world again',
|
|
566
|
+
items: [
|
|
567
|
+
{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
|
|
568
|
+
{ start: 6, end: 11, page: 2, x: 106, y: 700, width: 32, height: 12 },
|
|
569
|
+
],
|
|
570
|
+
});
|
|
571
|
+
const session = {
|
|
572
|
+
client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
|
|
573
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
574
|
+
|
|
575
|
+
render(
|
|
576
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
577
|
+
pdfUrl={mockPdfUrl}
|
|
578
|
+
drawingMode={null}
|
|
579
|
+
session={session}
|
|
580
|
+
/>
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const user = userEvent.setup();
|
|
584
|
+
await waitFor(() => {
|
|
585
|
+
expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
await user.click(screen.getByRole('button', { name: /next/i }));
|
|
589
|
+
await waitFor(() => {
|
|
590
|
+
expect(screen.getByText(/page 2 of 3/i)).toBeInTheDocument();
|
|
591
|
+
});
|
|
592
|
+
await user.click(screen.getByRole('button', { name: /next/i }));
|
|
593
|
+
await waitFor(() => {
|
|
594
|
+
expect(screen.getByText(/page 3 of 3/i)).toBeInTheDocument();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// The render half runs once per page load and starts alongside the
|
|
598
|
+
// anchoring half (Promise.all), so three render calls prove all three
|
|
599
|
+
// page-load effects ran to the point of resolving their map.
|
|
600
|
+
await waitFor(() => {
|
|
601
|
+
expect(vi.mocked(renderPdfPageToDataUrl)).toHaveBeenCalledTimes(3);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('a scanned page with no server map carries geometry only', async () => {
|
|
608
|
+
// Class B: pdf.js returns no runs, so the browser cannot do the job and
|
|
609
|
+
// the annotation stays geometry-only pending async enrichment (Phase 2).
|
|
610
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
611
|
+
numPages: 3,
|
|
612
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
613
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
614
|
+
|
|
615
|
+
const line = pdfToCanvasCoordinates(
|
|
616
|
+
{ page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
|
|
617
|
+
);
|
|
618
|
+
const request = await drawRectangle({
|
|
619
|
+
fromX: line.x - 2,
|
|
620
|
+
fromY: line.y - 2,
|
|
621
|
+
toX: line.x + line.width + 8,
|
|
622
|
+
toY: line.y + line.height + 4,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
626
|
+
|
|
627
|
+
const selector = request.mock.calls[0][1];
|
|
628
|
+
expect(selector).toHaveLength(1);
|
|
629
|
+
expect(selector[0].type).toBe('FragmentSelector');
|
|
630
|
+
});
|
|
631
|
+
|
|
316
632
|
test('accepts a drawing gesture without throwing when drawing mode is active', async () => {
|
|
317
633
|
render(
|
|
318
634
|
<PdfAnnotationCanvas resourceUri="res-1"
|
|
@@ -344,4 +660,693 @@ describe('PdfAnnotationCanvas', () => {
|
|
|
344
660
|
expect(container).toBeInTheDocument();
|
|
345
661
|
}
|
|
346
662
|
});
|
|
663
|
+
|
|
664
|
+
// PDF-CONTINUOUS-SCROLL S1. Browse mode scrolls: every page has a slot so
|
|
665
|
+
// the scrollbar is honest about the document's length, but only a window of
|
|
666
|
+
// pages is MOUNTED — unmounting is what releases a page's raster, so the
|
|
667
|
+
// window IS the memory budget (D2).
|
|
668
|
+
describe('scroll layout (browse mode)', () => {
|
|
669
|
+
const scannedDoc = () =>
|
|
670
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
671
|
+
numPages: 5,
|
|
672
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
673
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
674
|
+
|
|
675
|
+
const mountedPages = () =>
|
|
676
|
+
[...document.querySelectorAll('.semiont-pdf-annotation-canvas__image')]
|
|
677
|
+
.map((img) => Number((img as HTMLImageElement).alt.replace(/\D+/g, '')))
|
|
678
|
+
.sort((a, b) => a - b);
|
|
679
|
+
|
|
680
|
+
test('gives every page a slot but mounts only the visible window', async () => {
|
|
681
|
+
const io = stubIntersectionObserver();
|
|
682
|
+
scannedDoc();
|
|
683
|
+
|
|
684
|
+
render(
|
|
685
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
686
|
+
pdfUrl={mockPdfUrl}
|
|
687
|
+
drawingMode={null}
|
|
688
|
+
pageLayout="scroll"
|
|
689
|
+
/>
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
await waitFor(() => {
|
|
693
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
694
|
+
});
|
|
695
|
+
// Nothing rasterized before anything is visible.
|
|
696
|
+
expect(mountedPages()).toEqual([]);
|
|
697
|
+
|
|
698
|
+
io.fire([1, 2]);
|
|
699
|
+
await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
|
|
700
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test('releases a page that scrolls out of the window', async () => {
|
|
704
|
+
const io = stubIntersectionObserver();
|
|
705
|
+
scannedDoc();
|
|
706
|
+
|
|
707
|
+
render(
|
|
708
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
709
|
+
pdfUrl={mockPdfUrl}
|
|
710
|
+
drawingMode={null}
|
|
711
|
+
pageLayout="scroll"
|
|
712
|
+
/>
|
|
713
|
+
);
|
|
714
|
+
await waitFor(() => {
|
|
715
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
io.fire([1, 2]);
|
|
719
|
+
await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
|
|
720
|
+
|
|
721
|
+
io.fire([4, 5]);
|
|
722
|
+
await waitFor(() => expect(mountedPages()).toEqual([4, 5]));
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('still fetches the whole-resource map once, across many mounted pages (P4)', async () => {
|
|
726
|
+
// P4's invariant has to survive the move from one shared page-load
|
|
727
|
+
// effect into N independent page views.
|
|
728
|
+
const io = stubIntersectionObserver();
|
|
729
|
+
scannedDoc();
|
|
730
|
+
|
|
731
|
+
const resourceAnchoredText = vi.fn().mockResolvedValue({
|
|
732
|
+
text: 'Hello world again',
|
|
733
|
+
items: [{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 }],
|
|
734
|
+
});
|
|
735
|
+
const session = {
|
|
736
|
+
client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
|
|
737
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
738
|
+
|
|
739
|
+
render(
|
|
740
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
741
|
+
pdfUrl={mockPdfUrl}
|
|
742
|
+
drawingMode={null}
|
|
743
|
+
pageLayout="scroll"
|
|
744
|
+
session={session}
|
|
745
|
+
/>
|
|
746
|
+
);
|
|
747
|
+
await waitFor(() => {
|
|
748
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
io.fire([1, 2, 3]);
|
|
752
|
+
await waitFor(() => expect(mountedPages()).toEqual([1, 2, 3]));
|
|
753
|
+
io.fire([3, 4, 5]);
|
|
754
|
+
await waitFor(() => expect(mountedPages()).toEqual([3, 4, 5]));
|
|
755
|
+
|
|
756
|
+
expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test('a slot keeps its reserved height when its page mounts', async () => {
|
|
760
|
+
// S1b. Releasing the reservation on mount is what made the column's
|
|
761
|
+
// total height change on every scroll — the scrollbar jumped and
|
|
762
|
+
// resized under the cursor. The reservation must survive mounting so
|
|
763
|
+
// the column's geometry never depends on which pages are mounted.
|
|
764
|
+
const io = stubIntersectionObserver();
|
|
765
|
+
scannedDoc();
|
|
766
|
+
|
|
767
|
+
render(
|
|
768
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
769
|
+
pdfUrl={mockPdfUrl}
|
|
770
|
+
drawingMode={null}
|
|
771
|
+
pageLayout="scroll"
|
|
772
|
+
/>
|
|
773
|
+
);
|
|
774
|
+
await waitFor(() => {
|
|
775
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const slotOf = (page: number) =>
|
|
779
|
+
document.querySelector(`.semiont-pdf-annotation-canvas__slot[data-page="${page}"]`) as HTMLElement;
|
|
780
|
+
|
|
781
|
+
// Read whichever property expresses the reservation, so the pin is
|
|
782
|
+
// about the CONTRACT (space is reserved, and mounting does not release
|
|
783
|
+
// it) rather than about which CSS property implements it.
|
|
784
|
+
const reserved = (el: HTMLElement) => el.style.minHeight || el.style.height;
|
|
785
|
+
|
|
786
|
+
io.fire([1]);
|
|
787
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
788
|
+
|
|
789
|
+
// Page 5 is unmounted and must hold space open.
|
|
790
|
+
expect(reserved(slotOf(5))).not.toBe('');
|
|
791
|
+
// Page 1 is mounted and must hold exactly the same space, or the
|
|
792
|
+
// column's height changes as pages come and go.
|
|
793
|
+
expect(reserved(slotOf(1))).toBe(reserved(slotOf(5)));
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('a rectangle drawn on a scrolled page carries THAT page number', async () => {
|
|
797
|
+
// S2. The drag lives in the page view, so page identity comes from the
|
|
798
|
+
// component that owns the pixels rather than from a shared `pageNumber`
|
|
799
|
+
// — the invariant that makes a column safe to draw on at all.
|
|
800
|
+
const io = stubIntersectionObserver();
|
|
801
|
+
scannedDoc();
|
|
802
|
+
|
|
803
|
+
const request = vi.fn();
|
|
804
|
+
const session = {
|
|
805
|
+
client: { mark: { request }, beckon: { hover: vi.fn() } },
|
|
806
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
807
|
+
|
|
808
|
+
render(
|
|
809
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
810
|
+
pdfUrl={mockPdfUrl}
|
|
811
|
+
drawingMode="rectangle"
|
|
812
|
+
selectedMotivation="highlighting"
|
|
813
|
+
session={session}
|
|
814
|
+
pageLayout="scroll"
|
|
815
|
+
/>
|
|
816
|
+
);
|
|
817
|
+
await waitFor(() => {
|
|
818
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
io.fire([2, 3]);
|
|
822
|
+
await waitFor(() => expect(mountedPages()).toEqual([2, 3]));
|
|
823
|
+
|
|
824
|
+
// Draw on page 3's container, not page 2's.
|
|
825
|
+
const slot3 = document.querySelector('.semiont-pdf-annotation-canvas__slot[data-page="3"]')!;
|
|
826
|
+
const img = slot3.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
|
|
827
|
+
Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
|
|
828
|
+
Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
|
|
829
|
+
fireEvent.load(img);
|
|
830
|
+
|
|
831
|
+
const container = slot3.querySelector('.semiont-pdf-annotation-canvas__container')!;
|
|
832
|
+
fireEvent.mouseDown(container, { clientX: 40, clientY: 40 });
|
|
833
|
+
fireEvent.mouseMove(container, { clientX: 200, clientY: 160 });
|
|
834
|
+
fireEvent.mouseUp(container, { clientX: 200, clientY: 160 });
|
|
835
|
+
|
|
836
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
837
|
+
expect(request.mock.calls[0][1][0].value).toContain('page=3');
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test('annotate mode scrolls too — the primary mode is not left on the pager', async () => {
|
|
841
|
+
// The registries are the seam: both now ask for the column.
|
|
842
|
+
const io = stubIntersectionObserver();
|
|
843
|
+
scannedDoc();
|
|
844
|
+
|
|
845
|
+
render(
|
|
846
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
847
|
+
pdfUrl={mockPdfUrl}
|
|
848
|
+
drawingMode="rectangle"
|
|
849
|
+
selectedMotivation="highlighting"
|
|
850
|
+
pageLayout="scroll"
|
|
851
|
+
/>
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
await waitFor(() => {
|
|
855
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
856
|
+
});
|
|
857
|
+
io.fire([1]);
|
|
858
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
859
|
+
// Drawing still works in the column: the container is the page's own.
|
|
860
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__container'))
|
|
861
|
+
.toHaveAttribute('data-drawing-mode', 'rectangle');
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// S4. A strip of proportional rectangles — one per page, current
|
|
865
|
+
// highlighted, click to jump. Deliberately NOT thumbnails: it needs no
|
|
866
|
+
// rasterization, only the page count and the aspect ratio S1b already
|
|
867
|
+
// measures, so it costs nothing next to the document itself. It answers
|
|
868
|
+
// "where am I in this document", which neither the pager nor the
|
|
869
|
+
// scrollbar does.
|
|
870
|
+
test('renders one strip rectangle per page, marking the current one', async () => {
|
|
871
|
+
const io = stubIntersectionObserver();
|
|
872
|
+
scannedDoc();
|
|
873
|
+
|
|
874
|
+
render(
|
|
875
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
876
|
+
pdfUrl={mockPdfUrl}
|
|
877
|
+
drawingMode={null}
|
|
878
|
+
pageLayout="scroll"
|
|
879
|
+
/>
|
|
880
|
+
);
|
|
881
|
+
await waitFor(() => {
|
|
882
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const ticks = () => document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page');
|
|
886
|
+
await waitFor(() => expect(ticks()).toHaveLength(5));
|
|
887
|
+
|
|
888
|
+
io.fire([3, 4]);
|
|
889
|
+
await waitFor(() => {
|
|
890
|
+
expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test('clicking a strip rectangle jumps to that page', async () => {
|
|
895
|
+
const io = stubIntersectionObserver();
|
|
896
|
+
scannedDoc();
|
|
897
|
+
const scrollIntoView = vi.fn();
|
|
898
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
899
|
+
|
|
900
|
+
render(
|
|
901
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
902
|
+
pdfUrl={mockPdfUrl}
|
|
903
|
+
drawingMode={null}
|
|
904
|
+
pageLayout="scroll"
|
|
905
|
+
/>
|
|
906
|
+
);
|
|
907
|
+
await waitFor(() => {
|
|
908
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
909
|
+
});
|
|
910
|
+
io.fire([1]);
|
|
911
|
+
|
|
912
|
+
const tick4 = document.querySelector('.semiont-pdf-annotation-canvas__strip-page[data-page="4"]')!;
|
|
913
|
+
fireEvent.click(tick4);
|
|
914
|
+
|
|
915
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// S1a. Arrow keys step pages. The guards are the substance: a viewer that
|
|
919
|
+
// steals arrow keys from a text field is worse than one with no shortcut.
|
|
920
|
+
test('PageUp/PageDown step pages; Up/Down are left to scroll', async () => {
|
|
921
|
+
// The convention every mainstream viewer follows (Preview, Chrome's PDF
|
|
922
|
+
// viewer, Acrobat): PageUp/PageDown jump a page, Up/Down scroll finely.
|
|
923
|
+
// Taking Up/Down would leave no keyboard way to reach the bottom of a
|
|
924
|
+
// page taller than the viewport.
|
|
925
|
+
const io = stubIntersectionObserver();
|
|
926
|
+
scannedDoc();
|
|
927
|
+
const scrollIntoView = vi.fn();
|
|
928
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
929
|
+
|
|
930
|
+
render(
|
|
931
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
932
|
+
pdfUrl={mockPdfUrl}
|
|
933
|
+
drawingMode={null}
|
|
934
|
+
pageLayout="scroll"
|
|
935
|
+
/>
|
|
936
|
+
);
|
|
937
|
+
await waitFor(() => {
|
|
938
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
939
|
+
});
|
|
940
|
+
io.fire([2]);
|
|
941
|
+
await waitFor(() => expect(mountedPages()).toEqual([2]));
|
|
942
|
+
|
|
943
|
+
scrollIntoView.mockClear();
|
|
944
|
+
fireEvent.keyDown(window, { key: 'PageDown' });
|
|
945
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
946
|
+
|
|
947
|
+
scrollIntoView.mockClear();
|
|
948
|
+
fireEvent.keyDown(window, { key: 'PageUp' });
|
|
949
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
950
|
+
|
|
951
|
+
// Up/Down belong to the scroller, not to us.
|
|
952
|
+
scrollIntoView.mockClear();
|
|
953
|
+
fireEvent.keyDown(window, { key: 'ArrowDown' });
|
|
954
|
+
fireEvent.keyDown(window, { key: 'ArrowUp' });
|
|
955
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
956
|
+
|
|
957
|
+
// The same guard as the horizontal pair: never steal from a text field.
|
|
958
|
+
const input = document.createElement('input');
|
|
959
|
+
document.body.appendChild(input);
|
|
960
|
+
input.focus();
|
|
961
|
+
scrollIntoView.mockClear();
|
|
962
|
+
fireEvent.keyDown(input, { key: 'PageDown' });
|
|
963
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
964
|
+
input.remove();
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test('Left/Right step pages, and never steal keys from a text field', async () => {
|
|
968
|
+
const io = stubIntersectionObserver();
|
|
969
|
+
scannedDoc();
|
|
970
|
+
const scrollIntoView = vi.fn();
|
|
971
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
972
|
+
|
|
973
|
+
render(
|
|
974
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
975
|
+
pdfUrl={mockPdfUrl}
|
|
976
|
+
drawingMode={null}
|
|
977
|
+
pageLayout="scroll"
|
|
978
|
+
/>
|
|
979
|
+
);
|
|
980
|
+
await waitFor(() => {
|
|
981
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
982
|
+
});
|
|
983
|
+
io.fire([1]);
|
|
984
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
985
|
+
|
|
986
|
+
scrollIntoView.mockClear();
|
|
987
|
+
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
|
988
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
989
|
+
|
|
990
|
+
// Typing in an input must not page the document underneath it.
|
|
991
|
+
const input = document.createElement('input');
|
|
992
|
+
document.body.appendChild(input);
|
|
993
|
+
input.focus();
|
|
994
|
+
scrollIntoView.mockClear();
|
|
995
|
+
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
|
996
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
997
|
+
input.remove();
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test('focus follows the current page when the strip owns focus', async () => {
|
|
1001
|
+
// Reported: click a page in the strip, then arrow away — the focus ring
|
|
1002
|
+
// stays behind on the clicked rectangle while the current-page marker
|
|
1003
|
+
// moves, so two rectangles claim to be "here". (The ring only appears
|
|
1004
|
+
// after the arrow press because that is when the browser switches to
|
|
1005
|
+
// keyboard modality and the clicked button starts matching
|
|
1006
|
+
// :focus-visible.) Focus must travel with the current page — but ONLY
|
|
1007
|
+
// when the strip already had it, so scrolling with the mouse never
|
|
1008
|
+
// snatches focus away from whatever the reader was doing.
|
|
1009
|
+
const io = stubIntersectionObserver();
|
|
1010
|
+
scannedDoc();
|
|
1011
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1012
|
+
|
|
1013
|
+
render(
|
|
1014
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1015
|
+
pdfUrl={mockPdfUrl}
|
|
1016
|
+
drawingMode={null}
|
|
1017
|
+
pageLayout="scroll"
|
|
1018
|
+
/>
|
|
1019
|
+
);
|
|
1020
|
+
await waitFor(() => {
|
|
1021
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1022
|
+
});
|
|
1023
|
+
io.fire([1]);
|
|
1024
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
1025
|
+
|
|
1026
|
+
const tick = (page: number) =>
|
|
1027
|
+
document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`) as HTMLButtonElement;
|
|
1028
|
+
|
|
1029
|
+
tick(2).focus();
|
|
1030
|
+
fireEvent.click(tick(2));
|
|
1031
|
+
io.fire([3]);
|
|
1032
|
+
|
|
1033
|
+
await waitFor(() => expect(tick(3)).toHaveAttribute('aria-current', 'page'));
|
|
1034
|
+
expect(document.activeElement).toBe(tick(3));
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test('the strip is one tab stop, not one per page', async () => {
|
|
1038
|
+
// A 400-page document must not put 400 stops in the tab order. The
|
|
1039
|
+
// ARIA pattern: the current item is tabbable, the rest are reachable
|
|
1040
|
+
// with arrows (which already page the document).
|
|
1041
|
+
const io = stubIntersectionObserver();
|
|
1042
|
+
scannedDoc();
|
|
1043
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1044
|
+
|
|
1045
|
+
render(
|
|
1046
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1047
|
+
pdfUrl={mockPdfUrl}
|
|
1048
|
+
drawingMode={null}
|
|
1049
|
+
pageLayout="scroll"
|
|
1050
|
+
/>
|
|
1051
|
+
);
|
|
1052
|
+
await waitFor(() => {
|
|
1053
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1054
|
+
});
|
|
1055
|
+
io.fire([1]);
|
|
1056
|
+
|
|
1057
|
+
const tabbable = [...document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')]
|
|
1058
|
+
.filter((el) => (el as HTMLElement).tabIndex === 0);
|
|
1059
|
+
expect(tabbable).toHaveLength(1);
|
|
1060
|
+
expect(tabbable[0]).toHaveAttribute('data-page', '1');
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test('the strip runs along the same axis as the scrolling, beside the column', async () => {
|
|
1064
|
+
// Pages scroll vertically, so a horizontal strip reads across a
|
|
1065
|
+
// direction the document does not move in. The strip's axis follows the
|
|
1066
|
+
// scroll axis — which is also what makes horizontal scrolling (a later
|
|
1067
|
+
// phase) a change of one value rather than a second layout.
|
|
1068
|
+
const io = stubIntersectionObserver();
|
|
1069
|
+
scannedDoc();
|
|
1070
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1071
|
+
|
|
1072
|
+
render(
|
|
1073
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1074
|
+
pdfUrl={mockPdfUrl}
|
|
1075
|
+
drawingMode={null}
|
|
1076
|
+
pageLayout="scroll"
|
|
1077
|
+
/>
|
|
1078
|
+
);
|
|
1079
|
+
await waitFor(() => {
|
|
1080
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1081
|
+
});
|
|
1082
|
+
io.fire([1]);
|
|
1083
|
+
|
|
1084
|
+
const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
|
|
1085
|
+
const strip = document.querySelector('.semiont-pdf-annotation-canvas__strip')!;
|
|
1086
|
+
|
|
1087
|
+
// One axis, declared in one place, read by both.
|
|
1088
|
+
expect(column).toHaveAttribute('data-axis', 'vertical');
|
|
1089
|
+
expect(strip).toHaveAttribute('data-axis', 'vertical');
|
|
1090
|
+
// Tells assistive tech which arrow keys apply to the strip.
|
|
1091
|
+
expect(strip).toHaveAttribute('aria-orientation', 'vertical');
|
|
1092
|
+
// The strip leads: it hugs the left edge of the content, the
|
|
1093
|
+
// conventional place for a page rail (Preview, Acrobat), and the
|
|
1094
|
+
// reader's eye finds it before the page rather than after it.
|
|
1095
|
+
expect(strip.nextElementSibling).toBe(column);
|
|
1096
|
+
expect(strip.parentElement).toHaveClass('semiont-pdf-annotation-canvas__viewport');
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test('each strip rectangle shows its page number', async () => {
|
|
1100
|
+
// At 70 pages the rectangles are indistinguishable from one another —
|
|
1101
|
+
// the strip shows position but not WHICH page, which is most of what a
|
|
1102
|
+
// reader wants from it on a long document.
|
|
1103
|
+
const io = stubIntersectionObserver();
|
|
1104
|
+
scannedDoc();
|
|
1105
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1106
|
+
|
|
1107
|
+
render(
|
|
1108
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1109
|
+
pdfUrl={mockPdfUrl}
|
|
1110
|
+
drawingMode={null}
|
|
1111
|
+
pageLayout="scroll"
|
|
1112
|
+
/>
|
|
1113
|
+
);
|
|
1114
|
+
await waitFor(() => {
|
|
1115
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1116
|
+
});
|
|
1117
|
+
io.fire([1]);
|
|
1118
|
+
|
|
1119
|
+
const tick = (page: number) =>
|
|
1120
|
+
document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`)!;
|
|
1121
|
+
expect(tick(1)).toHaveTextContent('1');
|
|
1122
|
+
expect(tick(5)).toHaveTextContent('5');
|
|
1123
|
+
|
|
1124
|
+
// The accessible name keeps the context the bare digit loses, and
|
|
1125
|
+
// still contains the visible text (WCAG 2.5.3 Label in Name).
|
|
1126
|
+
expect(tick(5)).toHaveAttribute('aria-label', expect.stringContaining('5'));
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
test('the column does not add a second scrollbar inside the scrolling pane', async () => {
|
|
1131
|
+
// The strip and a nested column scrollbar were two controls for one
|
|
1132
|
+
// movement. The pane the viewer sits in already scrolls (flex: 1;
|
|
1133
|
+
// min-height: 0), so the column must not declare a viewport-relative
|
|
1134
|
+
// height of its own — that is what produced scroll-within-scroll.
|
|
1135
|
+
const io = stubIntersectionObserver();
|
|
1136
|
+
scannedDoc();
|
|
1137
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1138
|
+
|
|
1139
|
+
render(
|
|
1140
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1141
|
+
pdfUrl={mockPdfUrl}
|
|
1142
|
+
drawingMode={null}
|
|
1143
|
+
pageLayout="scroll"
|
|
1144
|
+
/>
|
|
1145
|
+
);
|
|
1146
|
+
await waitFor(() => {
|
|
1147
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1148
|
+
});
|
|
1149
|
+
io.fire([1]);
|
|
1150
|
+
|
|
1151
|
+
// jsdom applies no stylesheet, so assert the CONTRACT the CSS keys off:
|
|
1152
|
+
// the column declares the axis but claims no scroll role of its own.
|
|
1153
|
+
const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
|
|
1154
|
+
expect(column).toHaveAttribute('data-axis', 'vertical');
|
|
1155
|
+
expect(column).not.toHaveAttribute('data-scroller');
|
|
1156
|
+
// The strip is the one that scrolls independently, and silently.
|
|
1157
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__strip'))
|
|
1158
|
+
.toHaveAttribute('data-scroller', 'true');
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
test('the indicator reports a page the reader can SEE, not a preloaded one', async () => {
|
|
1162
|
+
// The mount window deliberately reaches a viewport beyond the view
|
|
1163
|
+
// (PRELOAD_MARGIN) so pages arrive early. Deriving the current page
|
|
1164
|
+
// from that same set makes the indicator name a page that is still
|
|
1165
|
+
// off-screen — it must come from actual visibility.
|
|
1166
|
+
const io = stubIntersectionObserver();
|
|
1167
|
+
scannedDoc();
|
|
1168
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1169
|
+
|
|
1170
|
+
render(
|
|
1171
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1172
|
+
drawingMode={null} pageLayout="scroll" />
|
|
1173
|
+
);
|
|
1174
|
+
await waitFor(() => {
|
|
1175
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Pages 1-3 are within the preload window; only 3 is actually on screen.
|
|
1179
|
+
io.fire([1, 2, 3]);
|
|
1180
|
+
io.fireOnscreen([3]);
|
|
1181
|
+
|
|
1182
|
+
await waitFor(() => {
|
|
1183
|
+
expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
test('paged navigation does not carry one page\'s state onto the next', async () => {
|
|
1188
|
+
// In the paged layout a single PdfPageView is reused as `pageNumber`
|
|
1189
|
+
// changes, so its state survives the switch: the previous page's raster
|
|
1190
|
+
// shows briefly under the new page's overlay, a drag could quote the
|
|
1191
|
+
// previous page's text, and a load failure sticks forever because
|
|
1192
|
+
// nothing clears it. Each page is a different thing and must mount as one.
|
|
1193
|
+
// Reject by PAGE, not by call order: the parent also calls getPage(1)
|
|
1194
|
+
// to measure the document's shape, so a `...Once` rejection would be
|
|
1195
|
+
// consumed there and never reach the page view.
|
|
1196
|
+
const failingDoc = {
|
|
1197
|
+
numPages: 3,
|
|
1198
|
+
getPage: vi.fn((n: number) => (n === 1
|
|
1199
|
+
? Promise.reject(new Error('page 1 is broken'))
|
|
1200
|
+
: Promise.resolve(mockPage(MOCK_TEXT_ITEMS)))),
|
|
1201
|
+
};
|
|
1202
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce(
|
|
1203
|
+
failingDoc as unknown as Awaited<ReturnType<typeof loadPdfDocument>>,
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
render(
|
|
1207
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
await waitFor(() => {
|
|
1211
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).toBeInTheDocument();
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
|
1215
|
+
|
|
1216
|
+
// Page 2 loads fine; the failure belonged to page 1 and must not persist.
|
|
1217
|
+
await waitFor(() => {
|
|
1218
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).not.toBeInTheDocument();
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
test('without IntersectionObserver every page mounts — degraded, not broken', async () => {
|
|
1223
|
+
// The virtualization is an optimization, not a correctness requirement.
|
|
1224
|
+
// An environment without the observer must show the whole document
|
|
1225
|
+
// rather than nothing.
|
|
1226
|
+
scannedDoc();
|
|
1227
|
+
vi.stubGlobal('IntersectionObserver', undefined);
|
|
1228
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1229
|
+
|
|
1230
|
+
render(
|
|
1231
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1232
|
+
drawingMode={null} pageLayout="scroll" />
|
|
1233
|
+
);
|
|
1234
|
+
|
|
1235
|
+
await waitFor(() => {
|
|
1236
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__image')).toHaveLength(5);
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
test('a failed map fetch is not cached — the next page retries', async () => {
|
|
1241
|
+
// Answers cache, including "no map". A transport failure must not, or
|
|
1242
|
+
// one bad moment pins the whole document to geometry-only.
|
|
1243
|
+
const io = stubIntersectionObserver();
|
|
1244
|
+
scannedDoc();
|
|
1245
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1246
|
+
|
|
1247
|
+
const resourceAnchoredText = vi.fn()
|
|
1248
|
+
.mockRejectedValueOnce(new Error('transport down'))
|
|
1249
|
+
.mockResolvedValue({ text: 'later', items: [] });
|
|
1250
|
+
const session = {
|
|
1251
|
+
client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
|
|
1252
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
1253
|
+
|
|
1254
|
+
render(
|
|
1255
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1256
|
+
drawingMode={null} pageLayout="scroll" session={session} />
|
|
1257
|
+
);
|
|
1258
|
+
await waitFor(() => {
|
|
1259
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
io.fire([1]);
|
|
1263
|
+
await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(1));
|
|
1264
|
+
|
|
1265
|
+
io.fire([2]);
|
|
1266
|
+
await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(2));
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
test('the pager still works in the column, and modifiers are left alone', async () => {
|
|
1270
|
+
const io = stubIntersectionObserver();
|
|
1271
|
+
scannedDoc();
|
|
1272
|
+
const scrollIntoView = vi.fn();
|
|
1273
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
1274
|
+
|
|
1275
|
+
render(
|
|
1276
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1277
|
+
drawingMode={null} pageLayout="scroll" />
|
|
1278
|
+
);
|
|
1279
|
+
await waitFor(() => {
|
|
1280
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1281
|
+
});
|
|
1282
|
+
io.fire([3]);
|
|
1283
|
+
await waitFor(() => {
|
|
1284
|
+
expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
scrollIntoView.mockClear();
|
|
1288
|
+
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
|
1289
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
1290
|
+
|
|
1291
|
+
// Cmd/Ctrl/Alt + arrow belongs to the browser and the OS.
|
|
1292
|
+
scrollIntoView.mockClear();
|
|
1293
|
+
fireEvent.keyDown(window, { key: 'ArrowRight', metaKey: true });
|
|
1294
|
+
fireEvent.keyDown(window, { key: 'PageDown', ctrlKey: true });
|
|
1295
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test('keeps the paged layout when no layout is requested', async () => {
|
|
1299
|
+
render(
|
|
1300
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1301
|
+
pdfUrl={mockPdfUrl}
|
|
1302
|
+
drawingMode="rectangle"
|
|
1303
|
+
selectedMotivation="highlighting"
|
|
1304
|
+
/>
|
|
1305
|
+
);
|
|
1306
|
+
|
|
1307
|
+
await waitFor(() => {
|
|
1308
|
+
expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
|
|
1309
|
+
});
|
|
1310
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(0);
|
|
1311
|
+
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// PDF-CONTINUOUS-SCROLL S3. The viewer chrome was the last hardcoded-English
|
|
1316
|
+
// surface in this component: Previous/Next, the page indicator, and both
|
|
1317
|
+
// failure lines. The mock translation manager echoes "<namespace>.<key>",
|
|
1318
|
+
// so asserting the echo proves the string came from translations rather
|
|
1319
|
+
// than from a literal that happens to read the same in English.
|
|
1320
|
+
describe('viewer chrome', () => {
|
|
1321
|
+
const renderTranslated = (ui: React.ReactElement) =>
|
|
1322
|
+
render(<TranslationProvider translationManager={defaultMocks.translationManager}>{ui}</TranslationProvider>);
|
|
1323
|
+
|
|
1324
|
+
test('takes its controls and page indicator from translations', async () => {
|
|
1325
|
+
renderTranslated(
|
|
1326
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
await waitFor(() => {
|
|
1330
|
+
expect(screen.getByRole('button', { name: 'PdfViewer.previous' })).toBeInTheDocument();
|
|
1331
|
+
});
|
|
1332
|
+
expect(screen.getByRole('button', { name: 'PdfViewer.next' })).toBeInTheDocument();
|
|
1333
|
+
expect(screen.getByText(/PdfViewer\.pageOf/)).toBeInTheDocument();
|
|
1334
|
+
expect(screen.queryByText('Previous')).not.toBeInTheDocument();
|
|
1335
|
+
expect(screen.queryByText(/^Page 1 of 3$/)).not.toBeInTheDocument();
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test('announces page changes — a silent indicator is invisible to a screen reader', async () => {
|
|
1339
|
+
renderTranslated(
|
|
1340
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
|
|
1341
|
+
);
|
|
1342
|
+
|
|
1343
|
+
await waitFor(() => {
|
|
1344
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__page-info')).toBeInTheDocument();
|
|
1345
|
+
});
|
|
1346
|
+
const indicator = document.querySelector('.semiont-pdf-annotation-canvas__page-info')!;
|
|
1347
|
+
expect(indicator).toHaveAttribute('aria-live', 'polite');
|
|
1348
|
+
// The pager is a navigation landmark, not loose buttons in the page.
|
|
1349
|
+
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
1350
|
+
});
|
|
1351
|
+
});
|
|
347
1352
|
});
|