@drawnagency/primitives 0.1.80 → 0.1.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-7XWER6YR.js → chunk-22GU6GNP.js} +3 -3
- package/dist/{chunk-4TVODHD2.js → chunk-5UDIQLGQ.js} +6 -6
- package/dist/{chunk-UNUJPLZV.js → chunk-6NRVX6NQ.js} +1 -1
- package/dist/{chunk-KVQFMVXE.js → chunk-DKXISNJ2.js} +2 -2
- package/dist/{chunk-RSITF24B.js → chunk-ETJYYUQX.js} +9 -3
- package/dist/{chunk-VI26O4E3.js → chunk-FDBZZ4AT.js} +1 -1
- package/dist/chunk-FM4M4Q2W.js +72 -0
- package/dist/{chunk-EMGLZQCM.js → chunk-MBJUWOM2.js} +1 -1
- package/dist/{chunk-2DDWWTRX.js → chunk-Q6LZOH5M.js} +1 -1
- package/dist/{chunk-QB5Q23G2.js → chunk-Y54G2XSC.js} +1 -1
- package/dist/{chunk-3LNKOLZP.js → chunk-YKOLOZB2.js} +1 -1
- package/dist/components/editor/PagesModal.d.ts +2 -0
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +6 -5
- package/dist/components/primitives/index.js +7 -6
- package/dist/components/sections/brand-guide.js +7 -6
- package/dist/components/sections/register-schemas.js +10 -9
- package/dist/components/sections/register.js +10 -9
- package/dist/components/shell/DocInspector.d.ts +18 -0
- package/dist/components/shell/DocInspector.d.ts.map +1 -0
- package/dist/components/shell/DocumentEditorOverlay.d.ts +29 -0
- package/dist/components/shell/DocumentEditorOverlay.d.ts.map +1 -0
- package/dist/components/shell/DocumentPresentOverlay.d.ts +30 -0
- package/dist/components/shell/DocumentPresentOverlay.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts +34 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
- package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
- package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
- package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
- package/dist/document/components/DocCoverPage.d.ts +11 -0
- package/dist/document/components/DocCoverPage.d.ts.map +1 -0
- package/dist/document/components/DocPage.d.ts +10 -0
- package/dist/document/components/DocPage.d.ts.map +1 -0
- package/dist/document/components/DocRail.d.ts +28 -0
- package/dist/document/components/DocRail.d.ts.map +1 -0
- package/dist/document/components/DocSectionPage.d.ts +12 -0
- package/dist/document/components/DocSectionPage.d.ts.map +1 -0
- package/dist/document/components/DocViewer.d.ts +18 -0
- package/dist/document/components/DocViewer.d.ts.map +1 -0
- package/dist/document/components/Wordmark.d.ts +4 -0
- package/dist/document/components/Wordmark.d.ts.map +1 -0
- package/dist/document/components/measure.d.ts +7 -0
- package/dist/document/components/measure.d.ts.map +1 -0
- package/dist/document/components/useDocPages.d.ts +12 -0
- package/dist/document/components/useDocPages.d.ts.map +1 -0
- package/dist/document/engine/blocks.d.ts +2 -0
- package/dist/document/engine/blocks.d.ts.map +1 -0
- package/dist/document/engine/harvest.d.ts +3 -0
- package/dist/document/engine/harvest.d.ts.map +1 -0
- package/dist/document/engine/masonry.d.ts +26 -0
- package/dist/document/engine/masonry.d.ts.map +1 -0
- package/dist/document/engine/paginate.d.ts +43 -0
- package/dist/document/engine/paginate.d.ts.map +1 -0
- package/dist/document/engine/settings.d.ts +88 -0
- package/dist/document/engine/settings.d.ts.map +1 -0
- package/dist/document/engine/types.d.ts +44 -0
- package/dist/document/engine/types.d.ts.map +1 -0
- package/dist/document/index.d.ts +13 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +965 -0
- package/dist/document/styles/document.css +217 -0
- package/dist/index.js +29 -28
- package/dist/lib/document-input.d.ts +25 -0
- package/dist/lib/document-input.d.ts.map +1 -0
- package/dist/lib/index.js +10 -9
- package/dist/lib/pages.d.ts +2 -0
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/media/index.js +4 -3
- package/dist/schemas/index.js +3 -2
- package/dist/schemas/site-config.d.ts +265 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/PagesModal.tsx +16 -2
- package/src/components/shell/DocInspector.tsx +323 -0
- package/src/components/shell/DocumentEditorOverlay.tsx +169 -0
- package/src/components/shell/DocumentPresentOverlay.tsx +87 -0
- package/src/components/shell/EditorShell.tsx +128 -5
- package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
- package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
- package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
- package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
- package/src/document/components/DocCoverPage.tsx +45 -0
- package/src/document/components/DocPage.tsx +40 -0
- package/src/document/components/DocRail.tsx +164 -0
- package/src/document/components/DocSectionPage.tsx +48 -0
- package/src/document/components/DocViewer.tsx +285 -0
- package/src/document/components/Wordmark.tsx +18 -0
- package/src/document/components/measure.tsx +117 -0
- package/src/document/components/useDocPages.ts +85 -0
- package/src/document/engine/blocks.ts +87 -0
- package/src/document/engine/harvest.ts +95 -0
- package/src/document/engine/masonry.ts +152 -0
- package/src/document/engine/paginate.ts +229 -0
- package/src/document/engine/settings.ts +88 -0
- package/src/document/engine/types.ts +64 -0
- package/src/document/index.ts +18 -0
- package/src/document/styles/document.css +217 -0
- package/src/lib/document-input.ts +57 -0
- package/src/lib/pages.ts +22 -0
- package/src/schemas/site-config.ts +22 -7
- /package/dist/{chunk-4YEOTFVU.js → chunk-YBVK254P.js} +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { splitProseBlocks } from "./blocks";
|
|
2
|
+
import { PREAMBLE_ID, type Chunk, type DocImage, type DocSectionInput, type SplitAt } from "./types";
|
|
3
|
+
|
|
4
|
+
// Shape of the fields harvest reads, checked structurally at runtime — the
|
|
5
|
+
// engine must tolerate any future portal section without crashing (allowlist).
|
|
6
|
+
type Loose = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
function str(v: unknown): string {
|
|
9
|
+
return typeof v === "string" ? v : "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Normalizes any runtime value (including null/undefined from malformed
|
|
13
|
+
// portal data) into a safe-to-index object so field access never throws.
|
|
14
|
+
function asRecord(v: unknown): Loose {
|
|
15
|
+
return v && typeof v === "object" ? (v as Loose) : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// One section's contribution to the current chunk (containers recurse).
|
|
19
|
+
function harvestInto(
|
|
20
|
+
target: Chunk,
|
|
21
|
+
type: string,
|
|
22
|
+
content: unknown,
|
|
23
|
+
imagesById: Record<string, DocImage>,
|
|
24
|
+
): void {
|
|
25
|
+
const c = asRecord(content);
|
|
26
|
+
switch (type) {
|
|
27
|
+
case "sub_heading":
|
|
28
|
+
case "link_heading": // nested (inside a container) — not a boundary here
|
|
29
|
+
target.textBlocks.push({ kind: "heading", level: 2, text: str(c.heading) });
|
|
30
|
+
break;
|
|
31
|
+
case "sub_sub_heading":
|
|
32
|
+
target.textBlocks.push({ kind: "heading", level: 3, text: str(c.heading) });
|
|
33
|
+
break;
|
|
34
|
+
case "prose":
|
|
35
|
+
for (const html of splitProseBlocks(str(c.body))) {
|
|
36
|
+
target.textBlocks.push({ kind: "prose", html });
|
|
37
|
+
}
|
|
38
|
+
break;
|
|
39
|
+
case "media": {
|
|
40
|
+
const ref = asRecord(c.ref);
|
|
41
|
+
const image = imagesById[str(ref.imageId)];
|
|
42
|
+
if (image) target.images.push(image);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "container": {
|
|
46
|
+
const children = Array.isArray(c.children) ? c.children : [];
|
|
47
|
+
for (const child of children) {
|
|
48
|
+
const cc = asRecord(child);
|
|
49
|
+
if (typeof cc.type === "string") {
|
|
50
|
+
harvestInto(target, cc.type, cc.content, imagesById);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
break; // allowlist: video, button, spacer, colors, do/dont, icon list, unknown future types
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function harvest(
|
|
61
|
+
sections: DocSectionInput[],
|
|
62
|
+
pageTitle: string,
|
|
63
|
+
imagesById: Record<string, DocImage>,
|
|
64
|
+
splitAtFor: (chunkId: string) => SplitAt = () => "none",
|
|
65
|
+
): Chunk[] {
|
|
66
|
+
const chunks: Chunk[] = [];
|
|
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);
|
|
71
|
+
|
|
72
|
+
for (const section of sections) {
|
|
73
|
+
const heading = str(asRecord(section.content).heading);
|
|
74
|
+
if (section.type === "link_heading") {
|
|
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 };
|
|
82
|
+
chunks.push(current);
|
|
83
|
+
} else {
|
|
84
|
+
harvestInto(current, section.type, section.content, imagesById);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
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;
|
|
95
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const EPS = 1e-6;
|
|
2
|
+
|
|
3
|
+
export type MasonryMode = "rows" | "columns";
|
|
4
|
+
|
|
5
|
+
export type Sized = { width: number; height: number };
|
|
6
|
+
|
|
7
|
+
export interface MasonryOpts {
|
|
8
|
+
mode: MasonryMode;
|
|
9
|
+
columns: 2 | 3 | 4 | 5 | 6;
|
|
10
|
+
bodyWIn: number;
|
|
11
|
+
bodyHIn: number;
|
|
12
|
+
gapIn: number;
|
|
13
|
+
targetRowHeightIn: number;
|
|
14
|
+
rows?: "auto" | 1 | 2 | 3 | 4;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MasonryBox<T extends Sized = Sized> {
|
|
18
|
+
asset: T;
|
|
19
|
+
xIn: number;
|
|
20
|
+
yIn: number;
|
|
21
|
+
wIn: number;
|
|
22
|
+
hIn: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A "cover" photo is laid out in a SQUARE cell (effective aspect 1) and the
|
|
26
|
+
// image crops to fill it; "contain" (default) uses the photo's natural aspect.
|
|
27
|
+
const effectiveAspect = (im: Sized, fit: "cover" | "contain" | undefined) =>
|
|
28
|
+
fit === "cover" ? 1 : im.width / im.height;
|
|
29
|
+
|
|
30
|
+
function layoutRows<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; endY: number } {
|
|
31
|
+
const asp = images.map((im, i) => effectiveAspect(im, fits[i]));
|
|
32
|
+
const boxes: MasonryBox<T>[] = [];
|
|
33
|
+
let y = 0, i = 0;
|
|
34
|
+
let used: number | null = null;
|
|
35
|
+
while (i < images.length) {
|
|
36
|
+
const row: number[] = []; // indices in this row
|
|
37
|
+
let natW = 0;
|
|
38
|
+
while (i + row.length < images.length) {
|
|
39
|
+
row.push(i + row.length);
|
|
40
|
+
const gaps = o.gapIn * (row.length - 1);
|
|
41
|
+
natW = row.reduce((s, idx) => s + asp[idx] * o.targetRowHeightIn, 0) + gaps;
|
|
42
|
+
if (natW >= o.bodyWIn) break;
|
|
43
|
+
}
|
|
44
|
+
const gaps = o.gapIn * (row.length - 1);
|
|
45
|
+
const sumAspect = row.reduce((s, idx) => s + asp[idx], 0);
|
|
46
|
+
const isFull = natW >= o.bodyWIn;
|
|
47
|
+
const rowH = isFull ? (o.bodyWIn - gaps) / sumAspect : o.targetRowHeightIn;
|
|
48
|
+
if (used === null && y + rowH > o.bodyHIn + EPS && boxes.length > 0) used = boxes.length;
|
|
49
|
+
let x = 0;
|
|
50
|
+
for (const idx of row) {
|
|
51
|
+
const w = asp[idx] * rowH;
|
|
52
|
+
boxes.push({ asset: images[idx], xIn: x, yIn: y, wIn: w, hIn: rowH });
|
|
53
|
+
x += w + o.gapIn;
|
|
54
|
+
}
|
|
55
|
+
y += rowH + o.gapIn;
|
|
56
|
+
i += row.length;
|
|
57
|
+
}
|
|
58
|
+
return { boxes, used: used ?? images.length, endY: y };
|
|
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
|
+
|
|
123
|
+
function layoutColumns<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; colHeights: number[]; colW: number } {
|
|
124
|
+
const n = o.columns;
|
|
125
|
+
const colW = (o.bodyWIn - o.gapIn * (n - 1)) / n;
|
|
126
|
+
const colH = new Array(n).fill(0);
|
|
127
|
+
const boxes: MasonryBox<T>[] = [];
|
|
128
|
+
let used: number | null = null;
|
|
129
|
+
images.forEach((im, idx) => {
|
|
130
|
+
let c = 0;
|
|
131
|
+
for (let k = 1; k < n; k++) if (colH[k] < colH[c]) c = k;
|
|
132
|
+
const y = colH[c];
|
|
133
|
+
const h = colW / effectiveAspect(im, fits[idx]);
|
|
134
|
+
if (used === null && y + h > o.bodyHIn + EPS && idx > 0) used = idx;
|
|
135
|
+
boxes.push({ asset: im, xIn: c * (colW + o.gapIn), yIn: y, wIn: colW, hIn: h });
|
|
136
|
+
colH[c] = y + h + o.gapIn;
|
|
137
|
+
});
|
|
138
|
+
return { boxes, used: used ?? images.length, colHeights: colH, colW };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function layoutMasonryPage<T extends Sized>(
|
|
142
|
+
images: T[],
|
|
143
|
+
o: MasonryOpts,
|
|
144
|
+
fits: ("cover" | "contain")[] = [],
|
|
145
|
+
): { boxes: MasonryBox<T>[]; used: number } {
|
|
146
|
+
if (o.mode === "columns") {
|
|
147
|
+
const r = layoutColumns(images, o, fits);
|
|
148
|
+
return { boxes: r.boxes, used: r.used };
|
|
149
|
+
}
|
|
150
|
+
const r = typeof o.rows === "number" ? layoutForcedRows(images, o, fits, o.rows) : layoutRows(images, o, fits);
|
|
151
|
+
return { boxes: r.boxes, used: r.used };
|
|
152
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { layoutMasonryPage } from "./masonry";
|
|
2
|
+
import type { SectionSettings } from "./settings";
|
|
3
|
+
import type { Chunk, DocImage, TextBlock } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface DocGeom {
|
|
6
|
+
bodyWIn: number;
|
|
7
|
+
bodyHIn: number;
|
|
8
|
+
titleBandIn: number;
|
|
9
|
+
gapIn: number;
|
|
10
|
+
leftColWIn: number;
|
|
11
|
+
colGutterIn: number;
|
|
12
|
+
minTextColIn: number;
|
|
13
|
+
minImageRegionIn: number;
|
|
14
|
+
masonryTargetRowIn: number;
|
|
15
|
+
topTextMaxFraction: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Mirrors .deck-body in deck.css (17×11 page, top 1.35in, sides/bottom 0.6in).
|
|
19
|
+
export const DOC_GEOM: DocGeom = {
|
|
20
|
+
bodyWIn: 15.8,
|
|
21
|
+
bodyHIn: 9.05,
|
|
22
|
+
titleBandIn: 0.9,
|
|
23
|
+
gapIn: 0.15,
|
|
24
|
+
leftColWIn: 4.6,
|
|
25
|
+
colGutterIn: 0.4,
|
|
26
|
+
minTextColIn: 1,
|
|
27
|
+
minImageRegionIn: 1.2,
|
|
28
|
+
masonryTargetRowIn: 2.6,
|
|
29
|
+
// Top text fills at most this fraction of the body height below the title
|
|
30
|
+
// band, so a text-heavy top section always leaves ≥ half the page for images.
|
|
31
|
+
topTextMaxFraction: 0.5,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const EPS = 1e-6;
|
|
35
|
+
|
|
36
|
+
export interface Region { xIn: number; yIn: number; wIn: number; hIn: number; }
|
|
37
|
+
export interface ImageBox {
|
|
38
|
+
image: DocImage;
|
|
39
|
+
xIn: number; yIn: number; wIn: number; hIn: number;
|
|
40
|
+
fit: "cover" | "contain";
|
|
41
|
+
}
|
|
42
|
+
export interface DerivedPage {
|
|
43
|
+
id: string;
|
|
44
|
+
chunkId: string;
|
|
45
|
+
title: string;
|
|
46
|
+
cont: boolean;
|
|
47
|
+
// Sub-chunk context (absent on top-level pages) — see Chunk.
|
|
48
|
+
kicker?: string;
|
|
49
|
+
depth?: 1;
|
|
50
|
+
textBlocks: TextBlock[];
|
|
51
|
+
textRegion: Region | null;
|
|
52
|
+
imageBoxes: ImageBox[];
|
|
53
|
+
warnings: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fitImages(
|
|
57
|
+
images: DocImage[],
|
|
58
|
+
layout: SectionSettings["imageLayout"],
|
|
59
|
+
region: Region,
|
|
60
|
+
geom: DocGeom,
|
|
61
|
+
): { boxes: ImageBox[]; used: number } {
|
|
62
|
+
if (images.length === 0 || region.hIn < geom.minImageRegionIn) return { boxes: [], used: 0 };
|
|
63
|
+
|
|
64
|
+
if (layout.mode === "grid") {
|
|
65
|
+
const used = Math.min(layout.rows * layout.columns, images.length);
|
|
66
|
+
const cellW = (region.wIn - (layout.columns - 1) * geom.gapIn) / layout.columns;
|
|
67
|
+
const cellH = (region.hIn - (layout.rows - 1) * geom.gapIn) / layout.rows;
|
|
68
|
+
const boxes = images.slice(0, used).map((image, i) => {
|
|
69
|
+
const r = Math.floor(i / layout.columns);
|
|
70
|
+
const c = i % layout.columns;
|
|
71
|
+
return {
|
|
72
|
+
image,
|
|
73
|
+
xIn: region.xIn + c * (cellW + geom.gapIn),
|
|
74
|
+
yIn: region.yIn + r * (cellH + geom.gapIn),
|
|
75
|
+
wIn: cellW,
|
|
76
|
+
hIn: cellH,
|
|
77
|
+
fit: layout.fit,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
return { boxes, used };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const r = layoutMasonryPage(images, {
|
|
84
|
+
mode: layout.flow,
|
|
85
|
+
columns: layout.columns,
|
|
86
|
+
rows: layout.rows,
|
|
87
|
+
bodyWIn: region.wIn,
|
|
88
|
+
bodyHIn: region.hIn,
|
|
89
|
+
gapIn: geom.gapIn,
|
|
90
|
+
targetRowHeightIn: geom.masonryTargetRowIn,
|
|
91
|
+
});
|
|
92
|
+
const boxes = r.boxes.slice(0, r.used).map((b) => ({
|
|
93
|
+
image: b.asset,
|
|
94
|
+
xIn: region.xIn + b.xIn,
|
|
95
|
+
yIn: region.yIn + b.yIn,
|
|
96
|
+
wIn: b.wIn,
|
|
97
|
+
hIn: b.hIn,
|
|
98
|
+
fit: "contain" as const,
|
|
99
|
+
}));
|
|
100
|
+
return { boxes, used: r.used };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function paginateChunk(
|
|
104
|
+
chunk: Chunk,
|
|
105
|
+
settings: SectionSettings,
|
|
106
|
+
textHeightsIn: number[],
|
|
107
|
+
geom: DocGeom = DOC_GEOM,
|
|
108
|
+
naturalTextWIn?: number,
|
|
109
|
+
): DerivedPage[] {
|
|
110
|
+
if (settings.excluded) return [];
|
|
111
|
+
if (chunk.textBlocks.length !== textHeightsIn.length) {
|
|
112
|
+
throw new Error("paginateChunk: textHeightsIn must match chunk.textBlocks");
|
|
113
|
+
}
|
|
114
|
+
if (chunk.textBlocks.length === 0 && chunk.images.length === 0) return [];
|
|
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
|
+
|
|
125
|
+
const pages: DerivedPage[] = [];
|
|
126
|
+
let ti = 0;
|
|
127
|
+
let ii = 0;
|
|
128
|
+
const top = geom.titleBandIn;
|
|
129
|
+
|
|
130
|
+
while (ti < chunk.textBlocks.length || ii < chunk.images.length) {
|
|
131
|
+
const cont = pages.length > 0;
|
|
132
|
+
const warnings: string[] = [];
|
|
133
|
+
const textW = settings.textPlacement === "top" ? geom.bodyWIn : colWIn;
|
|
134
|
+
|
|
135
|
+
// 1. Greedy text fill. Top text is capped at half the body so a text-heavy
|
|
136
|
+
// section still leaves room for images below; leftColumn keeps full height.
|
|
137
|
+
const capY =
|
|
138
|
+
settings.textPlacement === "top"
|
|
139
|
+
? geom.titleBandIn + (geom.bodyHIn - geom.titleBandIn) * geom.topTextMaxFraction
|
|
140
|
+
: geom.bodyHIn;
|
|
141
|
+
const placed: TextBlock[] = [];
|
|
142
|
+
let textEnd = top;
|
|
143
|
+
while (ti < chunk.textBlocks.length) {
|
|
144
|
+
const h = textHeightsIn[ti];
|
|
145
|
+
const yAfter = placed.length === 0 ? top + h : textEnd + geom.gapIn + h;
|
|
146
|
+
if (yAfter <= capY + EPS) {
|
|
147
|
+
placed.push(chunk.textBlocks[ti]);
|
|
148
|
+
textEnd = yAfter;
|
|
149
|
+
ti++;
|
|
150
|
+
} else if (placed.length === 0) {
|
|
151
|
+
// Oversize block: it gets the page, clipped at the cap (masonry-notice style).
|
|
152
|
+
placed.push(chunk.textBlocks[ti]);
|
|
153
|
+
textEnd = capY;
|
|
154
|
+
ti++;
|
|
155
|
+
warnings.push("A text block is taller than the page and was clipped.");
|
|
156
|
+
break;
|
|
157
|
+
} else {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// dropTextOverflow: on the FIRST page only, discard any text that didn't fit
|
|
163
|
+
// rather than flowing it onto Cont. pages. Images still paginate as usual.
|
|
164
|
+
if (!cont && settings.dropTextOverflow && ti < chunk.textBlocks.length) {
|
|
165
|
+
const dropped = chunk.textBlocks.length - ti;
|
|
166
|
+
ti = chunk.textBlocks.length;
|
|
167
|
+
warnings.push(`${dropped} text block(s) hidden (overflow dropped)`);
|
|
168
|
+
}
|
|
169
|
+
const textRegion: Region | null =
|
|
170
|
+
placed.length > 0 ? { xIn: 0, yIn: top, wIn: textW, hIn: textEnd - top } : null;
|
|
171
|
+
|
|
172
|
+
// 2. Image region. leftColumn+right runs beside the text; every other
|
|
173
|
+
// case — and any page whose text is exhausted — is full width below it.
|
|
174
|
+
let region: Region;
|
|
175
|
+
if (settings.textPlacement === "leftColumn" && settings.imagePlacement === "right" && placed.length > 0) {
|
|
176
|
+
region = {
|
|
177
|
+
xIn: colWIn + geom.colGutterIn,
|
|
178
|
+
yIn: top,
|
|
179
|
+
wIn: geom.bodyWIn - colWIn - geom.colGutterIn,
|
|
180
|
+
hIn: geom.bodyHIn - top,
|
|
181
|
+
};
|
|
182
|
+
} else {
|
|
183
|
+
const startY = placed.length > 0 ? textEnd + geom.gapIn : top;
|
|
184
|
+
region = { xIn: 0, yIn: startY, wIn: geom.bodyWIn, hIn: Math.max(0, geom.bodyHIn - startY) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3. Fit images into the region.
|
|
188
|
+
const { boxes, used } = fitImages(chunk.images.slice(ii), settings.imageLayout, region, geom);
|
|
189
|
+
ii += used;
|
|
190
|
+
if (boxes.some((b) => b.yIn + b.hIn > region.yIn + region.hIn + 0.01)) {
|
|
191
|
+
warnings.push("An image overflows the page and is clipped.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Progress guard: unreachable with sane geometry (a text-free page always
|
|
195
|
+
// gets the full region, and both fitters place ≥1 there), but a config
|
|
196
|
+
// mistake must not hang the browser.
|
|
197
|
+
if (placed.length === 0 && used === 0) {
|
|
198
|
+
warnings.push("Pagination could not place remaining content.");
|
|
199
|
+
pages.push({
|
|
200
|
+
id: `${chunk.id}#${pages.length}`,
|
|
201
|
+
chunkId: chunk.id,
|
|
202
|
+
title: cont ? `${chunk.title} Cont.` : chunk.title,
|
|
203
|
+
cont,
|
|
204
|
+
kicker: chunk.kicker,
|
|
205
|
+
depth: chunk.depth,
|
|
206
|
+
textBlocks: placed,
|
|
207
|
+
textRegion,
|
|
208
|
+
imageBoxes: boxes,
|
|
209
|
+
warnings,
|
|
210
|
+
});
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pages.push({
|
|
215
|
+
id: `${chunk.id}#${pages.length}`,
|
|
216
|
+
chunkId: chunk.id,
|
|
217
|
+
title: cont ? `${chunk.title} Cont.` : chunk.title,
|
|
218
|
+
cont,
|
|
219
|
+
kicker: chunk.kicker,
|
|
220
|
+
depth: chunk.depth,
|
|
221
|
+
textBlocks: placed,
|
|
222
|
+
textRegion,
|
|
223
|
+
imageBoxes: boxes,
|
|
224
|
+
warnings,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return pages;
|
|
229
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Presentation settings for one link-heading chunk. This shape is designed as
|
|
4
|
+
// if it were already a portal page's `document` options block — migrating it
|
|
5
|
+
// into the portal later must be a data move, not a redesign.
|
|
6
|
+
export const sectionSettingsSchema = z.object({
|
|
7
|
+
excluded: z.boolean().default(false),
|
|
8
|
+
textPlacement: z.enum(["top", "leftColumn"]).default("top"),
|
|
9
|
+
// "right" is only meaningful with textPlacement "leftColumn"; settingsFor
|
|
10
|
+
// normalizes the invalid combo.
|
|
11
|
+
imagePlacement: z.enum(["fullWidth", "right"]).default("fullWidth"),
|
|
12
|
+
// When true, text that overflows the first page is dropped instead of
|
|
13
|
+
// flowing onto Cont. pages (images still paginate normally).
|
|
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"),
|
|
18
|
+
imageLayout: z
|
|
19
|
+
.discriminatedUnion("mode", [
|
|
20
|
+
z.object({
|
|
21
|
+
mode: z.literal("grid"),
|
|
22
|
+
rows: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2),
|
|
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"),
|
|
27
|
+
}),
|
|
28
|
+
z.object({
|
|
29
|
+
mode: z.literal("masonry"),
|
|
30
|
+
flow: z.enum(["rows", "columns"]).default("rows"),
|
|
31
|
+
columns: z
|
|
32
|
+
.union([z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)])
|
|
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"),
|
|
39
|
+
}),
|
|
40
|
+
])
|
|
41
|
+
.default({ mode: "masonry", flow: "rows", columns: 3, rows: "auto" }),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type SectionSettings = z.infer<typeof sectionSettingsSchema>;
|
|
45
|
+
|
|
46
|
+
const DEFAULTS: SectionSettings = sectionSettingsSchema.parse({});
|
|
47
|
+
|
|
48
|
+
// Overlay lookup: unknown/stale/malformed entries fall back to defaults —
|
|
49
|
+
// settings for a deleted portal section must be inert, never fatal.
|
|
50
|
+
export function settingsFor(
|
|
51
|
+
all: Record<string, unknown> | undefined,
|
|
52
|
+
chunkId: string,
|
|
53
|
+
): SectionSettings {
|
|
54
|
+
const raw = all?.[chunkId];
|
|
55
|
+
const parsed = sectionSettingsSchema.safeParse(raw ?? {});
|
|
56
|
+
const s = parsed.success ? parsed.data : DEFAULTS;
|
|
57
|
+
if (s.textPlacement === "top" && s.imagePlacement === "right") {
|
|
58
|
+
return { ...s, imagePlacement: "fullWidth" };
|
|
59
|
+
}
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const documentMetaSchema = z.object({
|
|
64
|
+
client: z.string().default(""),
|
|
65
|
+
project: z.string().default(""),
|
|
66
|
+
dateLabel: z.string().default(""),
|
|
67
|
+
});
|
|
68
|
+
const documentCoverSchema = z.object({
|
|
69
|
+
bgColor: z.string().default("#333333"),
|
|
70
|
+
accentColor: z.string().default("#EB9500"),
|
|
71
|
+
title: z.string().default(""),
|
|
72
|
+
label: z.string().default("BRAND GUIDELINES"),
|
|
73
|
+
date: z.string().default(""),
|
|
74
|
+
// Media-library image id shown between masthead and title lockup; null = solid cover.
|
|
75
|
+
imageId: z.string().nullable().default(null),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const documentOptionsSchema = z.object({
|
|
79
|
+
enabled: z.boolean().default(false),
|
|
80
|
+
// `.default({})` doesn't typecheck even though `{}` parses fine at
|
|
81
|
+
// runtime — zod v4 infers the default's type from the object schema's
|
|
82
|
+
// required input shape, not its post-default output shape.
|
|
83
|
+
meta: documentMetaSchema.default(() => documentMetaSchema.parse({})),
|
|
84
|
+
cover: documentCoverSchema.default(() => documentCoverSchema.parse({})),
|
|
85
|
+
sections: z.record(z.string(), sectionSettingsSchema).default({}),
|
|
86
|
+
});
|
|
87
|
+
export type DocumentOptions = z.infer<typeof documentOptionsSchema>;
|
|
88
|
+
export type DocCover = DocumentOptions["cover"];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Engine-local structural types for portal content. Deliberately NOT imported
|
|
2
|
+
// from the rest of @drawnagency/primitives: the engine's contract is the
|
|
3
|
+
// portal content SHAPE, and staying import-free keeps it portable into its
|
|
4
|
+
// own package later.
|
|
5
|
+
// (The fixture test in tests/document/fixture.test.ts pins the shape against
|
|
6
|
+
// the real primitives schemas so drift is caught.)
|
|
7
|
+
|
|
8
|
+
export const PREAMBLE_ID = "__preamble";
|
|
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
|
+
|
|
15
|
+
// Breadcrumb metadata shown in the page chrome. Single definition — the
|
|
16
|
+
// harness carried this shape in two places (deck/DeckPage.tsx's `DeckMeta`
|
|
17
|
+
// and lib/deck/schema.ts's zod-inferred `DeckMeta`); the engine only needs
|
|
18
|
+
// the plain type, not a schema, so it lives here once.
|
|
19
|
+
export interface DocMeta {
|
|
20
|
+
client: string;
|
|
21
|
+
project: string;
|
|
22
|
+
dateLabel: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DocSectionInput {
|
|
26
|
+
id: string;
|
|
27
|
+
type: string;
|
|
28
|
+
content: Record<string, unknown>;
|
|
29
|
+
options?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One displayable image, dimensions + pre-chosen repo filenames baked in by
|
|
33
|
+
// the (scaffolding) fetch layer so the engine never touches the manifest.
|
|
34
|
+
export interface DocImage {
|
|
35
|
+
imageId: string;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
alt: string;
|
|
39
|
+
previewFile: string; // e.g. "1080.webp" — editor variant
|
|
40
|
+
printFile: string; // e.g. "1920.webp" — print/PDF variant
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type TextBlock =
|
|
44
|
+
| { kind: "heading"; level: 2 | 3; text: string }
|
|
45
|
+
| { kind: "prose"; html: string };
|
|
46
|
+
|
|
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.
|
|
49
|
+
export interface Chunk {
|
|
50
|
+
id: string; // boundary heading's section id, or PREAMBLE_ID
|
|
51
|
+
title: string;
|
|
52
|
+
textBlocks: TextBlock[];
|
|
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;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DocSourceContent {
|
|
61
|
+
pageTitle: string;
|
|
62
|
+
sections: DocSectionInput[];
|
|
63
|
+
images: Record<string, DocImage>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Public surface of the document engine + components. Sealed subtree: nothing
|
|
2
|
+
// in document/ imports from the rest of primitives (see spec — extraction to
|
|
3
|
+
// @drawnagency/document must stay a directory move).
|
|
4
|
+
export * from "./engine/types";
|
|
5
|
+
export * from "./engine/settings";
|
|
6
|
+
export { splitProseBlocks } from "./engine/blocks";
|
|
7
|
+
export { harvest } from "./engine/harvest";
|
|
8
|
+
export * from "./engine/paginate";
|
|
9
|
+
export { layoutMasonryPage, type MasonryOpts, type MasonryBox, type Sized } from "./engine/masonry";
|
|
10
|
+
|
|
11
|
+
export { DocPage, type DocMeta } from "./components/DocPage";
|
|
12
|
+
export { DocSectionPage, type DocHost } from "./components/DocSectionPage";
|
|
13
|
+
export { DocCoverPage } from "./components/DocCoverPage";
|
|
14
|
+
export { useDocPages } from "./components/useDocPages";
|
|
15
|
+
|
|
16
|
+
// Task 3 — shared document shell (presentation + editing).
|
|
17
|
+
export { DocViewer, type DocViewerProps } from "./components/DocViewer";
|
|
18
|
+
export { DocRail, entryId, type RailEntry } from "./components/DocRail";
|