@semiont/react-ui 0.5.25 → 0.5.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -4
- package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js +677 -0
- package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js.map +1 -0
- package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css +185 -0
- package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css.map +1 -0
- package/dist/{ar-CULRHLNG.js → ar-OU3DQGC6.js} +65 -7
- package/dist/ar-OU3DQGC6.js.map +1 -0
- package/dist/{bn-HYR7CEL2.js → bn-PHWCR6AZ.js} +65 -7
- package/dist/bn-PHWCR6AZ.js.map +1 -0
- package/dist/chunk-5VDTWRXJ.js +287 -0
- package/dist/chunk-5VDTWRXJ.js.map +1 -0
- package/dist/{chunk-TG2XQEC4.js → chunk-BTUG3VJJ.js} +18 -298
- package/dist/chunk-BTUG3VJJ.js.map +1 -0
- package/dist/{chunk-NH6L2GIL.js → chunk-PCRRBU7H.js} +65 -7
- package/dist/chunk-PCRRBU7H.js.map +1 -0
- package/dist/{cs-YBRWY7NH.js → cs-NEXYZ62I.js} +65 -7
- package/dist/cs-NEXYZ62I.js.map +1 -0
- package/dist/{da-PHDEOBV3.js → da-FAT43YCU.js} +65 -7
- package/dist/da-FAT43YCU.js.map +1 -0
- package/dist/{de-O4UCCVLJ.js → de-KRQ3WBCU.js} +65 -7
- package/dist/de-KRQ3WBCU.js.map +1 -0
- package/dist/{el-MVGO6WZV.js → el-Q4IU3EKC.js} +65 -7
- package/dist/el-Q4IU3EKC.js.map +1 -0
- package/dist/{en-2KQILFKQ.js → en-2KADE7N4.js} +6 -2
- package/dist/{es-USSLXQY7.js → es-2IO3R2MK.js} +65 -7
- package/dist/es-2IO3R2MK.js.map +1 -0
- package/dist/{fa-WMAREFWY.js → fa-Q5GOQFXX.js} +65 -7
- package/dist/fa-Q5GOQFXX.js.map +1 -0
- package/dist/{fi-B3NBNOTS.js → fi-R6NMRWBE.js} +65 -7
- package/dist/fi-R6NMRWBE.js.map +1 -0
- package/dist/{fr-TXYK5T5W.js → fr-QY4D5E6J.js} +65 -7
- package/dist/fr-QY4D5E6J.js.map +1 -0
- package/dist/{he-JCJACW5D.js → he-X6Y77XHY.js} +65 -7
- package/dist/he-X6Y77XHY.js.map +1 -0
- package/dist/{hi-VOQLZNJU.js → hi-XL7IOEQM.js} +65 -7
- package/dist/hi-XL7IOEQM.js.map +1 -0
- package/dist/{id-BFN5RZNZ.js → id-QZ4ZZS67.js} +65 -7
- package/dist/id-QZ4ZZS67.js.map +1 -0
- package/dist/index.css +241 -508
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +155 -54
- package/dist/index.js +1815 -1642
- package/dist/index.js.map +1 -1
- package/dist/integrations/css-modules-helper.js +1 -3
- package/dist/integrations/css-modules-helper.js.map +1 -1
- package/dist/{it-TWWDHKKM.js → it-ME2Q7OB6.js} +65 -7
- package/dist/it-ME2Q7OB6.js.map +1 -0
- package/dist/{ja-YRQLEBFM.js → ja-HX7QASRI.js} +65 -7
- package/dist/ja-HX7QASRI.js.map +1 -0
- package/dist/{ko-PG6B3CRB.js → ko-EQO5AHAV.js} +65 -7
- package/dist/ko-EQO5AHAV.js.map +1 -0
- package/dist/{ms-7EZECVKM.js → ms-UILFSF3I.js} +65 -7
- package/dist/ms-UILFSF3I.js.map +1 -0
- package/dist/{nl-HP753YAP.js → nl-NNI45PMK.js} +65 -7
- package/dist/nl-NNI45PMK.js.map +1 -0
- package/dist/{no-JUD5WX3N.js → no-MAL5F6P6.js} +65 -7
- package/dist/no-MAL5F6P6.js.map +1 -0
- package/dist/{pl-RVUL4CBM.js → pl-4RMY6JAJ.js} +65 -7
- package/dist/pl-4RMY6JAJ.js.map +1 -0
- package/dist/{pt-RPOVD4QM.js → pt-VQH2X3QQ.js} +65 -7
- package/dist/pt-VQH2X3QQ.js.map +1 -0
- package/dist/{ro-DMYWQTRD.js → ro-XXCQO2OG.js} +65 -7
- package/dist/ro-XXCQO2OG.js.map +1 -0
- package/dist/{sv-DMZ4FGXD.js → sv-7Z6EJ6U4.js} +65 -7
- package/dist/sv-7Z6EJ6U4.js.map +1 -0
- package/dist/test-utils.css +1 -1
- package/dist/test-utils.css.map +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/dist/test-utils.js +5 -3
- package/dist/test-utils.js.map +1 -1
- package/dist/{th-4HVBJBQ2.js → th-L3DXXYVQ.js} +65 -7
- package/dist/th-L3DXXYVQ.js.map +1 -0
- package/dist/{tr-6BDPU5ML.js → tr-P3DFVLFJ.js} +65 -7
- package/dist/tr-P3DFVLFJ.js.map +1 -0
- package/dist/{uk-TBZBT2TL.js → uk-7C5D3PXS.js} +65 -7
- package/dist/uk-7C5D3PXS.js.map +1 -0
- package/dist/{vi-UMKZKZL3.js → vi-ZLTLEO7P.js} +65 -7
- package/dist/vi-ZLTLEO7P.js.map +1 -0
- package/dist/{zh-YBJWJJYB.js → zh-5FHVWDIN.js} +65 -7
- package/dist/zh-5FHVWDIN.js.map +1 -0
- package/package.json +7 -7
- package/src/components/AssistProgress.tsx +144 -134
- package/src/components/{EntityFoundLog.tsx → ItemFoundLog.tsx} +12 -7
- package/src/components/ProtectedErrorBoundary.css +3 -1
- package/src/components/StatusDisplay.css +35 -35
- package/src/components/Toast.css +3 -1
- package/src/components/__tests__/AssistProgress.test.tsx +216 -148
- package/src/components/annotation/annotation-entries.css +0 -4
- package/src/components/annotation/annotations.css +1 -1
- package/src/components/annotation/references.css +0 -4
- package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
- package/src/components/image-annotation/SvgDrawingCanvas.tsx +1 -1
- package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +7 -9
- package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +1 -2
- package/src/components/layout/Header.css +0 -4
- package/src/components/layout/LeftSidebar.css +0 -4
- package/src/components/modals/ConfigureGenerationStep.tsx +109 -48
- package/src/components/modals/ConfigureSearchStep.tsx +23 -40
- package/src/components/modals/ReferenceWizardModal.tsx +55 -16
- package/src/components/modals/ResourceGenerateModal.tsx +24 -8
- package/src/components/modals/ResourceSearchModal.tsx +1 -1
- package/src/components/modals/SearchModal.css +5 -17
- package/src/components/modals/SearchModal.tsx +1 -1
- package/src/components/modals/SearchResultsStep.tsx +2 -20
- package/src/components/modals/WizardFooter.tsx +63 -0
- package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +255 -0
- package/src/components/modals/__tests__/ConfigureSearchStep.test.tsx +72 -0
- package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +256 -0
- package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +1 -0
- package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +6 -2
- package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +6 -2
- package/src/components/modals/__tests__/WizardFooter.test.tsx +235 -0
- package/src/components/modals/modals.css +16 -42
- package/src/components/navigation/NavigationTabs.css +0 -20
- package/src/components/pdf-annotation/PdfAnnotationCanvas.css +162 -6
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +623 -232
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +764 -7
- 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 +16 -5
- package/src/components/resource/AnnotationHistory.tsx +2 -0
- package/src/components/resource/BrowseView.tsx +15 -4
- package/src/components/resource/ResourceViewer.tsx +23 -24
- package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +50 -0
- package/src/components/resource/__tests__/AnnotationHistory.test.tsx +2 -0
- package/src/components/resource/__tests__/BrowseView.test.tsx +29 -3
- package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +47 -3
- package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +33 -0
- package/src/components/resource/annotate-renderers.tsx +10 -1
- package/src/components/resource/browse-renderers.tsx +10 -1
- package/src/components/resource/event-formatting.ts +0 -3
- package/src/components/resource/panels/AssessmentEntry.tsx +1 -1
- package/src/components/resource/panels/AssessmentPanel.css +5 -6
- package/src/components/resource/panels/AssistSection.css +68 -271
- package/src/components/resource/panels/AssistSection.tsx +4 -4
- package/src/components/resource/panels/AssistShell.tsx +12 -4
- package/src/components/resource/panels/CollaborationPanel.css +47 -22
- package/src/components/resource/panels/CollaborationPanel.tsx +93 -2
- package/src/components/resource/panels/CommentEntry.tsx +1 -1
- package/src/components/resource/panels/CommentsPanel.css +0 -16
- package/src/components/resource/panels/HighlightEntry.tsx +1 -1
- package/src/components/resource/panels/HighlightPanel.css +0 -7
- package/src/components/resource/panels/JsonLdPanel.css +0 -4
- package/src/components/resource/panels/ReferenceEntry.tsx +1 -1
- package/src/components/resource/panels/ReferencesPanel.css +0 -12
- package/src/components/resource/panels/ReferencesPanel.tsx +12 -20
- package/src/components/resource/panels/ResourceInfoPanel.css +0 -32
- package/src/components/resource/panels/ResourceInfoPanel.tsx +1 -1
- package/src/components/resource/panels/StatisticsPanel.css +0 -20
- package/src/components/resource/panels/TagEntry.tsx +1 -1
- package/src/components/resource/panels/TaggingPanel.css +0 -4
- package/src/components/resource/panels/TaggingPanel.tsx +2 -10
- package/src/components/resource/panels/UnifiedAnnotationsPanel.css +0 -4
- package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +0 -1
- package/src/components/resource/panels/__tests__/AssistSection.test.tsx +48 -50
- package/src/components/resource/panels/__tests__/AssistShell.test.tsx +11 -6
- package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +89 -0
- package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +1 -2
- package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +0 -1
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +44 -36
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +0 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +1 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +34 -53
- package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +37 -1
- package/src/components/resource/panels/__tests__/TagEntry.test.tsx +0 -1
- package/src/components/settings/SettingsPanel.css +0 -4
- package/src/features/auth/auth.css +9 -33
- package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +25 -1
- package/src/features/resource-compose/state/compose-page-state-unit.ts +11 -2
- package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +55 -0
- package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +29 -0
- package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +53 -3
- package/src/features/resource-discovery/state/discover-state-unit.ts +29 -8
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +26 -0
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +42 -21
- package/src/features/resource-viewer/hooks/__tests__/useResourceViewedReport.test.tsx +63 -0
- package/src/features/resource-viewer/hooks/useResourceViewedReport.ts +33 -0
- package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +2 -2
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +1 -1
- package/src/styles/core/buttons.css +3 -1
- package/src/styles/core/forms.css +0 -21
- package/src/styles/core/indicators.css +0 -4
- package/src/styles/core/inputs.css +1 -5
- package/src/styles/core/progress.css +2 -34
- package/src/styles/core/sliders.css +0 -4
- package/src/styles/core/toggles.css +0 -4
- package/src/styles/features/compose.css +1 -1
- package/src/styles/features/resource-discovery.css +13 -0
- package/src/styles/features/resource-viewer.css +1 -21
- package/src/styles/features/static-pages.css +11 -11
- package/src/styles/motivations/motivation-assessment.css +2 -2
- package/src/styles/motivations/motivation-comment.css +2 -2
- package/src/styles/motivations/motivation-reference.css +2 -2
- package/src/styles/panels/history-panel.css +0 -40
- package/src/styles/panels/user-panel.css +0 -3
- package/src/styles/patterns/panel-helpers.css +0 -27
- package/src/styles/patterns/panel-sections.css +0 -59
- package/src/styles/patterns/panels-base.css +4 -36
- package/src/styles/utilities/motion.css +15 -15
- package/src/styles/variables.css +29 -1
- package/translations/ar.json +59 -5
- package/translations/bn.json +59 -5
- package/translations/cs.json +59 -5
- package/translations/da.json +59 -5
- package/translations/de.json +59 -5
- package/translations/el.json +59 -5
- package/translations/en.json +59 -5
- package/translations/es.json +59 -5
- package/translations/fa.json +59 -5
- package/translations/fi.json +59 -5
- package/translations/fr.json +59 -5
- package/translations/he.json +59 -5
- package/translations/hi.json +59 -5
- package/translations/id.json +59 -5
- package/translations/it.json +59 -5
- package/translations/ja.json +59 -5
- package/translations/ko.json +59 -5
- package/translations/ms.json +59 -5
- package/translations/nl.json +59 -5
- package/translations/no.json +59 -5
- package/translations/pl.json +59 -5
- package/translations/pt.json +59 -5
- package/translations/ro.json +59 -5
- package/translations/sv.json +59 -5
- package/translations/th.json +59 -5
- package/translations/tr.json +59 -5
- package/translations/uk.json +59 -5
- package/translations/vi.json +59 -5
- package/translations/zh.json +59 -5
- package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js +0 -462
- package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js.map +0 -1
- package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css +0 -98
- package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css.map +0 -1
- package/dist/ar-CULRHLNG.js.map +0 -1
- package/dist/bn-HYR7CEL2.js.map +0 -1
- package/dist/chunk-NH6L2GIL.js.map +0 -1
- package/dist/chunk-TG2XQEC4.js.map +0 -1
- package/dist/chunk-XUDKYAVC.js +0 -21
- package/dist/chunk-XUDKYAVC.js.map +0 -1
- package/dist/cs-YBRWY7NH.js.map +0 -1
- package/dist/da-PHDEOBV3.js.map +0 -1
- package/dist/de-O4UCCVLJ.js.map +0 -1
- package/dist/el-MVGO6WZV.js.map +0 -1
- package/dist/es-USSLXQY7.js.map +0 -1
- package/dist/fa-WMAREFWY.js.map +0 -1
- package/dist/fi-B3NBNOTS.js.map +0 -1
- package/dist/fr-TXYK5T5W.js.map +0 -1
- package/dist/he-JCJACW5D.js.map +0 -1
- package/dist/hi-VOQLZNJU.js.map +0 -1
- package/dist/id-BFN5RZNZ.js.map +0 -1
- package/dist/it-TWWDHKKM.js.map +0 -1
- package/dist/ja-YRQLEBFM.js.map +0 -1
- package/dist/ko-PG6B3CRB.js.map +0 -1
- package/dist/ms-7EZECVKM.js.map +0 -1
- package/dist/nl-HP753YAP.js.map +0 -1
- package/dist/no-JUD5WX3N.js.map +0 -1
- package/dist/pl-RVUL4CBM.js.map +0 -1
- package/dist/pt-RPOVD4QM.js.map +0 -1
- package/dist/ro-DMYWQTRD.js.map +0 -1
- package/dist/sv-DMZ4FGXD.js.map +0 -1
- package/dist/th-4HVBJBQ2.js.map +0 -1
- package/dist/tr-6BDPU5ML.js.map +0 -1
- package/dist/uk-TBZBT2TL.js.map +0 -1
- package/dist/vi-UMKZKZL3.js.map +0 -1
- package/dist/zh-YBJWJJYB.js.map +0 -1
- package/src/integrations/tailwind-plugin.cjs +0 -224
- /package/dist/{en-2KQILFKQ.js.map → en-2KADE7N4.js.map} +0 -0
|
@@ -7,10 +7,12 @@
|
|
|
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';
|
|
16
18
|
import { loadPdfDocument, renderPdfPageToDataUrl } from '../../../lib/browser-pdfjs';
|
|
@@ -66,10 +68,78 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
|
|
|
66
68
|
})
|
|
67
69
|
}));
|
|
68
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
|
+
|
|
69
134
|
describe('PdfAnnotationCanvas', () => {
|
|
70
135
|
const mockResourceId = resourceId('123');
|
|
71
136
|
const mockPdfUrl = 'https://example.com/resources/123.pdf';
|
|
72
137
|
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
// Stubs must not leak into other files sharing this Vitest environment.
|
|
140
|
+
vi.unstubAllGlobals();
|
|
141
|
+
});
|
|
142
|
+
|
|
73
143
|
beforeEach(() => {
|
|
74
144
|
vi.clearAllMocks();
|
|
75
145
|
|
|
@@ -205,7 +275,7 @@ describe('PdfAnnotationCanvas', () => {
|
|
|
205
275
|
});
|
|
206
276
|
});
|
|
207
277
|
|
|
208
|
-
test('passes the annotation rect as browse.click
|
|
278
|
+
test('passes the annotation rect as browse.click second argument (A1 anchor)', async () => {
|
|
209
279
|
const click = vi.fn();
|
|
210
280
|
const session = {
|
|
211
281
|
client: { browse: { click }, beckon: { hover: vi.fn() } },
|
|
@@ -258,8 +328,7 @@ describe('PdfAnnotationCanvas', () => {
|
|
|
258
328
|
|
|
259
329
|
expect(click).toHaveBeenCalledTimes(1);
|
|
260
330
|
expect(click.mock.calls[0]?.[0]).toBe('ann-1');
|
|
261
|
-
|
|
262
|
-
const anchorRect = click.mock.calls[0]?.[2];
|
|
331
|
+
const anchorRect = click.mock.calls[0]?.[1];
|
|
263
332
|
expect(anchorRect).toBeDefined();
|
|
264
333
|
expect(typeof anchorRect.width).toBe('number');
|
|
265
334
|
});
|
|
@@ -331,8 +400,7 @@ describe('PdfAnnotationCanvas', () => {
|
|
|
331
400
|
|
|
332
401
|
expect(click).toHaveBeenCalledTimes(1);
|
|
333
402
|
expect(click.mock.calls[0]?.[0]).toBe('ann-hit-1');
|
|
334
|
-
expect(click.mock.calls[0]?.[1]).
|
|
335
|
-
expect(click.mock.calls[0]?.[2]).toMatchObject({
|
|
403
|
+
expect(click.mock.calls[0]?.[1]).toMatchObject({
|
|
336
404
|
left: displayRect.x,
|
|
337
405
|
top: displayRect.y,
|
|
338
406
|
width: displayRect.width,
|
|
@@ -590,4 +658,693 @@ describe('PdfAnnotationCanvas', () => {
|
|
|
590
658
|
expect(container).toBeInTheDocument();
|
|
591
659
|
}
|
|
592
660
|
});
|
|
661
|
+
|
|
662
|
+
// PDF-CONTINUOUS-SCROLL S1. Browse mode scrolls: every page has a slot so
|
|
663
|
+
// the scrollbar is honest about the document's length, but only a window of
|
|
664
|
+
// pages is MOUNTED — unmounting is what releases a page's raster, so the
|
|
665
|
+
// window IS the memory budget (D2).
|
|
666
|
+
describe('scroll layout (browse mode)', () => {
|
|
667
|
+
const scannedDoc = () =>
|
|
668
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce({
|
|
669
|
+
numPages: 5,
|
|
670
|
+
getPage: vi.fn().mockResolvedValue(mockPage([])),
|
|
671
|
+
} as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
|
|
672
|
+
|
|
673
|
+
const mountedPages = () =>
|
|
674
|
+
[...document.querySelectorAll('.semiont-pdf-annotation-canvas__image')]
|
|
675
|
+
.map((img) => Number((img as HTMLImageElement).alt.replace(/\D+/g, '')))
|
|
676
|
+
.sort((a, b) => a - b);
|
|
677
|
+
|
|
678
|
+
test('gives every page a slot but mounts only the visible window', async () => {
|
|
679
|
+
const io = stubIntersectionObserver();
|
|
680
|
+
scannedDoc();
|
|
681
|
+
|
|
682
|
+
render(
|
|
683
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
684
|
+
pdfUrl={mockPdfUrl}
|
|
685
|
+
drawingMode={null}
|
|
686
|
+
pageLayout="scroll"
|
|
687
|
+
/>
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
await waitFor(() => {
|
|
691
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
692
|
+
});
|
|
693
|
+
// Nothing rasterized before anything is visible.
|
|
694
|
+
expect(mountedPages()).toEqual([]);
|
|
695
|
+
|
|
696
|
+
io.fire([1, 2]);
|
|
697
|
+
await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
|
|
698
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test('releases a page that scrolls out of the window', async () => {
|
|
702
|
+
const io = stubIntersectionObserver();
|
|
703
|
+
scannedDoc();
|
|
704
|
+
|
|
705
|
+
render(
|
|
706
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
707
|
+
pdfUrl={mockPdfUrl}
|
|
708
|
+
drawingMode={null}
|
|
709
|
+
pageLayout="scroll"
|
|
710
|
+
/>
|
|
711
|
+
);
|
|
712
|
+
await waitFor(() => {
|
|
713
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
io.fire([1, 2]);
|
|
717
|
+
await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
|
|
718
|
+
|
|
719
|
+
io.fire([4, 5]);
|
|
720
|
+
await waitFor(() => expect(mountedPages()).toEqual([4, 5]));
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('still fetches the whole-resource map once, across many mounted pages (P4)', async () => {
|
|
724
|
+
// P4's invariant has to survive the move from one shared page-load
|
|
725
|
+
// effect into N independent page views.
|
|
726
|
+
const io = stubIntersectionObserver();
|
|
727
|
+
scannedDoc();
|
|
728
|
+
|
|
729
|
+
const resourceAnchoredText = vi.fn().mockResolvedValue({
|
|
730
|
+
text: 'Hello world again',
|
|
731
|
+
items: [{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 }],
|
|
732
|
+
});
|
|
733
|
+
const session = {
|
|
734
|
+
client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
|
|
735
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
736
|
+
|
|
737
|
+
render(
|
|
738
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
739
|
+
pdfUrl={mockPdfUrl}
|
|
740
|
+
drawingMode={null}
|
|
741
|
+
pageLayout="scroll"
|
|
742
|
+
session={session}
|
|
743
|
+
/>
|
|
744
|
+
);
|
|
745
|
+
await waitFor(() => {
|
|
746
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
io.fire([1, 2, 3]);
|
|
750
|
+
await waitFor(() => expect(mountedPages()).toEqual([1, 2, 3]));
|
|
751
|
+
io.fire([3, 4, 5]);
|
|
752
|
+
await waitFor(() => expect(mountedPages()).toEqual([3, 4, 5]));
|
|
753
|
+
|
|
754
|
+
expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test('a slot keeps its reserved height when its page mounts', async () => {
|
|
758
|
+
// S1b. Releasing the reservation on mount is what made the column's
|
|
759
|
+
// total height change on every scroll — the scrollbar jumped and
|
|
760
|
+
// resized under the cursor. The reservation must survive mounting so
|
|
761
|
+
// the column's geometry never depends on which pages are mounted.
|
|
762
|
+
const io = stubIntersectionObserver();
|
|
763
|
+
scannedDoc();
|
|
764
|
+
|
|
765
|
+
render(
|
|
766
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
767
|
+
pdfUrl={mockPdfUrl}
|
|
768
|
+
drawingMode={null}
|
|
769
|
+
pageLayout="scroll"
|
|
770
|
+
/>
|
|
771
|
+
);
|
|
772
|
+
await waitFor(() => {
|
|
773
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const slotOf = (page: number) =>
|
|
777
|
+
document.querySelector(`.semiont-pdf-annotation-canvas__slot[data-page="${page}"]`) as HTMLElement;
|
|
778
|
+
|
|
779
|
+
// Read whichever property expresses the reservation, so the pin is
|
|
780
|
+
// about the CONTRACT (space is reserved, and mounting does not release
|
|
781
|
+
// it) rather than about which CSS property implements it.
|
|
782
|
+
const reserved = (el: HTMLElement) => el.style.minHeight || el.style.height;
|
|
783
|
+
|
|
784
|
+
io.fire([1]);
|
|
785
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
786
|
+
|
|
787
|
+
// Page 5 is unmounted and must hold space open.
|
|
788
|
+
expect(reserved(slotOf(5))).not.toBe('');
|
|
789
|
+
// Page 1 is mounted and must hold exactly the same space, or the
|
|
790
|
+
// column's height changes as pages come and go.
|
|
791
|
+
expect(reserved(slotOf(1))).toBe(reserved(slotOf(5)));
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test('a rectangle drawn on a scrolled page carries THAT page number', async () => {
|
|
795
|
+
// S2. The drag lives in the page view, so page identity comes from the
|
|
796
|
+
// component that owns the pixels rather than from a shared `pageNumber`
|
|
797
|
+
// — the invariant that makes a column safe to draw on at all.
|
|
798
|
+
const io = stubIntersectionObserver();
|
|
799
|
+
scannedDoc();
|
|
800
|
+
|
|
801
|
+
const request = vi.fn();
|
|
802
|
+
const session = {
|
|
803
|
+
client: { mark: { request }, beckon: { hover: vi.fn() } },
|
|
804
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
805
|
+
|
|
806
|
+
render(
|
|
807
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
808
|
+
pdfUrl={mockPdfUrl}
|
|
809
|
+
drawingMode="rectangle"
|
|
810
|
+
selectedMotivation="highlighting"
|
|
811
|
+
session={session}
|
|
812
|
+
pageLayout="scroll"
|
|
813
|
+
/>
|
|
814
|
+
);
|
|
815
|
+
await waitFor(() => {
|
|
816
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
io.fire([2, 3]);
|
|
820
|
+
await waitFor(() => expect(mountedPages()).toEqual([2, 3]));
|
|
821
|
+
|
|
822
|
+
// Draw on page 3's container, not page 2's.
|
|
823
|
+
const slot3 = document.querySelector('.semiont-pdf-annotation-canvas__slot[data-page="3"]')!;
|
|
824
|
+
const img = slot3.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
|
|
825
|
+
Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
|
|
826
|
+
Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
|
|
827
|
+
fireEvent.load(img);
|
|
828
|
+
|
|
829
|
+
const container = slot3.querySelector('.semiont-pdf-annotation-canvas__container')!;
|
|
830
|
+
fireEvent.mouseDown(container, { clientX: 40, clientY: 40 });
|
|
831
|
+
fireEvent.mouseMove(container, { clientX: 200, clientY: 160 });
|
|
832
|
+
fireEvent.mouseUp(container, { clientX: 200, clientY: 160 });
|
|
833
|
+
|
|
834
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
835
|
+
expect(request.mock.calls[0][1][0].value).toContain('page=3');
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test('annotate mode scrolls too — the primary mode is not left on the pager', async () => {
|
|
839
|
+
// The registries are the seam: both now ask for the column.
|
|
840
|
+
const io = stubIntersectionObserver();
|
|
841
|
+
scannedDoc();
|
|
842
|
+
|
|
843
|
+
render(
|
|
844
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
845
|
+
pdfUrl={mockPdfUrl}
|
|
846
|
+
drawingMode="rectangle"
|
|
847
|
+
selectedMotivation="highlighting"
|
|
848
|
+
pageLayout="scroll"
|
|
849
|
+
/>
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
await waitFor(() => {
|
|
853
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
854
|
+
});
|
|
855
|
+
io.fire([1]);
|
|
856
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
857
|
+
// Drawing still works in the column: the container is the page's own.
|
|
858
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__container'))
|
|
859
|
+
.toHaveAttribute('data-drawing-mode', 'rectangle');
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// S4. A strip of proportional rectangles — one per page, current
|
|
863
|
+
// highlighted, click to jump. Deliberately NOT thumbnails: it needs no
|
|
864
|
+
// rasterization, only the page count and the aspect ratio S1b already
|
|
865
|
+
// measures, so it costs nothing next to the document itself. It answers
|
|
866
|
+
// "where am I in this document", which neither the pager nor the
|
|
867
|
+
// scrollbar does.
|
|
868
|
+
test('renders one strip rectangle per page, marking the current one', async () => {
|
|
869
|
+
const io = stubIntersectionObserver();
|
|
870
|
+
scannedDoc();
|
|
871
|
+
|
|
872
|
+
render(
|
|
873
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
874
|
+
pdfUrl={mockPdfUrl}
|
|
875
|
+
drawingMode={null}
|
|
876
|
+
pageLayout="scroll"
|
|
877
|
+
/>
|
|
878
|
+
);
|
|
879
|
+
await waitFor(() => {
|
|
880
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
const ticks = () => document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page');
|
|
884
|
+
await waitFor(() => expect(ticks()).toHaveLength(5));
|
|
885
|
+
|
|
886
|
+
io.fire([3, 4]);
|
|
887
|
+
await waitFor(() => {
|
|
888
|
+
expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('clicking a strip rectangle jumps to that page', async () => {
|
|
893
|
+
const io = stubIntersectionObserver();
|
|
894
|
+
scannedDoc();
|
|
895
|
+
const scrollIntoView = vi.fn();
|
|
896
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
897
|
+
|
|
898
|
+
render(
|
|
899
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
900
|
+
pdfUrl={mockPdfUrl}
|
|
901
|
+
drawingMode={null}
|
|
902
|
+
pageLayout="scroll"
|
|
903
|
+
/>
|
|
904
|
+
);
|
|
905
|
+
await waitFor(() => {
|
|
906
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
907
|
+
});
|
|
908
|
+
io.fire([1]);
|
|
909
|
+
|
|
910
|
+
const tick4 = document.querySelector('.semiont-pdf-annotation-canvas__strip-page[data-page="4"]')!;
|
|
911
|
+
fireEvent.click(tick4);
|
|
912
|
+
|
|
913
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// S1a. Arrow keys step pages. The guards are the substance: a viewer that
|
|
917
|
+
// steals arrow keys from a text field is worse than one with no shortcut.
|
|
918
|
+
test('PageUp/PageDown step pages; Up/Down are left to scroll', async () => {
|
|
919
|
+
// The convention every mainstream viewer follows (Preview, Chrome's PDF
|
|
920
|
+
// viewer, Acrobat): PageUp/PageDown jump a page, Up/Down scroll finely.
|
|
921
|
+
// Taking Up/Down would leave no keyboard way to reach the bottom of a
|
|
922
|
+
// page taller than the viewport.
|
|
923
|
+
const io = stubIntersectionObserver();
|
|
924
|
+
scannedDoc();
|
|
925
|
+
const scrollIntoView = vi.fn();
|
|
926
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
927
|
+
|
|
928
|
+
render(
|
|
929
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
930
|
+
pdfUrl={mockPdfUrl}
|
|
931
|
+
drawingMode={null}
|
|
932
|
+
pageLayout="scroll"
|
|
933
|
+
/>
|
|
934
|
+
);
|
|
935
|
+
await waitFor(() => {
|
|
936
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
937
|
+
});
|
|
938
|
+
io.fire([2]);
|
|
939
|
+
await waitFor(() => expect(mountedPages()).toEqual([2]));
|
|
940
|
+
|
|
941
|
+
scrollIntoView.mockClear();
|
|
942
|
+
fireEvent.keyDown(window, { key: 'PageDown' });
|
|
943
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
944
|
+
|
|
945
|
+
scrollIntoView.mockClear();
|
|
946
|
+
fireEvent.keyDown(window, { key: 'PageUp' });
|
|
947
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
948
|
+
|
|
949
|
+
// Up/Down belong to the scroller, not to us.
|
|
950
|
+
scrollIntoView.mockClear();
|
|
951
|
+
fireEvent.keyDown(window, { key: 'ArrowDown' });
|
|
952
|
+
fireEvent.keyDown(window, { key: 'ArrowUp' });
|
|
953
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
954
|
+
|
|
955
|
+
// The same guard as the horizontal pair: never steal from a text field.
|
|
956
|
+
const input = document.createElement('input');
|
|
957
|
+
document.body.appendChild(input);
|
|
958
|
+
input.focus();
|
|
959
|
+
scrollIntoView.mockClear();
|
|
960
|
+
fireEvent.keyDown(input, { key: 'PageDown' });
|
|
961
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
962
|
+
input.remove();
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test('Left/Right step pages, and never steal keys from a text field', async () => {
|
|
966
|
+
const io = stubIntersectionObserver();
|
|
967
|
+
scannedDoc();
|
|
968
|
+
const scrollIntoView = vi.fn();
|
|
969
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
970
|
+
|
|
971
|
+
render(
|
|
972
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
973
|
+
pdfUrl={mockPdfUrl}
|
|
974
|
+
drawingMode={null}
|
|
975
|
+
pageLayout="scroll"
|
|
976
|
+
/>
|
|
977
|
+
);
|
|
978
|
+
await waitFor(() => {
|
|
979
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
980
|
+
});
|
|
981
|
+
io.fire([1]);
|
|
982
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
983
|
+
|
|
984
|
+
scrollIntoView.mockClear();
|
|
985
|
+
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
|
986
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
987
|
+
|
|
988
|
+
// Typing in an input must not page the document underneath it.
|
|
989
|
+
const input = document.createElement('input');
|
|
990
|
+
document.body.appendChild(input);
|
|
991
|
+
input.focus();
|
|
992
|
+
scrollIntoView.mockClear();
|
|
993
|
+
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
|
994
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
995
|
+
input.remove();
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test('focus follows the current page when the strip owns focus', async () => {
|
|
999
|
+
// Reported: click a page in the strip, then arrow away — the focus ring
|
|
1000
|
+
// stays behind on the clicked rectangle while the current-page marker
|
|
1001
|
+
// moves, so two rectangles claim to be "here". (The ring only appears
|
|
1002
|
+
// after the arrow press because that is when the browser switches to
|
|
1003
|
+
// keyboard modality and the clicked button starts matching
|
|
1004
|
+
// :focus-visible.) Focus must travel with the current page — but ONLY
|
|
1005
|
+
// when the strip already had it, so scrolling with the mouse never
|
|
1006
|
+
// snatches focus away from whatever the reader was doing.
|
|
1007
|
+
const io = stubIntersectionObserver();
|
|
1008
|
+
scannedDoc();
|
|
1009
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1010
|
+
|
|
1011
|
+
render(
|
|
1012
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1013
|
+
pdfUrl={mockPdfUrl}
|
|
1014
|
+
drawingMode={null}
|
|
1015
|
+
pageLayout="scroll"
|
|
1016
|
+
/>
|
|
1017
|
+
);
|
|
1018
|
+
await waitFor(() => {
|
|
1019
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1020
|
+
});
|
|
1021
|
+
io.fire([1]);
|
|
1022
|
+
await waitFor(() => expect(mountedPages()).toEqual([1]));
|
|
1023
|
+
|
|
1024
|
+
const tick = (page: number) =>
|
|
1025
|
+
document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`) as HTMLButtonElement;
|
|
1026
|
+
|
|
1027
|
+
tick(2).focus();
|
|
1028
|
+
fireEvent.click(tick(2));
|
|
1029
|
+
io.fire([3]);
|
|
1030
|
+
|
|
1031
|
+
await waitFor(() => expect(tick(3)).toHaveAttribute('aria-current', 'page'));
|
|
1032
|
+
expect(document.activeElement).toBe(tick(3));
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
test('the strip is one tab stop, not one per page', async () => {
|
|
1036
|
+
// A 400-page document must not put 400 stops in the tab order. The
|
|
1037
|
+
// ARIA pattern: the current item is tabbable, the rest are reachable
|
|
1038
|
+
// with arrows (which already page the document).
|
|
1039
|
+
const io = stubIntersectionObserver();
|
|
1040
|
+
scannedDoc();
|
|
1041
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1042
|
+
|
|
1043
|
+
render(
|
|
1044
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1045
|
+
pdfUrl={mockPdfUrl}
|
|
1046
|
+
drawingMode={null}
|
|
1047
|
+
pageLayout="scroll"
|
|
1048
|
+
/>
|
|
1049
|
+
);
|
|
1050
|
+
await waitFor(() => {
|
|
1051
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1052
|
+
});
|
|
1053
|
+
io.fire([1]);
|
|
1054
|
+
|
|
1055
|
+
const tabbable = [...document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')]
|
|
1056
|
+
.filter((el) => (el as HTMLElement).tabIndex === 0);
|
|
1057
|
+
expect(tabbable).toHaveLength(1);
|
|
1058
|
+
expect(tabbable[0]).toHaveAttribute('data-page', '1');
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test('the strip runs along the same axis as the scrolling, beside the column', async () => {
|
|
1062
|
+
// Pages scroll vertically, so a horizontal strip reads across a
|
|
1063
|
+
// direction the document does not move in. The strip's axis follows the
|
|
1064
|
+
// scroll axis — which is also what makes horizontal scrolling (a later
|
|
1065
|
+
// phase) a change of one value rather than a second layout.
|
|
1066
|
+
const io = stubIntersectionObserver();
|
|
1067
|
+
scannedDoc();
|
|
1068
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1069
|
+
|
|
1070
|
+
render(
|
|
1071
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1072
|
+
pdfUrl={mockPdfUrl}
|
|
1073
|
+
drawingMode={null}
|
|
1074
|
+
pageLayout="scroll"
|
|
1075
|
+
/>
|
|
1076
|
+
);
|
|
1077
|
+
await waitFor(() => {
|
|
1078
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1079
|
+
});
|
|
1080
|
+
io.fire([1]);
|
|
1081
|
+
|
|
1082
|
+
const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
|
|
1083
|
+
const strip = document.querySelector('.semiont-pdf-annotation-canvas__strip')!;
|
|
1084
|
+
|
|
1085
|
+
// One axis, declared in one place, read by both.
|
|
1086
|
+
expect(column).toHaveAttribute('data-axis', 'vertical');
|
|
1087
|
+
expect(strip).toHaveAttribute('data-axis', 'vertical');
|
|
1088
|
+
// Tells assistive tech which arrow keys apply to the strip.
|
|
1089
|
+
expect(strip).toHaveAttribute('aria-orientation', 'vertical');
|
|
1090
|
+
// The strip leads: it hugs the left edge of the content, the
|
|
1091
|
+
// conventional place for a page rail (Preview, Acrobat), and the
|
|
1092
|
+
// reader's eye finds it before the page rather than after it.
|
|
1093
|
+
expect(strip.nextElementSibling).toBe(column);
|
|
1094
|
+
expect(strip.parentElement).toHaveClass('semiont-pdf-annotation-canvas__viewport');
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test('each strip rectangle shows its page number', async () => {
|
|
1098
|
+
// At 70 pages the rectangles are indistinguishable from one another —
|
|
1099
|
+
// the strip shows position but not WHICH page, which is most of what a
|
|
1100
|
+
// reader wants from it on a long document.
|
|
1101
|
+
const io = stubIntersectionObserver();
|
|
1102
|
+
scannedDoc();
|
|
1103
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1104
|
+
|
|
1105
|
+
render(
|
|
1106
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1107
|
+
pdfUrl={mockPdfUrl}
|
|
1108
|
+
drawingMode={null}
|
|
1109
|
+
pageLayout="scroll"
|
|
1110
|
+
/>
|
|
1111
|
+
);
|
|
1112
|
+
await waitFor(() => {
|
|
1113
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
|
|
1114
|
+
});
|
|
1115
|
+
io.fire([1]);
|
|
1116
|
+
|
|
1117
|
+
const tick = (page: number) =>
|
|
1118
|
+
document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`)!;
|
|
1119
|
+
expect(tick(1)).toHaveTextContent('1');
|
|
1120
|
+
expect(tick(5)).toHaveTextContent('5');
|
|
1121
|
+
|
|
1122
|
+
// The accessible name keeps the context the bare digit loses, and
|
|
1123
|
+
// still contains the visible text (WCAG 2.5.3 Label in Name).
|
|
1124
|
+
expect(tick(5)).toHaveAttribute('aria-label', expect.stringContaining('5'));
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
test('the column does not add a second scrollbar inside the scrolling pane', async () => {
|
|
1129
|
+
// The strip and a nested column scrollbar were two controls for one
|
|
1130
|
+
// movement. The pane the viewer sits in already scrolls (flex: 1;
|
|
1131
|
+
// min-height: 0), so the column must not declare a viewport-relative
|
|
1132
|
+
// height of its own — that is what produced scroll-within-scroll.
|
|
1133
|
+
const io = stubIntersectionObserver();
|
|
1134
|
+
scannedDoc();
|
|
1135
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1136
|
+
|
|
1137
|
+
render(
|
|
1138
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1139
|
+
pdfUrl={mockPdfUrl}
|
|
1140
|
+
drawingMode={null}
|
|
1141
|
+
pageLayout="scroll"
|
|
1142
|
+
/>
|
|
1143
|
+
);
|
|
1144
|
+
await waitFor(() => {
|
|
1145
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1146
|
+
});
|
|
1147
|
+
io.fire([1]);
|
|
1148
|
+
|
|
1149
|
+
// jsdom applies no stylesheet, so assert the CONTRACT the CSS keys off:
|
|
1150
|
+
// the column declares the axis but claims no scroll role of its own.
|
|
1151
|
+
const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
|
|
1152
|
+
expect(column).toHaveAttribute('data-axis', 'vertical');
|
|
1153
|
+
expect(column).not.toHaveAttribute('data-scroller');
|
|
1154
|
+
// The strip is the one that scrolls independently, and silently.
|
|
1155
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__strip'))
|
|
1156
|
+
.toHaveAttribute('data-scroller', 'true');
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
test('the indicator reports a page the reader can SEE, not a preloaded one', async () => {
|
|
1160
|
+
// The mount window deliberately reaches a viewport beyond the view
|
|
1161
|
+
// (PRELOAD_MARGIN) so pages arrive early. Deriving the current page
|
|
1162
|
+
// from that same set makes the indicator name a page that is still
|
|
1163
|
+
// off-screen — it must come from actual visibility.
|
|
1164
|
+
const io = stubIntersectionObserver();
|
|
1165
|
+
scannedDoc();
|
|
1166
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1167
|
+
|
|
1168
|
+
render(
|
|
1169
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1170
|
+
drawingMode={null} pageLayout="scroll" />
|
|
1171
|
+
);
|
|
1172
|
+
await waitFor(() => {
|
|
1173
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
// Pages 1-3 are within the preload window; only 3 is actually on screen.
|
|
1177
|
+
io.fire([1, 2, 3]);
|
|
1178
|
+
io.fireOnscreen([3]);
|
|
1179
|
+
|
|
1180
|
+
await waitFor(() => {
|
|
1181
|
+
expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test('paged navigation does not carry one page\'s state onto the next', async () => {
|
|
1186
|
+
// In the paged layout a single PdfPageView is reused as `pageNumber`
|
|
1187
|
+
// changes, so its state survives the switch: the previous page's raster
|
|
1188
|
+
// shows briefly under the new page's overlay, a drag could quote the
|
|
1189
|
+
// previous page's text, and a load failure sticks forever because
|
|
1190
|
+
// nothing clears it. Each page is a different thing and must mount as one.
|
|
1191
|
+
// Reject by PAGE, not by call order: the parent also calls getPage(1)
|
|
1192
|
+
// to measure the document's shape, so a `...Once` rejection would be
|
|
1193
|
+
// consumed there and never reach the page view.
|
|
1194
|
+
const failingDoc = {
|
|
1195
|
+
numPages: 3,
|
|
1196
|
+
getPage: vi.fn((n: number) => (n === 1
|
|
1197
|
+
? Promise.reject(new Error('page 1 is broken'))
|
|
1198
|
+
: Promise.resolve(mockPage(MOCK_TEXT_ITEMS)))),
|
|
1199
|
+
};
|
|
1200
|
+
vi.mocked(loadPdfDocument).mockResolvedValueOnce(
|
|
1201
|
+
failingDoc as unknown as Awaited<ReturnType<typeof loadPdfDocument>>,
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
render(
|
|
1205
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
|
|
1206
|
+
);
|
|
1207
|
+
|
|
1208
|
+
await waitFor(() => {
|
|
1209
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).toBeInTheDocument();
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
|
1213
|
+
|
|
1214
|
+
// Page 2 loads fine; the failure belonged to page 1 and must not persist.
|
|
1215
|
+
await waitFor(() => {
|
|
1216
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).not.toBeInTheDocument();
|
|
1217
|
+
});
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test('without IntersectionObserver every page mounts — degraded, not broken', async () => {
|
|
1221
|
+
// The virtualization is an optimization, not a correctness requirement.
|
|
1222
|
+
// An environment without the observer must show the whole document
|
|
1223
|
+
// rather than nothing.
|
|
1224
|
+
scannedDoc();
|
|
1225
|
+
vi.stubGlobal('IntersectionObserver', undefined);
|
|
1226
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1227
|
+
|
|
1228
|
+
render(
|
|
1229
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1230
|
+
drawingMode={null} pageLayout="scroll" />
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
await waitFor(() => {
|
|
1234
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__image')).toHaveLength(5);
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
test('a failed map fetch is not cached — the next page retries', async () => {
|
|
1239
|
+
// Answers cache, including "no map". A transport failure must not, or
|
|
1240
|
+
// one bad moment pins the whole document to geometry-only.
|
|
1241
|
+
const io = stubIntersectionObserver();
|
|
1242
|
+
scannedDoc();
|
|
1243
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
1244
|
+
|
|
1245
|
+
const resourceAnchoredText = vi.fn()
|
|
1246
|
+
.mockRejectedValueOnce(new Error('transport down'))
|
|
1247
|
+
.mockResolvedValue({ text: 'later', items: [] });
|
|
1248
|
+
const session = {
|
|
1249
|
+
client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
|
|
1250
|
+
} as unknown as import('@semiont/sdk').SemiontSession;
|
|
1251
|
+
|
|
1252
|
+
render(
|
|
1253
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1254
|
+
drawingMode={null} pageLayout="scroll" session={session} />
|
|
1255
|
+
);
|
|
1256
|
+
await waitFor(() => {
|
|
1257
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
io.fire([1]);
|
|
1261
|
+
await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(1));
|
|
1262
|
+
|
|
1263
|
+
io.fire([2]);
|
|
1264
|
+
await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(2));
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test('the pager still works in the column, and modifiers are left alone', async () => {
|
|
1268
|
+
const io = stubIntersectionObserver();
|
|
1269
|
+
scannedDoc();
|
|
1270
|
+
const scrollIntoView = vi.fn();
|
|
1271
|
+
Element.prototype.scrollIntoView = scrollIntoView;
|
|
1272
|
+
|
|
1273
|
+
render(
|
|
1274
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
|
|
1275
|
+
drawingMode={null} pageLayout="scroll" />
|
|
1276
|
+
);
|
|
1277
|
+
await waitFor(() => {
|
|
1278
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
|
|
1279
|
+
});
|
|
1280
|
+
io.fire([3]);
|
|
1281
|
+
await waitFor(() => {
|
|
1282
|
+
expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
scrollIntoView.mockClear();
|
|
1286
|
+
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
|
1287
|
+
expect(scrollIntoView).toHaveBeenCalled();
|
|
1288
|
+
|
|
1289
|
+
// Cmd/Ctrl/Alt + arrow belongs to the browser and the OS.
|
|
1290
|
+
scrollIntoView.mockClear();
|
|
1291
|
+
fireEvent.keyDown(window, { key: 'ArrowRight', metaKey: true });
|
|
1292
|
+
fireEvent.keyDown(window, { key: 'PageDown', ctrlKey: true });
|
|
1293
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
test('keeps the paged layout when no layout is requested', async () => {
|
|
1297
|
+
render(
|
|
1298
|
+
<PdfAnnotationCanvas resourceUri="res-1"
|
|
1299
|
+
pdfUrl={mockPdfUrl}
|
|
1300
|
+
drawingMode="rectangle"
|
|
1301
|
+
selectedMotivation="highlighting"
|
|
1302
|
+
/>
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
await waitFor(() => {
|
|
1306
|
+
expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
|
|
1307
|
+
});
|
|
1308
|
+
expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(0);
|
|
1309
|
+
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
// PDF-CONTINUOUS-SCROLL S3. The viewer chrome was the last hardcoded-English
|
|
1314
|
+
// surface in this component: Previous/Next, the page indicator, and both
|
|
1315
|
+
// failure lines. The mock translation manager echoes "<namespace>.<key>",
|
|
1316
|
+
// so asserting the echo proves the string came from translations rather
|
|
1317
|
+
// than from a literal that happens to read the same in English.
|
|
1318
|
+
describe('viewer chrome', () => {
|
|
1319
|
+
const renderTranslated = (ui: React.ReactElement) =>
|
|
1320
|
+
render(<TranslationProvider translationManager={defaultMocks.translationManager}>{ui}</TranslationProvider>);
|
|
1321
|
+
|
|
1322
|
+
test('takes its controls and page indicator from translations', async () => {
|
|
1323
|
+
renderTranslated(
|
|
1324
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
await waitFor(() => {
|
|
1328
|
+
expect(screen.getByRole('button', { name: 'PdfViewer.previous' })).toBeInTheDocument();
|
|
1329
|
+
});
|
|
1330
|
+
expect(screen.getByRole('button', { name: 'PdfViewer.next' })).toBeInTheDocument();
|
|
1331
|
+
expect(screen.getByText(/PdfViewer\.pageOf/)).toBeInTheDocument();
|
|
1332
|
+
expect(screen.queryByText('Previous')).not.toBeInTheDocument();
|
|
1333
|
+
expect(screen.queryByText(/^Page 1 of 3$/)).not.toBeInTheDocument();
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
test('announces page changes — a silent indicator is invisible to a screen reader', async () => {
|
|
1337
|
+
renderTranslated(
|
|
1338
|
+
<PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
await waitFor(() => {
|
|
1342
|
+
expect(document.querySelector('.semiont-pdf-annotation-canvas__page-info')).toBeInTheDocument();
|
|
1343
|
+
});
|
|
1344
|
+
const indicator = document.querySelector('.semiont-pdf-annotation-canvas__page-info')!;
|
|
1345
|
+
expect(indicator).toHaveAttribute('aria-live', 'polite');
|
|
1346
|
+
// The pager is a navigation landmark, not loose buttons in the page.
|
|
1347
|
+
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
593
1350
|
});
|