@drawnagency/primitives 0.1.81 → 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.
- package/dist/{chunk-ZEXR7JYQ.js → chunk-22GU6GNP.js} +1 -1
- package/dist/{chunk-P4ZNHZMP.js → chunk-5UDIQLGQ.js} +2 -2
- package/dist/{chunk-PJOHAWNV.js → chunk-6NRVX6NQ.js} +1 -1
- package/dist/{chunk-EVNEMJZ6.js → chunk-ETJYYUQX.js} +1 -1
- package/dist/{chunk-WP5UX4MI.js → chunk-FM4M4Q2W.js} +12 -3
- package/dist/{chunk-FN4NKUZU.js → chunk-Y54G2XSC.js} +1 -1
- package/dist/components/editor/index.js +2 -2
- package/dist/components/primitives/index.js +2 -2
- package/dist/components/sections/brand-guide.js +3 -3
- package/dist/components/sections/register-schemas.js +4 -4
- package/dist/components/sections/register.js +4 -4
- package/dist/components/shell/DocInspector.d.ts +1 -0
- package/dist/components/shell/DocInspector.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts +3 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/document/components/DocRail.d.ts +1 -0
- package/dist/document/components/DocRail.d.ts.map +1 -1
- package/dist/document/components/DocSectionPage.d.ts.map +1 -1
- package/dist/document/components/measure.d.ts +1 -0
- package/dist/document/components/measure.d.ts.map +1 -1
- package/dist/document/components/useDocPages.d.ts.map +1 -1
- package/dist/document/engine/harvest.d.ts +2 -2
- package/dist/document/engine/harvest.d.ts.map +1 -1
- package/dist/document/engine/masonry.d.ts +1 -0
- package/dist/document/engine/masonry.d.ts.map +1 -1
- package/dist/document/engine/paginate.d.ts +4 -1
- package/dist/document/engine/paginate.d.ts.map +1 -1
- package/dist/document/engine/settings.d.ts +18 -0
- package/dist/document/engine/settings.d.ts.map +1 -1
- package/dist/document/engine/types.d.ts +4 -0
- package/dist/document/engine/types.d.ts.map +1 -1
- package/dist/document/index.js +109 -25
- package/dist/document/styles/document.css +9 -0
- package/dist/index.js +4 -4
- package/dist/lib/index.js +3 -3
- package/dist/media/index.js +2 -2
- package/dist/schemas/index.js +3 -3
- package/dist/schemas/site-config.d.ts +42 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/shell/DocInspector.tsx +46 -1
- package/src/components/shell/DocumentEditorOverlay.tsx +1 -1
- package/src/document/components/DocRail.tsx +10 -6
- package/src/document/components/DocSectionPage.tsx +4 -1
- package/src/document/components/DocViewer.tsx +1 -1
- package/src/document/components/measure.tsx +14 -2
- package/src/document/components/useDocPages.ts +25 -7
- package/src/document/engine/harvest.ts +21 -6
- package/src/document/engine/masonry.ts +64 -1
- package/src/document/engine/paginate.ts +24 -4
- package/src/document/engine/settings.ts +12 -1
- package/src/document/engine/types.ts +12 -2
- package/src/document/styles/document.css +9 -0
|
@@ -21,7 +21,7 @@ function thumbSrc(imageId: string, item: MediaItem | undefined): string | null {
|
|
|
21
21
|
|
|
22
22
|
export interface DocInspectorProps {
|
|
23
23
|
document: DocumentOptions;
|
|
24
|
-
selection: { kind: "cover" } | { kind: "chunk"; chunkId: string; title: string };
|
|
24
|
+
selection: { kind: "cover" } | { kind: "chunk"; chunkId: string; title: string; depth?: number };
|
|
25
25
|
warnings: string[]; // warningsByChunk for the selected chunk
|
|
26
26
|
onUpdateCover: (patch: Partial<DocumentOptions["cover"]>) => void;
|
|
27
27
|
onUpdateMeta: (patch: Partial<DocumentOptions["meta"]>) => void;
|
|
@@ -84,6 +84,7 @@ export function DocInspector({
|
|
|
84
84
|
key={selection.chunkId}
|
|
85
85
|
chunkId={selection.chunkId}
|
|
86
86
|
title={selection.title}
|
|
87
|
+
depth={selection.depth}
|
|
87
88
|
doc={doc}
|
|
88
89
|
warnings={warnings}
|
|
89
90
|
onUpdateSection={onUpdateSection}
|
|
@@ -153,12 +154,14 @@ function CoverPanel({
|
|
|
153
154
|
function ChunkPanel({
|
|
154
155
|
chunkId,
|
|
155
156
|
title,
|
|
157
|
+
depth,
|
|
156
158
|
doc,
|
|
157
159
|
warnings,
|
|
158
160
|
onUpdateSection,
|
|
159
161
|
}: {
|
|
160
162
|
chunkId: string;
|
|
161
163
|
title: string;
|
|
164
|
+
depth?: number;
|
|
162
165
|
doc: DocumentOptions;
|
|
163
166
|
warnings: string[];
|
|
164
167
|
onUpdateSection: (chunkId: string, patch: Record<string, unknown>) => void;
|
|
@@ -177,6 +180,22 @@ function ChunkPanel({
|
|
|
177
180
|
label="Include in document"
|
|
178
181
|
/>
|
|
179
182
|
|
|
183
|
+
{/* Only parent sections own the boundary setting — a sub-page can't
|
|
184
|
+
split further, and its entry would be inert anyway (harvest reads
|
|
185
|
+
splitAt from parent ids only). */}
|
|
186
|
+
{(depth ?? 0) === 0 ? (
|
|
187
|
+
<Select
|
|
188
|
+
className="mb-4"
|
|
189
|
+
label="Split pages at"
|
|
190
|
+
value={settings.splitAt}
|
|
191
|
+
options={[
|
|
192
|
+
{ value: "none", label: "Off" },
|
|
193
|
+
{ value: "sub_heading", label: "Sub-headings" },
|
|
194
|
+
]}
|
|
195
|
+
onChange={(splitAt) => onUpdateSection(chunkId, { splitAt })}
|
|
196
|
+
/>
|
|
197
|
+
) : null}
|
|
198
|
+
|
|
180
199
|
{/* Image placement is linked, not independent (Drew, 2026-07-19): text
|
|
181
200
|
left → images right of the column, text top → images below full
|
|
182
201
|
width. One control drives both fields. */}
|
|
@@ -237,6 +256,16 @@ function ChunkPanel({
|
|
|
237
256
|
options={[2, 3, 4].map((n) => ({ value: String(n), label: String(n) }))}
|
|
238
257
|
onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, columns: Number(v) } })}
|
|
239
258
|
/>
|
|
259
|
+
<Select
|
|
260
|
+
className="mb-4"
|
|
261
|
+
label="Image fit"
|
|
262
|
+
value={layout.fit}
|
|
263
|
+
options={[
|
|
264
|
+
{ value: "cover", label: "Crop" },
|
|
265
|
+
{ value: "contain", label: "Contain" },
|
|
266
|
+
]}
|
|
267
|
+
onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, fit: v } })}
|
|
268
|
+
/>
|
|
240
269
|
</>
|
|
241
270
|
) : (
|
|
242
271
|
<>
|
|
@@ -250,6 +279,22 @@ function ChunkPanel({
|
|
|
250
279
|
]}
|
|
251
280
|
onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, flow: v } })}
|
|
252
281
|
/>
|
|
282
|
+
{/* Rows count only drives the rows flow; "auto" keeps the greedy
|
|
283
|
+
target-height fill. Mirrors Columns, which only drives columns. */}
|
|
284
|
+
{layout.flow === "rows" ? (
|
|
285
|
+
<Select
|
|
286
|
+
className="mb-4"
|
|
287
|
+
label="Rows"
|
|
288
|
+
value={String(layout.rows)}
|
|
289
|
+
options={[
|
|
290
|
+
{ value: "auto", label: "Auto" },
|
|
291
|
+
...[1, 2, 3, 4].map((n) => ({ value: String(n), label: String(n) })),
|
|
292
|
+
]}
|
|
293
|
+
onChange={(v) =>
|
|
294
|
+
onUpdateSection(chunkId, { imageLayout: { ...layout, rows: v === "auto" ? "auto" : Number(v) } })
|
|
295
|
+
}
|
|
296
|
+
/>
|
|
297
|
+
) : null}
|
|
253
298
|
{/* Column count only drives the columns flow (layoutRows never reads
|
|
254
299
|
it) — hide it when irrelevant. */}
|
|
255
300
|
{layout.flow === "columns" ? (
|
|
@@ -110,7 +110,7 @@ export function DocumentEditorOverlay({
|
|
|
110
110
|
const selection: DocInspectorProps["selection"] = useMemo(() => {
|
|
111
111
|
if (selectedChunkId === null) return { kind: "cover" };
|
|
112
112
|
const chunk = chunks.find((c) => c.id === selectedChunkId);
|
|
113
|
-
return { kind: "chunk", chunkId: selectedChunkId, title: chunk?.title ?? selectedChunkId };
|
|
113
|
+
return { kind: "chunk", chunkId: selectedChunkId, title: chunk?.title ?? selectedChunkId, depth: chunk?.depth };
|
|
114
114
|
}, [selectedChunkId, chunks]);
|
|
115
115
|
|
|
116
116
|
const warnings = selectedChunkId ? warningsByChunk[selectedChunkId] ?? [] : [];
|
|
@@ -13,7 +13,7 @@ const PAGE_H = 1056;
|
|
|
13
13
|
export type RailEntry =
|
|
14
14
|
| { kind: "cover" }
|
|
15
15
|
| { kind: "page"; page: DerivedPage }
|
|
16
|
-
| { kind: "ghost"; chunkId: string; title: string; reason: "excluded" | "empty" };
|
|
16
|
+
| { kind: "ghost"; chunkId: string; title: string; reason: "excluded" | "empty"; depth?: 1 };
|
|
17
17
|
|
|
18
18
|
export function entryId(entry: RailEntry): string {
|
|
19
19
|
if (entry.kind === "cover") return "cover";
|
|
@@ -21,12 +21,16 @@ export function entryId(entry: RailEntry): string {
|
|
|
21
21
|
return `ghost:${entry.chunkId}`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Rail nesting: sub-pages sit one step in per depth, continuations one more.
|
|
25
|
+
const INDENT = ["", "pl-4", "pl-8"];
|
|
26
|
+
const indentClass = (level: number) => INDENT[Math.min(level, INDENT.length - 1)] || undefined;
|
|
27
|
+
|
|
24
28
|
function ThumbButton({
|
|
25
29
|
id,
|
|
26
30
|
selectedId,
|
|
27
31
|
scale,
|
|
28
32
|
label,
|
|
29
|
-
|
|
33
|
+
indentLevel,
|
|
30
34
|
onSelect,
|
|
31
35
|
children,
|
|
32
36
|
}: {
|
|
@@ -34,12 +38,12 @@ function ThumbButton({
|
|
|
34
38
|
selectedId: string;
|
|
35
39
|
scale: number;
|
|
36
40
|
label: string;
|
|
37
|
-
|
|
41
|
+
indentLevel?: number;
|
|
38
42
|
onSelect: (id: string) => void;
|
|
39
43
|
children: ReactNode;
|
|
40
44
|
}) {
|
|
41
45
|
return (
|
|
42
|
-
<li className={
|
|
46
|
+
<li className={indentClass(indentLevel ?? 0)}>
|
|
43
47
|
<button
|
|
44
48
|
type="button"
|
|
45
49
|
onClick={() => onSelect(id)}
|
|
@@ -128,7 +132,7 @@ export function DocRail({
|
|
|
128
132
|
selectedId={selectedId}
|
|
129
133
|
scale={scale}
|
|
130
134
|
label={entry.page.title}
|
|
131
|
-
|
|
135
|
+
indentLevel={(entry.page.depth ?? 0) + (entry.page.cont ? 1 : 0)}
|
|
132
136
|
onSelect={onSelect}
|
|
133
137
|
>
|
|
134
138
|
<DocSectionPage page={entry.page} meta={meta} host={host} />
|
|
@@ -136,7 +140,7 @@ export function DocRail({
|
|
|
136
140
|
);
|
|
137
141
|
}
|
|
138
142
|
return (
|
|
139
|
-
<li key={id}>
|
|
143
|
+
<li key={id} className={indentClass(entry.depth ?? 0)}>
|
|
140
144
|
<button
|
|
141
145
|
type="button"
|
|
142
146
|
onClick={() => onSelect(id)}
|
|
@@ -12,7 +12,10 @@ const inch = (v: number) => `${v}in`;
|
|
|
12
12
|
export function DocSectionPage({ page, meta, host }: { page: DerivedPage; meta: DocMeta; host: DocHost }) {
|
|
13
13
|
return (
|
|
14
14
|
<DocPage meta={meta}>
|
|
15
|
-
<h2 className="pdoc-title">
|
|
15
|
+
<h2 className="pdoc-title">
|
|
16
|
+
{page.kicker ? <span className="pdoc-kicker" aria-hidden="true">{page.kicker}</span> : null}
|
|
17
|
+
{page.title}
|
|
18
|
+
</h2>
|
|
16
19
|
{page.textRegion ? (
|
|
17
20
|
<div
|
|
18
21
|
className="pdoc-text"
|
|
@@ -74,7 +74,7 @@ export function DocViewer({
|
|
|
74
74
|
const empty = chunk.textBlocks.length === 0 && chunk.images.length === 0;
|
|
75
75
|
if (s.excluded || empty) {
|
|
76
76
|
if (mode === "edit") {
|
|
77
|
-
list.push({ kind: "ghost", chunkId: chunk.id, title: chunk.title, reason: s.excluded ? "excluded" : "empty" });
|
|
77
|
+
list.push({ kind: "ghost", chunkId: chunk.id, title: chunk.title, reason: s.excluded ? "excluded" : "empty", depth: chunk.depth });
|
|
78
78
|
}
|
|
79
79
|
} else {
|
|
80
80
|
for (const page of pages.filter((p) => p.chunkId === chunk.id)) list.push({ kind: "page", page });
|
|
@@ -39,8 +39,9 @@ function signatureFor(chunks: Chunk[], settingsMap: Record<string, SectionSettin
|
|
|
39
39
|
export function useTextMeasurements(
|
|
40
40
|
chunks: Chunk[],
|
|
41
41
|
settingsMap: Record<string, SectionSettings>,
|
|
42
|
-
): { heights: Record<string, number[]> | null } {
|
|
42
|
+
): { heights: Record<string, number[]> | null; textWidths: Record<string, number> | null } {
|
|
43
43
|
const [heights, setHeights] = useState<Record<string, number[]> | null>(null);
|
|
44
|
+
const [textWidths, setTextWidths] = useState<Record<string, number> | null>(null);
|
|
44
45
|
const [fontsReady, setFontsReady] = useState(false);
|
|
45
46
|
|
|
46
47
|
// jsdom (tests) has no document.fonts — optional-chain so this resolves
|
|
@@ -65,10 +66,12 @@ export function useTextMeasurements(
|
|
|
65
66
|
if (!fontsReady) return;
|
|
66
67
|
const containers: HTMLDivElement[] = [];
|
|
67
68
|
const next: Record<string, number[]> = {};
|
|
69
|
+
const nextW: Record<string, number> = {};
|
|
68
70
|
|
|
69
71
|
for (const chunk of chunks) {
|
|
70
72
|
if (chunk.textBlocks.length === 0) {
|
|
71
73
|
next[chunk.id] = [];
|
|
74
|
+
nextW[chunk.id] = 0;
|
|
72
75
|
continue;
|
|
73
76
|
}
|
|
74
77
|
const container = document.createElement("div");
|
|
@@ -78,6 +81,7 @@ export function useTextMeasurements(
|
|
|
78
81
|
containers.push(container);
|
|
79
82
|
|
|
80
83
|
const measured: number[] = [];
|
|
84
|
+
let wMax = 0;
|
|
81
85
|
for (const block of chunk.textBlocks) {
|
|
82
86
|
const wrapper = document.createElement("div");
|
|
83
87
|
wrapper.className = blockClassName(block);
|
|
@@ -86,13 +90,21 @@ export function useTextMeasurements(
|
|
|
86
90
|
} else {
|
|
87
91
|
wrapper.innerHTML = block.html;
|
|
88
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%";
|
|
89
98
|
container.appendChild(wrapper);
|
|
90
99
|
measured.push(wrapper.offsetHeight / PX_PER_IN);
|
|
100
|
+
wMax = Math.max(wMax, wrapper.getBoundingClientRect().width / PX_PER_IN);
|
|
91
101
|
}
|
|
92
102
|
next[chunk.id] = measured;
|
|
103
|
+
nextW[chunk.id] = wMax;
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
setHeights(next);
|
|
107
|
+
setTextWidths(nextW);
|
|
96
108
|
return () => {
|
|
97
109
|
for (const container of containers) container.remove();
|
|
98
110
|
};
|
|
@@ -101,5 +113,5 @@ export function useTextMeasurements(
|
|
|
101
113
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
114
|
}, [signature, fontsReady]);
|
|
103
115
|
|
|
104
|
-
return { heights };
|
|
116
|
+
return { heights, textWidths };
|
|
105
117
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { harvest } from "../engine/harvest";
|
|
3
|
-
import { paginateChunk, type DerivedPage } from "../engine/paginate";
|
|
3
|
+
import { DOC_GEOM, paginateChunk, type DerivedPage } from "../engine/paginate";
|
|
4
4
|
import { settingsFor, type SectionSettings } from "../engine/settings";
|
|
5
|
-
import type { Chunk, DocSourceContent } from "../engine/types";
|
|
5
|
+
import type { Chunk, DocSourceContent, SplitAt } from "../engine/types";
|
|
6
6
|
import { useTextMeasurements } from "./measure";
|
|
7
7
|
|
|
8
8
|
export function useDocPages(input: {
|
|
@@ -11,9 +11,27 @@ export function useDocPages(input: {
|
|
|
11
11
|
}): { chunks: Chunk[]; pages: DerivedPage[]; measuring: boolean; warningsByChunk: Record<string, string[]> } {
|
|
12
12
|
const { source, sections } = input;
|
|
13
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
|
+
|
|
14
32
|
const chunks = useMemo(
|
|
15
|
-
() => (source ? harvest(source.sections, source.pageTitle, source.images) : []),
|
|
16
|
-
[source],
|
|
33
|
+
() => (source ? harvest(source.sections, source.pageTitle, source.images, splitAtFor) : []),
|
|
34
|
+
[source, splitAtFor],
|
|
17
35
|
);
|
|
18
36
|
|
|
19
37
|
const settingsMap = useMemo(() => {
|
|
@@ -22,7 +40,7 @@ export function useDocPages(input: {
|
|
|
22
40
|
return map;
|
|
23
41
|
}, [chunks, sections]);
|
|
24
42
|
|
|
25
|
-
const { heights } = useTextMeasurements(chunks, settingsMap);
|
|
43
|
+
const { heights, textWidths } = useTextMeasurements(chunks, settingsMap);
|
|
26
44
|
|
|
27
45
|
// `heights` is state set by an effect, so it always lags one render behind
|
|
28
46
|
// `chunks`: when `source` changes shape (a block added/removed, a new
|
|
@@ -36,9 +54,9 @@ export function useDocPages(input: {
|
|
|
36
54
|
return chunks.flatMap((c) => {
|
|
37
55
|
const h = heights?.[c.id];
|
|
38
56
|
if (c.textBlocks.length > 0 && (!h || h.length !== c.textBlocks.length)) return [];
|
|
39
|
-
return paginateChunk(c, settingsMap[c.id], h ?? []);
|
|
57
|
+
return paginateChunk(c, settingsMap[c.id], h ?? [], DOC_GEOM, textWidths?.[c.id]);
|
|
40
58
|
});
|
|
41
|
-
}, [chunks, settingsMap, heights]);
|
|
59
|
+
}, [chunks, settingsMap, heights, textWidths]);
|
|
42
60
|
|
|
43
61
|
// Mirrors the pages memo's per-chunk guard: still "measuring" until every
|
|
44
62
|
// non-empty chunk has current heights, not just until `heights` is non-null.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { splitProseBlocks } from "./blocks";
|
|
2
|
-
import { PREAMBLE_ID, type Chunk, type DocImage, type DocSectionInput } from "./types";
|
|
2
|
+
import { PREAMBLE_ID, type Chunk, type DocImage, type DocSectionInput, type SplitAt } from "./types";
|
|
3
3
|
|
|
4
4
|
// Shape of the fields harvest reads, checked structurally at runtime — the
|
|
5
5
|
// engine must tolerate any future portal section without crashing (allowlist).
|
|
@@ -61,20 +61,35 @@ export function harvest(
|
|
|
61
61
|
sections: DocSectionInput[],
|
|
62
62
|
pageTitle: string,
|
|
63
63
|
imagesById: Record<string, DocImage>,
|
|
64
|
+
splitAtFor: (chunkId: string) => SplitAt = () => "none",
|
|
64
65
|
): Chunk[] {
|
|
65
66
|
const chunks: Chunk[] = [];
|
|
66
|
-
|
|
67
|
-
let
|
|
67
|
+
const preamble: Chunk = { id: PREAMBLE_ID, title: pageTitle, textBlocks: [], images: [] };
|
|
68
|
+
let parent = preamble; // current top-level chunk — owns the splitAt setting
|
|
69
|
+
let current = preamble; // chunk receiving harvested content
|
|
70
|
+
let splitAt = splitAtFor(PREAMBLE_ID);
|
|
68
71
|
|
|
69
72
|
for (const section of sections) {
|
|
73
|
+
const heading = str(asRecord(section.content).heading);
|
|
70
74
|
if (section.type === "link_heading") {
|
|
71
|
-
|
|
75
|
+
parent = { id: section.id, title: heading, textBlocks: [], images: [] };
|
|
76
|
+
current = parent;
|
|
77
|
+
splitAt = splitAtFor(section.id);
|
|
78
|
+
chunks.push(parent);
|
|
79
|
+
} else if (section.type === "sub_heading" && splitAt === "sub_heading") {
|
|
80
|
+
// Promotion: the boundary heading becomes the chunk title, not a TextBlock.
|
|
81
|
+
current = { id: section.id, title: heading, textBlocks: [], images: [], parentId: parent.id, kicker: parent.title, depth: 1 };
|
|
72
82
|
chunks.push(current);
|
|
73
83
|
} else {
|
|
74
84
|
harvestInto(current, section.type, section.content, imagesById);
|
|
75
85
|
}
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
// Kept when it has own content OR spawned sub-chunks — an emptied-but-split
|
|
89
|
+
// preamble must stay selectable (its ghost is the way back to splitAt).
|
|
90
|
+
const keepPreamble =
|
|
91
|
+
preamble.textBlocks.length > 0 ||
|
|
92
|
+
preamble.images.length > 0 ||
|
|
93
|
+
chunks.some((c) => c.parentId === PREAMBLE_ID);
|
|
94
|
+
return keepPreamble ? [preamble, ...chunks] : chunks;
|
|
80
95
|
}
|
|
@@ -11,6 +11,7 @@ export interface MasonryOpts {
|
|
|
11
11
|
bodyHIn: number;
|
|
12
12
|
gapIn: number;
|
|
13
13
|
targetRowHeightIn: number;
|
|
14
|
+
rows?: "auto" | 1 | 2 | 3 | 4;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface MasonryBox<T extends Sized = Sized> {
|
|
@@ -57,6 +58,68 @@ function layoutRows<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover"
|
|
|
57
58
|
return { boxes, used: used ?? images.length, endY: y };
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
// Forced row count: partition the images into exactly N rows (fewer only if
|
|
62
|
+
// there are fewer images), aspect-balanced, each row scaled to full width —
|
|
63
|
+
// unless the stack would exceed the region height, in which case the whole
|
|
64
|
+
// block scales down uniformly to fit (never up), left-aligned. Unlike
|
|
65
|
+
// layoutRows there is no target height — the row's share of the total aspect
|
|
66
|
+
// determines its height.
|
|
67
|
+
function layoutForcedRows<T extends Sized>(
|
|
68
|
+
images: T[],
|
|
69
|
+
o: MasonryOpts,
|
|
70
|
+
fits: ("cover" | "contain")[],
|
|
71
|
+
n: number,
|
|
72
|
+
): { boxes: MasonryBox<T>[]; used: number; endY: number } {
|
|
73
|
+
const asp = images.map((im, i) => effectiveAspect(im, fits[i]));
|
|
74
|
+
const count = Math.min(n, images.length);
|
|
75
|
+
// Greedy balance: each row takes images until it reaches the average
|
|
76
|
+
// aspect-sum of what's left, always leaving >=1 image per remaining row.
|
|
77
|
+
const rows: number[][] = [];
|
|
78
|
+
let i = 0;
|
|
79
|
+
for (let r = 0; r < count; r++) {
|
|
80
|
+
const remainingRows = count - r;
|
|
81
|
+
const target = asp.slice(i).reduce((s, a) => s + a, 0) / remainingRows;
|
|
82
|
+
const row: number[] = [];
|
|
83
|
+
let sum = 0;
|
|
84
|
+
while (i < asp.length) {
|
|
85
|
+
if (row.length > 0) {
|
|
86
|
+
if (asp.length - (i + 1) < remainingRows - 1) break; // taking i must leave >=1 image per remaining row
|
|
87
|
+
if (sum >= target - EPS) break;
|
|
88
|
+
}
|
|
89
|
+
row.push(i);
|
|
90
|
+
sum += asp[i];
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
rows.push(row);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pass 1: full-width row heights from the aspect math.
|
|
97
|
+
const rowMeta = rows.map((row) => {
|
|
98
|
+
const gaps = o.gapIn * (row.length - 1);
|
|
99
|
+
const sumAspect = row.reduce((s, a) => s + asp[a], 0);
|
|
100
|
+
return { row, gaps, sumAspect, hIn: (o.bodyWIn - gaps) / sumAspect };
|
|
101
|
+
});
|
|
102
|
+
// Pass 2: if the stack exceeds the region, scale everything down uniformly —
|
|
103
|
+
// natural aspects kept, gaps fixed, block left-aligned. Never upscale.
|
|
104
|
+
const rowGaps = o.gapIn * (rowMeta.length - 1);
|
|
105
|
+
const totalH = rowMeta.reduce((s, m) => s + m.hIn, 0);
|
|
106
|
+
const scale = totalH > 0 ? Math.min(1, Math.max(0, o.bodyHIn - rowGaps) / totalH) : 1;
|
|
107
|
+
|
|
108
|
+
const boxes: MasonryBox<T>[] = [];
|
|
109
|
+
let y = 0;
|
|
110
|
+
for (const m of rowMeta) {
|
|
111
|
+
const rowH = m.hIn * scale;
|
|
112
|
+
let x = 0;
|
|
113
|
+
for (const idx of m.row) {
|
|
114
|
+
const w = asp[idx] * rowH;
|
|
115
|
+
boxes.push({ asset: images[idx], xIn: x, yIn: y, wIn: w, hIn: rowH });
|
|
116
|
+
x += w + o.gapIn;
|
|
117
|
+
}
|
|
118
|
+
y += rowH + o.gapIn;
|
|
119
|
+
}
|
|
120
|
+
return { boxes, used: images.length, endY: y };
|
|
121
|
+
}
|
|
122
|
+
|
|
60
123
|
function layoutColumns<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; colHeights: number[]; colW: number } {
|
|
61
124
|
const n = o.columns;
|
|
62
125
|
const colW = (o.bodyWIn - o.gapIn * (n - 1)) / n;
|
|
@@ -84,6 +147,6 @@ export function layoutMasonryPage<T extends Sized>(
|
|
|
84
147
|
const r = layoutColumns(images, o, fits);
|
|
85
148
|
return { boxes: r.boxes, used: r.used };
|
|
86
149
|
}
|
|
87
|
-
const r = layoutRows(images, o, fits);
|
|
150
|
+
const r = typeof o.rows === "number" ? layoutForcedRows(images, o, fits, o.rows) : layoutRows(images, o, fits);
|
|
88
151
|
return { boxes: r.boxes, used: r.used };
|
|
89
152
|
}
|
|
@@ -9,6 +9,7 @@ export interface DocGeom {
|
|
|
9
9
|
gapIn: number;
|
|
10
10
|
leftColWIn: number;
|
|
11
11
|
colGutterIn: number;
|
|
12
|
+
minTextColIn: number;
|
|
12
13
|
minImageRegionIn: number;
|
|
13
14
|
masonryTargetRowIn: number;
|
|
14
15
|
topTextMaxFraction: number;
|
|
@@ -22,6 +23,7 @@ export const DOC_GEOM: DocGeom = {
|
|
|
22
23
|
gapIn: 0.15,
|
|
23
24
|
leftColWIn: 4.6,
|
|
24
25
|
colGutterIn: 0.4,
|
|
26
|
+
minTextColIn: 1,
|
|
25
27
|
minImageRegionIn: 1.2,
|
|
26
28
|
masonryTargetRowIn: 2.6,
|
|
27
29
|
// Top text fills at most this fraction of the body height below the title
|
|
@@ -42,6 +44,9 @@ export interface DerivedPage {
|
|
|
42
44
|
chunkId: string;
|
|
43
45
|
title: string;
|
|
44
46
|
cont: boolean;
|
|
47
|
+
// Sub-chunk context (absent on top-level pages) — see Chunk.
|
|
48
|
+
kicker?: string;
|
|
49
|
+
depth?: 1;
|
|
45
50
|
textBlocks: TextBlock[];
|
|
46
51
|
textRegion: Region | null;
|
|
47
52
|
imageBoxes: ImageBox[];
|
|
@@ -69,7 +74,7 @@ function fitImages(
|
|
|
69
74
|
yIn: region.yIn + r * (cellH + geom.gapIn),
|
|
70
75
|
wIn: cellW,
|
|
71
76
|
hIn: cellH,
|
|
72
|
-
fit:
|
|
77
|
+
fit: layout.fit,
|
|
73
78
|
};
|
|
74
79
|
});
|
|
75
80
|
return { boxes, used };
|
|
@@ -78,6 +83,7 @@ function fitImages(
|
|
|
78
83
|
const r = layoutMasonryPage(images, {
|
|
79
84
|
mode: layout.flow,
|
|
80
85
|
columns: layout.columns,
|
|
86
|
+
rows: layout.rows,
|
|
81
87
|
bodyWIn: region.wIn,
|
|
82
88
|
bodyHIn: region.hIn,
|
|
83
89
|
gapIn: geom.gapIn,
|
|
@@ -99,6 +105,7 @@ export function paginateChunk(
|
|
|
99
105
|
settings: SectionSettings,
|
|
100
106
|
textHeightsIn: number[],
|
|
101
107
|
geom: DocGeom = DOC_GEOM,
|
|
108
|
+
naturalTextWIn?: number,
|
|
102
109
|
): DerivedPage[] {
|
|
103
110
|
if (settings.excluded) return [];
|
|
104
111
|
if (chunk.textBlocks.length !== textHeightsIn.length) {
|
|
@@ -106,6 +113,15 @@ export function paginateChunk(
|
|
|
106
113
|
}
|
|
107
114
|
if (chunk.textBlocks.length === 0 && chunk.images.length === 0) return [];
|
|
108
115
|
|
|
116
|
+
// leftColumn shrinks to the widest natural text line (+ slack so sub-pixel
|
|
117
|
+
// rounding can't re-wrap), floored/capped; absent measurement = legacy fixed
|
|
118
|
+
// column. The shrunk width is >= every block's natural width by
|
|
119
|
+
// construction, so measured heights remain valid.
|
|
120
|
+
const colWIn =
|
|
121
|
+
naturalTextWIn === undefined
|
|
122
|
+
? geom.leftColWIn
|
|
123
|
+
: Math.min(geom.leftColWIn, Math.max(geom.minTextColIn, naturalTextWIn + 0.05));
|
|
124
|
+
|
|
109
125
|
const pages: DerivedPage[] = [];
|
|
110
126
|
let ti = 0;
|
|
111
127
|
let ii = 0;
|
|
@@ -114,7 +130,7 @@ export function paginateChunk(
|
|
|
114
130
|
while (ti < chunk.textBlocks.length || ii < chunk.images.length) {
|
|
115
131
|
const cont = pages.length > 0;
|
|
116
132
|
const warnings: string[] = [];
|
|
117
|
-
const textW = settings.textPlacement === "top" ? geom.bodyWIn :
|
|
133
|
+
const textW = settings.textPlacement === "top" ? geom.bodyWIn : colWIn;
|
|
118
134
|
|
|
119
135
|
// 1. Greedy text fill. Top text is capped at half the body so a text-heavy
|
|
120
136
|
// section still leaves room for images below; leftColumn keeps full height.
|
|
@@ -158,9 +174,9 @@ export function paginateChunk(
|
|
|
158
174
|
let region: Region;
|
|
159
175
|
if (settings.textPlacement === "leftColumn" && settings.imagePlacement === "right" && placed.length > 0) {
|
|
160
176
|
region = {
|
|
161
|
-
xIn:
|
|
177
|
+
xIn: colWIn + geom.colGutterIn,
|
|
162
178
|
yIn: top,
|
|
163
|
-
wIn: geom.bodyWIn -
|
|
179
|
+
wIn: geom.bodyWIn - colWIn - geom.colGutterIn,
|
|
164
180
|
hIn: geom.bodyHIn - top,
|
|
165
181
|
};
|
|
166
182
|
} else {
|
|
@@ -185,6 +201,8 @@ export function paginateChunk(
|
|
|
185
201
|
chunkId: chunk.id,
|
|
186
202
|
title: cont ? `${chunk.title} Cont.` : chunk.title,
|
|
187
203
|
cont,
|
|
204
|
+
kicker: chunk.kicker,
|
|
205
|
+
depth: chunk.depth,
|
|
188
206
|
textBlocks: placed,
|
|
189
207
|
textRegion,
|
|
190
208
|
imageBoxes: boxes,
|
|
@@ -198,6 +216,8 @@ export function paginateChunk(
|
|
|
198
216
|
chunkId: chunk.id,
|
|
199
217
|
title: cont ? `${chunk.title} Cont.` : chunk.title,
|
|
200
218
|
cont,
|
|
219
|
+
kicker: chunk.kicker,
|
|
220
|
+
depth: chunk.depth,
|
|
201
221
|
textBlocks: placed,
|
|
202
222
|
textRegion,
|
|
203
223
|
imageBoxes: boxes,
|
|
@@ -12,12 +12,18 @@ export const sectionSettingsSchema = z.object({
|
|
|
12
12
|
// When true, text that overflows the first page is dropped instead of
|
|
13
13
|
// flowing onto Cont. pages (images still paginate normally).
|
|
14
14
|
dropTextOverflow: z.boolean().default(false),
|
|
15
|
+
// Also start new pages at sub-heading boundaries. Only read from PARENT
|
|
16
|
+
// entries (link_heading ids / __preamble); inert on sub-chunk entries.
|
|
17
|
+
splitAt: z.enum(["none", "sub_heading"]).default("none"),
|
|
15
18
|
imageLayout: z
|
|
16
19
|
.discriminatedUnion("mode", [
|
|
17
20
|
z.object({
|
|
18
21
|
mode: z.literal("grid"),
|
|
19
22
|
rows: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2),
|
|
20
23
|
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(4),
|
|
24
|
+
// "cover" crops each image to its cell; "contain" letterboxes the
|
|
25
|
+
// natural image inside it.
|
|
26
|
+
fit: z.enum(["cover", "contain"]).default("cover"),
|
|
21
27
|
}),
|
|
22
28
|
z.object({
|
|
23
29
|
mode: z.literal("masonry"),
|
|
@@ -25,9 +31,14 @@ export const sectionSettingsSchema = z.object({
|
|
|
25
31
|
columns: z
|
|
26
32
|
.union([z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)])
|
|
27
33
|
.default(3),
|
|
34
|
+
// rows flow only: "auto" = greedy target-height fill (legacy behavior);
|
|
35
|
+
// 1-4 forces an exact aspect-balanced row count.
|
|
36
|
+
rows: z
|
|
37
|
+
.union([z.literal("auto"), z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
|
|
38
|
+
.default("auto"),
|
|
28
39
|
}),
|
|
29
40
|
])
|
|
30
|
-
.default({ mode: "masonry", flow: "rows", columns: 3 }),
|
|
41
|
+
.default({ mode: "masonry", flow: "rows", columns: 3, rows: "auto" }),
|
|
31
42
|
});
|
|
32
43
|
|
|
33
44
|
export type SectionSettings = z.infer<typeof sectionSettingsSchema>;
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
export const PREAMBLE_ID = "__preamble";
|
|
9
9
|
|
|
10
|
+
// Page-split boundary level for one parent section. Sub-headings are the
|
|
11
|
+
// deepest deck boundary (internal convention, 2026-07-19) — sub_sub_heading
|
|
12
|
+
// sections always stay in-page as level-3 text blocks.
|
|
13
|
+
export type SplitAt = "none" | "sub_heading";
|
|
14
|
+
|
|
10
15
|
// Breadcrumb metadata shown in the page chrome. Single definition — the
|
|
11
16
|
// harness carried this shape in two places (deck/DeckPage.tsx's `DeckMeta`
|
|
12
17
|
// and lib/deck/schema.ts's zod-inferred `DeckMeta`); the engine only needs
|
|
@@ -39,12 +44,17 @@ export type TextBlock =
|
|
|
39
44
|
| { kind: "heading"; level: 2 | 3; text: string }
|
|
40
45
|
| { kind: "prose"; html: string };
|
|
41
46
|
|
|
42
|
-
// A
|
|
47
|
+
// A page-producing chunk: a link_heading section (or the preamble), or — when
|
|
48
|
+
// the parent's splitAt says so — a sub heading section.
|
|
43
49
|
export interface Chunk {
|
|
44
|
-
id: string; //
|
|
50
|
+
id: string; // boundary heading's section id, or PREAMBLE_ID
|
|
45
51
|
title: string;
|
|
46
52
|
textBlocks: TextBlock[];
|
|
47
53
|
images: DocImage[];
|
|
54
|
+
// Sub-chunk fields; absent on top-level chunks.
|
|
55
|
+
parentId?: string;
|
|
56
|
+
kicker?: string; // masthead context line — the parent link_heading title
|
|
57
|
+
depth?: 1;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export interface DocSourceContent {
|
|
@@ -178,6 +178,15 @@
|
|
|
178
178
|
margin: 0;
|
|
179
179
|
height: 0.9in; /* = DOC_GEOM.titleBandIn; keep in sync */
|
|
180
180
|
}
|
|
181
|
+
/* Sub-page context line inside the title band (uppercase inherits from
|
|
182
|
+
.pdoc-title). Sized to sit with the title within the fixed 0.9in band. */
|
|
183
|
+
.pdoc-kicker {
|
|
184
|
+
display: block;
|
|
185
|
+
font-size: 9pt;
|
|
186
|
+
letter-spacing: 0.12em;
|
|
187
|
+
color: #999999;
|
|
188
|
+
margin-bottom: 0.05in;
|
|
189
|
+
}
|
|
181
190
|
.pdoc-text { position: absolute; }
|
|
182
191
|
.pdoc-h2, .pdoc-h3 {
|
|
183
192
|
font-family: "Kapra Neue Expanded", Helvetica, Arial, sans-serif;
|