@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.
@@ -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-P3NNI3QI.js";
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-LBHQWIVL.js";
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":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAOxD;;;;;;;;;GASG;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,2CA6G9D"}
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-LBHQWIVL.js";
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-P3NNI3QI.js";
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-6SLGKRLW.js";
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-6SLGKRLW.js";
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.101",
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 { Download, X } from "lucide-react";
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 frame may sit on "loading" before we offer a retry. */
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
- // PDF open-parameters hide the native viewer's toolbar/side panel in
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. PDFs get the browser's native
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,12 +1,12 @@
1
+ import {
2
+ Button,
3
+ usePages
4
+ } from "./chunk-ZSPCE6MZ.js";
1
5
  import {
2
6
  Checkbox,
3
7
  FormLabel,
4
8
  Input
5
9
  } from "./chunk-5THS4K5D.js";
6
- import {
7
- Button,
8
- usePages
9
- } from "./chunk-ZSPCE6MZ.js";
10
10
  import {
11
11
  DeleteIcon,
12
12
  DragHandle,
@@ -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,