@drawnagency/primitives 0.1.118 → 0.1.119

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 (258) hide show
  1. package/dist/{chunk-O37AV27P.js → chunk-5JYHTHVR.js} +28 -0
  2. package/dist/{chunk-W72BPGR3.js → chunk-BZKHHUIN.js} +44 -8
  3. package/dist/{chunk-CEFFP3CH.js → chunk-G6O3TYPJ.js} +297 -83
  4. package/dist/{chunk-4NSOKEA7.js → chunk-HFHM4MNK.js} +12 -7
  5. package/dist/{chunk-RIITGTV3.js → chunk-LQHBC7C6.js} +37 -2
  6. package/dist/{chunk-VY3IFGE3.js → chunk-QAMAIDXZ.js} +1 -1
  7. package/dist/{chunk-HRX3XJBU.js → chunk-RIN2QCYJ.js} +9 -11
  8. package/dist/{chunk-ZOTRHHUB.js → chunk-UNFPATDE.js} +8 -10
  9. package/dist/{chunk-ERLIL4RU.js → chunk-YKKGVSJH.js} +35 -37
  10. package/dist/{chunk-67P34WNW.js → chunk-ZKVQMIMJ.js} +3 -20
  11. package/dist/collab/rebase.js +4 -5
  12. package/dist/components/chat/ChatComposer.d.ts +0 -4
  13. package/dist/components/chat/ChatComposer.d.ts.map +1 -1
  14. package/dist/components/chat/ChatPanel.d.ts +2 -3
  15. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  16. package/dist/components/chat/markdown.d.ts +2 -3
  17. package/dist/components/chat/markdown.d.ts.map +1 -1
  18. package/dist/components/editor/PagesModal.d.ts +0 -2
  19. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  20. package/dist/components/editor/index.js +15 -19
  21. package/dist/components/primitives/index.js +11 -15
  22. package/dist/components/sections/brand-guide.js +10 -13
  23. package/dist/components/sections/register-schemas.js +16 -21
  24. package/dist/components/sections/register.js +16 -21
  25. package/dist/components/shell/AudienceChips.d.ts +1 -3
  26. package/dist/components/shell/AudienceChips.d.ts.map +1 -1
  27. package/dist/components/shell/EditorShell.d.ts +0 -34
  28. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  29. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  30. package/dist/hooks/useSortableNavRow.d.ts +1 -1
  31. package/dist/hooks/useSortableRow.d.ts +1 -1
  32. package/dist/index.js +83 -90
  33. package/dist/lib/index-ops.js +5 -6
  34. package/dist/lib/index.js +27 -32
  35. package/dist/lib/pages.d.ts +0 -2
  36. package/dist/lib/pages.d.ts.map +1 -1
  37. package/dist/lib/sidecars.js +2 -2
  38. package/dist/lib/site-validation.js +13 -14
  39. package/dist/lib/storage-tier.js +4 -4
  40. package/dist/schemas/index.js +42 -45
  41. package/dist/schemas/site-config.d.ts +1 -277
  42. package/dist/schemas/site-config.d.ts.map +1 -1
  43. package/package.json +2 -42
  44. package/src/components/chat/ChatComposer.tsx +0 -4
  45. package/src/components/chat/ChatPanel.tsx +2 -3
  46. package/src/components/chat/markdown.ts +2 -3
  47. package/src/components/editor/PagesModal.tsx +1 -15
  48. package/src/components/editor/StatusIndicator.tsx +4 -4
  49. package/src/components/shell/AudienceChips.tsx +1 -3
  50. package/src/components/shell/EditorShell.tsx +5 -113
  51. package/src/components/shell/SiteSettingsModal.tsx +0 -11
  52. package/src/lib/pages.ts +0 -22
  53. package/src/schemas/index.ts +0 -4
  54. package/src/schemas/site-config.ts +4 -32
  55. package/dist/chunk-4TZJZU2T.js +0 -92
  56. package/dist/chunk-DIMDRBFK.js +0 -11
  57. package/dist/chunk-FV7JQRCN.js +0 -94
  58. package/dist/chunk-IIDKW7AV.js +0 -41
  59. package/dist/chunk-JCDZAFX2.js +0 -224
  60. package/dist/chunk-MM7KZS5C.js +0 -30
  61. package/dist/chunk-QQ3S3P5P.js +0 -38
  62. package/dist/chunk-TEOAKGZA.js +0 -784
  63. package/dist/chunk-VAYZJKVR.js +0 -209
  64. package/dist/components/shell/DocInspector.d.ts +0 -18
  65. package/dist/components/shell/DocInspector.d.ts.map +0 -1
  66. package/dist/components/shell/DocumentEditorOverlay.d.ts +0 -29
  67. package/dist/components/shell/DocumentEditorOverlay.d.ts.map +0 -1
  68. package/dist/components/shell/DocumentPresentOverlay.d.ts +0 -30
  69. package/dist/components/shell/DocumentPresentOverlay.d.ts.map +0 -1
  70. package/dist/components/shell/SiteSettingsDecks.d.ts +0 -13
  71. package/dist/components/shell/SiteSettingsDecks.d.ts.map +0 -1
  72. package/dist/deck/DeckComponentView.d.ts +0 -13
  73. package/dist/deck/DeckComponentView.d.ts.map +0 -1
  74. package/dist/deck/DeckPlayer.d.ts +0 -26
  75. package/dist/deck/DeckPlayer.d.ts.map +0 -1
  76. package/dist/deck/DeckSlideView.d.ts +0 -17
  77. package/dist/deck/DeckSlideView.d.ts.map +0 -1
  78. package/dist/deck/DeckStage.d.ts +0 -16
  79. package/dist/deck/DeckStage.d.ts.map +0 -1
  80. package/dist/deck/components/DeckImage.d.ts +0 -14
  81. package/dist/deck/components/DeckImage.d.ts.map +0 -1
  82. package/dist/deck/components/DeckImageGrid.d.ts +0 -27
  83. package/dist/deck/components/DeckImageGrid.d.ts.map +0 -1
  84. package/dist/deck/components/DeckRichText.d.ts +0 -22
  85. package/dist/deck/components/DeckRichText.d.ts.map +0 -1
  86. package/dist/deck/components/DeckVideo.d.ts +0 -15
  87. package/dist/deck/components/DeckVideo.d.ts.map +0 -1
  88. package/dist/deck/editing.d.ts +0 -50
  89. package/dist/deck/editing.d.ts.map +0 -1
  90. package/dist/deck/flip.d.ts +0 -11
  91. package/dist/deck/flip.d.ts.map +0 -1
  92. package/dist/deck/index.d.ts +0 -24
  93. package/dist/deck/index.d.ts.map +0 -1
  94. package/dist/deck/index.js +0 -51
  95. package/dist/deck/layouts/CenteredTextLayout.d.ts +0 -12
  96. package/dist/deck/layouts/CenteredTextLayout.d.ts.map +0 -1
  97. package/dist/deck/layouts/ColumnSplitLayout.d.ts +0 -14
  98. package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +0 -1
  99. package/dist/deck/layouts/FullBleedImageLayout.d.ts +0 -14
  100. package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +0 -1
  101. package/dist/deck/media.d.ts +0 -33
  102. package/dist/deck/media.d.ts.map +0 -1
  103. package/dist/deck/tokens.d.ts +0 -22
  104. package/dist/deck/tokens.d.ts.map +0 -1
  105. package/dist/deck/useStageScale.d.ts +0 -10
  106. package/dist/deck/useStageScale.d.ts.map +0 -1
  107. package/dist/deck-builder/DeckBuilderApp.d.ts +0 -30
  108. package/dist/deck-builder/DeckBuilderApp.d.ts.map +0 -1
  109. package/dist/deck-builder/DeckBuilderTopBar.d.ts +0 -24
  110. package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +0 -1
  111. package/dist/deck-builder/DeckChatPanel.d.ts +0 -45
  112. package/dist/deck-builder/DeckChatPanel.d.ts.map +0 -1
  113. package/dist/deck-builder/DeckEditor.d.ts +0 -37
  114. package/dist/deck-builder/DeckEditor.d.ts.map +0 -1
  115. package/dist/deck-builder/DeckLibrary.d.ts +0 -22
  116. package/dist/deck-builder/DeckLibrary.d.ts.map +0 -1
  117. package/dist/deck-builder/DeckSlideSurface.d.ts +0 -31
  118. package/dist/deck-builder/DeckSlideSurface.d.ts.map +0 -1
  119. package/dist/deck-builder/deck-api.d.ts +0 -76
  120. package/dist/deck-builder/deck-api.d.ts.map +0 -1
  121. package/dist/deck-builder/deck-chat-client.d.ts +0 -44
  122. package/dist/deck-builder/deck-chat-client.d.ts.map +0 -1
  123. package/dist/deck-builder/deck-chat-history.d.ts +0 -10
  124. package/dist/deck-builder/deck-chat-history.d.ts.map +0 -1
  125. package/dist/deck-builder/index.d.ts +0 -13
  126. package/dist/deck-builder/index.d.ts.map +0 -1
  127. package/dist/deck-builder/index.js +0 -1498
  128. package/dist/deck-builder/starter-deck.d.ts +0 -23
  129. package/dist/deck-builder/starter-deck.d.ts.map +0 -1
  130. package/dist/deck-builder/types.d.ts +0 -29
  131. package/dist/deck-builder/types.d.ts.map +0 -1
  132. package/dist/deck-builder/useSortableSlide.d.ts +0 -29
  133. package/dist/deck-builder/useSortableSlide.d.ts.map +0 -1
  134. package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  135. package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  136. package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  137. package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  138. package/dist/document/components/DocCoverPage.d.ts +0 -11
  139. package/dist/document/components/DocCoverPage.d.ts.map +0 -1
  140. package/dist/document/components/DocPage.d.ts +0 -10
  141. package/dist/document/components/DocPage.d.ts.map +0 -1
  142. package/dist/document/components/DocRail.d.ts +0 -28
  143. package/dist/document/components/DocRail.d.ts.map +0 -1
  144. package/dist/document/components/DocSectionPage.d.ts +0 -12
  145. package/dist/document/components/DocSectionPage.d.ts.map +0 -1
  146. package/dist/document/components/DocViewer.d.ts +0 -21
  147. package/dist/document/components/DocViewer.d.ts.map +0 -1
  148. package/dist/document/components/Wordmark.d.ts +0 -4
  149. package/dist/document/components/Wordmark.d.ts.map +0 -1
  150. package/dist/document/components/measure.d.ts +0 -7
  151. package/dist/document/components/measure.d.ts.map +0 -1
  152. package/dist/document/components/useDocPages.d.ts +0 -12
  153. package/dist/document/components/useDocPages.d.ts.map +0 -1
  154. package/dist/document/engine/blocks.d.ts +0 -2
  155. package/dist/document/engine/blocks.d.ts.map +0 -1
  156. package/dist/document/engine/harvest.d.ts +0 -3
  157. package/dist/document/engine/harvest.d.ts.map +0 -1
  158. package/dist/document/engine/masonry.d.ts +0 -26
  159. package/dist/document/engine/masonry.d.ts.map +0 -1
  160. package/dist/document/engine/paginate.d.ts +0 -43
  161. package/dist/document/engine/paginate.d.ts.map +0 -1
  162. package/dist/document/engine/settings.d.ts +0 -91
  163. package/dist/document/engine/settings.d.ts.map +0 -1
  164. package/dist/document/engine/types.d.ts +0 -44
  165. package/dist/document/engine/types.d.ts.map +0 -1
  166. package/dist/document/index.d.ts +0 -13
  167. package/dist/document/index.d.ts.map +0 -1
  168. package/dist/document/index.js +0 -1030
  169. package/dist/document/styles/document.css +0 -217
  170. package/dist/lib/deck-access.d.ts +0 -86
  171. package/dist/lib/deck-access.d.ts.map +0 -1
  172. package/dist/lib/deck-access.js +0 -58
  173. package/dist/lib/deck-dexie.d.ts +0 -88
  174. package/dist/lib/deck-dexie.d.ts.map +0 -1
  175. package/dist/lib/deck-dexie.js +0 -22
  176. package/dist/lib/deck-edit.d.ts +0 -67
  177. package/dist/lib/deck-edit.d.ts.map +0 -1
  178. package/dist/lib/deck-media-assets.d.ts +0 -27
  179. package/dist/lib/deck-media-assets.d.ts.map +0 -1
  180. package/dist/lib/deck-media-assets.js +0 -25
  181. package/dist/lib/deck-media-refs.d.ts +0 -35
  182. package/dist/lib/deck-media-refs.d.ts.map +0 -1
  183. package/dist/lib/deck-media-refs.js +0 -36
  184. package/dist/lib/deck-tool.d.ts +0 -25
  185. package/dist/lib/deck-tool.d.ts.map +0 -1
  186. package/dist/lib/deck-tool.js +0 -58
  187. package/dist/lib/document-input.d.ts +0 -25
  188. package/dist/lib/document-input.d.ts.map +0 -1
  189. package/dist/schemas/deck-meta.d.ts +0 -85
  190. package/dist/schemas/deck-meta.d.ts.map +0 -1
  191. package/dist/schemas/deck-meta.js +0 -64
  192. package/dist/schemas/deck.d.ts +0 -954
  193. package/dist/schemas/deck.d.ts.map +0 -1
  194. package/dist/schemas/deck.js +0 -65
  195. package/src/components/shell/DocInspector.tsx +0 -314
  196. package/src/components/shell/DocumentEditorOverlay.tsx +0 -179
  197. package/src/components/shell/DocumentPresentOverlay.tsx +0 -87
  198. package/src/components/shell/SiteSettingsDecks.tsx +0 -41
  199. package/src/deck/DeckComponentView.tsx +0 -28
  200. package/src/deck/DeckPlayer.tsx +0 -166
  201. package/src/deck/DeckSlideView.tsx +0 -38
  202. package/src/deck/DeckStage.tsx +0 -39
  203. package/src/deck/components/DeckImage.tsx +0 -38
  204. package/src/deck/components/DeckImageGrid.tsx +0 -254
  205. package/src/deck/components/DeckRichText.tsx +0 -111
  206. package/src/deck/components/DeckVideo.tsx +0 -81
  207. package/src/deck/editing.tsx +0 -201
  208. package/src/deck/flip.ts +0 -70
  209. package/src/deck/index.ts +0 -30
  210. package/src/deck/layouts/CenteredTextLayout.tsx +0 -29
  211. package/src/deck/layouts/ColumnSplitLayout.tsx +0 -69
  212. package/src/deck/layouts/FullBleedImageLayout.tsx +0 -58
  213. package/src/deck/media.tsx +0 -46
  214. package/src/deck/tokens.ts +0 -57
  215. package/src/deck/useStageScale.ts +0 -44
  216. package/src/deck-builder/DeckBuilderApp.tsx +0 -572
  217. package/src/deck-builder/DeckBuilderTopBar.tsx +0 -62
  218. package/src/deck-builder/DeckChatPanel.tsx +0 -334
  219. package/src/deck-builder/DeckEditor.tsx +0 -258
  220. package/src/deck-builder/DeckLibrary.tsx +0 -195
  221. package/src/deck-builder/DeckSlideSurface.tsx +0 -55
  222. package/src/deck-builder/deck-api.ts +0 -177
  223. package/src/deck-builder/deck-chat-client.ts +0 -129
  224. package/src/deck-builder/deck-chat-history.ts +0 -62
  225. package/src/deck-builder/index.ts +0 -12
  226. package/src/deck-builder/starter-deck.ts +0 -86
  227. package/src/deck-builder/types.ts +0 -24
  228. package/src/deck-builder/useSortableSlide.ts +0 -102
  229. package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
  230. package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
  231. package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
  232. package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
  233. package/src/document/components/DocCoverPage.tsx +0 -45
  234. package/src/document/components/DocPage.tsx +0 -40
  235. package/src/document/components/DocRail.tsx +0 -189
  236. package/src/document/components/DocSectionPage.tsx +0 -48
  237. package/src/document/components/DocViewer.tsx +0 -313
  238. package/src/document/components/Wordmark.tsx +0 -18
  239. package/src/document/components/measure.tsx +0 -133
  240. package/src/document/components/useDocPages.ts +0 -95
  241. package/src/document/engine/blocks.ts +0 -116
  242. package/src/document/engine/harvest.ts +0 -95
  243. package/src/document/engine/masonry.ts +0 -158
  244. package/src/document/engine/paginate.ts +0 -232
  245. package/src/document/engine/settings.ts +0 -125
  246. package/src/document/engine/types.ts +0 -64
  247. package/src/document/index.ts +0 -18
  248. package/src/document/styles/document.css +0 -217
  249. package/src/lib/deck-access.ts +0 -132
  250. package/src/lib/deck-dexie.ts +0 -190
  251. package/src/lib/deck-edit.ts +0 -196
  252. package/src/lib/deck-media-assets.ts +0 -57
  253. package/src/lib/deck-media-refs.ts +0 -67
  254. package/src/lib/deck-tool.ts +0 -95
  255. package/src/lib/document-input.ts +0 -57
  256. package/src/schemas/deck-meta.ts +0 -88
  257. package/src/schemas/deck.ts +0 -478
  258. package/dist/{chunk-DJABADOO.js → chunk-AJ2QOAZI.js} +3 -3
@@ -1,313 +0,0 @@
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
- /** Present mode only: when set, the toolbar shows a "Download PDF" link (the /present
24
- * route passes the gated /api/document-pdf href when a PDF exists for this viewer). */
25
- downloadHref?: string | null;
26
- // Optional pagination-state hook (present mode unaffected — the present route
27
- // never passes it). Fires, identity-guarded, whenever the internal useDocPages
28
- // output changes, so a host (the edit overlay) can reuse this single pass for
29
- // its inspector titles/warnings instead of running a second useDocPages.
30
- onDocState?: (state: { chunks: Chunk[]; warningsByChunk: Record<string, string[]> }) => void;
31
- }
32
-
33
- // Rail-entry id → the chunk it belongs to (null for the cover).
34
- function chunkIdOf(id: string): string | null {
35
- if (id === "cover") return null;
36
- if (id.startsWith("ghost:")) return id.slice("ghost:".length);
37
- if (id.includes("#")) return id.slice(0, id.lastIndexOf("#"));
38
- return id;
39
- }
40
-
41
- export function DocViewer({
42
- source,
43
- document: doc,
44
- host,
45
- mode,
46
- selectedChunkId,
47
- onSelectChunk,
48
- onClose,
49
- downloadHref,
50
- onDocState,
51
- }: DocViewerProps) {
52
- const { chunks, pages, measuring, warningsByChunk } = useDocPages({ source, sections: doc.sections });
53
- const [activeId, setActiveId] = useState<string>("cover");
54
- const [railOpen, setRailOpen] = useState(true);
55
-
56
- // Resolve the cover's media-library image once here; missing id or missing
57
- // source entry (deleted image) both degrade to null → solid cover.
58
- const coverImage: DocImage | null = doc.cover.imageId ? (source?.images[doc.cover.imageId] ?? null) : null;
59
-
60
- // Publish the pagination output to an optional host, guarded so it only fires
61
- // when the memoized chunks/warnings identities actually change (and reading
62
- // onDocState via a ref so an unstable callback identity can't cause a loop).
63
- const onDocStateRef = useRef(onDocState);
64
- onDocStateRef.current = onDocState;
65
- const lastEmitRef = useRef<{ chunks: Chunk[]; warningsByChunk: Record<string, string[]> } | null>(null);
66
- useEffect(() => {
67
- if (lastEmitRef.current?.chunks === chunks && lastEmitRef.current?.warningsByChunk === warningsByChunk) return;
68
- lastEmitRef.current = { chunks, warningsByChunk };
69
- onDocStateRef.current?.({ chunks, warningsByChunk });
70
- }, [chunks, warningsByChunk]);
71
-
72
- // Present mode: excluded/empty chunks are simply absent. Edit mode: they
73
- // surface as ghost entries (excluded takes priority over empty).
74
- const entries: RailEntry[] = useMemo(() => {
75
- const list: RailEntry[] = [{ kind: "cover" }];
76
- for (const chunk of chunks) {
77
- const s = settingsFor(doc.sections, chunk.id);
78
- const empty = chunk.textBlocks.length === 0 && chunk.images.length === 0;
79
- if (s.excluded || empty) {
80
- if (mode === "edit") {
81
- list.push({ kind: "ghost", chunkId: chunk.id, title: chunk.title, reason: s.excluded ? "excluded" : "empty", depth: chunk.depth });
82
- }
83
- } else {
84
- for (const page of pages.filter((p) => p.chunkId === chunk.id)) list.push({ kind: "page", page });
85
- }
86
- }
87
- return list;
88
- }, [chunks, pages, doc.sections, mode]);
89
-
90
- const selectEntry = useCallback(
91
- (id: string) => {
92
- setActiveId(id);
93
- if (mode === "edit") onSelectChunk?.(chunkIdOf(id));
94
- },
95
- [mode, onSelectChunk],
96
- );
97
-
98
- // Stale active-entry guard: if the active entry vanished (chunk excluded, page
99
- // count shrank), re-point to the matching ghost or the cover. Guarded on
100
- // !measuring so transient empty-`pages` frames during layout don't churn it.
101
- useEffect(() => {
102
- if (measuring) return;
103
- if (entries.some((e) => entryId(e) === activeId)) return;
104
- const chunkId = chunkIdOf(activeId);
105
- const ghost = entries.find((e) => e.kind === "ghost" && e.chunkId === chunkId);
106
- setActiveId(ghost ? entryId(ghost) : "cover");
107
- }, [entries, activeId, measuring]);
108
-
109
- // Controlled selection (edit): follow an externally-driven selectedChunkId.
110
- // Undefined means uncontrolled — the viewer owns selection outright.
111
- useEffect(() => {
112
- if (mode !== "edit" || selectedChunkId === undefined) return;
113
- if (chunkIdOf(activeId) === selectedChunkId) return;
114
- if (selectedChunkId === null) {
115
- setActiveId("cover");
116
- return;
117
- }
118
- const match = entries.find(
119
- (e) =>
120
- (e.kind === "page" && e.page.chunkId === selectedChunkId) ||
121
- (e.kind === "ghost" && e.chunkId === selectedChunkId),
122
- );
123
- if (match) setActiveId(entryId(match));
124
- }, [selectedChunkId, mode, entries, activeId]);
125
-
126
- // Arrow-key paging on `window`. Ignores events targeting form fields, clamps
127
- // at the ends, preventDefault only when it consumes the key, listener removed
128
- // on unmount. A ref keeps the handler reading the live active id without
129
- // resubscribing on every step.
130
- const activeIdRef = useRef(activeId);
131
- activeIdRef.current = activeId;
132
- useEffect(() => {
133
- // Present mode only. In the editor overlay the same keys belong to the
134
- // surface the author is working in, and a window-level listener stole them
135
- // to flip the previewed page instead.
136
- if (mode !== "present") return;
137
- const onKey = (e: KeyboardEvent) => {
138
- const t = e.target as HTMLElement | null;
139
- if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
140
- let dir = 0;
141
- if (e.key === "ArrowRight" || e.key === "ArrowDown") dir = 1;
142
- else if (e.key === "ArrowLeft" || e.key === "ArrowUp") dir = -1;
143
- else return;
144
- e.preventDefault();
145
- const ids = entries.map(entryId);
146
- const cur = Math.max(0, ids.indexOf(activeIdRef.current));
147
- const next = Math.min(ids.length - 1, Math.max(0, cur + dir));
148
- if (ids[next] !== activeIdRef.current) selectEntry(ids[next]);
149
- };
150
- window.addEventListener("keydown", onKey);
151
- return () => window.removeEventListener("keydown", onKey);
152
- }, [entries, selectEntry, mode]);
153
-
154
- const active = entries.find((e) => entryId(e) === activeId) ?? null;
155
-
156
- return (
157
- // min-w-0 (here and on the rail/canvas row): without it these flex items
158
- // refuse to shrink below their content width, and opening the rail shoves
159
- // the overlay's inspector off the right edge instead of narrowing the
160
- // canvas.
161
- <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
162
- {mode === "present" ? (
163
- <div className="flex items-center justify-between border-b border-base-200 bg-base px-4 py-2">
164
- <span className="text-sm font-medium text-base-contrast">{source?.pageTitle ?? "Document"}</span>
165
- <div className="flex items-center gap-2">
166
- {downloadHref ? (
167
- <a
168
- href={downloadHref}
169
- download
170
- aria-label="Download PDF"
171
- className="rounded border border-base-200 px-3 py-1 text-xs font-medium text-base-contrast hover:bg-base-accent/30"
172
- >
173
- Download PDF
174
- </a>
175
- ) : null}
176
- {onClose ? (
177
- <button
178
- type="button"
179
- aria-label="Close"
180
- onClick={onClose}
181
- className="rounded border border-base-200 px-3 py-1 text-xs font-medium text-base-contrast hover:bg-base-accent/30"
182
- >
183
- ✕
184
- </button>
185
- ) : null}
186
- </div>
187
- </div>
188
- ) : null}
189
- <div className="flex min-h-0 min-w-0 flex-1">
190
- {railOpen ? (
191
- <DocRail
192
- entries={entries}
193
- selectedId={activeId}
194
- meta={doc.meta}
195
- cover={doc.cover}
196
- coverImage={coverImage}
197
- host={host}
198
- onSelect={selectEntry}
199
- onCollapse={() => setRailOpen(false)}
200
- />
201
- ) : (
202
- <div className="flex w-10 shrink-0 flex-col border-r border-neutral-200 bg-neutral-50">
203
- <button
204
- type="button"
205
- aria-label="Expand page rail"
206
- onClick={() => setRailOpen(true)}
207
- className="px-2 py-3 text-lg leading-none text-neutral-500 hover:text-neutral-800"
208
- >
209
- ›
210
- </button>
211
- </div>
212
- )}
213
- <DocCanvas
214
- active={active}
215
- cover={doc.cover}
216
- coverImage={coverImage}
217
- meta={doc.meta}
218
- host={host}
219
- measuring={measuring}
220
- noContent={chunks.length === 0}
221
- />
222
- </div>
223
- </div>
224
- );
225
- }
226
-
227
- function DocCanvas({
228
- active,
229
- cover,
230
- coverImage,
231
- meta,
232
- host,
233
- measuring,
234
- noContent,
235
- }: {
236
- active: RailEntry | null;
237
- cover: DocumentOptions["cover"];
238
- coverImage: DocImage | null;
239
- meta: DocumentOptions["meta"];
240
- host: DocHost;
241
- measuring: boolean;
242
- noContent: boolean;
243
- }) {
244
- const containerRef = useRef<HTMLDivElement>(null);
245
- const [scale, setScale] = useState(0.4);
246
- useEffect(() => {
247
- const el = containerRef.current;
248
- if (!el || typeof ResizeObserver === "undefined") return;
249
- const observer = new ResizeObserver(() => {
250
- // Fill the canvas: fit both dimensions (clientWidth/Height include the
251
- // p-6 padding — subtract it) and take the limiting axis. The page keeps
252
- // its true 1632×1056 layout and is transform-scaled, so what's shown is
253
- // byte-identical to the print layout at any size.
254
- const availW = el.clientWidth - 48;
255
- const availH = el.clientHeight - 48;
256
- setScale(Math.max(0.15, Math.min(availW / PAGE_W, availH / PAGE_H)));
257
- });
258
- observer.observe(el);
259
- return () => observer.disconnect();
260
- }, []);
261
-
262
- const note = (text: string) => <p className="text-sm text-neutral-400">{text}</p>;
263
-
264
- let body: ReactNode;
265
- if (active?.kind === "cover") {
266
- body = noContent ? (
267
- <div className="flex flex-col items-center gap-4">
268
- <Scaled scale={scale}>
269
- <DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
270
- </Scaled>
271
- <p className="text-sm text-neutral-400">This page has no displayable content.</p>
272
- </div>
273
- ) : (
274
- <Scaled scale={scale}>
275
- <DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
276
- </Scaled>
277
- );
278
- } else if (active?.kind === "page") {
279
- body = (
280
- <Scaled scale={scale}>
281
- <DocSectionPage page={active.page} meta={meta} host={host} />
282
- </Scaled>
283
- );
284
- } else if (active?.kind === "ghost") {
285
- body = note(active.reason === "excluded" ? "Excluded — re-include from the inspector" : "No displayable content");
286
- } else {
287
- // Active id resolved to nothing — its page is still being measured.
288
- body = measuring ? note("Laying out…") : note("This page has no displayable content.");
289
- }
290
-
291
- return (
292
- <div
293
- ref={containerRef}
294
- data-testid="doc-canvas"
295
- className="flex min-h-0 min-w-0 flex-1 overflow-auto bg-neutral-100 p-6"
296
- >
297
- {/* m-auto instead of items/justify-center: centers both axes when the
298
- page fits, but keeps the top/left edge reachable when it overflows
299
- (flex centering clips overflow irretrievably; auto margins don't). */}
300
- <div className="m-auto">{body}</div>
301
- </div>
302
- );
303
- }
304
-
305
- function Scaled({ scale, children }: { scale: number; children: ReactNode }) {
306
- return (
307
- <div className="shrink-0 overflow-hidden shadow-lg" style={{ width: PAGE_W * scale, height: PAGE_H * scale }}>
308
- <div className="origin-top-left" style={{ transform: `scale(${scale})`, width: PAGE_W, height: PAGE_H }}>
309
- {children}
310
- </div>
311
- </div>
312
- );
313
- }
@@ -1,18 +0,0 @@
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
- }
@@ -1,133 +0,0 @@
1
- import { useEffect, useLayoutEffect, useMemo, 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
- // Memoized: this walks every chunk's full prose HTML through JSON.stringify,
64
- // and it ran on every DocViewer render. Both inputs are memoized upstream
65
- // (useDocPages), so the identity check actually holds.
66
- const signature = useMemo(() => signatureFor(chunks, settingsMap), [chunks, settingsMap]);
67
-
68
- useLayoutEffect(() => {
69
- if (!fontsReady) return;
70
- const containers: HTMLDivElement[] = [];
71
- const next: Record<string, number[]> = {};
72
- const nextW: Record<string, number> = {};
73
-
74
- // WRITE PASS. Every wrapper is appended before anything is read: interleaving
75
- // the two forced a synchronous reflow per text block, so a 200-block document
76
- // flushed layout ~400 times inside this one layout effect.
77
- const pending: { chunkId: string; wrappers: HTMLDivElement[] }[] = [];
78
- for (const chunk of chunks) {
79
- if (chunk.textBlocks.length === 0) {
80
- next[chunk.id] = [];
81
- nextW[chunk.id] = 0;
82
- continue;
83
- }
84
- const container = document.createElement("div");
85
- container.className = "pdoc-measure";
86
- container.style.width = `${widthInFor(settingsMap[chunk.id])}in`;
87
- document.body.appendChild(container);
88
- containers.push(container);
89
-
90
- const wrappers: HTMLDivElement[] = [];
91
- for (const block of chunk.textBlocks) {
92
- const wrapper = document.createElement("div");
93
- wrapper.className = blockClassName(block);
94
- if (block.kind === "heading") {
95
- wrapper.textContent = block.text;
96
- } else {
97
- wrapper.innerHTML = block.html;
98
- }
99
- // max-content + max-width:100% = shrink-to-fit capped at the column:
100
- // wrapping blocks report the full cap (same wrap as before -> heights
101
- // unchanged); short blocks report their natural width.
102
- wrapper.style.width = "max-content";
103
- wrapper.style.maxWidth = "100%";
104
- container.appendChild(wrapper);
105
- wrappers.push(wrapper);
106
- }
107
- pending.push({ chunkId: chunk.id, wrappers });
108
- }
109
-
110
- // READ PASS. One layout for the whole document.
111
- for (const { chunkId, wrappers } of pending) {
112
- const measured: number[] = [];
113
- let wMax = 0;
114
- for (const wrapper of wrappers) {
115
- measured.push(wrapper.offsetHeight / PX_PER_IN);
116
- wMax = Math.max(wMax, wrapper.getBoundingClientRect().width / PX_PER_IN);
117
- }
118
- next[chunkId] = measured;
119
- nextW[chunkId] = wMax;
120
- }
121
-
122
- setHeights(next);
123
- setTextWidths(nextW);
124
- return () => {
125
- for (const container of containers) container.remove();
126
- };
127
- // Re-measure only when chunk/width content actually changes, or once
128
- // fonts finish loading (font swap changes heights).
129
- // eslint-disable-next-line react-hooks/exhaustive-deps
130
- }, [signature, fontsReady]);
131
-
132
- return { heights, textWidths };
133
- }
@@ -1,95 +0,0 @@
1
- import { useMemo } from "react";
2
- import { harvest } from "../engine/harvest";
3
- import { DOC_GEOM, paginateChunk, type DerivedPage } from "../engine/paginate";
4
- import { settingsFor, settingsIssuesFor, 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
- // A stored setting the schema no longer recognises is reset to its
79
- // default. Silent, that reads as the document ignoring an explicit
80
- // choice — most visibly `excluded`, whose default puts the section back
81
- // in the output.
82
- const rejected = settingsIssuesFor(sections, c.id);
83
- const settingWarnings = rejected.length > 0
84
- ? [`Unrecognised value reset to default: ${rejected.join(", ")}`]
85
- : [];
86
- const warnings = noContent
87
- ? ["Section has no content", ...settingWarnings, ...pageWarnings]
88
- : [...settingWarnings, ...pageWarnings];
89
- if (warnings.length > 0) map[c.id] = warnings;
90
- }
91
- return map;
92
- }, [chunks, pages, sections]);
93
-
94
- return { chunks, pages, measuring, warningsByChunk };
95
- }