@drawnagency/primitives 0.1.80 → 0.1.81

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-KVQFMVXE.js → chunk-DKXISNJ2.js} +2 -2
  2. package/dist/{chunk-RSITF24B.js → chunk-EVNEMJZ6.js} +9 -3
  3. package/dist/{chunk-VI26O4E3.js → chunk-FDBZZ4AT.js} +1 -1
  4. package/dist/{chunk-QB5Q23G2.js → chunk-FN4NKUZU.js} +1 -1
  5. package/dist/{chunk-EMGLZQCM.js → chunk-MBJUWOM2.js} +1 -1
  6. package/dist/{chunk-4TVODHD2.js → chunk-P4ZNHZMP.js} +6 -6
  7. package/dist/{chunk-UNUJPLZV.js → chunk-PJOHAWNV.js} +1 -1
  8. package/dist/{chunk-2DDWWTRX.js → chunk-Q6LZOH5M.js} +1 -1
  9. package/dist/chunk-WP5UX4MI.js +63 -0
  10. package/dist/{chunk-3LNKOLZP.js → chunk-YKOLOZB2.js} +1 -1
  11. package/dist/{chunk-7XWER6YR.js → chunk-ZEXR7JYQ.js} +3 -3
  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 +17 -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 +31 -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 +27 -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 +6 -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 +25 -0
  52. package/dist/document/engine/masonry.d.ts.map +1 -0
  53. package/dist/document/engine/paginate.d.ts +40 -0
  54. package/dist/document/engine/paginate.d.ts.map +1 -0
  55. package/dist/document/engine/settings.d.ts +70 -0
  56. package/dist/document/engine/settings.d.ts.map +1 -0
  57. package/dist/document/engine/types.d.ts +40 -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 +881 -0
  62. package/dist/document/styles/document.css +208 -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 +223 -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 +278 -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 +160 -0
  86. package/src/document/components/DocSectionPage.tsx +45 -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 +105 -0
  90. package/src/document/components/useDocPages.ts +67 -0
  91. package/src/document/engine/blocks.ts +87 -0
  92. package/src/document/engine/harvest.ts +80 -0
  93. package/src/document/engine/masonry.ts +89 -0
  94. package/src/document/engine/paginate.ts +209 -0
  95. package/src/document/engine/settings.ts +77 -0
  96. package/src/document/engine/types.ts +54 -0
  97. package/src/document/index.ts +18 -0
  98. package/src/document/styles/document.css +208 -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,160 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import type { DerivedPage } from "../engine/paginate";
3
+ import type { DocCover } from "../engine/settings";
4
+ import type { DocImage, DocMeta } from "../engine/types";
5
+ import { DocCoverPage } from "./DocCoverPage";
6
+ import { DocSectionPage, type DocHost } from "./DocSectionPage";
7
+
8
+ // 17in @96dpi = 1632px wide; thumbnails render the real page scaled to fill the
9
+ // rail width at a locked 17:11 aspect ratio (mirrors the harness PageRail).
10
+ const PAGE_W = 1632;
11
+ const PAGE_H = 1056;
12
+
13
+ export type RailEntry =
14
+ | { kind: "cover" }
15
+ | { kind: "page"; page: DerivedPage }
16
+ | { kind: "ghost"; chunkId: string; title: string; reason: "excluded" | "empty" };
17
+
18
+ export function entryId(entry: RailEntry): string {
19
+ if (entry.kind === "cover") return "cover";
20
+ if (entry.kind === "page") return entry.page.id;
21
+ return `ghost:${entry.chunkId}`;
22
+ }
23
+
24
+ function ThumbButton({
25
+ id,
26
+ selectedId,
27
+ scale,
28
+ label,
29
+ indent,
30
+ onSelect,
31
+ children,
32
+ }: {
33
+ id: string;
34
+ selectedId: string;
35
+ scale: number;
36
+ label: string;
37
+ indent?: boolean;
38
+ onSelect: (id: string) => void;
39
+ children: ReactNode;
40
+ }) {
41
+ return (
42
+ <li className={indent ? "pl-4" : undefined}>
43
+ <button
44
+ type="button"
45
+ onClick={() => onSelect(id)}
46
+ className={`relative block w-full rounded border-2 bg-white ${
47
+ id === selectedId ? "border-teal-600" : "border-neutral-200 hover:border-neutral-300"
48
+ }`}
49
+ >
50
+ <span className="pointer-events-none block w-full overflow-hidden aspect-[17/11]">
51
+ <span
52
+ className="block origin-top-left"
53
+ style={{ transform: `scale(${scale})`, width: PAGE_W, height: PAGE_H }}
54
+ >
55
+ {children}
56
+ </span>
57
+ </span>
58
+ <span className="absolute bottom-0.5 left-1 max-w-[90%] truncate text-[10px] text-neutral-400">
59
+ {label}
60
+ </span>
61
+ </button>
62
+ </li>
63
+ );
64
+ }
65
+
66
+ export function DocRail({
67
+ entries,
68
+ selectedId,
69
+ meta,
70
+ cover,
71
+ coverImage,
72
+ host,
73
+ onSelect,
74
+ onCollapse,
75
+ }: {
76
+ entries: RailEntry[];
77
+ selectedId: string;
78
+ meta: DocMeta;
79
+ cover: DocCover;
80
+ coverImage?: DocImage | null;
81
+ host: DocHost;
82
+ onSelect: (id: string) => void;
83
+ onCollapse: () => void;
84
+ }) {
85
+ const listRef = useRef<HTMLOListElement>(null);
86
+ const [scale, setScale] = useState(0.11);
87
+ useEffect(() => {
88
+ const el = listRef.current;
89
+ if (!el || typeof ResizeObserver === "undefined") return;
90
+ const obs = new ResizeObserver(() => {
91
+ // content width = list width minus the button's 2px border on each side
92
+ const w = el.clientWidth - 4;
93
+ if (w > 0) setScale(w / PAGE_W);
94
+ });
95
+ obs.observe(el);
96
+ return () => obs.disconnect();
97
+ }, []);
98
+
99
+ return (
100
+ <nav
101
+ data-testid="doc-rail"
102
+ className="flex w-52 shrink-0 flex-col overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3"
103
+ >
104
+ <button
105
+ type="button"
106
+ aria-label="Collapse page rail"
107
+ onClick={onCollapse}
108
+ className="mb-2 flex w-full items-center justify-between rounded px-2 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
109
+ >
110
+ <span>Pages</span>
111
+ <span aria-hidden="true" className="text-lg leading-none">‹</span>
112
+ </button>
113
+ <ol ref={listRef} className="flex flex-col gap-2">
114
+ {entries.map((entry) => {
115
+ const id = entryId(entry);
116
+ if (entry.kind === "cover") {
117
+ return (
118
+ <ThumbButton key={id} id={id} selectedId={selectedId} scale={scale} label="Cover" onSelect={onSelect}>
119
+ <DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
120
+ </ThumbButton>
121
+ );
122
+ }
123
+ if (entry.kind === "page") {
124
+ return (
125
+ <ThumbButton
126
+ key={id}
127
+ id={id}
128
+ selectedId={selectedId}
129
+ scale={scale}
130
+ label={entry.page.title}
131
+ indent={entry.page.cont}
132
+ onSelect={onSelect}
133
+ >
134
+ <DocSectionPage page={entry.page} meta={meta} host={host} />
135
+ </ThumbButton>
136
+ );
137
+ }
138
+ return (
139
+ <li key={id}>
140
+ <button
141
+ type="button"
142
+ onClick={() => onSelect(id)}
143
+ className={`flex w-full items-center justify-between gap-2 rounded border px-2 py-2 text-left text-xs ${
144
+ id === selectedId
145
+ ? "border-teal-600 bg-white"
146
+ : "border-dashed border-neutral-300 bg-neutral-50 hover:border-neutral-400"
147
+ }`}
148
+ >
149
+ <span className="truncate text-neutral-400">{entry.title}</span>
150
+ <span className="shrink-0 rounded bg-neutral-200 px-1 text-[10px] uppercase tracking-wide text-neutral-500">
151
+ {entry.reason}
152
+ </span>
153
+ </button>
154
+ </li>
155
+ );
156
+ })}
157
+ </ol>
158
+ </nav>
159
+ );
160
+ }
@@ -0,0 +1,45 @@
1
+ import type { DocImage, DocMeta } from "../engine/types";
2
+ import type { DerivedPage } from "../engine/paginate";
3
+ import { DocPage } from "./DocPage";
4
+
5
+ export interface DocHost {
6
+ imageSrc: (image: DocImage, variant: "preview" | "print") => string;
7
+ variant: "preview" | "print";
8
+ }
9
+
10
+ const inch = (v: number) => `${v}in`;
11
+
12
+ export function DocSectionPage({ page, meta, host }: { page: DerivedPage; meta: DocMeta; host: DocHost }) {
13
+ return (
14
+ <DocPage meta={meta}>
15
+ <h2 className="pdoc-title">{page.title}</h2>
16
+ {page.textRegion ? (
17
+ <div
18
+ className="pdoc-text"
19
+ style={{ left: inch(page.textRegion.xIn), top: inch(page.textRegion.yIn), width: inch(page.textRegion.wIn) }}
20
+ >
21
+ {page.textBlocks.map((b, i) => {
22
+ const spacing = i > 0 ? { marginTop: inch(0.15) } : undefined;
23
+ if (b.kind === "heading") {
24
+ return b.level === 2 ? (
25
+ <h3 key={i} className="pdoc-h2" style={spacing}>{b.text}</h3>
26
+ ) : (
27
+ <h4 key={i} className="pdoc-h3" style={spacing}>{b.text}</h4>
28
+ );
29
+ }
30
+ return <div key={i} className="pdoc-prose" style={spacing} dangerouslySetInnerHTML={{ __html: b.html }} />;
31
+ })}
32
+ </div>
33
+ ) : null}
34
+ {page.imageBoxes.map((box, i) => (
35
+ <div
36
+ key={`${box.image.imageId}-${i}`}
37
+ className="pdoc-img"
38
+ style={{ left: inch(box.xIn), top: inch(box.yIn), width: inch(box.wIn), height: inch(box.hIn) }}
39
+ >
40
+ <img src={host.imageSrc(box.image, host.variant)} alt={box.image.alt} style={{ objectFit: box.fit }} />
41
+ </div>
42
+ ))}
43
+ </DocPage>
44
+ );
45
+ }
@@ -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" });
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,105 @@
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 } {
43
+ const [heights, setHeights] = useState<Record<string, number[]> | null>(null);
44
+ const [fontsReady, setFontsReady] = useState(false);
45
+
46
+ // jsdom (tests) has no document.fonts — optional-chain so this resolves
47
+ // immediately there instead of hanging measurement forever.
48
+ useEffect(() => {
49
+ let cancelled = false;
50
+ const markReady = () => {
51
+ if (!cancelled) setFontsReady(true);
52
+ };
53
+ // A rejected `fonts.ready` (e.g. a font failing to load) must not wedge
54
+ // `measuring` true forever — fall back to treating fonts as ready so
55
+ // measurement still runs with whatever's loaded.
56
+ Promise.resolve(document.fonts?.ready).then(markReady).catch(markReady);
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, []);
61
+
62
+ const signature = signatureFor(chunks, settingsMap);
63
+
64
+ useLayoutEffect(() => {
65
+ if (!fontsReady) return;
66
+ const containers: HTMLDivElement[] = [];
67
+ const next: Record<string, number[]> = {};
68
+
69
+ for (const chunk of chunks) {
70
+ if (chunk.textBlocks.length === 0) {
71
+ next[chunk.id] = [];
72
+ continue;
73
+ }
74
+ const container = document.createElement("div");
75
+ container.className = "pdoc-measure";
76
+ container.style.width = `${widthInFor(settingsMap[chunk.id])}in`;
77
+ document.body.appendChild(container);
78
+ containers.push(container);
79
+
80
+ const measured: number[] = [];
81
+ for (const block of chunk.textBlocks) {
82
+ const wrapper = document.createElement("div");
83
+ wrapper.className = blockClassName(block);
84
+ if (block.kind === "heading") {
85
+ wrapper.textContent = block.text;
86
+ } else {
87
+ wrapper.innerHTML = block.html;
88
+ }
89
+ container.appendChild(wrapper);
90
+ measured.push(wrapper.offsetHeight / PX_PER_IN);
91
+ }
92
+ next[chunk.id] = measured;
93
+ }
94
+
95
+ setHeights(next);
96
+ return () => {
97
+ for (const container of containers) container.remove();
98
+ };
99
+ // Re-measure only when chunk/width content actually changes, or once
100
+ // fonts finish loading (font swap changes heights).
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ }, [signature, fontsReady]);
103
+
104
+ return { heights };
105
+ }