@drawnagency/primitives 0.1.80 → 0.1.82

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.
Files changed (102) hide show
  1. package/dist/{chunk-7XWER6YR.js → chunk-22GU6GNP.js} +3 -3
  2. package/dist/{chunk-4TVODHD2.js → chunk-5UDIQLGQ.js} +6 -6
  3. package/dist/{chunk-UNUJPLZV.js → chunk-6NRVX6NQ.js} +1 -1
  4. package/dist/{chunk-KVQFMVXE.js → chunk-DKXISNJ2.js} +2 -2
  5. package/dist/{chunk-RSITF24B.js → chunk-ETJYYUQX.js} +9 -3
  6. package/dist/{chunk-VI26O4E3.js → chunk-FDBZZ4AT.js} +1 -1
  7. package/dist/chunk-FM4M4Q2W.js +72 -0
  8. package/dist/{chunk-EMGLZQCM.js → chunk-MBJUWOM2.js} +1 -1
  9. package/dist/{chunk-2DDWWTRX.js → chunk-Q6LZOH5M.js} +1 -1
  10. package/dist/{chunk-QB5Q23G2.js → chunk-Y54G2XSC.js} +1 -1
  11. package/dist/{chunk-3LNKOLZP.js → chunk-YKOLOZB2.js} +1 -1
  12. package/dist/components/editor/PagesModal.d.ts +2 -0
  13. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  14. package/dist/components/editor/index.js +6 -5
  15. package/dist/components/primitives/index.js +7 -6
  16. package/dist/components/sections/brand-guide.js +7 -6
  17. package/dist/components/sections/register-schemas.js +10 -9
  18. package/dist/components/sections/register.js +10 -9
  19. package/dist/components/shell/DocInspector.d.ts +18 -0
  20. package/dist/components/shell/DocInspector.d.ts.map +1 -0
  21. package/dist/components/shell/DocumentEditorOverlay.d.ts +29 -0
  22. package/dist/components/shell/DocumentEditorOverlay.d.ts.map +1 -0
  23. package/dist/components/shell/DocumentPresentOverlay.d.ts +30 -0
  24. package/dist/components/shell/DocumentPresentOverlay.d.ts.map +1 -0
  25. package/dist/components/shell/EditorShell.d.ts +34 -0
  26. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  27. package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  28. package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  29. package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  30. package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  31. package/dist/document/components/DocCoverPage.d.ts +11 -0
  32. package/dist/document/components/DocCoverPage.d.ts.map +1 -0
  33. package/dist/document/components/DocPage.d.ts +10 -0
  34. package/dist/document/components/DocPage.d.ts.map +1 -0
  35. package/dist/document/components/DocRail.d.ts +28 -0
  36. package/dist/document/components/DocRail.d.ts.map +1 -0
  37. package/dist/document/components/DocSectionPage.d.ts +12 -0
  38. package/dist/document/components/DocSectionPage.d.ts.map +1 -0
  39. package/dist/document/components/DocViewer.d.ts +18 -0
  40. package/dist/document/components/DocViewer.d.ts.map +1 -0
  41. package/dist/document/components/Wordmark.d.ts +4 -0
  42. package/dist/document/components/Wordmark.d.ts.map +1 -0
  43. package/dist/document/components/measure.d.ts +7 -0
  44. package/dist/document/components/measure.d.ts.map +1 -0
  45. package/dist/document/components/useDocPages.d.ts +12 -0
  46. package/dist/document/components/useDocPages.d.ts.map +1 -0
  47. package/dist/document/engine/blocks.d.ts +2 -0
  48. package/dist/document/engine/blocks.d.ts.map +1 -0
  49. package/dist/document/engine/harvest.d.ts +3 -0
  50. package/dist/document/engine/harvest.d.ts.map +1 -0
  51. package/dist/document/engine/masonry.d.ts +26 -0
  52. package/dist/document/engine/masonry.d.ts.map +1 -0
  53. package/dist/document/engine/paginate.d.ts +43 -0
  54. package/dist/document/engine/paginate.d.ts.map +1 -0
  55. package/dist/document/engine/settings.d.ts +88 -0
  56. package/dist/document/engine/settings.d.ts.map +1 -0
  57. package/dist/document/engine/types.d.ts +44 -0
  58. package/dist/document/engine/types.d.ts.map +1 -0
  59. package/dist/document/index.d.ts +13 -0
  60. package/dist/document/index.d.ts.map +1 -0
  61. package/dist/document/index.js +965 -0
  62. package/dist/document/styles/document.css +217 -0
  63. package/dist/index.js +29 -28
  64. package/dist/lib/document-input.d.ts +25 -0
  65. package/dist/lib/document-input.d.ts.map +1 -0
  66. package/dist/lib/index.js +10 -9
  67. package/dist/lib/pages.d.ts +2 -0
  68. package/dist/lib/pages.d.ts.map +1 -1
  69. package/dist/media/index.js +4 -3
  70. package/dist/schemas/index.js +3 -2
  71. package/dist/schemas/site-config.d.ts +265 -1
  72. package/dist/schemas/site-config.d.ts.map +1 -1
  73. package/package.json +5 -1
  74. package/src/components/editor/PagesModal.tsx +16 -2
  75. package/src/components/shell/DocInspector.tsx +323 -0
  76. package/src/components/shell/DocumentEditorOverlay.tsx +169 -0
  77. package/src/components/shell/DocumentPresentOverlay.tsx +87 -0
  78. package/src/components/shell/EditorShell.tsx +128 -5
  79. package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  80. package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  81. package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  82. package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  83. package/src/document/components/DocCoverPage.tsx +45 -0
  84. package/src/document/components/DocPage.tsx +40 -0
  85. package/src/document/components/DocRail.tsx +164 -0
  86. package/src/document/components/DocSectionPage.tsx +48 -0
  87. package/src/document/components/DocViewer.tsx +285 -0
  88. package/src/document/components/Wordmark.tsx +18 -0
  89. package/src/document/components/measure.tsx +117 -0
  90. package/src/document/components/useDocPages.ts +85 -0
  91. package/src/document/engine/blocks.ts +87 -0
  92. package/src/document/engine/harvest.ts +95 -0
  93. package/src/document/engine/masonry.ts +152 -0
  94. package/src/document/engine/paginate.ts +229 -0
  95. package/src/document/engine/settings.ts +88 -0
  96. package/src/document/engine/types.ts +64 -0
  97. package/src/document/index.ts +18 -0
  98. package/src/document/styles/document.css +217 -0
  99. package/src/lib/document-input.ts +57 -0
  100. package/src/lib/pages.ts +22 -0
  101. package/src/schemas/site-config.ts +22 -7
  102. /package/dist/{chunk-4YEOTFVU.js → chunk-YBVK254P.js} +0 -0
@@ -0,0 +1,285 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import type { DocumentOptions } from "../engine/settings";
3
+ import { settingsFor } from "../engine/settings";
4
+ import type { Chunk, DocImage, DocSourceContent } from "../engine/types";
5
+ import { DocCoverPage } from "./DocCoverPage";
6
+ import { DocRail, entryId, type RailEntry } from "./DocRail";
7
+ import { DocSectionPage, type DocHost } from "./DocSectionPage";
8
+ import { useDocPages } from "./useDocPages";
9
+
10
+ // 17in @96dpi = 1632×1056; the canvas scales the real page to fit its width.
11
+ const PAGE_W = 1632;
12
+ const PAGE_H = 1056;
13
+
14
+ export interface DocViewerProps {
15
+ source: DocSourceContent | null;
16
+ document: DocumentOptions; // { enabled, meta, cover, sections } — PageSchema.document shape
17
+ host: DocHost;
18
+ mode: "present" | "edit";
19
+ // edit mode only:
20
+ selectedChunkId?: string | null;
21
+ onSelectChunk?: (chunkId: string | null) => void; // null = cover selected
22
+ onClose?: () => void; // present mode: close control
23
+ // Optional pagination-state hook (present mode unaffected — the present route
24
+ // never passes it). Fires, identity-guarded, whenever the internal useDocPages
25
+ // output changes, so a host (the edit overlay) can reuse this single pass for
26
+ // its inspector titles/warnings instead of running a second useDocPages.
27
+ onDocState?: (state: { chunks: Chunk[]; warningsByChunk: Record<string, string[]> }) => void;
28
+ }
29
+
30
+ // Rail-entry id → the chunk it belongs to (null for the cover).
31
+ function chunkIdOf(id: string): string | null {
32
+ if (id === "cover") return null;
33
+ if (id.startsWith("ghost:")) return id.slice("ghost:".length);
34
+ if (id.includes("#")) return id.slice(0, id.lastIndexOf("#"));
35
+ return id;
36
+ }
37
+
38
+ export function DocViewer({
39
+ source,
40
+ document: doc,
41
+ host,
42
+ mode,
43
+ selectedChunkId,
44
+ onSelectChunk,
45
+ onClose,
46
+ onDocState,
47
+ }: DocViewerProps) {
48
+ const { chunks, pages, measuring, warningsByChunk } = useDocPages({ source, sections: doc.sections });
49
+ const [activeId, setActiveId] = useState<string>("cover");
50
+ const [railOpen, setRailOpen] = useState(true);
51
+
52
+ // Resolve the cover's media-library image once here; missing id or missing
53
+ // source entry (deleted image) both degrade to null → solid cover.
54
+ const coverImage: DocImage | null = doc.cover.imageId ? (source?.images[doc.cover.imageId] ?? null) : null;
55
+
56
+ // Publish the pagination output to an optional host, guarded so it only fires
57
+ // when the memoized chunks/warnings identities actually change (and reading
58
+ // onDocState via a ref so an unstable callback identity can't cause a loop).
59
+ const onDocStateRef = useRef(onDocState);
60
+ onDocStateRef.current = onDocState;
61
+ const lastEmitRef = useRef<{ chunks: Chunk[]; warningsByChunk: Record<string, string[]> } | null>(null);
62
+ useEffect(() => {
63
+ if (lastEmitRef.current?.chunks === chunks && lastEmitRef.current?.warningsByChunk === warningsByChunk) return;
64
+ lastEmitRef.current = { chunks, warningsByChunk };
65
+ onDocStateRef.current?.({ chunks, warningsByChunk });
66
+ }, [chunks, warningsByChunk]);
67
+
68
+ // Present mode: excluded/empty chunks are simply absent. Edit mode: they
69
+ // surface as ghost entries (excluded takes priority over empty).
70
+ const entries: RailEntry[] = useMemo(() => {
71
+ const list: RailEntry[] = [{ kind: "cover" }];
72
+ for (const chunk of chunks) {
73
+ const s = settingsFor(doc.sections, chunk.id);
74
+ const empty = chunk.textBlocks.length === 0 && chunk.images.length === 0;
75
+ if (s.excluded || empty) {
76
+ if (mode === "edit") {
77
+ list.push({ kind: "ghost", chunkId: chunk.id, title: chunk.title, reason: s.excluded ? "excluded" : "empty", depth: chunk.depth });
78
+ }
79
+ } else {
80
+ for (const page of pages.filter((p) => p.chunkId === chunk.id)) list.push({ kind: "page", page });
81
+ }
82
+ }
83
+ return list;
84
+ }, [chunks, pages, doc.sections, mode]);
85
+
86
+ const selectEntry = useCallback(
87
+ (id: string) => {
88
+ setActiveId(id);
89
+ if (mode === "edit") onSelectChunk?.(chunkIdOf(id));
90
+ },
91
+ [mode, onSelectChunk],
92
+ );
93
+
94
+ // Stale active-entry guard: if the active entry vanished (chunk excluded, page
95
+ // count shrank), re-point to the matching ghost or the cover. Guarded on
96
+ // !measuring so transient empty-`pages` frames during layout don't churn it.
97
+ useEffect(() => {
98
+ if (measuring) return;
99
+ if (entries.some((e) => entryId(e) === activeId)) return;
100
+ const chunkId = chunkIdOf(activeId);
101
+ const ghost = entries.find((e) => e.kind === "ghost" && e.chunkId === chunkId);
102
+ setActiveId(ghost ? entryId(ghost) : "cover");
103
+ }, [entries, activeId, measuring]);
104
+
105
+ // Controlled selection (edit): follow an externally-driven selectedChunkId.
106
+ // Undefined means uncontrolled — the viewer owns selection outright.
107
+ useEffect(() => {
108
+ if (mode !== "edit" || selectedChunkId === undefined) return;
109
+ if (chunkIdOf(activeId) === selectedChunkId) return;
110
+ if (selectedChunkId === null) {
111
+ setActiveId("cover");
112
+ return;
113
+ }
114
+ const match = entries.find(
115
+ (e) =>
116
+ (e.kind === "page" && e.page.chunkId === selectedChunkId) ||
117
+ (e.kind === "ghost" && e.chunkId === selectedChunkId),
118
+ );
119
+ if (match) setActiveId(entryId(match));
120
+ }, [selectedChunkId, mode, entries, activeId]);
121
+
122
+ // Arrow-key paging on `window`. Ignores events targeting form fields, clamps
123
+ // at the ends, preventDefault only when it consumes the key, listener removed
124
+ // on unmount. A ref keeps the handler reading the live active id without
125
+ // resubscribing on every step.
126
+ const activeIdRef = useRef(activeId);
127
+ activeIdRef.current = activeId;
128
+ useEffect(() => {
129
+ const onKey = (e: KeyboardEvent) => {
130
+ const t = e.target as HTMLElement | null;
131
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
132
+ let dir = 0;
133
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") dir = 1;
134
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") dir = -1;
135
+ else return;
136
+ e.preventDefault();
137
+ const ids = entries.map(entryId);
138
+ const cur = Math.max(0, ids.indexOf(activeIdRef.current));
139
+ const next = Math.min(ids.length - 1, Math.max(0, cur + dir));
140
+ if (ids[next] !== activeIdRef.current) selectEntry(ids[next]);
141
+ };
142
+ window.addEventListener("keydown", onKey);
143
+ return () => window.removeEventListener("keydown", onKey);
144
+ }, [entries, selectEntry]);
145
+
146
+ const active = entries.find((e) => entryId(e) === activeId) ?? null;
147
+
148
+ return (
149
+ // min-w-0 (here and on the rail/canvas row): without it these flex items
150
+ // refuse to shrink below their content width, and opening the rail shoves
151
+ // the overlay's inspector off the right edge instead of narrowing the
152
+ // canvas.
153
+ <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
154
+ {mode === "present" ? (
155
+ <div className="flex items-center justify-between border-b border-base-200 bg-base px-4 py-2">
156
+ <span className="text-sm font-medium text-base-contrast">{source?.pageTitle ?? "Document"}</span>
157
+ {onClose ? (
158
+ <button
159
+ type="button"
160
+ aria-label="Close"
161
+ onClick={onClose}
162
+ className="rounded border border-base-200 px-3 py-1 text-xs font-medium text-base-contrast hover:bg-base-accent/30"
163
+ >
164
+ ✕
165
+ </button>
166
+ ) : null}
167
+ </div>
168
+ ) : null}
169
+ <div className="flex min-h-0 min-w-0 flex-1">
170
+ {railOpen ? (
171
+ <DocRail
172
+ entries={entries}
173
+ selectedId={activeId}
174
+ meta={doc.meta}
175
+ cover={doc.cover}
176
+ coverImage={coverImage}
177
+ host={host}
178
+ onSelect={selectEntry}
179
+ onCollapse={() => setRailOpen(false)}
180
+ />
181
+ ) : (
182
+ <div className="flex w-10 shrink-0 flex-col border-r border-neutral-200 bg-neutral-50">
183
+ <button
184
+ type="button"
185
+ aria-label="Expand page rail"
186
+ onClick={() => setRailOpen(true)}
187
+ className="px-2 py-3 text-lg leading-none text-neutral-500 hover:text-neutral-800"
188
+ >
189
+ ›
190
+ </button>
191
+ </div>
192
+ )}
193
+ <DocCanvas
194
+ active={active}
195
+ cover={doc.cover}
196
+ coverImage={coverImage}
197
+ meta={doc.meta}
198
+ host={host}
199
+ measuring={measuring}
200
+ noContent={chunks.length === 0}
201
+ />
202
+ </div>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ function DocCanvas({
208
+ active,
209
+ cover,
210
+ coverImage,
211
+ meta,
212
+ host,
213
+ measuring,
214
+ noContent,
215
+ }: {
216
+ active: RailEntry | null;
217
+ cover: DocumentOptions["cover"];
218
+ coverImage: DocImage | null;
219
+ meta: DocumentOptions["meta"];
220
+ host: DocHost;
221
+ measuring: boolean;
222
+ noContent: boolean;
223
+ }) {
224
+ const containerRef = useRef<HTMLDivElement>(null);
225
+ const [scale, setScale] = useState(0.4);
226
+ useEffect(() => {
227
+ const el = containerRef.current;
228
+ if (!el || typeof ResizeObserver === "undefined") return;
229
+ const observer = new ResizeObserver(() => {
230
+ const available = el.clientWidth - 48;
231
+ setScale(Math.min(0.75, Math.max(0.15, available / PAGE_W)));
232
+ });
233
+ observer.observe(el);
234
+ return () => observer.disconnect();
235
+ }, []);
236
+
237
+ const note = (text: string) => <p className="mt-24 text-sm text-neutral-400">{text}</p>;
238
+
239
+ let body: ReactNode;
240
+ if (active?.kind === "cover") {
241
+ body = noContent ? (
242
+ <div className="flex flex-col items-center gap-4">
243
+ <Scaled scale={scale}>
244
+ <DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
245
+ </Scaled>
246
+ <p className="text-sm text-neutral-400">This page has no displayable content.</p>
247
+ </div>
248
+ ) : (
249
+ <Scaled scale={scale}>
250
+ <DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
251
+ </Scaled>
252
+ );
253
+ } else if (active?.kind === "page") {
254
+ body = (
255
+ <Scaled scale={scale}>
256
+ <DocSectionPage page={active.page} meta={meta} host={host} />
257
+ </Scaled>
258
+ );
259
+ } else if (active?.kind === "ghost") {
260
+ body = note(active.reason === "excluded" ? "Excluded — re-include from the inspector" : "No displayable content");
261
+ } else {
262
+ // Active id resolved to nothing — its page is still being measured.
263
+ body = measuring ? note("Laying out…") : note("This page has no displayable content.");
264
+ }
265
+
266
+ return (
267
+ <div
268
+ ref={containerRef}
269
+ data-testid="doc-canvas"
270
+ className="flex flex-1 items-start justify-center overflow-auto bg-neutral-100 p-6"
271
+ >
272
+ {body}
273
+ </div>
274
+ );
275
+ }
276
+
277
+ function Scaled({ scale, children }: { scale: number; children: ReactNode }) {
278
+ return (
279
+ <div className="shrink-0 overflow-hidden shadow-lg" style={{ width: PAGE_W * scale, height: PAGE_H * scale }}>
280
+ <div className="origin-top-left" style={{ transform: `scale(${scale})`, width: PAGE_W, height: PAGE_H }}>
281
+ {children}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
@@ -0,0 +1,18 @@
1
+ // Drawn script wordmark. Path data copied verbatim from
2
+ // ~/drawn/frontend/src/components/shell/Logo.astro — do not edit paths by hand.
3
+ export function Wordmark({ className }: { className?: string }) {
4
+ return (
5
+ <svg
6
+ viewBox="0 0 360 154.474"
7
+ fill="currentColor"
8
+ role="img"
9
+ aria-label="Drawn"
10
+ className={className}
11
+ xmlns="http://www.w3.org/2000/svg"
12
+ >
13
+ <path d="M93.211 18.436c-2.177.868 2.024 1.375 6.374 5.14 3.913 3.392 5.542 6.526 8.126 5.794l.084-.019c15.002-4.018 30.605-4.343 45.642-3.985 37.577.895 76.5 13.868 127.052 10.736 52.074-3.226 64.063-33.293 64.423-35.34.335-1.903-4.473.223-6.71 1.341-2.461 1.23-12.375 22.056-56.817 24.605-54.58 3.13-76.949-13.42-127.948-16.105-24.157-1.272-44.308 1.912-60.225 7.832ZM353.301 104.05c-3.497-.813-8.22-.393-14.355 3.083a42.817 42.817 0 0 0-1.464.863l-.646.396c-.153.094-.302.192-.452.286-.185.121-.375.239-.56.36-.158.1-.315.202-.47.305-.18.119-.36.234-.539.354-.177.118-.353.235-.53.35l-.557.373c-.113.076-.23.15-.344.225-2.209 1.475-4.596 3.046-7.903 4.822l-.002-.001c-.178.093-.354.189-.534.285-.25.132-.504.264-.764.398-.28.145-.574.292-.87.442-.268.133-.537.269-.817.403-.32.16-.653.319-.988.477-.282.136-.561.27-.857.407a124.187 124.187 0 0 1-4.258 1.882c-22.372 9.426-46.637 12.894-83.75 9.349-37.286-3.562-76.22-13.87-126.593-10.74-51.887 3.228-63.837 33.296-64.195 35.346-.336 1.898 4.455-.225 6.685-1.345 2.45-1.228 12.332-22.055 56.612-24.603 54.385-3.132 84.076 13.469 131.52 17.447 34.494 2.89 59.914-3.453 77.117-14.422 3.42-2.183 6.945-4.758 10.342-7.353.093-.067.176-.134.264-.202.111-.085.217-.168.329-.25.563-.434 1.122-.87 1.68-1.302.098-.077.2-.152.3-.23 1.027-.8 2.038-1.593 3.021-2.37 2.518-1.98 4.866-3.835 6.939-5.395l.823-.614.382-.28c.344-.251.675-.49.999-.718a42.728 42.728 0 0 1 1.547-1.058c.07-.047.14-.084.206-.13.31-.197.608-.379.89-.544.094-.054.189-.11.281-.16a12.3 12.3 0 0 1 .933-.486c4.427-2.017 8.968-2.27 9.22 1.837.124 2.074-1.999 4.029-5.691 4.449-2.565.292-6.454.442-9.699-.803-.026-.012-.048-.012-.077-.023-.5-.17-1.841 1.036-2.762 2-.922.966-2.05 1.673-1.084 2.163 4.29 2.29 8.832 3.056 11.611 3.137 8.07.221 14.336-1.887 15.567-8.04.93-4.653-1.559-9.227-6.507-10.371Z" />
14
+ <path d="M340.048 55.675c-3.094-2.81-8.125-2.34-11.568-2.069-5.92.466-11.54 3.713-15.45 6.582-.477-3.394-1.684-5.828-3.908-6.64-6.393-2.34-11.45 2.035-16.42 5.387-1.95 1.254-4.322-4.012-5.878-4.895-1.55-.882-4.995-1.347-7.803.126-4.404 2.305-7.465 5.91-7.21 8.136.257 2.297 4.149 4.28 4.065 6.879-.13 4.019-2.04 7.87-4.668 13.225-1.998 4.068-4.108 7.762-6.38 10.711-1.537 1.954-3.631 4.144-6.278 5.748-.514.277-1.04.496-1.57.685-3.299.82-4.712-3.26-3.843-8.882 1.006-6.444 9.235-29.756 9.738-30.966.502-1.21-.382-1.121-2.396-1.49-2.015-.37-6.724-.041-8.45.241-1.727.279-5.21 15.477-7.354 22.253-2.143 6.778-5.96 14.898-8.942 17.284-2.568 2.058-4.896 3.11-7.137 2.31-6.262-2.243-2.25-19.315 1.117-28.63 3.801-10.515 9.353-20.009 6.934-19.91-5.368.224-11.976 2.01-13.28 4.058-2.33 3.673-8.074 21.308-9.999 26.677-1.072 2.989-1.506 6.316-1.412 9.579-1.124.544-6.17 6.036-12.888 5.767-7.49-.303 4.697-29.98 5.369-31.32.67-1.342-11.354-1.567-12.026-.222-.668 1.34-6.037 31.487-20.409 31.487-10.457 0-5.145-25.45 7.604-34.614 10.985-7.9 25.36-2.188 26.924-2.862 1.565-.668 4.17-9.668 4.17-10.786s-4.698-1.119-18.792-.222c-14.09.893-21.92 7.38-30.195 20.69-8.278 13.31-10.068 26.73-19.412 26.73-9.22 0-1.169-18.788 3.082-27.738 4.248-8.946 6.937-17.895 5.592-19.907-1.343-2.014-16.332-.46-17.673-1.58-1.344-1.119 1.121-3.12.45-4.913-.67-1.787-10.515-1.787-14.205 0-1.887.916-2.185 3.287-2.021 5.893 1.254 7.163 1.232 14.979-.318 22.603.064.302.197.384.438.138 2.586-2.63 8.876-11.227 9.55-12.12.669-.897 8.836-.71 9.239.043.645 1.203-6.796 17.224-8.723 22.59-3.61 10.057-2.573 16.894 2.973 20.234 4.967 2.996 9.777 2.584 16.73-1.624 6.977-4.222 9.599-6.526 9.599-6.526s0 14.087 15.437 14.087c11.406 0 14.089-8.048 14.089-8.048s1.797 10.698 12.414 9.087c6.176-.937 11.652-9.47 13.41-12.763 1.045 6.066 3.97 11.25 8.174 12.625 5.887 1.922 13.06.388 19.818-4.645a15.195 15.195 0 0 0 2.495-2.362c2.356 5.703 5.865 9.538 9.681 10.008 19.478 2.399 28.458-19.807 33.299-34.217.993-2.955 1.71-5.846 2.654-9.364.268-1.007 1.183-2.504 2.994-2.939 4.188-.933 7.848-1.282 8.583.371 3.784 8.503-13.171 43.36-10.747 43.36 3.3 0 10.047-1.037 12.48-4.353 4.145-5.64 10.948-25.812 11.45-39.149 2.636-2.101 5.458-3.664 8.179-3.883 9.395-.757 6.167 14.162 3.22 22.537-4.906 13.966-3.351 22.982.632 28.735.709.9 1.775-.014 3.49-.917 1.718-.906 2.7-1.413 2.527-2.26-.011-.062-.019-.12-.029-.183-1.323-5.592 3.407-13.519 7.318-24.085 3.762-10.158 7.001-24.749 1.465-29.782ZM46.09 89.63c1.282-2.736 2.616-5.56 4.002-8.468C56.528 67.684 66.09 53.946 75.049 46.32a80.636 80.636 0 0 1 14.747-9.98c.07-.044.148-.092.221-.132 1.173-.631 1.17-1.394-.013-3.301-1.354-2.185-3.87-4.955-8.296-7.144-1.703-.842-3.224-.362-4.618.396-4.817 2.932-9.012 6.161-12.569 9.6C46.448 53.23 37.23 76.32 33.231 88.318l-1.407 4.22c-.406 1.22-.8 2.397-1.185 3.546l-.149.437c-.38 1.13-.748 2.214-1.11 3.27-.008.02-.014.043-.022.061-.993 2.89-1.932 5.525-2.846 7.885h-.01c-.22.568-.44 1.153-.66 1.689l-.006.015a79.329 79.329 0 0 1-1.105 2.609 51.44 51.44 0 0 1-1.022 2.16l-.106.22c-2.926 5.805-5.963 8.77-10.193 8.77-8.5 0-6.377-11.156 6.71-15.516a18.611 18.611 0 0 1 3.791-.83c.064-.01.118-.024.188-.035 1.761-.268 1.54-1.49 2.059-3.246.376-1.273 1.282-2.543-.193-2.404a57.899 57.899 0 0 0-6.293 1.147c-23.152 5.477-24.366 25.123-11.446 28.413 9.656 2.457 17.26-3.099 23.006-11.475.24-.356.493-.743.75-1.154.084-.132.173-.28.26-.42.177-.29.354-.584.54-.901.102-.175.208-.362.313-.545.176-.305.355-.623.537-.95.11-.198.22-.393.33-.598.198-.364.4-.74.604-1.122.098-.188.197-.368.294-.557.304-.578.614-1.173.93-1.796.028-.052.055-.112.083-.164l.337-.668c1.406-2.791 2.94-5.988 4.603-9.504 1.64-3.475 3.4-7.248 5.277-11.245Z" />
15
+ <path d="M79.206 111.375c35.531-11.52 39.87-52.054 28.828-74.15a34.694 34.694 0 0 0-2.573-4.302c-4.379-6.306-10.605-10.865-17.802-14.027-5.346-2.35-11.22-3.94-17.254-4.914C43.94 9.708 6.14 18.749 5.74 39.95 5.354 60.374 30.548 57.9 32.533 58.025c2.247.14 1.678-1.678-.67-4.028-2.35-2.347-7.946-7.147-7.382-15.433.844-12.415 17.78-22.482 44.612-18.03 3.758.625 6.956 1.438 9.75 2.425 8.204 2.907 12.703 7.418 16.072 13.73a55.714 55.714 0 0 1 2.154 4.559c8.11 19.313-4.926 52.567-24.438 59.737-14.466 5.317-21.625 3.08-29.713 1.281-1.304-.287-1.495 1.185-2.543 3.446-1.047 2.264-2.092 3.906-1.052 4.293 9.395 3.44 20.77 7.563 39.883 1.37Z" />
16
+ </svg>
17
+ );
18
+ }
@@ -0,0 +1,117 @@
1
+ import { useEffect, useLayoutEffect, useState } from "react";
2
+ import { DOC_GEOM } from "../engine/paginate";
3
+ import type { SectionSettings } from "../engine/settings";
4
+ import type { Chunk, TextBlock } from "../engine/types";
5
+
6
+ const PX_PER_IN = 96;
7
+
8
+ function widthInFor(settings: SectionSettings | undefined): number {
9
+ return settings?.textPlacement === "leftColumn" ? DOC_GEOM.leftColWIn : DOC_GEOM.bodyWIn;
10
+ }
11
+
12
+ function blockClassName(block: TextBlock): string {
13
+ if (block.kind === "heading") return block.level === 2 ? "pdoc-h2" : "pdoc-h3";
14
+ return "pdoc-prose";
15
+ }
16
+
17
+ // Content that affects a block's measured height, serialized so the
18
+ // re-measure effect can key on structural equality rather than object
19
+ // identity (chunks are re-derived on every harvest() call). JSON-encoded —
20
+ // hand-joined delimiters can occur verbatim in content, letting two different
21
+ // block lists collide and the re-measure effect never fire.
22
+ function signatureFor(chunks: Chunk[], settingsMap: Record<string, SectionSettings>): string {
23
+ return JSON.stringify(
24
+ chunks.map((c) => [
25
+ c.id,
26
+ widthInFor(settingsMap[c.id]),
27
+ c.textBlocks.map((b) => (b.kind === "heading" ? ["h", b.level, b.text] : ["p", b.html])),
28
+ ]),
29
+ );
30
+ }
31
+
32
+ // Measures every text block of every chunk by rendering it into a hidden,
33
+ // fixed-position container at the chunk's real text width with the real
34
+ // pdoc-* classes (see document.css `.pdoc-measure`) — plain DOM nodes, not a
35
+ // React subtree, since measurement needs no reconciliation or events, only
36
+ // the browser's own layout of the real markup shape. Blocks get no CSS
37
+ // margins (pagination owns inter-block spacing via `gapIn`), so a wrapper's
38
+ // offsetHeight is the whole truth.
39
+ export function useTextMeasurements(
40
+ chunks: Chunk[],
41
+ settingsMap: Record<string, SectionSettings>,
42
+ ): { heights: Record<string, number[]> | null; textWidths: Record<string, number> | null } {
43
+ const [heights, setHeights] = useState<Record<string, number[]> | null>(null);
44
+ const [textWidths, setTextWidths] = useState<Record<string, number> | null>(null);
45
+ const [fontsReady, setFontsReady] = useState(false);
46
+
47
+ // jsdom (tests) has no document.fonts — optional-chain so this resolves
48
+ // immediately there instead of hanging measurement forever.
49
+ useEffect(() => {
50
+ let cancelled = false;
51
+ const markReady = () => {
52
+ if (!cancelled) setFontsReady(true);
53
+ };
54
+ // A rejected `fonts.ready` (e.g. a font failing to load) must not wedge
55
+ // `measuring` true forever — fall back to treating fonts as ready so
56
+ // measurement still runs with whatever's loaded.
57
+ Promise.resolve(document.fonts?.ready).then(markReady).catch(markReady);
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, []);
62
+
63
+ const signature = signatureFor(chunks, settingsMap);
64
+
65
+ useLayoutEffect(() => {
66
+ if (!fontsReady) return;
67
+ const containers: HTMLDivElement[] = [];
68
+ const next: Record<string, number[]> = {};
69
+ const nextW: Record<string, number> = {};
70
+
71
+ for (const chunk of chunks) {
72
+ if (chunk.textBlocks.length === 0) {
73
+ next[chunk.id] = [];
74
+ nextW[chunk.id] = 0;
75
+ continue;
76
+ }
77
+ const container = document.createElement("div");
78
+ container.className = "pdoc-measure";
79
+ container.style.width = `${widthInFor(settingsMap[chunk.id])}in`;
80
+ document.body.appendChild(container);
81
+ containers.push(container);
82
+
83
+ const measured: number[] = [];
84
+ let wMax = 0;
85
+ for (const block of chunk.textBlocks) {
86
+ const wrapper = document.createElement("div");
87
+ wrapper.className = blockClassName(block);
88
+ if (block.kind === "heading") {
89
+ wrapper.textContent = block.text;
90
+ } else {
91
+ wrapper.innerHTML = block.html;
92
+ }
93
+ // max-content + max-width:100% = shrink-to-fit capped at the column:
94
+ // wrapping blocks report the full cap (same wrap as before -> heights
95
+ // unchanged); short blocks report their natural width.
96
+ wrapper.style.width = "max-content";
97
+ wrapper.style.maxWidth = "100%";
98
+ container.appendChild(wrapper);
99
+ measured.push(wrapper.offsetHeight / PX_PER_IN);
100
+ wMax = Math.max(wMax, wrapper.getBoundingClientRect().width / PX_PER_IN);
101
+ }
102
+ next[chunk.id] = measured;
103
+ nextW[chunk.id] = wMax;
104
+ }
105
+
106
+ setHeights(next);
107
+ setTextWidths(nextW);
108
+ return () => {
109
+ for (const container of containers) container.remove();
110
+ };
111
+ // Re-measure only when chunk/width content actually changes, or once
112
+ // fonts finish loading (font swap changes heights).
113
+ // eslint-disable-next-line react-hooks/exhaustive-deps
114
+ }, [signature, fontsReady]);
115
+
116
+ return { heights, textWidths };
117
+ }
@@ -0,0 +1,85 @@
1
+ import { useMemo } from "react";
2
+ import { harvest } from "../engine/harvest";
3
+ import { DOC_GEOM, paginateChunk, type DerivedPage } from "../engine/paginate";
4
+ import { settingsFor, type SectionSettings } from "../engine/settings";
5
+ import type { Chunk, DocSourceContent, SplitAt } from "../engine/types";
6
+ import { useTextMeasurements } from "./measure";
7
+
8
+ export function useDocPages(input: {
9
+ source: DocSourceContent | null;
10
+ sections: Record<string, unknown>;
11
+ }): { chunks: Chunk[]; pages: DerivedPage[]; measuring: boolean; warningsByChunk: Record<string, string[]> } {
12
+ const { source, sections } = input;
13
+
14
+ // splitAt drives harvesting, so chunks must re-derive when it changes — but
15
+ // ONLY then. Keying on a JSON signature of just the non-default splitAt
16
+ // entries keeps chunk identities stable across unrelated settings edits
17
+ // (exclude, layout), which would otherwise churn measurement and onDocState.
18
+ const splitSig = useMemo(() => {
19
+ const m: Record<string, SplitAt> = {};
20
+ for (const id of Object.keys(sections ?? {})) {
21
+ const v = settingsFor(sections, id).splitAt;
22
+ if (v !== "none") m[id] = v;
23
+ }
24
+ return JSON.stringify(m);
25
+ }, [sections]);
26
+
27
+ const splitAtFor = useMemo(() => {
28
+ const m = JSON.parse(splitSig) as Record<string, SplitAt>;
29
+ return (id: string) => m[id] ?? "none";
30
+ }, [splitSig]);
31
+
32
+ const chunks = useMemo(
33
+ () => (source ? harvest(source.sections, source.pageTitle, source.images, splitAtFor) : []),
34
+ [source, splitAtFor],
35
+ );
36
+
37
+ const settingsMap = useMemo(() => {
38
+ const map: Record<string, SectionSettings> = {};
39
+ for (const c of chunks) map[c.id] = settingsFor(sections, c.id);
40
+ return map;
41
+ }, [chunks, sections]);
42
+
43
+ const { heights, textWidths } = useTextMeasurements(chunks, settingsMap);
44
+
45
+ // `heights` is state set by an effect, so it always lags one render behind
46
+ // `chunks`: when `source` changes shape (a block added/removed, a new
47
+ // section), this memo can run with heights measured for the PREVIOUS chunk
48
+ // shape. Calling paginateChunk with a stale (mismatched-length) heights
49
+ // array is its documented throw condition, so each chunk is guarded
50
+ // independently — a chunk whose current heights aren't in yet contributes
51
+ // no pages (rather than crash) until the measurement effect catches up.
52
+ // Zero-text chunks have nothing to measure, so they always paginate.
53
+ const pages = useMemo(() => {
54
+ return chunks.flatMap((c) => {
55
+ const h = heights?.[c.id];
56
+ if (c.textBlocks.length > 0 && (!h || h.length !== c.textBlocks.length)) return [];
57
+ return paginateChunk(c, settingsMap[c.id], h ?? [], DOC_GEOM, textWidths?.[c.id]);
58
+ });
59
+ }, [chunks, settingsMap, heights, textWidths]);
60
+
61
+ // Mirrors the pages memo's per-chunk guard: still "measuring" until every
62
+ // non-empty chunk has current heights, not just until `heights` is non-null.
63
+ const measuring = useMemo(
64
+ () =>
65
+ chunks.some((c) => {
66
+ if (c.textBlocks.length === 0) return false;
67
+ const h = heights?.[c.id];
68
+ return !h || h.length !== c.textBlocks.length;
69
+ }),
70
+ [chunks, heights],
71
+ );
72
+
73
+ const warningsByChunk = useMemo(() => {
74
+ const map: Record<string, string[]> = {};
75
+ for (const c of chunks) {
76
+ const noContent = c.textBlocks.length === 0 && c.images.length === 0;
77
+ const pageWarnings = pages.filter((p) => p.chunkId === c.id).flatMap((p) => p.warnings);
78
+ const warnings = noContent ? ["Section has no content", ...pageWarnings] : pageWarnings;
79
+ if (warnings.length > 0) map[c.id] = warnings;
80
+ }
81
+ return map;
82
+ }, [chunks, pages]);
83
+
84
+ return { chunks, pages, measuring, warningsByChunk };
85
+ }
@@ -0,0 +1,87 @@
1
+ // Elements that are never allowed to survive into rendered output, wherever
2
+ // they occur (top-level or nested).
3
+ const DISALLOWED_TAGS = new Set([
4
+ "script",
5
+ "style",
6
+ "iframe",
7
+ "object",
8
+ "embed",
9
+ "link",
10
+ "meta",
11
+ "base",
12
+ "form",
13
+ // Raw-text / escapable-raw-text and declarative elements whose contents are
14
+ // not parsed as normal markup. Left in place they enable serialize/reparse
15
+ // (mXSS) bypasses — e.g. an <img onerror> smuggled inside <noscript> text.
16
+ // Removing the element drops its contents too (including <template> content).
17
+ "noscript",
18
+ "xmp",
19
+ "noembed",
20
+ "noframes",
21
+ "plaintext",
22
+ "template",
23
+ ]);
24
+ const DISALLOWED_SELECTOR = Array.from(DISALLOWED_TAGS).join(",");
25
+
26
+ // Attributes that can carry a URL and therefore need scheme scrutiny.
27
+ const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction"]);
28
+
29
+ function isDangerousUrl(attrName: string, rawValue: string): boolean {
30
+ // Browsers strip ASCII tab/CR/LF anywhere in a URL (and ignore leading
31
+ // controls) before resolving its scheme, so "jav\tascript:" runs like
32
+ // "javascript:". Over-stripping here only makes the check more conservative.
33
+ const value = rawValue.toLowerCase().replace(/[\u0000-\u0020]/g, "");
34
+ if (value.startsWith("javascript:") || value.startsWith("vbscript:")) return true;
35
+ if (attrName === "src" && value.startsWith("data:text/html")) return true;
36
+ return false;
37
+ }
38
+
39
+ // Defense-in-depth scrub applied at the dangerouslySetInnerHTML/innerHTML
40
+ // boundary. Upstream portal content is sanitized at authoring time, but
41
+ // /print runs in an authed headless browser, so this engine (kept
42
+ // dependency-free — no DOMPurify) removes known-dangerous elements and
43
+ // attributes from the DOM it already parsed, in place.
44
+ function scrubElement(root: Element): void {
45
+ root.querySelectorAll(DISALLOWED_SELECTOR).forEach((el) => el.remove());
46
+
47
+ const elements: Element[] = [root, ...Array.from(root.querySelectorAll("*"))];
48
+ for (const el of elements) {
49
+ for (const attr of Array.from(el.attributes)) {
50
+ const name = attr.name.toLowerCase();
51
+ if (name.startsWith("on") || name === "srcdoc") {
52
+ el.removeAttribute(attr.name);
53
+ continue;
54
+ }
55
+ if (URL_ATTRS.has(name) && isDangerousUrl(name, attr.value)) {
56
+ el.removeAttribute(attr.name);
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // Split sanitized rich-text HTML into atomic top-level blocks. Pagination
63
+ // never splits inside a block — a block that doesn't fit moves wholesale to
64
+ // the next page (spec: "pagination by block measurement").
65
+ export function splitProseBlocks(html: string): string[] {
66
+ if (!html.trim()) return [];
67
+ const doc = new DOMParser().parseFromString(html, "text/html");
68
+ const blocks: string[] = [];
69
+ for (const node of Array.from(doc.body.childNodes)) {
70
+ if (node.nodeType === Node.ELEMENT_NODE) {
71
+ const el = node as Element;
72
+ if (DISALLOWED_TAGS.has(el.tagName.toLowerCase())) continue;
73
+ scrubElement(el);
74
+ if ((el.textContent ?? "").trim() || el.matches("img") || el.querySelector("img")) {
75
+ blocks.push(el.outerHTML);
76
+ }
77
+ } else if (node.nodeType === Node.TEXT_NODE) {
78
+ const text = (node.textContent ?? "").trim();
79
+ if (text) {
80
+ const p = doc.createElement("p");
81
+ p.textContent = text;
82
+ blocks.push(p.outerHTML);
83
+ }
84
+ }
85
+ }
86
+ return blocks;
87
+ }