@drawnagency/primitives 0.1.101 → 0.1.103
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/{chunk-6SLGKRLW.js → chunk-CXQWLG6X.js} +16 -16
- package/dist/components/docfile/DocFilePane.d.ts +4 -0
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
- package/dist/components/docfile/PdfCanvasViewer.d.ts +28 -0
- package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -0
- package/dist/components/editor/index.js +2 -2
- package/dist/components/sections/brand-guide.js +2 -2
- package/dist/components/sections/register-schemas.js +5 -5
- package/dist/components/sections/register.js +5 -5
- package/dist/index.js +6 -6
- package/dist/lib/index.js +6 -6
- package/dist/lib/pdfjs-assets.d.ts +18 -0
- package/dist/lib/pdfjs-assets.d.ts.map +1 -0
- package/dist/lib/pdfjs-assets.js +11 -0
- package/package.json +6 -1
- package/src/components/docfile/DocFilePane.tsx +105 -10
- package/src/components/docfile/PdfCanvasViewer.tsx +459 -0
- package/src/lib/pdfjs-assets.ts +19 -0
- package/dist/{chunk-LBHQWIVL.js → chunk-WA6QPDJI.js} +4 -4
- package/dist/{chunk-P3NNI3QI.js → chunk-X4PRBGB3.js} +5 -5
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EditableRichText,
|
|
3
|
+
defineHeadingSection
|
|
4
|
+
} from "./chunk-EWNDBKYK.js";
|
|
1
5
|
import {
|
|
2
6
|
brandGuideSectionDefs,
|
|
3
7
|
duplicateChildAt,
|
|
@@ -9,7 +13,17 @@ import {
|
|
|
9
13
|
stripHtmlToPlainText,
|
|
10
14
|
trimTrailingSpacers,
|
|
11
15
|
truncate
|
|
12
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-X4PRBGB3.js";
|
|
17
|
+
import {
|
|
18
|
+
EditablePlainText,
|
|
19
|
+
ImageDropZone,
|
|
20
|
+
MAX_CONTAINER_COLUMNS,
|
|
21
|
+
MediaBlock,
|
|
22
|
+
colSpanClass,
|
|
23
|
+
containerGridClass,
|
|
24
|
+
masonryColumnsClass,
|
|
25
|
+
spacerHiddenClass
|
|
26
|
+
} from "./chunk-MUTHF2GT.js";
|
|
13
27
|
import {
|
|
14
28
|
DeleteButton,
|
|
15
29
|
DragHandle,
|
|
@@ -23,21 +37,7 @@ import {
|
|
|
23
37
|
buildBlockDropData,
|
|
24
38
|
canDropBlock,
|
|
25
39
|
isBlockDragData
|
|
26
|
-
} from "./chunk-
|
|
27
|
-
import {
|
|
28
|
-
EditableRichText,
|
|
29
|
-
defineHeadingSection
|
|
30
|
-
} from "./chunk-EWNDBKYK.js";
|
|
31
|
-
import {
|
|
32
|
-
EditablePlainText,
|
|
33
|
-
ImageDropZone,
|
|
34
|
-
MAX_CONTAINER_COLUMNS,
|
|
35
|
-
MediaBlock,
|
|
36
|
-
colSpanClass,
|
|
37
|
-
containerGridClass,
|
|
38
|
-
masonryColumnsClass,
|
|
39
|
-
spacerHiddenClass
|
|
40
|
-
} from "./chunk-MUTHF2GT.js";
|
|
40
|
+
} from "./chunk-WA6QPDJI.js";
|
|
41
41
|
import {
|
|
42
42
|
useMediaLibrary,
|
|
43
43
|
useResolvedMedia
|
|
@@ -8,6 +8,10 @@ import type { SiteDocument } from "../../lib/documents";
|
|
|
8
8
|
* BELOW the nav and docked chat (z-40) and the mobile nav header / chat sheet
|
|
9
9
|
* (z-50), so all chrome stays reachable. `editor` adds top clearance for the
|
|
10
10
|
* edit route's always-fixed h-16 toolbar.
|
|
11
|
+
*
|
|
12
|
+
* PDFs render in PdfCanvasViewer (pdf.js) — themed chrome, identical in every
|
|
13
|
+
* browser; HTML keeps the sandboxed iframe. The top bar carries the paging and
|
|
14
|
+
* zoom controls the native viewer chrome used to provide.
|
|
11
15
|
*/
|
|
12
16
|
export default function DocFilePane({ doc, onClose, editor, }: {
|
|
13
17
|
doc: SiteDocument;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DocFilePane.d.ts","sourceRoot":"","sources":["../../../src/components/docfile/DocFilePane.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"DocFilePane.d.ts","sourceRoot":"","sources":["../../../src/components/docfile/DocFilePane.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAexD;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,GAAG,EACH,OAAO,EACP,MAAc,GACf,EAAE;IAAE,GAAG,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,2CAwL9D"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MutableRefObject } from "react";
|
|
2
|
+
export interface PdfCanvasViewerHandle {
|
|
3
|
+
goToPage(n: number): void;
|
|
4
|
+
nextPage(): void;
|
|
5
|
+
prevPage(): void;
|
|
6
|
+
zoomIn(): void;
|
|
7
|
+
zoomOut(): void;
|
|
8
|
+
fitWidth(): void;
|
|
9
|
+
}
|
|
10
|
+
interface PdfCanvasViewerProps {
|
|
11
|
+
url: string;
|
|
12
|
+
/** Plain mutable ref (not forwardRef) so React.lazy stays trivial. */
|
|
13
|
+
handleRef?: MutableRefObject<PdfCanvasViewerHandle | null>;
|
|
14
|
+
onReady: (numPages: number) => void;
|
|
15
|
+
onError: () => void;
|
|
16
|
+
/** 1-based, fired only on change; feeds the pane top bar's page indicator. */
|
|
17
|
+
onPageChange: (page: number) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Canvas-based PDF viewer: continuous vertical scroll of all pages, fit-width
|
|
21
|
+
* by default, virtualized rendering. Owns its scroll container; chrome (page
|
|
22
|
+
* indicator, zoom buttons, close) belongs to DocFilePane's top bar, driven
|
|
23
|
+
* through `handleRef`. Page CONTENT is rendered untinted in both themes — a
|
|
24
|
+
* brand guide's colours are the product — only the surround follows `.dark`.
|
|
25
|
+
*/
|
|
26
|
+
export default function PdfCanvasViewer({ url, handleRef, onReady, onError, onPageChange, }: PdfCanvasViewerProps): import("react/jsx-runtime").JSX.Element;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=PdfCanvasViewer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PdfCanvasViewer.d.ts","sourceRoot":"","sources":["../../../src/components/docfile/PdfCanvasViewer.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAyC7D,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,IAAI,IAAI,CAAC;IACjB,QAAQ,IAAI,IAAI,CAAC;IACjB,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;IAChB,QAAQ,IAAI,IAAI,CAAC;CAClB;AAED,UAAU,oBAAoB;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,sEAAsE;IACtE,SAAS,CAAC,EAAE,gBAAgB,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IAC3D,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,8EAA8E;IAC9E,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC;AAiBD;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EACtC,GAAG,EACH,SAAS,EACT,OAAO,EACP,OAAO,EACP,YAAY,GACb,EAAE,oBAAoB,2CAiXtB"}
|
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
buildBlockDropData,
|
|
12
12
|
canDropBlock,
|
|
13
13
|
isBlockDragData
|
|
14
|
-
} from "../../chunk-
|
|
15
|
-
import "../../chunk-5THS4K5D.js";
|
|
14
|
+
} from "../../chunk-WA6QPDJI.js";
|
|
16
15
|
import "../../chunk-ZSPCE6MZ.js";
|
|
16
|
+
import "../../chunk-5THS4K5D.js";
|
|
17
17
|
import {
|
|
18
18
|
AddIcon,
|
|
19
19
|
IconButton
|
|
@@ -2,9 +2,9 @@ import {
|
|
|
2
2
|
ColorItemSchema,
|
|
3
3
|
ColorSpaceSchema,
|
|
4
4
|
brandGuideSectionDefs
|
|
5
|
-
} from "../../chunk-
|
|
6
|
-
import "../../chunk-5THS4K5D.js";
|
|
5
|
+
} from "../../chunk-X4PRBGB3.js";
|
|
7
6
|
import "../../chunk-MUTHF2GT.js";
|
|
7
|
+
import "../../chunk-5THS4K5D.js";
|
|
8
8
|
import "../../chunk-26ALJO4K.js";
|
|
9
9
|
import "../../chunk-3COSXTOY.js";
|
|
10
10
|
import "../../chunk-PZJL2S3Z.js";
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
allSectionDefs
|
|
3
|
-
} from "../../chunk-
|
|
4
|
-
import "../../chunk-P3NNI3QI.js";
|
|
5
|
-
import "../../chunk-LBHQWIVL.js";
|
|
6
|
-
import "../../chunk-5THS4K5D.js";
|
|
3
|
+
} from "../../chunk-CXQWLG6X.js";
|
|
7
4
|
import "../../chunk-EWNDBKYK.js";
|
|
5
|
+
import "../../chunk-X4PRBGB3.js";
|
|
8
6
|
import "../../chunk-MUTHF2GT.js";
|
|
7
|
+
import "../../chunk-WA6QPDJI.js";
|
|
9
8
|
import "../../chunk-ZSPCE6MZ.js";
|
|
9
|
+
import "../../chunk-5THS4K5D.js";
|
|
10
10
|
import "../../chunk-26ALJO4K.js";
|
|
11
11
|
import "../../chunk-Y4JQGO6G.js";
|
|
12
12
|
import "../../chunk-3COSXTOY.js";
|
|
13
13
|
import "../../chunk-PZJL2S3Z.js";
|
|
14
14
|
import "../../chunk-BS5AQHZG.js";
|
|
15
|
+
import "../../chunk-72CG6AA2.js";
|
|
15
16
|
import "../../chunk-WDSVHKAH.js";
|
|
16
17
|
import "../../chunk-PTOLUMGV.js";
|
|
17
|
-
import "../../chunk-72CG6AA2.js";
|
|
18
18
|
import "../../chunk-7EOSDFS3.js";
|
|
19
19
|
import "../../chunk-TGNS7D4F.js";
|
|
20
20
|
import "../../chunk-TZQPOR5A.js";
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
allSectionDefs
|
|
3
|
-
} from "../../chunk-
|
|
4
|
-
import "../../chunk-P3NNI3QI.js";
|
|
5
|
-
import "../../chunk-LBHQWIVL.js";
|
|
6
|
-
import "../../chunk-5THS4K5D.js";
|
|
3
|
+
} from "../../chunk-CXQWLG6X.js";
|
|
7
4
|
import "../../chunk-EWNDBKYK.js";
|
|
5
|
+
import "../../chunk-X4PRBGB3.js";
|
|
8
6
|
import "../../chunk-MUTHF2GT.js";
|
|
7
|
+
import "../../chunk-WA6QPDJI.js";
|
|
9
8
|
import "../../chunk-ZSPCE6MZ.js";
|
|
9
|
+
import "../../chunk-5THS4K5D.js";
|
|
10
10
|
import "../../chunk-26ALJO4K.js";
|
|
11
11
|
import "../../chunk-Y4JQGO6G.js";
|
|
12
12
|
import "../../chunk-3COSXTOY.js";
|
|
13
13
|
import "../../chunk-PZJL2S3Z.js";
|
|
14
14
|
import "../../chunk-BS5AQHZG.js";
|
|
15
|
+
import "../../chunk-72CG6AA2.js";
|
|
15
16
|
import "../../chunk-WDSVHKAH.js";
|
|
16
17
|
import "../../chunk-PTOLUMGV.js";
|
|
17
|
-
import "../../chunk-72CG6AA2.js";
|
|
18
18
|
import "../../chunk-7EOSDFS3.js";
|
|
19
19
|
import "../../chunk-TGNS7D4F.js";
|
|
20
20
|
import "../../chunk-TZQPOR5A.js";
|
package/dist/index.js
CHANGED
|
@@ -111,6 +111,12 @@ import {
|
|
|
111
111
|
requireSessionSecret,
|
|
112
112
|
safeNextPath
|
|
113
113
|
} from "./chunk-S2L3BPLS.js";
|
|
114
|
+
import {
|
|
115
|
+
ensureSanitizer,
|
|
116
|
+
generateNavLinks,
|
|
117
|
+
sanitizeHtml,
|
|
118
|
+
toSectionId
|
|
119
|
+
} from "./chunk-72CG6AA2.js";
|
|
114
120
|
import {
|
|
115
121
|
curatedIcons,
|
|
116
122
|
getIcon
|
|
@@ -121,12 +127,6 @@ import {
|
|
|
121
127
|
editModeEvent,
|
|
122
128
|
navChangeEvent
|
|
123
129
|
} from "./chunk-PTOLUMGV.js";
|
|
124
|
-
import {
|
|
125
|
-
ensureSanitizer,
|
|
126
|
-
generateNavLinks,
|
|
127
|
-
sanitizeHtml,
|
|
128
|
-
toSectionId
|
|
129
|
-
} from "./chunk-72CG6AA2.js";
|
|
130
130
|
import {
|
|
131
131
|
cn
|
|
132
132
|
} from "./chunk-7EOSDFS3.js";
|
package/dist/lib/index.js
CHANGED
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
wcagLevel
|
|
21
21
|
} from "../chunk-CZLYECHC.js";
|
|
22
22
|
import "../chunk-S2L3BPLS.js";
|
|
23
|
+
import {
|
|
24
|
+
ensureSanitizer,
|
|
25
|
+
generateNavLinks,
|
|
26
|
+
sanitizeHtml,
|
|
27
|
+
toSectionId
|
|
28
|
+
} from "../chunk-72CG6AA2.js";
|
|
23
29
|
import {
|
|
24
30
|
curatedIcons,
|
|
25
31
|
getIcon
|
|
@@ -30,12 +36,6 @@ import {
|
|
|
30
36
|
editModeEvent,
|
|
31
37
|
navChangeEvent
|
|
32
38
|
} from "../chunk-PTOLUMGV.js";
|
|
33
|
-
import {
|
|
34
|
-
ensureSanitizer,
|
|
35
|
-
generateNavLinks,
|
|
36
|
-
sanitizeHtml,
|
|
37
|
-
toSectionId
|
|
38
|
-
} from "../chunk-72CG6AA2.js";
|
|
39
39
|
import {
|
|
40
40
|
cn
|
|
41
41
|
} from "../chunk-7EOSDFS3.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL contract for pdf.js's runtime assets, shared by the viewer
|
|
3
|
+
* (PdfCanvasViewer builds getDocument() URLs from it) and by core (its dev
|
|
4
|
+
* middleware serves, and its build hook copies, the pdfjs-dist package's asset
|
|
5
|
+
* directories under this path). Self-served from the portal's own origin — a
|
|
6
|
+
* deliberate decision (2026-08-02) over CDN URLs, so opening a PDF never makes
|
|
7
|
+
* a third-party request from a client-facing portal.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PDFJS_ASSET_BASE = "/_pdfjs/";
|
|
10
|
+
/** Served at PDFJS_ASSET_BASE + PDFJS_WORKER_FILE; copied from pdfjs-dist/build/. */
|
|
11
|
+
export declare const PDFJS_WORKER_FILE = "pdf.worker.min.mjs";
|
|
12
|
+
/**
|
|
13
|
+
* Asset directories mirrored verbatim from the pdfjs-dist package root.
|
|
14
|
+
* cmaps: CJK text; standard_fonts: the standard 14 fonts when not embedded;
|
|
15
|
+
* wasm: JPX/JBIG2 image decoding + ICC + JS-action sandbox; iccs: ICC profiles.
|
|
16
|
+
*/
|
|
17
|
+
export declare const PDFJS_ASSET_DIRS: readonly ["cmaps", "iccs", "standard_fonts", "wasm"];
|
|
18
|
+
//# sourceMappingURL=pdfjs-assets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pdfjs-assets.d.ts","sourceRoot":"","sources":["../../src/lib/pdfjs-assets.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,aAAa,CAAC;AAE3C,qFAAqF;AACrF,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,sDAAuD,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import "../chunk-JSBRDJBE.js";
|
|
2
|
+
|
|
3
|
+
// src/lib/pdfjs-assets.ts
|
|
4
|
+
var PDFJS_ASSET_BASE = "/_pdfjs/";
|
|
5
|
+
var PDFJS_WORKER_FILE = "pdf.worker.min.mjs";
|
|
6
|
+
var PDFJS_ASSET_DIRS = ["cmaps", "iccs", "standard_fonts", "wasm"];
|
|
7
|
+
export {
|
|
8
|
+
PDFJS_ASSET_BASE,
|
|
9
|
+
PDFJS_ASSET_DIRS,
|
|
10
|
+
PDFJS_WORKER_FILE
|
|
11
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drawnagency/primitives",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.103",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -68,6 +68,10 @@
|
|
|
68
68
|
"types": "./dist/lib/index-ops.d.ts",
|
|
69
69
|
"default": "./dist/lib/index-ops.js"
|
|
70
70
|
},
|
|
71
|
+
"./lib/pdfjs-assets": {
|
|
72
|
+
"types": "./dist/lib/pdfjs-assets.d.ts",
|
|
73
|
+
"default": "./dist/lib/pdfjs-assets.js"
|
|
74
|
+
},
|
|
71
75
|
"./lib/media-folders": {
|
|
72
76
|
"types": "./dist/lib/media-folders.d.ts",
|
|
73
77
|
"default": "./dist/lib/media-folders.js"
|
|
@@ -143,6 +147,7 @@
|
|
|
143
147
|
"jose": "^6.2.2",
|
|
144
148
|
"lucide-react": "^1.7.0",
|
|
145
149
|
"marked": "^18.0.5",
|
|
150
|
+
"pdfjs-dist": "^6.2.108",
|
|
146
151
|
"tailwind-merge": "^3.5.0",
|
|
147
152
|
"zod": "^4.3.6"
|
|
148
153
|
},
|
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ChevronDown,
|
|
4
|
+
ChevronUp,
|
|
5
|
+
Download,
|
|
6
|
+
MoveHorizontal,
|
|
7
|
+
X,
|
|
8
|
+
ZoomIn,
|
|
9
|
+
ZoomOut,
|
|
10
|
+
} from "lucide-react";
|
|
3
11
|
import type { SiteDocument } from "../../lib/documents";
|
|
4
12
|
import { documentFileUrl } from "../../schemas/documents";
|
|
5
13
|
import { lockBodyScroll } from "../../lib/scroll-lock";
|
|
14
|
+
import type { PdfCanvasViewerHandle } from "./PdfCanvasViewer";
|
|
6
15
|
|
|
7
|
-
/** How long a
|
|
16
|
+
/** How long a document may sit on "loading" before we offer a retry. */
|
|
8
17
|
const LOAD_TIMEOUT_MS = 15_000;
|
|
9
18
|
|
|
19
|
+
// Second lazy layer below DocFileOverlay's: opening an HTML document must not
|
|
20
|
+
// pull the pdf.js-backed viewer (or pdf.js itself) over the wire.
|
|
21
|
+
const PdfCanvasViewer = lazy(() => import("./PdfCanvasViewer"));
|
|
22
|
+
|
|
23
|
+
const barButtonClass =
|
|
24
|
+
"cursor-pointer rounded-inner p-1.5 text-base-contrast/60 transition-colors hover:text-primary disabled:cursor-default disabled:opacity-40 disabled:hover:text-base-contrast/60";
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
27
|
* The solid stage: an opaque, flat, bordered pane between the nav edge and the
|
|
12
28
|
* chat edge. Geometry lives in base.css (.doc-file-pane) because it is calc()
|
|
@@ -16,6 +32,10 @@ const LOAD_TIMEOUT_MS = 15_000;
|
|
|
16
32
|
* BELOW the nav and docked chat (z-40) and the mobile nav header / chat sheet
|
|
17
33
|
* (z-50), so all chrome stays reachable. `editor` adds top clearance for the
|
|
18
34
|
* edit route's always-fixed h-16 toolbar.
|
|
35
|
+
*
|
|
36
|
+
* PDFs render in PdfCanvasViewer (pdf.js) — themed chrome, identical in every
|
|
37
|
+
* browser; HTML keeps the sandboxed iframe. The top bar carries the paging and
|
|
38
|
+
* zoom controls the native viewer chrome used to provide.
|
|
19
39
|
*/
|
|
20
40
|
export default function DocFilePane({
|
|
21
41
|
doc,
|
|
@@ -24,7 +44,10 @@ export default function DocFilePane({
|
|
|
24
44
|
}: { doc: SiteDocument; onClose: () => void; editor?: boolean }) {
|
|
25
45
|
const [attempt, setAttempt] = useState(0);
|
|
26
46
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
|
47
|
+
const [page, setPage] = useState(1);
|
|
48
|
+
const [numPages, setNumPages] = useState(0);
|
|
27
49
|
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
|
50
|
+
const viewerRef = useRef<PdfCanvasViewerHandle | null>(null);
|
|
28
51
|
|
|
29
52
|
useEffect(() => lockBodyScroll(), []);
|
|
30
53
|
|
|
@@ -40,6 +63,8 @@ export default function DocFilePane({
|
|
|
40
63
|
|
|
41
64
|
useEffect(() => {
|
|
42
65
|
setState("loading");
|
|
66
|
+
setPage(1);
|
|
67
|
+
setNumPages(0);
|
|
43
68
|
const timer = setTimeout(() => setState((s) => (s === "loading" ? "error" : s)), LOAD_TIMEOUT_MS);
|
|
44
69
|
return () => clearTimeout(timer);
|
|
45
70
|
}, [attempt, doc.url]);
|
|
@@ -57,6 +82,8 @@ export default function DocFilePane({
|
|
|
57
82
|
return () => frame.removeEventListener("error", onError);
|
|
58
83
|
}, [attempt, doc.url]);
|
|
59
84
|
|
|
85
|
+
const isPdf = doc.kind === "pdf";
|
|
86
|
+
|
|
60
87
|
return (
|
|
61
88
|
<div
|
|
62
89
|
role="dialog"
|
|
@@ -69,6 +96,59 @@ export default function DocFilePane({
|
|
|
69
96
|
<span className="truncate text-sm font-medium text-base-contrast">{doc.title}</span>
|
|
70
97
|
<span className="truncate font-mono text-xs text-base-contrast/70">{doc.filename}</span>
|
|
71
98
|
</div>
|
|
99
|
+
{isPdf && state === "ready" && numPages > 0 && (
|
|
100
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => viewerRef.current?.prevPage()}
|
|
104
|
+
disabled={page <= 1}
|
|
105
|
+
aria-label="Previous page"
|
|
106
|
+
className={barButtonClass}
|
|
107
|
+
>
|
|
108
|
+
<ChevronUp size={16} aria-hidden="true" />
|
|
109
|
+
</button>
|
|
110
|
+
<span
|
|
111
|
+
aria-label={`Page ${page} of ${numPages}`}
|
|
112
|
+
className="font-mono text-xs tabular-nums text-base-contrast/70"
|
|
113
|
+
>
|
|
114
|
+
{page} / {numPages}
|
|
115
|
+
</span>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => viewerRef.current?.nextPage()}
|
|
119
|
+
disabled={page >= numPages}
|
|
120
|
+
aria-label="Next page"
|
|
121
|
+
className={barButtonClass}
|
|
122
|
+
>
|
|
123
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
124
|
+
</button>
|
|
125
|
+
<span aria-hidden="true" className="mx-1 h-4 w-px bg-base-200" />
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => viewerRef.current?.zoomOut()}
|
|
129
|
+
aria-label="Zoom out"
|
|
130
|
+
className={barButtonClass}
|
|
131
|
+
>
|
|
132
|
+
<ZoomOut size={16} aria-hidden="true" />
|
|
133
|
+
</button>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={() => viewerRef.current?.fitWidth()}
|
|
137
|
+
aria-label="Fit to width"
|
|
138
|
+
className={barButtonClass}
|
|
139
|
+
>
|
|
140
|
+
<MoveHorizontal size={16} aria-hidden="true" />
|
|
141
|
+
</button>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() => viewerRef.current?.zoomIn()}
|
|
145
|
+
aria-label="Zoom in"
|
|
146
|
+
className={barButtonClass}
|
|
147
|
+
>
|
|
148
|
+
<ZoomIn size={16} aria-hidden="true" />
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
72
152
|
<div className="flex shrink-0 items-center gap-1">
|
|
73
153
|
<a
|
|
74
154
|
// Built by documentFileUrl's `download` option, never by appending
|
|
@@ -108,21 +188,36 @@ export default function DocFilePane({
|
|
|
108
188
|
Retry
|
|
109
189
|
</button>
|
|
110
190
|
</div>
|
|
191
|
+
) : isPdf ? (
|
|
192
|
+
<Suspense
|
|
193
|
+
fallback={
|
|
194
|
+
<div className="flex min-h-0 flex-1 items-center justify-center bg-base">
|
|
195
|
+
<p className="text-sm text-base-contrast/60">Loading document…</p>
|
|
196
|
+
</div>
|
|
197
|
+
}
|
|
198
|
+
>
|
|
199
|
+
<PdfCanvasViewer
|
|
200
|
+
key={attempt}
|
|
201
|
+
url={doc.url}
|
|
202
|
+
handleRef={viewerRef}
|
|
203
|
+
onReady={(n) => {
|
|
204
|
+
setNumPages(n);
|
|
205
|
+
setState("ready");
|
|
206
|
+
}}
|
|
207
|
+
onError={() => setState("error")}
|
|
208
|
+
onPageChange={setPage}
|
|
209
|
+
/>
|
|
210
|
+
</Suspense>
|
|
111
211
|
) : (
|
|
112
212
|
<iframe
|
|
113
213
|
key={attempt}
|
|
114
214
|
ref={frameRef}
|
|
115
|
-
|
|
116
|
-
// Chromium so the pane's own top bar is the only chrome; Firefox and
|
|
117
|
-
// Safari ignore them harmlessly. A client-side fragment: it survives
|
|
118
|
-
// the gated route's 302 and works on pre-save blob: URLs too.
|
|
119
|
-
src={doc.kind === "pdf" ? `${doc.url}#toolbar=0&navpanes=0` : doc.url}
|
|
215
|
+
src={doc.url}
|
|
120
216
|
title={doc.title}
|
|
121
217
|
// Opaque origin for authored HTML: no portal cookies, storage or
|
|
122
218
|
// parent DOM. The ATTRIBUTE applies regardless of origin, so
|
|
123
219
|
// containment survives the bucket tier's 302 to a presigned R2 GET,
|
|
124
|
-
// where no CSP header comes back.
|
|
125
|
-
// viewer, which a sandbox would break.
|
|
220
|
+
// where no CSP header comes back.
|
|
126
221
|
sandbox={doc.kind === "html" ? "allow-scripts" : undefined}
|
|
127
222
|
onLoad={() => setState("ready")}
|
|
128
223
|
className="min-h-0 w-full flex-1 border-0 bg-base"
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { CSSProperties, MutableRefObject } from "react";
|
|
3
|
+
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from "pdfjs-dist";
|
|
4
|
+
import { PDFJS_ASSET_BASE, PDFJS_WORKER_FILE } from "../../lib/pdfjs-assets";
|
|
5
|
+
|
|
6
|
+
type PdfjsModule = typeof import("pdfjs-dist");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* pdf.js is imported dynamically and only in the browser: primitives is
|
|
10
|
+
* noExternal in the Astro SSR build, and a static import would pull the
|
|
11
|
+
* library into the server bundle. The worker and its runtime assets (wasm,
|
|
12
|
+
* fonts, cmaps) are fetched by URL from /_pdfjs/ — served by core's dev
|
|
13
|
+
* middleware and copied into the client build output by core's integration —
|
|
14
|
+
* which sidesteps `new URL(..., import.meta.url)` entirely; that pattern is
|
|
15
|
+
* fragile under the .portal/ symlink dev architecture.
|
|
16
|
+
*/
|
|
17
|
+
let pdfjsPromise: Promise<PdfjsModule> | null = null;
|
|
18
|
+
function loadPdfjs(): Promise<PdfjsModule> {
|
|
19
|
+
pdfjsPromise ??= import("pdfjs-dist").then((lib) => {
|
|
20
|
+
lib.GlobalWorkerOptions.workerSrc = new URL(
|
|
21
|
+
PDFJS_ASSET_BASE + PDFJS_WORKER_FILE,
|
|
22
|
+
window.location.origin,
|
|
23
|
+
).href;
|
|
24
|
+
return lib;
|
|
25
|
+
});
|
|
26
|
+
return pdfjsPromise;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ZOOM_MIN = 0.5;
|
|
30
|
+
const ZOOM_MAX = 4;
|
|
31
|
+
const ZOOM_STEP = 1.25;
|
|
32
|
+
/** Content padding of the scroll area (p-4), subtracted for the fit-width scale. */
|
|
33
|
+
const GUTTER_PX = 32;
|
|
34
|
+
/** Debounce for re-rendering canvases while the pane is being resized. */
|
|
35
|
+
const RERENDER_DEBOUNCE_MS = 120;
|
|
36
|
+
/**
|
|
37
|
+
* Backing-store ceiling per canvas. 2^24 is Safari's canvas area limit — the
|
|
38
|
+
* pane is desktop-only today, but iPads at >=1024px pass the desktop gate. Past
|
|
39
|
+
* the cap the canvas renders at reduced resolution and CSS upscales it.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_CANVAS_PIXELS = 16_777_216;
|
|
42
|
+
|
|
43
|
+
export interface PdfCanvasViewerHandle {
|
|
44
|
+
goToPage(n: number): void;
|
|
45
|
+
nextPage(): void;
|
|
46
|
+
prevPage(): void;
|
|
47
|
+
zoomIn(): void;
|
|
48
|
+
zoomOut(): void;
|
|
49
|
+
fitWidth(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PdfCanvasViewerProps {
|
|
53
|
+
url: string;
|
|
54
|
+
/** Plain mutable ref (not forwardRef) so React.lazy stays trivial. */
|
|
55
|
+
handleRef?: MutableRefObject<PdfCanvasViewerHandle | null>;
|
|
56
|
+
onReady: (numPages: number) => void;
|
|
57
|
+
onError: () => void;
|
|
58
|
+
/** 1-based, fired only on change; feeds the pane top bar's page indicator. */
|
|
59
|
+
onPageChange: (page: number) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PageSlot {
|
|
63
|
+
/** Backing-store scale of the last completed or in-flight render. */
|
|
64
|
+
renderedKey: string | null;
|
|
65
|
+
pendingKey: string | null;
|
|
66
|
+
task: RenderTask | null;
|
|
67
|
+
textLayerStarted: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const emptySlot = (): PageSlot => ({
|
|
71
|
+
renderedKey: null,
|
|
72
|
+
pendingKey: null,
|
|
73
|
+
task: null,
|
|
74
|
+
textLayerStarted: false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Canvas-based PDF viewer: continuous vertical scroll of all pages, fit-width
|
|
79
|
+
* by default, virtualized rendering. Owns its scroll container; chrome (page
|
|
80
|
+
* indicator, zoom buttons, close) belongs to DocFilePane's top bar, driven
|
|
81
|
+
* through `handleRef`. Page CONTENT is rendered untinted in both themes — a
|
|
82
|
+
* brand guide's colours are the product — only the surround follows `.dark`.
|
|
83
|
+
*/
|
|
84
|
+
export default function PdfCanvasViewer({
|
|
85
|
+
url,
|
|
86
|
+
handleRef,
|
|
87
|
+
onReady,
|
|
88
|
+
onError,
|
|
89
|
+
onPageChange,
|
|
90
|
+
}: PdfCanvasViewerProps) {
|
|
91
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
92
|
+
const wrapperRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
93
|
+
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
|
|
94
|
+
const textRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
95
|
+
const slots = useRef<PageSlot[]>([]);
|
|
96
|
+
const libRef = useRef<PdfjsModule | null>(null);
|
|
97
|
+
const pagesRef = useRef<PDFPageProxy[]>([]);
|
|
98
|
+
const currentPageRef = useRef(1);
|
|
99
|
+
|
|
100
|
+
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
|
101
|
+
const [dims, setDims] = useState<{ w: number; h: number }[] | null>(null);
|
|
102
|
+
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
|
103
|
+
const [zoom, setZoom] = useState(1);
|
|
104
|
+
const [visible, setVisible] = useState<ReadonlySet<number>>(new Set());
|
|
105
|
+
|
|
106
|
+
// Parent callbacks are kept in refs so inline lambdas in DocFilePane don't
|
|
107
|
+
// re-trigger the load effect.
|
|
108
|
+
const callbacks = useRef({ onReady, onError, onPageChange });
|
|
109
|
+
callbacks.current = { onReady, onError, onPageChange };
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
let loadingTask: ReturnType<PdfjsModule["getDocument"]> | null = null;
|
|
114
|
+
void (async () => {
|
|
115
|
+
try {
|
|
116
|
+
const lib = await loadPdfjs();
|
|
117
|
+
if (cancelled) return;
|
|
118
|
+
libRef.current = lib;
|
|
119
|
+
const base = new URL(PDFJS_ASSET_BASE, window.location.origin).href;
|
|
120
|
+
loadingTask = lib.getDocument({
|
|
121
|
+
url,
|
|
122
|
+
cMapUrl: `${base}cmaps/`,
|
|
123
|
+
standardFontDataUrl: `${base}standard_fonts/`,
|
|
124
|
+
wasmUrl: `${base}wasm/`,
|
|
125
|
+
iccUrl: `${base}iccs/`,
|
|
126
|
+
});
|
|
127
|
+
const pdf = await loadingTask.promise;
|
|
128
|
+
if (cancelled) return;
|
|
129
|
+
// All page proxies up front: their scale-1 viewports give every
|
|
130
|
+
// placeholder its true aspect ratio, so the scroll height is stable
|
|
131
|
+
// from the first paint. Page parsing stays off-thread in the worker.
|
|
132
|
+
const pages = await Promise.all(
|
|
133
|
+
Array.from({ length: pdf.numPages }, (_, i) => pdf.getPage(i + 1)),
|
|
134
|
+
);
|
|
135
|
+
if (cancelled) return;
|
|
136
|
+
pagesRef.current = pages;
|
|
137
|
+
slots.current = pages.map(emptySlot);
|
|
138
|
+
setDoc(pdf);
|
|
139
|
+
setDims(
|
|
140
|
+
pages.map((p) => {
|
|
141
|
+
const v = p.getViewport({ scale: 1 });
|
|
142
|
+
return { w: v.width, h: v.height };
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
callbacks.current.onReady(pdf.numPages);
|
|
146
|
+
} catch {
|
|
147
|
+
if (!cancelled) callbacks.current.onError();
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
return () => {
|
|
151
|
+
cancelled = true;
|
|
152
|
+
for (const slot of slots.current) slot.task?.cancel();
|
|
153
|
+
slots.current = [];
|
|
154
|
+
pagesRef.current = [];
|
|
155
|
+
// Destroys the document and its worker channel too.
|
|
156
|
+
void loadingTask?.destroy();
|
|
157
|
+
};
|
|
158
|
+
}, [url]);
|
|
159
|
+
|
|
160
|
+
// Deps on `dims`: the scroll container only exists once the document has
|
|
161
|
+
// loaded (the early "Loading document…" return renders no container), so an
|
|
162
|
+
// on-mount observer would attach to nothing.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const el = containerRef.current;
|
|
165
|
+
if (!el) return;
|
|
166
|
+
if (typeof ResizeObserver === "undefined") {
|
|
167
|
+
setContainerWidth(el.getBoundingClientRect().width || null);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const observer = new ResizeObserver((entries) => {
|
|
171
|
+
const width = entries[0]?.contentRect.width;
|
|
172
|
+
if (width) setContainerWidth(width);
|
|
173
|
+
});
|
|
174
|
+
observer.observe(el);
|
|
175
|
+
return () => observer.disconnect();
|
|
176
|
+
}, [dims]);
|
|
177
|
+
|
|
178
|
+
// Virtualization: only pages within ~one viewport of the visible band get a
|
|
179
|
+
// canvas and text layer; the rest stay as correctly-sized placeholders.
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!dims) return;
|
|
182
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
183
|
+
setVisible(new Set(dims.map((_, i) => i)));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const observer = new IntersectionObserver(
|
|
187
|
+
(entries) => {
|
|
188
|
+
setVisible((prev) => {
|
|
189
|
+
const next = new Set(prev);
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const idx = Number((entry.target as HTMLElement).dataset.pageIndex);
|
|
192
|
+
if (entry.isIntersecting) next.add(idx);
|
|
193
|
+
else next.delete(idx);
|
|
194
|
+
}
|
|
195
|
+
return next;
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
{ root: containerRef.current, rootMargin: "100% 0px 100% 0px" },
|
|
199
|
+
);
|
|
200
|
+
for (const el of wrapperRefs.current) if (el) observer.observe(el);
|
|
201
|
+
return () => observer.disconnect();
|
|
202
|
+
}, [dims]);
|
|
203
|
+
|
|
204
|
+
const fitScale = useMemo(() => {
|
|
205
|
+
if (!dims?.length || !containerWidth) return null;
|
|
206
|
+
const maxW = Math.max(...dims.map((d) => d.w));
|
|
207
|
+
return Math.max(0.1, (containerWidth - GUTTER_PX) / maxW);
|
|
208
|
+
}, [dims, containerWidth]);
|
|
209
|
+
const scale = fitScale == null ? null : fitScale * zoom;
|
|
210
|
+
|
|
211
|
+
// Wrapper geometry and the text layer's --total-scale-factor track `scale`
|
|
212
|
+
// live; the expensive canvas re-render trails behind a debounce. While it
|
|
213
|
+
// lags, CSS stretches the stale bitmap — same behaviour as every pdf.js
|
|
214
|
+
// viewer mid-pinch. The first scale renders immediately.
|
|
215
|
+
const [renderScale, setRenderScale] = useState<number | null>(null);
|
|
216
|
+
const renderScaleRef = useRef<number | null>(null);
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (scale == null || scale === renderScaleRef.current) return;
|
|
219
|
+
const apply = () => {
|
|
220
|
+
renderScaleRef.current = scale;
|
|
221
|
+
setRenderScale(scale);
|
|
222
|
+
};
|
|
223
|
+
if (renderScaleRef.current == null) {
|
|
224
|
+
apply();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const t = setTimeout(apply, RERENDER_DEBOUNCE_MS);
|
|
228
|
+
return () => clearTimeout(t);
|
|
229
|
+
}, [scale]);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!doc || !dims || renderScale == null) return;
|
|
233
|
+
const lib = libRef.current;
|
|
234
|
+
if (!lib) return;
|
|
235
|
+
|
|
236
|
+
const renderPage = (i: number) => {
|
|
237
|
+
const slot = slots.current[i];
|
|
238
|
+
const canvas = canvasRefs.current[i];
|
|
239
|
+
const page = pagesRef.current[i];
|
|
240
|
+
if (!slot || !canvas || !page) return;
|
|
241
|
+
|
|
242
|
+
const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
|
|
243
|
+
let backingScale = renderScale * dpr;
|
|
244
|
+
const px = dims[i].w * backingScale * (dims[i].h * backingScale);
|
|
245
|
+
if (px > MAX_CANVAS_PIXELS) backingScale *= Math.sqrt(MAX_CANVAS_PIXELS / px);
|
|
246
|
+
const key = backingScale.toFixed(4);
|
|
247
|
+
if (slot.renderedKey === key || slot.pendingKey === key) return;
|
|
248
|
+
|
|
249
|
+
slot.task?.cancel();
|
|
250
|
+
const viewport = page.getViewport({ scale: backingScale });
|
|
251
|
+
canvas.width = Math.floor(viewport.width);
|
|
252
|
+
canvas.height = Math.floor(viewport.height);
|
|
253
|
+
slot.pendingKey = key;
|
|
254
|
+
const task = page.render({ canvas, viewport });
|
|
255
|
+
slot.task = task;
|
|
256
|
+
task.promise.then(
|
|
257
|
+
() => {
|
|
258
|
+
slot.renderedKey = key;
|
|
259
|
+
slot.pendingKey = null;
|
|
260
|
+
slot.task = null;
|
|
261
|
+
},
|
|
262
|
+
() => {
|
|
263
|
+
// Cancelled (scale changed / page scrolled away) — a successor
|
|
264
|
+
// render owns the canvas now.
|
|
265
|
+
slot.pendingKey = null;
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// The text layer is scale-independent: spans are positioned in
|
|
270
|
+
// percentages and sized via --total-scale-factor, so it is built once
|
|
271
|
+
// per page and zoom is handled entirely by the CSS variable.
|
|
272
|
+
const textDiv = textRefs.current[i];
|
|
273
|
+
if (textDiv && !slot.textLayerStarted) {
|
|
274
|
+
slot.textLayerStarted = true;
|
|
275
|
+
const textLayer = new lib.TextLayer({
|
|
276
|
+
textContentSource: page.streamTextContent(),
|
|
277
|
+
container: textDiv,
|
|
278
|
+
viewport: page.getViewport({ scale: renderScale }),
|
|
279
|
+
});
|
|
280
|
+
textLayer.render().catch(() => {
|
|
281
|
+
slot.textLayerStarted = false;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
for (const i of visible) renderPage(i);
|
|
287
|
+
// Pages that scrolled out of the margin lost their canvas/text-layer DOM
|
|
288
|
+
// (React unmounted them) — reset bookkeeping so a return renders fresh.
|
|
289
|
+
slots.current.forEach((slot, i) => {
|
|
290
|
+
if (visible.has(i)) return;
|
|
291
|
+
slot.task?.cancel();
|
|
292
|
+
Object.assign(slot, emptySlot());
|
|
293
|
+
});
|
|
294
|
+
}, [doc, dims, renderScale, visible]);
|
|
295
|
+
|
|
296
|
+
// Zoom preserves the reading position: wrappers resized this commit, so
|
|
297
|
+
// scale the scroll offsets by the same ratio (the fixed 16px gaps make it
|
|
298
|
+
// approximate, but within a fraction of a page). The scrollTop write fires a
|
|
299
|
+
// scroll event, which also keeps the page indicator honest.
|
|
300
|
+
const prevScaleRef = useRef<number | null>(null);
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (scale == null) return;
|
|
303
|
+
const prev = prevScaleRef.current;
|
|
304
|
+
prevScaleRef.current = scale;
|
|
305
|
+
const container = containerRef.current;
|
|
306
|
+
if (!container || prev == null || prev === scale) return;
|
|
307
|
+
const ratio = scale / prev;
|
|
308
|
+
container.scrollTop *= ratio;
|
|
309
|
+
container.scrollLeft *= ratio;
|
|
310
|
+
}, [scale]);
|
|
311
|
+
|
|
312
|
+
const goToPage = (n: number) => {
|
|
313
|
+
const total = pagesRef.current.length;
|
|
314
|
+
const clamped = Math.min(Math.max(n, 1), total || 1);
|
|
315
|
+
const wrapper = wrapperRefs.current[clamped - 1];
|
|
316
|
+
const container = containerRef.current;
|
|
317
|
+
if (!wrapper || !container) return;
|
|
318
|
+
container.scrollTo({ top: wrapper.offsetTop - 16 });
|
|
319
|
+
// Update eagerly rather than waiting for the scroll handler's rAF, so a
|
|
320
|
+
// rapid prev/next click series advances one page per click, every click.
|
|
321
|
+
if (clamped !== currentPageRef.current) {
|
|
322
|
+
currentPageRef.current = clamped;
|
|
323
|
+
callbacks.current.onPageChange(clamped);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
const nextPage = () => goToPage(currentPageRef.current + 1);
|
|
327
|
+
const prevPage = () => goToPage(currentPageRef.current - 1);
|
|
328
|
+
const clampZoom = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
|
|
329
|
+
const zoomIn = () => setZoom((z) => clampZoom(z * ZOOM_STEP));
|
|
330
|
+
const zoomOut = () => setZoom((z) => clampZoom(z / ZOOM_STEP));
|
|
331
|
+
const fitWidth = () => setZoom(1);
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (!handleRef) return;
|
|
335
|
+
handleRef.current = { goToPage, nextPage, prevPage, zoomIn, zoomOut, fitWidth };
|
|
336
|
+
return () => {
|
|
337
|
+
handleRef.current = null;
|
|
338
|
+
};
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Escape stays DocFilePane's: it closes the pane. Everything here bails on
|
|
342
|
+
// modifier chords so browser zoom (Ctrl/Cmd +/-) keeps working.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (!doc) return;
|
|
345
|
+
const onKey = (e: KeyboardEvent) => {
|
|
346
|
+
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
347
|
+
const target = e.target as HTMLElement | null;
|
|
348
|
+
if (
|
|
349
|
+
target &&
|
|
350
|
+
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
|
|
351
|
+
) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
switch (e.key) {
|
|
355
|
+
case "PageDown":
|
|
356
|
+
nextPage();
|
|
357
|
+
break;
|
|
358
|
+
case "PageUp":
|
|
359
|
+
prevPage();
|
|
360
|
+
break;
|
|
361
|
+
case "Home":
|
|
362
|
+
goToPage(1);
|
|
363
|
+
break;
|
|
364
|
+
case "End":
|
|
365
|
+
goToPage(pagesRef.current.length);
|
|
366
|
+
break;
|
|
367
|
+
case "+":
|
|
368
|
+
case "=":
|
|
369
|
+
zoomIn();
|
|
370
|
+
break;
|
|
371
|
+
case "-":
|
|
372
|
+
case "_":
|
|
373
|
+
zoomOut();
|
|
374
|
+
break;
|
|
375
|
+
default:
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
};
|
|
380
|
+
window.addEventListener("keydown", onKey);
|
|
381
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
382
|
+
}, [doc]);
|
|
383
|
+
|
|
384
|
+
const scrollRaf = useRef(0);
|
|
385
|
+
const onScroll = () => {
|
|
386
|
+
if (scrollRaf.current) return;
|
|
387
|
+
scrollRaf.current = requestAnimationFrame(() => {
|
|
388
|
+
scrollRaf.current = 0;
|
|
389
|
+
const container = containerRef.current;
|
|
390
|
+
if (!container) return;
|
|
391
|
+
// Current page = the last one whose top has passed the upper 40% of the
|
|
392
|
+
// viewport — matches how the native viewer flips its indicator.
|
|
393
|
+
const threshold = container.scrollTop + container.clientHeight * 0.4;
|
|
394
|
+
let current = 1;
|
|
395
|
+
wrapperRefs.current.forEach((el, i) => {
|
|
396
|
+
if (el && el.offsetTop <= threshold) current = i + 1;
|
|
397
|
+
});
|
|
398
|
+
if (current !== currentPageRef.current) {
|
|
399
|
+
currentPageRef.current = current;
|
|
400
|
+
callbacks.current.onPageChange(current);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
useEffect(() => () => cancelAnimationFrame(scrollRaf.current), []);
|
|
405
|
+
|
|
406
|
+
if (!dims) {
|
|
407
|
+
return (
|
|
408
|
+
<div className="flex min-h-0 flex-1 items-center justify-center bg-base">
|
|
409
|
+
<p className="text-sm text-base-contrast/60">Loading document…</p>
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div
|
|
416
|
+
ref={containerRef}
|
|
417
|
+
onScroll={onScroll}
|
|
418
|
+
tabIndex={-1}
|
|
419
|
+
data-testid="pdf-canvas-viewer"
|
|
420
|
+
className="relative min-h-0 flex-1 overflow-auto bg-base outline-none"
|
|
421
|
+
>
|
|
422
|
+
<div className="mx-auto flex w-max min-w-full flex-col items-center gap-4 p-4">
|
|
423
|
+
{dims.map((d, i) => (
|
|
424
|
+
<div
|
|
425
|
+
key={i}
|
|
426
|
+
ref={(el) => {
|
|
427
|
+
wrapperRefs.current[i] = el;
|
|
428
|
+
}}
|
|
429
|
+
data-page-index={i}
|
|
430
|
+
className="relative border border-base-200 bg-white shadow-sm"
|
|
431
|
+
style={{
|
|
432
|
+
width: Math.floor(d.w * (scale ?? 1)),
|
|
433
|
+
height: Math.floor(d.h * (scale ?? 1)),
|
|
434
|
+
}}
|
|
435
|
+
>
|
|
436
|
+
{visible.has(i) && (
|
|
437
|
+
<>
|
|
438
|
+
<canvas
|
|
439
|
+
ref={(el) => {
|
|
440
|
+
canvasRefs.current[i] = el;
|
|
441
|
+
}}
|
|
442
|
+
aria-hidden="true"
|
|
443
|
+
className="absolute inset-0 h-full w-full"
|
|
444
|
+
/>
|
|
445
|
+
<div
|
|
446
|
+
ref={(el) => {
|
|
447
|
+
textRefs.current[i] = el;
|
|
448
|
+
}}
|
|
449
|
+
className="pdf-text-layer"
|
|
450
|
+
style={{ "--total-scale-factor": String(scale ?? 1) } as CSSProperties}
|
|
451
|
+
/>
|
|
452
|
+
</>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
))}
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL contract for pdf.js's runtime assets, shared by the viewer
|
|
3
|
+
* (PdfCanvasViewer builds getDocument() URLs from it) and by core (its dev
|
|
4
|
+
* middleware serves, and its build hook copies, the pdfjs-dist package's asset
|
|
5
|
+
* directories under this path). Self-served from the portal's own origin — a
|
|
6
|
+
* deliberate decision (2026-08-02) over CDN URLs, so opening a PDF never makes
|
|
7
|
+
* a third-party request from a client-facing portal.
|
|
8
|
+
*/
|
|
9
|
+
export const PDFJS_ASSET_BASE = "/_pdfjs/";
|
|
10
|
+
|
|
11
|
+
/** Served at PDFJS_ASSET_BASE + PDFJS_WORKER_FILE; copied from pdfjs-dist/build/. */
|
|
12
|
+
export const PDFJS_WORKER_FILE = "pdf.worker.min.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Asset directories mirrored verbatim from the pdfjs-dist package root.
|
|
16
|
+
* cmaps: CJK text; standard_fonts: the standard 14 fonts when not embedded;
|
|
17
|
+
* wasm: JPX/JBIG2 image decoding + ICC + JS-action sandbox; iccs: ICC profiles.
|
|
18
|
+
*/
|
|
19
|
+
export const PDFJS_ASSET_DIRS = ["cmaps", "iccs", "standard_fonts", "wasm"] as const;
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Checkbox,
|
|
3
|
-
FormLabel,
|
|
4
|
-
Input
|
|
5
|
-
} from "./chunk-5THS4K5D.js";
|
|
6
1
|
import {
|
|
7
2
|
EditableGrid,
|
|
8
3
|
EditablePlainText,
|
|
@@ -10,6 +5,11 @@ import {
|
|
|
10
5
|
containerGridClass,
|
|
11
6
|
useEditableCollection
|
|
12
7
|
} from "./chunk-MUTHF2GT.js";
|
|
8
|
+
import {
|
|
9
|
+
Checkbox,
|
|
10
|
+
FormLabel,
|
|
11
|
+
Input
|
|
12
|
+
} from "./chunk-5THS4K5D.js";
|
|
13
13
|
import {
|
|
14
14
|
AddIcon,
|
|
15
15
|
DeleteIcon,
|