@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
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
|
4
|
-
import type { Annotation, AnchorRect } from '@semiont/core';
|
|
4
|
+
import type { Annotation, AnchorRect, AnchoredText } from '@semiont/core';
|
|
5
5
|
import { resourceId as toResourceId } from '@semiont/core';
|
|
6
6
|
import { toViewportAnchorRect } from '../../lib/anchor-rect';
|
|
7
|
-
import { createFragmentSelector } from '@semiont/core';
|
|
7
|
+
import { createFragmentSelector, anchorRuns, isTextRun, textUnder } from '@semiont/core';
|
|
8
8
|
import { rectsForPage } from './rects-for-page';
|
|
9
|
+
import { estimateSlotHeight } from './estimate-slot-height';
|
|
10
|
+
import { useTranslations } from '../../contexts/TranslationContext';
|
|
9
11
|
import { createHoverHandlers, type SemiontSession } from '@semiont/sdk';
|
|
10
12
|
import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
|
|
11
13
|
import {
|
|
@@ -22,6 +24,55 @@ import './PdfAnnotationCanvas.css';
|
|
|
22
24
|
|
|
23
25
|
export type DrawingMode = 'rectangle' | 'circle' | 'polygon' | null;
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* How the document's pages are laid out.
|
|
29
|
+
*
|
|
30
|
+
* `paged` is one page with Previous/Next. `scroll` is a virtualized column:
|
|
31
|
+
* every page gets a slot so the scrollbar tells the truth about the
|
|
32
|
+
* document's length, but only the pages near the viewport are mounted.
|
|
33
|
+
*
|
|
34
|
+
* Declared explicitly rather than inferred from `drawingMode`, which does NOT
|
|
35
|
+
* distinguish the modes — AnnotateView passes `drawingMode={null}` whenever no
|
|
36
|
+
* motivation is selected, so keying layout on it would flip a reader between
|
|
37
|
+
* scrolling and paged views as they picked up and put down a tool.
|
|
38
|
+
* See .plans/PDF-CONTINUOUS-SCROLL.md D3.
|
|
39
|
+
*/
|
|
40
|
+
export type PageLayout = 'paged' | 'scroll';
|
|
41
|
+
|
|
42
|
+
/** How far outside the viewport a page starts loading, and stays loaded. */
|
|
43
|
+
const PRELOAD_MARGIN = '100% 0px';
|
|
44
|
+
|
|
45
|
+
/** Width of one page rectangle in the strip, in CSS px. Height follows the
|
|
46
|
+
* document's own aspect ratio, so the strip looks like the document — wide
|
|
47
|
+
* enough to carry a three-digit page number, which is what makes the strip
|
|
48
|
+
* useful rather than merely positional on a long document. */
|
|
49
|
+
const STRIP_PAGE_WIDTH = 30;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The axis pages advance along.
|
|
53
|
+
*
|
|
54
|
+
* ONE source for two consumers: the column scrolls along it, and the page
|
|
55
|
+
* strip runs parallel to it. A strip that ran across the scroll direction
|
|
56
|
+
* would read as a control for a movement the document does not make.
|
|
57
|
+
*
|
|
58
|
+
* Constant today because scrolling is vertical. The horizontal-scrolling
|
|
59
|
+
* setting turns this into a value read from preferences — both consumers
|
|
60
|
+
* follow automatically, which is the point of routing them through one name.
|
|
61
|
+
*/
|
|
62
|
+
const SCROLL_AXIS: 'vertical' | 'horizontal' = 'vertical';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Scroll an element into view where the environment supports it.
|
|
66
|
+
*
|
|
67
|
+
* Same posture as this file's guarded observers: jsdom implements no layout
|
|
68
|
+
* and therefore no `scrollIntoView`, and a viewer that throws in a test
|
|
69
|
+
* harness (or an exotic embedding host) is worse than one that simply does
|
|
70
|
+
* not scroll.
|
|
71
|
+
*/
|
|
72
|
+
function scrollElementIntoView(el: Element | null | undefined, options: ScrollIntoViewOptions): void {
|
|
73
|
+
if (typeof el?.scrollIntoView === 'function') el.scrollIntoView(options);
|
|
74
|
+
}
|
|
75
|
+
|
|
25
76
|
/**
|
|
26
77
|
* Get color for annotation based on motivation
|
|
27
78
|
*/
|
|
@@ -44,118 +95,139 @@ function getMotivationColor(motivation: SelectionMotivation | null): { stroke: s
|
|
|
44
95
|
}
|
|
45
96
|
}
|
|
46
97
|
|
|
47
|
-
interface
|
|
48
|
-
|
|
49
|
-
|
|
98
|
+
interface PdfPageViewProps {
|
|
99
|
+
doc: PDFDocumentProxy;
|
|
100
|
+
pageNumber: number;
|
|
101
|
+
/** Raster scale. Display-only: the overlay's geometry never reads it. */
|
|
102
|
+
scale: number;
|
|
50
103
|
resourceUri: string;
|
|
51
|
-
existingAnnotations
|
|
104
|
+
existingAnnotations: Annotation[];
|
|
52
105
|
drawingMode: DrawingMode;
|
|
53
106
|
selectedMotivation?: SelectionMotivation | null;
|
|
54
107
|
session?: SemiontSession | null | undefined;
|
|
55
108
|
hoveredAnnotationId?: string | null;
|
|
56
109
|
selectedAnnotationId?: string | null;
|
|
57
|
-
hoverDelayMs
|
|
110
|
+
hoverDelayMs: number;
|
|
111
|
+
/** The document-wide server map, fetched once per resource by the parent. */
|
|
112
|
+
fetchResourceAnchored: () => Promise<AnchoredText | null>;
|
|
58
113
|
}
|
|
59
114
|
|
|
60
115
|
/**
|
|
61
|
-
*
|
|
116
|
+
* One rendered page: its raster, its text map, its annotation overlay, and
|
|
117
|
+
* the drag that draws on it.
|
|
62
118
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
119
|
+
* Everything a page needs lives HERE rather than in the parent, which is what
|
|
120
|
+
* makes a scrolling column possible: mounting a page loads it, and unmounting
|
|
121
|
+
* releases it. The raster is a data-URL string, so the last reference going
|
|
122
|
+
* away IS the memory release — no eviction bookkeeping, no object-URL revoke.
|
|
123
|
+
* The drag lives here too because it needs this page's display dimensions and
|
|
124
|
+
* this page's text; a drag can no more span pages than a rectangle can.
|
|
66
125
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
126
|
+
function PdfPageView({
|
|
127
|
+
doc,
|
|
128
|
+
pageNumber,
|
|
129
|
+
scale,
|
|
69
130
|
resourceUri,
|
|
70
|
-
existingAnnotations
|
|
131
|
+
existingAnnotations,
|
|
71
132
|
drawingMode,
|
|
72
133
|
selectedMotivation,
|
|
73
134
|
session,
|
|
74
135
|
hoveredAnnotationId,
|
|
75
136
|
selectedAnnotationId,
|
|
76
|
-
hoverDelayMs
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
|
|
80
|
-
const [numPages, setNumPages] = useState<number>(0);
|
|
81
|
-
const [pageNumber, setPageNumber] = useState(1);
|
|
137
|
+
hoverDelayMs,
|
|
138
|
+
fetchResourceAnchored,
|
|
139
|
+
}: PdfPageViewProps) {
|
|
82
140
|
const [pageImageUrl, setPageImageUrl] = useState<string | null>(null);
|
|
83
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
84
|
-
const [error, setError] = useState<string | null>(null);
|
|
85
141
|
const [pageDimensions, setPageDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
142
|
+
/**
|
|
143
|
+
* This page's text and per-run geometry, read once when the page loads so a
|
|
144
|
+
* drag can be quoted without a round trip. Null while loading; `items` is
|
|
145
|
+
* empty on a scanned page, which has no text layer for the browser to read.
|
|
146
|
+
*/
|
|
147
|
+
const [pageAnchored, setPageAnchored] = useState<AnchoredText | null>(null);
|
|
86
148
|
const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
87
|
-
|
|
149
|
+
/** Translation KEY, not text: `t` is a new closure each render, so keeping
|
|
150
|
+
* it out of the load effect's deps is what stops a reload loop. */
|
|
151
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
152
|
+
const t = useTranslations('PdfViewer');
|
|
88
153
|
|
|
89
|
-
// Drawing state
|
|
90
154
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
91
155
|
const [selection, setSelection] = useState<CanvasRectangle | null>(null);
|
|
92
156
|
|
|
93
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
94
157
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
95
158
|
|
|
96
|
-
// Load PDF document on mount
|
|
97
159
|
useEffect(() => {
|
|
98
160
|
let cancelled = false;
|
|
99
161
|
|
|
100
|
-
|
|
162
|
+
/**
|
|
163
|
+
* The map a rectangle on this page quotes from, or null when there is
|
|
164
|
+
* none to be had.
|
|
165
|
+
*
|
|
166
|
+
* Never rejects, which is what lets the render run alongside it: quoting
|
|
167
|
+
* is the optional half of loading a page, so a failure here degrades to
|
|
168
|
+
* geometry-only rather than reaching the caller's error path. It is also
|
|
169
|
+
* the only reason `Promise.all` below is safe — a rejection from either
|
|
170
|
+
* side would leave the other promise dangling, and only the render can
|
|
171
|
+
* reject.
|
|
172
|
+
*/
|
|
173
|
+
async function resolveAnchored(page: Awaited<ReturnType<typeof doc.getPage>>): Promise<AnchoredText | null> {
|
|
101
174
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
175
|
+
// The page's text layer, read once here rather than at drag time —
|
|
176
|
+
// `handleMouseUp` stays synchronous, and a native page costs nothing
|
|
177
|
+
// extra since pdf.js already parsed it to draw the page.
|
|
178
|
+
const runs = (await page.getTextContent()).items.filter(isTextRun);
|
|
179
|
+
if (runs.length > 0) return anchorRuns(runs, pageNumber);
|
|
180
|
+
|
|
181
|
+
// No runs means a scanned page: the characters exist only as pixels
|
|
182
|
+
// and pdf.js has nothing to give. The server derived a map at ingest,
|
|
183
|
+
// so ask for it rather than leaving the annotation anonymous.
|
|
184
|
+
// Whole-resource — served once per document by the parent's cache —
|
|
185
|
+
// and `textUnder` filters by page: the same shape the native branch
|
|
186
|
+
// produces, so nothing downstream branches.
|
|
187
|
+
//
|
|
188
|
+
// `null` is the ordinary answer for a document that has no map and
|
|
189
|
+
// never will; a failure is equally non-fatal. Either way the
|
|
190
|
+
// annotation ships with geometry only, which is what shipped before
|
|
191
|
+
// this existed. The served record is the full extraction outcome
|
|
192
|
+
// (PERSIST-ANCHORS D1); a stored decline means extraction ran and
|
|
193
|
+
// found nothing to anchor — for this canvas the same degradation as
|
|
194
|
+
// no map at all.
|
|
195
|
+
return await fetchResourceAnchored();
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
118
198
|
}
|
|
119
199
|
}
|
|
120
200
|
|
|
121
|
-
loadPdf();
|
|
122
|
-
|
|
123
|
-
return () => {
|
|
124
|
-
cancelled = true;
|
|
125
|
-
};
|
|
126
|
-
}, [pdfUrl]);
|
|
127
|
-
|
|
128
|
-
// Load current page when page number changes
|
|
129
|
-
useEffect(() => {
|
|
130
|
-
if (!pdfDoc) return;
|
|
131
|
-
|
|
132
|
-
let cancelled = false;
|
|
133
|
-
const doc = pdfDoc;
|
|
134
|
-
|
|
135
201
|
async function loadPage() {
|
|
136
202
|
try {
|
|
137
203
|
const page = await doc.getPage(pageNumber);
|
|
138
|
-
|
|
139
204
|
if (cancelled) return;
|
|
140
205
|
|
|
141
206
|
// Get page dimensions (at scale 1.0)
|
|
142
207
|
const viewport = page.getViewport({ scale: 1.0 });
|
|
143
|
-
setPageDimensions({
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
208
|
+
setPageDimensions({ width: viewport.width, height: viewport.height });
|
|
209
|
+
|
|
210
|
+
// Anchoring and rendering are independent, and only one of them the
|
|
211
|
+
// reader is waiting on. Sequencing them put a network round-trip in
|
|
212
|
+
// front of the pixels on exactly the documents that need it most: a
|
|
213
|
+
// scanned page fetches its map from the server, and rendering behind
|
|
214
|
+
// that await is how "failing to quote it must not fail to show it"
|
|
215
|
+
// became true of errors but not of latency. Started together, the
|
|
216
|
+
// page appears on its own schedule.
|
|
217
|
+
const [anchored, { dataUrl }] = await Promise.all([
|
|
218
|
+
resolveAnchored(page),
|
|
219
|
+
renderPdfPageToDataUrl(page, scale),
|
|
220
|
+
]);
|
|
150
221
|
|
|
151
222
|
if (cancelled) return;
|
|
152
223
|
|
|
224
|
+
setPageAnchored(anchored);
|
|
153
225
|
setPageImageUrl(dataUrl);
|
|
154
226
|
} catch (err) {
|
|
155
227
|
if (cancelled) return;
|
|
156
228
|
|
|
157
229
|
console.error('Error loading page:', err);
|
|
158
|
-
|
|
230
|
+
setErrorKey('pageLoadFailed');
|
|
159
231
|
}
|
|
160
232
|
}
|
|
161
233
|
|
|
@@ -164,7 +236,7 @@ export function PdfAnnotationCanvas({
|
|
|
164
236
|
return () => {
|
|
165
237
|
cancelled = true;
|
|
166
238
|
};
|
|
167
|
-
}, [
|
|
239
|
+
}, [doc, pageNumber, scale, fetchResourceAnchored]);
|
|
168
240
|
|
|
169
241
|
// Update display dimensions on resize
|
|
170
242
|
useEffect(() => {
|
|
@@ -318,15 +390,25 @@ export function PdfAnnotationCanvas({
|
|
|
318
390
|
// Create FragmentSelector
|
|
319
391
|
const fragmentSelector = createFragmentSelector(pdfCoord);
|
|
320
392
|
|
|
393
|
+
// What the box was drawn around. Without it the annotation is a rectangle
|
|
394
|
+
// with no memory of its own content: the panel entry is blank, search over
|
|
395
|
+
// annotation text misses it, and an export has nothing to print. Empty on a
|
|
396
|
+
// scanned page or over an image — emit no quote at all rather than an empty
|
|
397
|
+
// one, which would assert the box was drawn around nothing.
|
|
398
|
+
const quoted = pageAnchored ? textUnder(pageAnchored, pdfCoord) : '';
|
|
399
|
+
|
|
321
400
|
// Emit annotation:requested event with FragmentSelector
|
|
322
401
|
if (selectedMotivation) {
|
|
323
402
|
session.client.mark.request(
|
|
324
403
|
toResourceId(resourceUri),
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
404
|
+
[
|
|
405
|
+
{
|
|
406
|
+
type: 'FragmentSelector',
|
|
407
|
+
conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
|
|
408
|
+
value: fragmentSelector,
|
|
409
|
+
},
|
|
410
|
+
...(quoted ? [{ type: 'TextQuoteSelector' as const, exact: quoted }] : []),
|
|
411
|
+
],
|
|
330
412
|
selectedMotivation,
|
|
331
413
|
);
|
|
332
414
|
}
|
|
@@ -336,10 +418,10 @@ export function PdfAnnotationCanvas({
|
|
|
336
418
|
setIsDrawing(false);
|
|
337
419
|
// Note: We keep selection so the preview remains visible
|
|
338
420
|
// It will be cleared when drawingMode changes or user starts new selection
|
|
339
|
-
}, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri]);
|
|
421
|
+
}, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri, pageAnchored]);
|
|
340
422
|
|
|
341
|
-
// Every FragmentSelector rect on
|
|
342
|
-
//
|
|
423
|
+
// Every FragmentSelector rect on this page — one per line for a multi-line
|
|
424
|
+
// (multi-selector) annotation, exactly one for a manual annotation.
|
|
343
425
|
const pageRects = rectsForPage(existingAnnotations, pageNumber);
|
|
344
426
|
|
|
345
427
|
// Hover handlers with currentHover guard and dwell delay
|
|
@@ -351,150 +433,559 @@ export function PdfAnnotationCanvas({
|
|
|
351
433
|
// Calculate motivation color
|
|
352
434
|
const { stroke, fill } = getMotivationColor(selectedMotivation ?? null);
|
|
353
435
|
|
|
354
|
-
if (
|
|
355
|
-
return <div className="semiont-pdf-annotation-canvas__error">{
|
|
436
|
+
if (errorKey) {
|
|
437
|
+
return <div className="semiont-pdf-annotation-canvas__error">{t(errorKey)}</div>;
|
|
356
438
|
}
|
|
357
439
|
|
|
358
440
|
return (
|
|
359
|
-
<div
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
draggable={false}
|
|
385
|
-
style={{ pointerEvents: 'none' }}
|
|
386
|
-
onLoad={() => {
|
|
387
|
-
// Use double RAF to ensure layout is complete even in onLoad
|
|
441
|
+
<div
|
|
442
|
+
className="semiont-pdf-annotation-canvas__container"
|
|
443
|
+
onMouseDown={handleMouseDown}
|
|
444
|
+
onMouseMove={handleMouseMove}
|
|
445
|
+
onMouseUp={handleMouseUp}
|
|
446
|
+
onMouseLeave={() => {
|
|
447
|
+
if (isDrawing) {
|
|
448
|
+
setIsDrawing(false);
|
|
449
|
+
setSelection(null);
|
|
450
|
+
}
|
|
451
|
+
}}
|
|
452
|
+
data-drawing-mode={drawingMode || 'none'}
|
|
453
|
+
>
|
|
454
|
+
{/* PDF page rendered as image */}
|
|
455
|
+
{pageImageUrl && (
|
|
456
|
+
<img
|
|
457
|
+
ref={imageRef}
|
|
458
|
+
src={pageImageUrl}
|
|
459
|
+
alt={`PDF page ${pageNumber}`}
|
|
460
|
+
className="semiont-pdf-annotation-canvas__image"
|
|
461
|
+
draggable={false}
|
|
462
|
+
style={{ pointerEvents: 'none' }}
|
|
463
|
+
onLoad={() => {
|
|
464
|
+
// Use double RAF to ensure layout is complete even in onLoad
|
|
465
|
+
requestAnimationFrame(() => {
|
|
388
466
|
requestAnimationFrame(() => {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
});
|
|
467
|
+
if (imageRef.current) {
|
|
468
|
+
setDisplayDimensions({
|
|
469
|
+
width: imageRef.current.clientWidth,
|
|
470
|
+
height: imageRef.current.clientHeight
|
|
471
|
+
});
|
|
472
|
+
}
|
|
397
473
|
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
474
|
+
});
|
|
475
|
+
}}
|
|
476
|
+
/>
|
|
477
|
+
)}
|
|
478
|
+
|
|
479
|
+
{/* SVG overlay for annotations */}
|
|
480
|
+
{displayDimensions && pageDimensions && (
|
|
481
|
+
<div className="semiont-pdf-annotation-canvas__overlay-container">
|
|
482
|
+
<div className="semiont-pdf-annotation-canvas__overlay">
|
|
483
|
+
<svg
|
|
484
|
+
className="semiont-pdf-annotation-canvas__svg"
|
|
485
|
+
width={displayDimensions.width}
|
|
486
|
+
height={displayDimensions.height}
|
|
487
|
+
>
|
|
488
|
+
{/* Render existing annotations for this page */}
|
|
489
|
+
{pageRects.map(r => {
|
|
490
|
+
const rect = pdfToCanvasCoordinates(r.coord, pageDimensions.height, 1.0);
|
|
491
|
+
|
|
492
|
+
// Scale to display coordinates
|
|
493
|
+
const scaleX = displayDimensions.width / pageDimensions.width;
|
|
494
|
+
const scaleY = displayDimensions.height / pageDimensions.height;
|
|
495
|
+
|
|
496
|
+
const isHovered = r.annId === hoveredAnnotationId;
|
|
497
|
+
const isSelected = r.annId === selectedAnnotationId;
|
|
498
|
+
|
|
499
|
+
// Colour by the annotation's own motivation (not the toolbar's).
|
|
500
|
+
const annMotivation = r.annotation.motivation as SelectionMotivation | null;
|
|
501
|
+
const { stroke: annStroke, fill: annFill } = getMotivationColor(annMotivation);
|
|
502
|
+
|
|
503
|
+
return (
|
|
504
|
+
<rect
|
|
505
|
+
key={`${r.annId}:${r.selectorIndex}`}
|
|
506
|
+
x={rect.x * scaleX}
|
|
507
|
+
y={rect.y * scaleY}
|
|
508
|
+
width={rect.width * scaleX}
|
|
509
|
+
height={rect.height * scaleY}
|
|
510
|
+
stroke={annStroke}
|
|
511
|
+
strokeWidth={isSelected ? 4 : isHovered ? 3 : 2}
|
|
512
|
+
fill={annFill}
|
|
513
|
+
style={{
|
|
514
|
+
pointerEvents: 'auto',
|
|
515
|
+
cursor: 'pointer',
|
|
516
|
+
opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7
|
|
517
|
+
}}
|
|
518
|
+
onClick={(e) => session?.client.browse.click(r.annId, r.annotation.motivation, e.currentTarget.getBoundingClientRect())}
|
|
519
|
+
onMouseEnter={() => handleMouseEnter(r.annId)}
|
|
520
|
+
onMouseLeave={handleMouseLeave}
|
|
521
|
+
/>
|
|
522
|
+
);
|
|
523
|
+
})}
|
|
524
|
+
|
|
525
|
+
{/* Render current selection while drawing or awaiting save */}
|
|
526
|
+
{selection && (() => {
|
|
527
|
+
const rectX = Math.min(selection.startX, selection.endX);
|
|
528
|
+
const rectY = Math.min(selection.startY, selection.endY);
|
|
529
|
+
const rectWidth = Math.abs(selection.endX - selection.startX);
|
|
530
|
+
const rectHeight = Math.abs(selection.endY - selection.startY);
|
|
531
|
+
|
|
532
|
+
// PDF only supports rectangle shapes (FragmentSelector with viewrect)
|
|
533
|
+
// Circle/polygon are disabled in the UI for PDF media types
|
|
534
|
+
return (
|
|
535
|
+
<rect
|
|
536
|
+
x={rectX}
|
|
537
|
+
y={rectY}
|
|
538
|
+
width={rectWidth}
|
|
539
|
+
height={rectHeight}
|
|
540
|
+
stroke={stroke}
|
|
541
|
+
strokeWidth={2}
|
|
542
|
+
strokeDasharray="5,5"
|
|
543
|
+
fill={fill}
|
|
544
|
+
pointerEvents="none"
|
|
545
|
+
/>
|
|
546
|
+
);
|
|
547
|
+
})()}
|
|
548
|
+
</svg>
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
interface PdfAnnotationCanvasProps {
|
|
557
|
+
pdfUrl: string;
|
|
558
|
+
/** The '@id' of the annotated resource — stamped as `source` on mark:requested (multi-viewer routing). */
|
|
559
|
+
resourceUri: string;
|
|
560
|
+
existingAnnotations?: Annotation[];
|
|
561
|
+
drawingMode: DrawingMode;
|
|
562
|
+
selectedMotivation?: SelectionMotivation | null;
|
|
563
|
+
session?: SemiontSession | null | undefined;
|
|
564
|
+
hoveredAnnotationId?: string | null;
|
|
565
|
+
selectedAnnotationId?: string | null;
|
|
566
|
+
hoverDelayMs?: number;
|
|
567
|
+
/** `paged` (default) or the virtualized `scroll` column. See `PageLayout`. */
|
|
568
|
+
pageLayout?: PageLayout;
|
|
569
|
+
}
|
|
401
570
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
571
|
+
/**
|
|
572
|
+
* PDF annotation canvas with page navigation and rectangle drawing
|
|
573
|
+
*
|
|
574
|
+
* @emits browse:click - Annotation clicked on PDF. Payload: { annotationId: string, motivation: Motivation }
|
|
575
|
+
* @emits mark:requested - New annotation drawn on PDF. Payload: { selector: [FragmentSelector, TextQuoteSelector?], motivation: SelectionMotivation } — the quote is the text under the rectangle, omitted when the page has no text layer
|
|
576
|
+
* @emits beckon:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }
|
|
577
|
+
*/
|
|
578
|
+
export function PdfAnnotationCanvas({
|
|
579
|
+
pdfUrl,
|
|
580
|
+
resourceUri,
|
|
581
|
+
existingAnnotations = [],
|
|
582
|
+
drawingMode,
|
|
583
|
+
selectedMotivation,
|
|
584
|
+
session,
|
|
585
|
+
hoveredAnnotationId,
|
|
586
|
+
selectedAnnotationId,
|
|
587
|
+
hoverDelayMs = 150,
|
|
588
|
+
pageLayout = 'paged'
|
|
589
|
+
}: PdfAnnotationCanvasProps) {
|
|
590
|
+
// PDF state
|
|
591
|
+
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
|
|
592
|
+
const [numPages, setNumPages] = useState<number>(0);
|
|
593
|
+
const [pageNumber, setPageNumber] = useState(1);
|
|
594
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
595
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
596
|
+
const t = useTranslations('PdfViewer');
|
|
597
|
+
/**
|
|
598
|
+
* Page 1's shape, for reserving space in slots that have not mounted yet:
|
|
599
|
+
* its aspect ratio and its raster width. NOT its raster height — the image
|
|
600
|
+
* renders under `max-width: 100%; height: auto`, so its displayed height
|
|
601
|
+
* depends on the column's width, and reserving raster pixels made the
|
|
602
|
+
* column's height lurch on every mount (S1b).
|
|
603
|
+
* See .plans/PDF-CONTINUOUS-SCROLL.md D4 + S1b.
|
|
604
|
+
*/
|
|
605
|
+
const [pageShape, setPageShape] = useState<{ aspect: number; rasterWidth: number } | null>(null);
|
|
606
|
+
/** Measured inner width of the column — the other half of the reservation. */
|
|
607
|
+
const [columnWidth, setColumnWidth] = useState<number | null>(null);
|
|
608
|
+
const columnRef = useRef<HTMLDivElement>(null);
|
|
609
|
+
const [scale] = useState(1.5); // Fixed scale for better quality
|
|
610
|
+
|
|
611
|
+
/** Pages currently intersecting the viewport (plus the preload margin). */
|
|
612
|
+
const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set());
|
|
613
|
+
/** Pages genuinely in view — observed with NO preload margin, so it answers
|
|
614
|
+
* "what is the reader looking at" rather than "what should be loaded".
|
|
615
|
+
* Drives the indicator and the rail; never the mount window. */
|
|
616
|
+
const [onscreenPages, setOnscreenPages] = useState<Set<number>>(new Set());
|
|
617
|
+
const slotRefs = useRef(new Map<number, HTMLElement>());
|
|
618
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
619
|
+
const onscreenRef = useRef<IntersectionObserver | null>(null);
|
|
620
|
+
|
|
621
|
+
// Load PDF document on mount
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
let cancelled = false;
|
|
624
|
+
|
|
625
|
+
async function loadPdf() {
|
|
626
|
+
try {
|
|
627
|
+
setIsLoading(true);
|
|
628
|
+
setErrorKey(null);
|
|
629
|
+
|
|
630
|
+
const doc = await loadPdfDocument(pdfUrl);
|
|
631
|
+
|
|
632
|
+
if (cancelled) return;
|
|
633
|
+
|
|
634
|
+
setPdfDoc(doc);
|
|
635
|
+
setNumPages(doc.numPages);
|
|
636
|
+
setIsLoading(false);
|
|
637
|
+
|
|
638
|
+
// One extra getPage, for slot sizing (D4). Failure is not fatal:
|
|
639
|
+
// unsized slots still scroll, just less faithfully.
|
|
640
|
+
try {
|
|
641
|
+
const first = await doc.getPage(1);
|
|
642
|
+
if (cancelled) return;
|
|
643
|
+
const natural = first.getViewport({ scale: 1.0 });
|
|
644
|
+
setPageShape({
|
|
645
|
+
aspect: natural.height / natural.width,
|
|
646
|
+
rasterWidth: first.getViewport({ scale }).width,
|
|
647
|
+
});
|
|
648
|
+
} catch {
|
|
649
|
+
/* leave unsized — an unsized slot is stable; a mis-sized one moves */
|
|
650
|
+
}
|
|
651
|
+
} catch (err) {
|
|
652
|
+
if (cancelled) return;
|
|
653
|
+
|
|
654
|
+
console.error('Error loading PDF:', err);
|
|
655
|
+
setErrorKey('loadFailed');
|
|
656
|
+
setIsLoading(false);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
loadPdf();
|
|
661
|
+
|
|
662
|
+
return () => {
|
|
663
|
+
cancelled = true;
|
|
664
|
+
};
|
|
665
|
+
}, [pdfUrl, scale]);
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The server-derived map, fetched once per document rather than once per
|
|
669
|
+
* page (PERSIST-ANCHORS P4). The map is WHOLE-RESOURCE — one artifact
|
|
670
|
+
* covering every page — so re-reading it per page meant a full refetch and
|
|
671
|
+
* re-decode on every page turn; on a 400-page scan that is one decode per
|
|
672
|
+
* interaction instead of one per document. Living on the parent is also
|
|
673
|
+
* what lets a scrolling column mount many pages against a single fetch.
|
|
674
|
+
*
|
|
675
|
+
* The cache holds the in-flight promise so concurrent page loads share one
|
|
676
|
+
* fetch. Answers cache — including "no map" and a stored decline, which are
|
|
677
|
+
* definitive — but a transport failure clears the entry, so the next page
|
|
678
|
+
* load retries instead of pinning the whole document to geometry-only.
|
|
679
|
+
*/
|
|
680
|
+
const resourceAnchoredRef = useRef<{ uri: string; outcome: Promise<AnchoredText | null> } | null>(null);
|
|
681
|
+
const fetchResourceAnchored = useCallback((): Promise<AnchoredText | null> => {
|
|
682
|
+
if (!session) return Promise.resolve(null); // no session yet — don't cache its absence
|
|
683
|
+
const cached = resourceAnchoredRef.current;
|
|
684
|
+
if (cached && cached.uri === resourceUri) return cached.outcome;
|
|
685
|
+
|
|
686
|
+
const uri = resourceUri;
|
|
687
|
+
const outcome = session.client.browse.resourceAnchoredText(toResourceId(uri)).then(
|
|
688
|
+
(served) => (served && !('declined' in served) ? served : null),
|
|
689
|
+
() => {
|
|
690
|
+
if (resourceAnchoredRef.current?.uri === uri) resourceAnchoredRef.current = null;
|
|
691
|
+
return null;
|
|
692
|
+
},
|
|
693
|
+
);
|
|
694
|
+
resourceAnchoredRef.current = { uri, outcome };
|
|
695
|
+
return outcome;
|
|
696
|
+
}, [session, resourceUri]);
|
|
697
|
+
|
|
698
|
+
// The column's width decides every page's displayed height, so the
|
|
699
|
+
// reservation cannot be computed without it.
|
|
700
|
+
useEffect(() => {
|
|
701
|
+
if (pageLayout !== 'scroll') return;
|
|
702
|
+
const measure = () => {
|
|
703
|
+
if (columnRef.current) setColumnWidth(columnRef.current.clientWidth);
|
|
704
|
+
};
|
|
705
|
+
measure();
|
|
706
|
+
let observer: ResizeObserver | null = null;
|
|
707
|
+
try {
|
|
708
|
+
observer = new ResizeObserver(measure);
|
|
709
|
+
if (columnRef.current) observer.observe(columnRef.current);
|
|
710
|
+
} catch {
|
|
711
|
+
window.addEventListener('resize', measure);
|
|
712
|
+
}
|
|
713
|
+
return () => {
|
|
714
|
+
if (observer) observer.disconnect();
|
|
715
|
+
else window.removeEventListener('resize', measure);
|
|
716
|
+
};
|
|
717
|
+
}, [pageLayout]);
|
|
718
|
+
|
|
719
|
+
const slotHeight = estimateSlotHeight(columnWidth, pageShape?.rasterWidth ?? null, pageShape?.aspect ?? null)
|
|
720
|
+
// Before the column has been measured there is still a sane reservation:
|
|
721
|
+
// the raster's own height. Wrong in a narrow column, but every slot is
|
|
722
|
+
// wrong by the SAME amount, so the column is at least internally
|
|
723
|
+
// consistent until the first measurement lands.
|
|
724
|
+
?? (pageShape ? Math.round(pageShape.rasterWidth * pageShape.aspect) : null);
|
|
725
|
+
|
|
726
|
+
// The mount window. Slots report their own visibility; a page is mounted
|
|
727
|
+
// while its slot intersects (widened by PRELOAD_MARGIN so the next page is
|
|
728
|
+
// ready before it is reached), and unmounting is what frees its raster.
|
|
729
|
+
useEffect(() => {
|
|
730
|
+
if (pageLayout !== 'scroll' || numPages === 0) return;
|
|
731
|
+
|
|
732
|
+
let observer: IntersectionObserver | null = null;
|
|
733
|
+
try {
|
|
734
|
+
observer = new IntersectionObserver(
|
|
735
|
+
(entries) => {
|
|
736
|
+
setVisiblePages((prev) => {
|
|
737
|
+
const next = new Set(prev);
|
|
738
|
+
for (const entry of entries) {
|
|
739
|
+
const page = Number((entry.target as HTMLElement).dataset.page);
|
|
740
|
+
if (!page) continue;
|
|
741
|
+
if (entry.isIntersecting) next.add(page);
|
|
742
|
+
else next.delete(page);
|
|
743
|
+
}
|
|
744
|
+
return next;
|
|
745
|
+
});
|
|
746
|
+
},
|
|
747
|
+
{ rootMargin: PRELOAD_MARGIN },
|
|
748
|
+
);
|
|
749
|
+
} catch {
|
|
750
|
+
// No IntersectionObserver: mount every page rather than none. Correct,
|
|
751
|
+
// simply not virtualized — the same posture as the ResizeObserver
|
|
752
|
+
// fallback in PdfPageView.
|
|
753
|
+
setVisiblePages(new Set(Array.from({ length: numPages }, (_, i) => i + 1)));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
observerRef.current = observer;
|
|
758
|
+
for (const el of slotRefs.current.values()) observer.observe(el);
|
|
759
|
+
|
|
760
|
+
// A SECOND observer, with no preload margin, answers a different question:
|
|
761
|
+
// which pages the reader can actually see. Two observers rather than one
|
|
762
|
+
// set doing both jobs, because widening the mount window must never move
|
|
763
|
+
// the page indicator.
|
|
764
|
+
let onscreen: IntersectionObserver | null = null;
|
|
765
|
+
try {
|
|
766
|
+
onscreen = new IntersectionObserver((entries) => {
|
|
767
|
+
setOnscreenPages((prev) => {
|
|
768
|
+
const next = new Set(prev);
|
|
769
|
+
for (const entry of entries) {
|
|
770
|
+
const page = Number((entry.target as HTMLElement).dataset.page);
|
|
771
|
+
if (!page) continue;
|
|
772
|
+
if (entry.isIntersecting) next.add(page);
|
|
773
|
+
else next.delete(page);
|
|
774
|
+
}
|
|
775
|
+
return next;
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
onscreenRef.current = onscreen;
|
|
779
|
+
for (const el of slotRefs.current.values()) onscreen.observe(el);
|
|
780
|
+
} catch {
|
|
781
|
+
// Without an observer there is no notion of "on screen"; fall back to
|
|
782
|
+
// page 1 so the indicator names something rather than nothing.
|
|
783
|
+
setOnscreenPages(new Set([1]));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return () => {
|
|
787
|
+
observer.disconnect();
|
|
788
|
+
observerRef.current = null;
|
|
789
|
+
onscreen?.disconnect();
|
|
790
|
+
onscreenRef.current = null;
|
|
791
|
+
};
|
|
792
|
+
}, [pageLayout, numPages]);
|
|
793
|
+
|
|
794
|
+
const registerSlot = useCallback((page: number) => (el: HTMLDivElement | null) => {
|
|
795
|
+
const previous = slotRefs.current.get(page);
|
|
796
|
+
if (previous) {
|
|
797
|
+
observerRef.current?.unobserve(previous);
|
|
798
|
+
onscreenRef.current?.unobserve(previous);
|
|
799
|
+
}
|
|
800
|
+
if (el) {
|
|
801
|
+
slotRefs.current.set(page, el);
|
|
802
|
+
observerRef.current?.observe(el);
|
|
803
|
+
onscreenRef.current?.observe(el);
|
|
804
|
+
} else {
|
|
805
|
+
slotRefs.current.delete(page);
|
|
806
|
+
}
|
|
807
|
+
}, []);
|
|
808
|
+
|
|
809
|
+
/** Keeps the current page's rectangle in view as the reader scrolls. */
|
|
810
|
+
const currentTickRef = useRef<HTMLButtonElement>(null);
|
|
811
|
+
|
|
812
|
+
const scrollToPage = useCallback((page: number) => {
|
|
813
|
+
scrollElementIntoView(slotRefs.current.get(page), { block: 'start' });
|
|
814
|
+
}, []);
|
|
815
|
+
|
|
816
|
+
// In the column the reader decides which page they are on by scrolling, so
|
|
817
|
+
// the indicator reports rather than controls: the topmost page actually ON
|
|
818
|
+
// SCREEN. NOT `visiblePages` — that set is deliberately widened by
|
|
819
|
+
// PRELOAD_MARGIN to mount pages before they are reached, so reading it here
|
|
820
|
+
// would name a page still a viewport away.
|
|
821
|
+
const currentPage = pageLayout === 'scroll'
|
|
822
|
+
? (onscreenPages.size > 0 ? Math.min(...onscreenPages) : 1)
|
|
823
|
+
: pageNumber;
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Left/Right step pages (S1a).
|
|
827
|
+
*
|
|
828
|
+
* Bound to the window rather than a focusable wrapper so it works without
|
|
829
|
+
* the reader hunting for the viewer's focus — but that reach is exactly why
|
|
830
|
+
* the guards matter more than the feature: a viewer that eats arrow keys
|
|
831
|
+
* while someone is editing an annotation body, or mid-drag, is worse than
|
|
832
|
+
* one with no shortcut at all.
|
|
833
|
+
*/
|
|
834
|
+
useEffect(() => {
|
|
835
|
+
if (numPages === 0) return;
|
|
836
|
+
const BACK = ['ArrowLeft', 'PageUp'];
|
|
837
|
+
const FORWARD = ['ArrowRight', 'PageDown'];
|
|
838
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
839
|
+
// PageUp/PageDown jump a page; Left/Right do too, because they have no
|
|
840
|
+
// native meaning in a vertical layout so taking them costs nothing.
|
|
841
|
+
//
|
|
842
|
+
// Up/Down are deliberately NOT here. They are the scroller's fine
|
|
843
|
+
// movement, and a page can be taller than the viewport — taking them
|
|
844
|
+
// would leave no keyboard way to reach the bottom of one. This is the
|
|
845
|
+
// split Preview, Chrome's PDF viewer and Acrobat all use.
|
|
846
|
+
if (!BACK.includes(e.key) && !FORWARD.includes(e.key)) return;
|
|
847
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return; // browser/OS navigation
|
|
848
|
+
const el = e.target as HTMLElement | null;
|
|
849
|
+
const tag = el?.tagName;
|
|
850
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el?.isContentEditable) return;
|
|
851
|
+
|
|
852
|
+
const next = FORWARD.includes(e.key) ? currentPage + 1 : currentPage - 1;
|
|
853
|
+
if (next < 1 || next > numPages) return;
|
|
854
|
+
e.preventDefault();
|
|
855
|
+
if (pageLayout === 'scroll') scrollToPage(next);
|
|
856
|
+
else setPageNumber(next);
|
|
857
|
+
};
|
|
858
|
+
window.addEventListener('keydown', onKeyDown);
|
|
859
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
860
|
+
}, [numPages, currentPage, pageLayout, scrollToPage]);
|
|
861
|
+
|
|
862
|
+
const stripRef = useRef<HTMLDivElement>(null);
|
|
863
|
+
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
// `nearest` so following the reader never yanks the strip around; it
|
|
866
|
+
// scrolls only when the current rectangle would otherwise leave view.
|
|
867
|
+
scrollElementIntoView(currentTickRef.current, { block: 'nearest', inline: 'nearest' });
|
|
868
|
+
|
|
869
|
+
// Move focus with the current page, but ONLY when the strip already owns
|
|
870
|
+
// focus. Otherwise a clicked rectangle keeps focus while the current-page
|
|
871
|
+
// marker moves on, and the focus ring — which appears as soon as the
|
|
872
|
+
// reader touches an arrow key and the browser switches to keyboard
|
|
873
|
+
// modality — sits on a page that is no longer current: two rectangles
|
|
874
|
+
// claiming to be "here". Guarding on ownership means scrolling with the
|
|
875
|
+
// mouse never snatches focus from whatever the reader was doing.
|
|
876
|
+
const active = typeof document !== 'undefined' ? document.activeElement : null;
|
|
877
|
+
if (active && stripRef.current?.contains(active) && active !== currentTickRef.current) {
|
|
878
|
+
currentTickRef.current?.focus();
|
|
879
|
+
}
|
|
880
|
+
}, [currentPage]);
|
|
881
|
+
|
|
882
|
+
const pageProps = {
|
|
883
|
+
scale,
|
|
884
|
+
resourceUri,
|
|
885
|
+
existingAnnotations,
|
|
886
|
+
drawingMode,
|
|
887
|
+
selectedMotivation,
|
|
888
|
+
session,
|
|
889
|
+
hoveredAnnotationId,
|
|
890
|
+
selectedAnnotationId,
|
|
891
|
+
hoverDelayMs,
|
|
892
|
+
fetchResourceAnchored,
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
if (errorKey) {
|
|
896
|
+
return <div className="semiont-pdf-annotation-canvas__error">{t(errorKey)}</div>;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return (
|
|
900
|
+
<div className="semiont-pdf-annotation-canvas">
|
|
901
|
+
{isLoading && <div className="semiont-pdf-annotation-canvas__loading">{t('loading')}</div>}
|
|
902
|
+
|
|
903
|
+
{pdfDoc && pageLayout === 'scroll' ? (
|
|
904
|
+
<div className="semiont-pdf-annotation-canvas__viewport" data-axis={SCROLL_AXIS}>
|
|
905
|
+
{numPages > 0 && pageShape && (
|
|
906
|
+
<div
|
|
907
|
+
className="semiont-pdf-annotation-canvas__strip"
|
|
908
|
+
data-axis={SCROLL_AXIS}
|
|
909
|
+
data-scroller="true"
|
|
910
|
+
aria-orientation={SCROLL_AXIS}
|
|
911
|
+
ref={stripRef}
|
|
912
|
+
>
|
|
913
|
+
{Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
|
|
914
|
+
<button
|
|
915
|
+
key={page}
|
|
916
|
+
type="button"
|
|
917
|
+
data-page={page}
|
|
918
|
+
className="semiont-pdf-annotation-canvas__strip-page"
|
|
919
|
+
style={{ height: `${Math.round(STRIP_PAGE_WIDTH * pageShape.aspect)}px` }}
|
|
920
|
+
aria-label={t('pageOf', { page, total: numPages })}
|
|
921
|
+
tabIndex={page === currentPage ? 0 : -1}
|
|
922
|
+
{...(page === currentPage ? { 'aria-current': 'page' as const } : {})}
|
|
923
|
+
ref={page === currentPage ? currentTickRef : undefined}
|
|
924
|
+
onClick={() => scrollToPage(page)}
|
|
410
925
|
>
|
|
411
|
-
{
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
// Scale to display coordinates
|
|
416
|
-
const scaleX = displayDimensions.width / pageDimensions.width;
|
|
417
|
-
const scaleY = displayDimensions.height / pageDimensions.height;
|
|
418
|
-
|
|
419
|
-
const isHovered = r.annId === hoveredAnnotationId;
|
|
420
|
-
const isSelected = r.annId === selectedAnnotationId;
|
|
421
|
-
|
|
422
|
-
// Colour by the annotation's own motivation (not the toolbar's).
|
|
423
|
-
const annMotivation = r.annotation.motivation as SelectionMotivation | null;
|
|
424
|
-
const { stroke: annStroke, fill: annFill } = getMotivationColor(annMotivation);
|
|
425
|
-
|
|
426
|
-
return (
|
|
427
|
-
<rect
|
|
428
|
-
key={`${r.annId}:${r.selectorIndex}`}
|
|
429
|
-
x={rect.x * scaleX}
|
|
430
|
-
y={rect.y * scaleY}
|
|
431
|
-
width={rect.width * scaleX}
|
|
432
|
-
height={rect.height * scaleY}
|
|
433
|
-
stroke={annStroke}
|
|
434
|
-
strokeWidth={isSelected ? 4 : isHovered ? 3 : 2}
|
|
435
|
-
fill={annFill}
|
|
436
|
-
style={{
|
|
437
|
-
pointerEvents: 'auto',
|
|
438
|
-
cursor: 'pointer',
|
|
439
|
-
opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7
|
|
440
|
-
}}
|
|
441
|
-
onClick={(e) => session?.client.browse.click(r.annId, r.annotation.motivation, e.currentTarget.getBoundingClientRect())}
|
|
442
|
-
onMouseEnter={() => handleMouseEnter(r.annId)}
|
|
443
|
-
onMouseLeave={handleMouseLeave}
|
|
444
|
-
/>
|
|
445
|
-
);
|
|
446
|
-
})}
|
|
447
|
-
|
|
448
|
-
{/* Render current selection while drawing or awaiting save */}
|
|
449
|
-
{selection && (() => {
|
|
450
|
-
const rectX = Math.min(selection.startX, selection.endX);
|
|
451
|
-
const rectY = Math.min(selection.startY, selection.endY);
|
|
452
|
-
const rectWidth = Math.abs(selection.endX - selection.startX);
|
|
453
|
-
const rectHeight = Math.abs(selection.endY - selection.startY);
|
|
454
|
-
|
|
455
|
-
// PDF only supports rectangle shapes (FragmentSelector with viewrect)
|
|
456
|
-
// Circle/polygon are disabled in the UI for PDF media types
|
|
457
|
-
return (
|
|
458
|
-
<rect
|
|
459
|
-
x={rectX}
|
|
460
|
-
y={rectY}
|
|
461
|
-
width={rectWidth}
|
|
462
|
-
height={rectHeight}
|
|
463
|
-
stroke={stroke}
|
|
464
|
-
strokeWidth={2}
|
|
465
|
-
strokeDasharray="5,5"
|
|
466
|
-
fill={fill}
|
|
467
|
-
pointerEvents="none"
|
|
468
|
-
/>
|
|
469
|
-
);
|
|
470
|
-
})()}
|
|
471
|
-
</svg>
|
|
472
|
-
</div>
|
|
926
|
+
{page}
|
|
927
|
+
</button>
|
|
928
|
+
))}
|
|
473
929
|
</div>
|
|
474
930
|
)}
|
|
475
|
-
|
|
931
|
+
<div className="semiont-pdf-annotation-canvas__column" data-axis={SCROLL_AXIS} ref={columnRef}>
|
|
932
|
+
{Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
|
|
933
|
+
<div
|
|
934
|
+
key={page}
|
|
935
|
+
ref={registerSlot(page)}
|
|
936
|
+
data-page={page}
|
|
937
|
+
className="semiont-pdf-annotation-canvas__slot"
|
|
938
|
+
// min-height, and applied whether or not the page is mounted:
|
|
939
|
+
// releasing it on mount is what made the scrollbar jump. `min`
|
|
940
|
+
// rather than a fixed height so a page that renders slightly
|
|
941
|
+
// taller expands instead of clipping.
|
|
942
|
+
style={slotHeight ? { minHeight: slotHeight } : undefined}
|
|
943
|
+
>
|
|
944
|
+
{visiblePages.has(page) && (
|
|
945
|
+
<PdfPageView doc={pdfDoc} pageNumber={page} {...pageProps} />
|
|
946
|
+
)}
|
|
947
|
+
</div>
|
|
948
|
+
))}
|
|
949
|
+
</div>
|
|
950
|
+
</div>
|
|
951
|
+
) : (
|
|
952
|
+
pdfDoc && !isLoading && (
|
|
953
|
+
// `key` is load-bearing: without it React reuses this instance
|
|
954
|
+
// across page changes, so the previous page's raster, text map and
|
|
955
|
+
// load error survive into the next page — a stale overlay, a quote
|
|
956
|
+
// taken from the wrong page, and an error that never clears. Each
|
|
957
|
+
// page is a different thing; mounting it as one resets all of it.
|
|
958
|
+
<PdfPageView key={pageNumber} doc={pdfDoc} pageNumber={pageNumber} {...pageProps} />
|
|
959
|
+
)
|
|
960
|
+
)}
|
|
476
961
|
|
|
477
962
|
{/* Page navigation controls */}
|
|
478
963
|
{numPages > 0 && (
|
|
479
|
-
<
|
|
964
|
+
<nav className="semiont-pdf-annotation-canvas__controls" aria-label={t('pagination')}>
|
|
480
965
|
<button
|
|
481
|
-
disabled={
|
|
482
|
-
onClick={() => setPageNumber(pageNumber - 1)}
|
|
966
|
+
disabled={currentPage <= 1}
|
|
967
|
+
onClick={() => (pageLayout === 'scroll' ? scrollToPage(currentPage - 1) : setPageNumber(pageNumber - 1))}
|
|
483
968
|
className="semiont-pdf-annotation-canvas__button"
|
|
484
969
|
>
|
|
485
|
-
|
|
970
|
+
{t('previous')}
|
|
486
971
|
</button>
|
|
487
|
-
|
|
488
|
-
|
|
972
|
+
{/*
|
|
973
|
+
aria-live: the page number is the only feedback a page change gives,
|
|
974
|
+
and in the scrolling column it changes without any control being
|
|
975
|
+
activated at all — so a screen-reader user who scrolls would
|
|
976
|
+
otherwise get silence.
|
|
977
|
+
*/}
|
|
978
|
+
<span className="semiont-pdf-annotation-canvas__page-info" aria-live="polite">
|
|
979
|
+
{t('pageOf', { page: currentPage, total: numPages })}
|
|
489
980
|
</span>
|
|
490
981
|
<button
|
|
491
|
-
disabled={
|
|
492
|
-
onClick={() => setPageNumber(pageNumber + 1)}
|
|
982
|
+
disabled={currentPage >= numPages}
|
|
983
|
+
onClick={() => (pageLayout === 'scroll' ? scrollToPage(currentPage + 1) : setPageNumber(pageNumber + 1))}
|
|
493
984
|
className="semiont-pdf-annotation-canvas__button"
|
|
494
985
|
>
|
|
495
|
-
|
|
986
|
+
{t('next')}
|
|
496
987
|
</button>
|
|
497
|
-
</
|
|
988
|
+
</nav>
|
|
498
989
|
)}
|
|
499
990
|
</div>
|
|
500
991
|
);
|