@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.
- package/dist/{chunk-KVQFMVXE.js → chunk-DKXISNJ2.js} +2 -2
- package/dist/{chunk-RSITF24B.js → chunk-EVNEMJZ6.js} +9 -3
- package/dist/{chunk-VI26O4E3.js → chunk-FDBZZ4AT.js} +1 -1
- package/dist/{chunk-QB5Q23G2.js → chunk-FN4NKUZU.js} +1 -1
- package/dist/{chunk-EMGLZQCM.js → chunk-MBJUWOM2.js} +1 -1
- package/dist/{chunk-4TVODHD2.js → chunk-P4ZNHZMP.js} +6 -6
- package/dist/{chunk-UNUJPLZV.js → chunk-PJOHAWNV.js} +1 -1
- package/dist/{chunk-2DDWWTRX.js → chunk-Q6LZOH5M.js} +1 -1
- package/dist/chunk-WP5UX4MI.js +63 -0
- package/dist/{chunk-3LNKOLZP.js → chunk-YKOLOZB2.js} +1 -1
- package/dist/{chunk-7XWER6YR.js → chunk-ZEXR7JYQ.js} +3 -3
- 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 +17 -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 +31 -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 +27 -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 +6 -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 +25 -0
- package/dist/document/engine/masonry.d.ts.map +1 -0
- package/dist/document/engine/paginate.d.ts +40 -0
- package/dist/document/engine/paginate.d.ts.map +1 -0
- package/dist/document/engine/settings.d.ts +70 -0
- package/dist/document/engine/settings.d.ts.map +1 -0
- package/dist/document/engine/types.d.ts +40 -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 +881 -0
- package/dist/document/styles/document.css +208 -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 +223 -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 +278 -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 +160 -0
- package/src/document/components/DocSectionPage.tsx +45 -0
- package/src/document/components/DocViewer.tsx +285 -0
- package/src/document/components/Wordmark.tsx +18 -0
- package/src/document/components/measure.tsx +105 -0
- package/src/document/components/useDocPages.ts +67 -0
- package/src/document/engine/blocks.ts +87 -0
- package/src/document/engine/harvest.ts +80 -0
- package/src/document/engine/masonry.ts +89 -0
- package/src/document/engine/paginate.ts +209 -0
- package/src/document/engine/settings.ts +77 -0
- package/src/document/engine/types.ts +54 -0
- package/src/document/index.ts +18 -0
- package/src/document/styles/document.css +208 -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,67 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { harvest } from "../engine/harvest";
|
|
3
|
+
import { paginateChunk, type DerivedPage } from "../engine/paginate";
|
|
4
|
+
import { settingsFor, type SectionSettings } from "../engine/settings";
|
|
5
|
+
import type { Chunk, DocSourceContent } from "../engine/types";
|
|
6
|
+
import { useTextMeasurements } from "./measure";
|
|
7
|
+
|
|
8
|
+
export function useDocPages(input: {
|
|
9
|
+
source: DocSourceContent | null;
|
|
10
|
+
sections: Record<string, unknown>;
|
|
11
|
+
}): { chunks: Chunk[]; pages: DerivedPage[]; measuring: boolean; warningsByChunk: Record<string, string[]> } {
|
|
12
|
+
const { source, sections } = input;
|
|
13
|
+
|
|
14
|
+
const chunks = useMemo(
|
|
15
|
+
() => (source ? harvest(source.sections, source.pageTitle, source.images) : []),
|
|
16
|
+
[source],
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const settingsMap = useMemo(() => {
|
|
20
|
+
const map: Record<string, SectionSettings> = {};
|
|
21
|
+
for (const c of chunks) map[c.id] = settingsFor(sections, c.id);
|
|
22
|
+
return map;
|
|
23
|
+
}, [chunks, sections]);
|
|
24
|
+
|
|
25
|
+
const { heights } = useTextMeasurements(chunks, settingsMap);
|
|
26
|
+
|
|
27
|
+
// `heights` is state set by an effect, so it always lags one render behind
|
|
28
|
+
// `chunks`: when `source` changes shape (a block added/removed, a new
|
|
29
|
+
// section), this memo can run with heights measured for the PREVIOUS chunk
|
|
30
|
+
// shape. Calling paginateChunk with a stale (mismatched-length) heights
|
|
31
|
+
// array is its documented throw condition, so each chunk is guarded
|
|
32
|
+
// independently — a chunk whose current heights aren't in yet contributes
|
|
33
|
+
// no pages (rather than crash) until the measurement effect catches up.
|
|
34
|
+
// Zero-text chunks have nothing to measure, so they always paginate.
|
|
35
|
+
const pages = useMemo(() => {
|
|
36
|
+
return chunks.flatMap((c) => {
|
|
37
|
+
const h = heights?.[c.id];
|
|
38
|
+
if (c.textBlocks.length > 0 && (!h || h.length !== c.textBlocks.length)) return [];
|
|
39
|
+
return paginateChunk(c, settingsMap[c.id], h ?? []);
|
|
40
|
+
});
|
|
41
|
+
}, [chunks, settingsMap, heights]);
|
|
42
|
+
|
|
43
|
+
// Mirrors the pages memo's per-chunk guard: still "measuring" until every
|
|
44
|
+
// non-empty chunk has current heights, not just until `heights` is non-null.
|
|
45
|
+
const measuring = useMemo(
|
|
46
|
+
() =>
|
|
47
|
+
chunks.some((c) => {
|
|
48
|
+
if (c.textBlocks.length === 0) return false;
|
|
49
|
+
const h = heights?.[c.id];
|
|
50
|
+
return !h || h.length !== c.textBlocks.length;
|
|
51
|
+
}),
|
|
52
|
+
[chunks, heights],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const warningsByChunk = useMemo(() => {
|
|
56
|
+
const map: Record<string, string[]> = {};
|
|
57
|
+
for (const c of chunks) {
|
|
58
|
+
const noContent = c.textBlocks.length === 0 && c.images.length === 0;
|
|
59
|
+
const pageWarnings = pages.filter((p) => p.chunkId === c.id).flatMap((p) => p.warnings);
|
|
60
|
+
const warnings = noContent ? ["Section has no content", ...pageWarnings] : pageWarnings;
|
|
61
|
+
if (warnings.length > 0) map[c.id] = warnings;
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}, [chunks, pages]);
|
|
65
|
+
|
|
66
|
+
return { chunks, pages, measuring, warningsByChunk };
|
|
67
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Elements that are never allowed to survive into rendered output, wherever
|
|
2
|
+
// they occur (top-level or nested).
|
|
3
|
+
const DISALLOWED_TAGS = new Set([
|
|
4
|
+
"script",
|
|
5
|
+
"style",
|
|
6
|
+
"iframe",
|
|
7
|
+
"object",
|
|
8
|
+
"embed",
|
|
9
|
+
"link",
|
|
10
|
+
"meta",
|
|
11
|
+
"base",
|
|
12
|
+
"form",
|
|
13
|
+
// Raw-text / escapable-raw-text and declarative elements whose contents are
|
|
14
|
+
// not parsed as normal markup. Left in place they enable serialize/reparse
|
|
15
|
+
// (mXSS) bypasses — e.g. an <img onerror> smuggled inside <noscript> text.
|
|
16
|
+
// Removing the element drops its contents too (including <template> content).
|
|
17
|
+
"noscript",
|
|
18
|
+
"xmp",
|
|
19
|
+
"noembed",
|
|
20
|
+
"noframes",
|
|
21
|
+
"plaintext",
|
|
22
|
+
"template",
|
|
23
|
+
]);
|
|
24
|
+
const DISALLOWED_SELECTOR = Array.from(DISALLOWED_TAGS).join(",");
|
|
25
|
+
|
|
26
|
+
// Attributes that can carry a URL and therefore need scheme scrutiny.
|
|
27
|
+
const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction"]);
|
|
28
|
+
|
|
29
|
+
function isDangerousUrl(attrName: string, rawValue: string): boolean {
|
|
30
|
+
// Browsers strip ASCII tab/CR/LF anywhere in a URL (and ignore leading
|
|
31
|
+
// controls) before resolving its scheme, so "jav\tascript:" runs like
|
|
32
|
+
// "javascript:". Over-stripping here only makes the check more conservative.
|
|
33
|
+
const value = rawValue.toLowerCase().replace(/[\u0000-\u0020]/g, "");
|
|
34
|
+
if (value.startsWith("javascript:") || value.startsWith("vbscript:")) return true;
|
|
35
|
+
if (attrName === "src" && value.startsWith("data:text/html")) return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Defense-in-depth scrub applied at the dangerouslySetInnerHTML/innerHTML
|
|
40
|
+
// boundary. Upstream portal content is sanitized at authoring time, but
|
|
41
|
+
// /print runs in an authed headless browser, so this engine (kept
|
|
42
|
+
// dependency-free — no DOMPurify) removes known-dangerous elements and
|
|
43
|
+
// attributes from the DOM it already parsed, in place.
|
|
44
|
+
function scrubElement(root: Element): void {
|
|
45
|
+
root.querySelectorAll(DISALLOWED_SELECTOR).forEach((el) => el.remove());
|
|
46
|
+
|
|
47
|
+
const elements: Element[] = [root, ...Array.from(root.querySelectorAll("*"))];
|
|
48
|
+
for (const el of elements) {
|
|
49
|
+
for (const attr of Array.from(el.attributes)) {
|
|
50
|
+
const name = attr.name.toLowerCase();
|
|
51
|
+
if (name.startsWith("on") || name === "srcdoc") {
|
|
52
|
+
el.removeAttribute(attr.name);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (URL_ATTRS.has(name) && isDangerousUrl(name, attr.value)) {
|
|
56
|
+
el.removeAttribute(attr.name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Split sanitized rich-text HTML into atomic top-level blocks. Pagination
|
|
63
|
+
// never splits inside a block — a block that doesn't fit moves wholesale to
|
|
64
|
+
// the next page (spec: "pagination by block measurement").
|
|
65
|
+
export function splitProseBlocks(html: string): string[] {
|
|
66
|
+
if (!html.trim()) return [];
|
|
67
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
68
|
+
const blocks: string[] = [];
|
|
69
|
+
for (const node of Array.from(doc.body.childNodes)) {
|
|
70
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
71
|
+
const el = node as Element;
|
|
72
|
+
if (DISALLOWED_TAGS.has(el.tagName.toLowerCase())) continue;
|
|
73
|
+
scrubElement(el);
|
|
74
|
+
if ((el.textContent ?? "").trim() || el.matches("img") || el.querySelector("img")) {
|
|
75
|
+
blocks.push(el.outerHTML);
|
|
76
|
+
}
|
|
77
|
+
} else if (node.nodeType === Node.TEXT_NODE) {
|
|
78
|
+
const text = (node.textContent ?? "").trim();
|
|
79
|
+
if (text) {
|
|
80
|
+
const p = doc.createElement("p");
|
|
81
|
+
p.textContent = text;
|
|
82
|
+
blocks.push(p.outerHTML);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return blocks;
|
|
87
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { splitProseBlocks } from "./blocks";
|
|
2
|
+
import { PREAMBLE_ID, type Chunk, type DocImage, type DocSectionInput } 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
|
+
): Chunk[] {
|
|
65
|
+
const chunks: Chunk[] = [];
|
|
66
|
+
let current: Chunk = { id: PREAMBLE_ID, title: pageTitle, textBlocks: [], images: [] };
|
|
67
|
+
let preamble = current;
|
|
68
|
+
|
|
69
|
+
for (const section of sections) {
|
|
70
|
+
if (section.type === "link_heading") {
|
|
71
|
+
current = { id: section.id, title: str(asRecord(section.content).heading), textBlocks: [], images: [] };
|
|
72
|
+
chunks.push(current);
|
|
73
|
+
} else {
|
|
74
|
+
harvestInto(current, section.type, section.content, imagesById);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const preambleHasContent = preamble.textBlocks.length > 0 || preamble.images.length > 0;
|
|
79
|
+
return preambleHasContent ? [preamble, ...chunks] : chunks;
|
|
80
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MasonryBox<T extends Sized = Sized> {
|
|
17
|
+
asset: T;
|
|
18
|
+
xIn: number;
|
|
19
|
+
yIn: number;
|
|
20
|
+
wIn: number;
|
|
21
|
+
hIn: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A "cover" photo is laid out in a SQUARE cell (effective aspect 1) and the
|
|
25
|
+
// image crops to fill it; "contain" (default) uses the photo's natural aspect.
|
|
26
|
+
const effectiveAspect = (im: Sized, fit: "cover" | "contain" | undefined) =>
|
|
27
|
+
fit === "cover" ? 1 : im.width / im.height;
|
|
28
|
+
|
|
29
|
+
function layoutRows<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; endY: number } {
|
|
30
|
+
const asp = images.map((im, i) => effectiveAspect(im, fits[i]));
|
|
31
|
+
const boxes: MasonryBox<T>[] = [];
|
|
32
|
+
let y = 0, i = 0;
|
|
33
|
+
let used: number | null = null;
|
|
34
|
+
while (i < images.length) {
|
|
35
|
+
const row: number[] = []; // indices in this row
|
|
36
|
+
let natW = 0;
|
|
37
|
+
while (i + row.length < images.length) {
|
|
38
|
+
row.push(i + row.length);
|
|
39
|
+
const gaps = o.gapIn * (row.length - 1);
|
|
40
|
+
natW = row.reduce((s, idx) => s + asp[idx] * o.targetRowHeightIn, 0) + gaps;
|
|
41
|
+
if (natW >= o.bodyWIn) break;
|
|
42
|
+
}
|
|
43
|
+
const gaps = o.gapIn * (row.length - 1);
|
|
44
|
+
const sumAspect = row.reduce((s, idx) => s + asp[idx], 0);
|
|
45
|
+
const isFull = natW >= o.bodyWIn;
|
|
46
|
+
const rowH = isFull ? (o.bodyWIn - gaps) / sumAspect : o.targetRowHeightIn;
|
|
47
|
+
if (used === null && y + rowH > o.bodyHIn + EPS && boxes.length > 0) used = boxes.length;
|
|
48
|
+
let x = 0;
|
|
49
|
+
for (const idx of row) {
|
|
50
|
+
const w = asp[idx] * rowH;
|
|
51
|
+
boxes.push({ asset: images[idx], xIn: x, yIn: y, wIn: w, hIn: rowH });
|
|
52
|
+
x += w + o.gapIn;
|
|
53
|
+
}
|
|
54
|
+
y += rowH + o.gapIn;
|
|
55
|
+
i += row.length;
|
|
56
|
+
}
|
|
57
|
+
return { boxes, used: used ?? images.length, endY: y };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function layoutColumns<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; colHeights: number[]; colW: number } {
|
|
61
|
+
const n = o.columns;
|
|
62
|
+
const colW = (o.bodyWIn - o.gapIn * (n - 1)) / n;
|
|
63
|
+
const colH = new Array(n).fill(0);
|
|
64
|
+
const boxes: MasonryBox<T>[] = [];
|
|
65
|
+
let used: number | null = null;
|
|
66
|
+
images.forEach((im, idx) => {
|
|
67
|
+
let c = 0;
|
|
68
|
+
for (let k = 1; k < n; k++) if (colH[k] < colH[c]) c = k;
|
|
69
|
+
const y = colH[c];
|
|
70
|
+
const h = colW / effectiveAspect(im, fits[idx]);
|
|
71
|
+
if (used === null && y + h > o.bodyHIn + EPS && idx > 0) used = idx;
|
|
72
|
+
boxes.push({ asset: im, xIn: c * (colW + o.gapIn), yIn: y, wIn: colW, hIn: h });
|
|
73
|
+
colH[c] = y + h + o.gapIn;
|
|
74
|
+
});
|
|
75
|
+
return { boxes, used: used ?? images.length, colHeights: colH, colW };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function layoutMasonryPage<T extends Sized>(
|
|
79
|
+
images: T[],
|
|
80
|
+
o: MasonryOpts,
|
|
81
|
+
fits: ("cover" | "contain")[] = [],
|
|
82
|
+
): { boxes: MasonryBox<T>[]; used: number } {
|
|
83
|
+
if (o.mode === "columns") {
|
|
84
|
+
const r = layoutColumns(images, o, fits);
|
|
85
|
+
return { boxes: r.boxes, used: r.used };
|
|
86
|
+
}
|
|
87
|
+
const r = layoutRows(images, o, fits);
|
|
88
|
+
return { boxes: r.boxes, used: r.used };
|
|
89
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
minImageRegionIn: number;
|
|
13
|
+
masonryTargetRowIn: number;
|
|
14
|
+
topTextMaxFraction: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Mirrors .deck-body in deck.css (17×11 page, top 1.35in, sides/bottom 0.6in).
|
|
18
|
+
export const DOC_GEOM: DocGeom = {
|
|
19
|
+
bodyWIn: 15.8,
|
|
20
|
+
bodyHIn: 9.05,
|
|
21
|
+
titleBandIn: 0.9,
|
|
22
|
+
gapIn: 0.15,
|
|
23
|
+
leftColWIn: 4.6,
|
|
24
|
+
colGutterIn: 0.4,
|
|
25
|
+
minImageRegionIn: 1.2,
|
|
26
|
+
masonryTargetRowIn: 2.6,
|
|
27
|
+
// Top text fills at most this fraction of the body height below the title
|
|
28
|
+
// band, so a text-heavy top section always leaves ≥ half the page for images.
|
|
29
|
+
topTextMaxFraction: 0.5,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const EPS = 1e-6;
|
|
33
|
+
|
|
34
|
+
export interface Region { xIn: number; yIn: number; wIn: number; hIn: number; }
|
|
35
|
+
export interface ImageBox {
|
|
36
|
+
image: DocImage;
|
|
37
|
+
xIn: number; yIn: number; wIn: number; hIn: number;
|
|
38
|
+
fit: "cover" | "contain";
|
|
39
|
+
}
|
|
40
|
+
export interface DerivedPage {
|
|
41
|
+
id: string;
|
|
42
|
+
chunkId: string;
|
|
43
|
+
title: string;
|
|
44
|
+
cont: boolean;
|
|
45
|
+
textBlocks: TextBlock[];
|
|
46
|
+
textRegion: Region | null;
|
|
47
|
+
imageBoxes: ImageBox[];
|
|
48
|
+
warnings: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fitImages(
|
|
52
|
+
images: DocImage[],
|
|
53
|
+
layout: SectionSettings["imageLayout"],
|
|
54
|
+
region: Region,
|
|
55
|
+
geom: DocGeom,
|
|
56
|
+
): { boxes: ImageBox[]; used: number } {
|
|
57
|
+
if (images.length === 0 || region.hIn < geom.minImageRegionIn) return { boxes: [], used: 0 };
|
|
58
|
+
|
|
59
|
+
if (layout.mode === "grid") {
|
|
60
|
+
const used = Math.min(layout.rows * layout.columns, images.length);
|
|
61
|
+
const cellW = (region.wIn - (layout.columns - 1) * geom.gapIn) / layout.columns;
|
|
62
|
+
const cellH = (region.hIn - (layout.rows - 1) * geom.gapIn) / layout.rows;
|
|
63
|
+
const boxes = images.slice(0, used).map((image, i) => {
|
|
64
|
+
const r = Math.floor(i / layout.columns);
|
|
65
|
+
const c = i % layout.columns;
|
|
66
|
+
return {
|
|
67
|
+
image,
|
|
68
|
+
xIn: region.xIn + c * (cellW + geom.gapIn),
|
|
69
|
+
yIn: region.yIn + r * (cellH + geom.gapIn),
|
|
70
|
+
wIn: cellW,
|
|
71
|
+
hIn: cellH,
|
|
72
|
+
fit: "cover" as const,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
return { boxes, used };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const r = layoutMasonryPage(images, {
|
|
79
|
+
mode: layout.flow,
|
|
80
|
+
columns: layout.columns,
|
|
81
|
+
bodyWIn: region.wIn,
|
|
82
|
+
bodyHIn: region.hIn,
|
|
83
|
+
gapIn: geom.gapIn,
|
|
84
|
+
targetRowHeightIn: geom.masonryTargetRowIn,
|
|
85
|
+
});
|
|
86
|
+
const boxes = r.boxes.slice(0, r.used).map((b) => ({
|
|
87
|
+
image: b.asset,
|
|
88
|
+
xIn: region.xIn + b.xIn,
|
|
89
|
+
yIn: region.yIn + b.yIn,
|
|
90
|
+
wIn: b.wIn,
|
|
91
|
+
hIn: b.hIn,
|
|
92
|
+
fit: "contain" as const,
|
|
93
|
+
}));
|
|
94
|
+
return { boxes, used: r.used };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function paginateChunk(
|
|
98
|
+
chunk: Chunk,
|
|
99
|
+
settings: SectionSettings,
|
|
100
|
+
textHeightsIn: number[],
|
|
101
|
+
geom: DocGeom = DOC_GEOM,
|
|
102
|
+
): DerivedPage[] {
|
|
103
|
+
if (settings.excluded) return [];
|
|
104
|
+
if (chunk.textBlocks.length !== textHeightsIn.length) {
|
|
105
|
+
throw new Error("paginateChunk: textHeightsIn must match chunk.textBlocks");
|
|
106
|
+
}
|
|
107
|
+
if (chunk.textBlocks.length === 0 && chunk.images.length === 0) return [];
|
|
108
|
+
|
|
109
|
+
const pages: DerivedPage[] = [];
|
|
110
|
+
let ti = 0;
|
|
111
|
+
let ii = 0;
|
|
112
|
+
const top = geom.titleBandIn;
|
|
113
|
+
|
|
114
|
+
while (ti < chunk.textBlocks.length || ii < chunk.images.length) {
|
|
115
|
+
const cont = pages.length > 0;
|
|
116
|
+
const warnings: string[] = [];
|
|
117
|
+
const textW = settings.textPlacement === "top" ? geom.bodyWIn : geom.leftColWIn;
|
|
118
|
+
|
|
119
|
+
// 1. Greedy text fill. Top text is capped at half the body so a text-heavy
|
|
120
|
+
// section still leaves room for images below; leftColumn keeps full height.
|
|
121
|
+
const capY =
|
|
122
|
+
settings.textPlacement === "top"
|
|
123
|
+
? geom.titleBandIn + (geom.bodyHIn - geom.titleBandIn) * geom.topTextMaxFraction
|
|
124
|
+
: geom.bodyHIn;
|
|
125
|
+
const placed: TextBlock[] = [];
|
|
126
|
+
let textEnd = top;
|
|
127
|
+
while (ti < chunk.textBlocks.length) {
|
|
128
|
+
const h = textHeightsIn[ti];
|
|
129
|
+
const yAfter = placed.length === 0 ? top + h : textEnd + geom.gapIn + h;
|
|
130
|
+
if (yAfter <= capY + EPS) {
|
|
131
|
+
placed.push(chunk.textBlocks[ti]);
|
|
132
|
+
textEnd = yAfter;
|
|
133
|
+
ti++;
|
|
134
|
+
} else if (placed.length === 0) {
|
|
135
|
+
// Oversize block: it gets the page, clipped at the cap (masonry-notice style).
|
|
136
|
+
placed.push(chunk.textBlocks[ti]);
|
|
137
|
+
textEnd = capY;
|
|
138
|
+
ti++;
|
|
139
|
+
warnings.push("A text block is taller than the page and was clipped.");
|
|
140
|
+
break;
|
|
141
|
+
} else {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// dropTextOverflow: on the FIRST page only, discard any text that didn't fit
|
|
147
|
+
// rather than flowing it onto Cont. pages. Images still paginate as usual.
|
|
148
|
+
if (!cont && settings.dropTextOverflow && ti < chunk.textBlocks.length) {
|
|
149
|
+
const dropped = chunk.textBlocks.length - ti;
|
|
150
|
+
ti = chunk.textBlocks.length;
|
|
151
|
+
warnings.push(`${dropped} text block(s) hidden (overflow dropped)`);
|
|
152
|
+
}
|
|
153
|
+
const textRegion: Region | null =
|
|
154
|
+
placed.length > 0 ? { xIn: 0, yIn: top, wIn: textW, hIn: textEnd - top } : null;
|
|
155
|
+
|
|
156
|
+
// 2. Image region. leftColumn+right runs beside the text; every other
|
|
157
|
+
// case — and any page whose text is exhausted — is full width below it.
|
|
158
|
+
let region: Region;
|
|
159
|
+
if (settings.textPlacement === "leftColumn" && settings.imagePlacement === "right" && placed.length > 0) {
|
|
160
|
+
region = {
|
|
161
|
+
xIn: geom.leftColWIn + geom.colGutterIn,
|
|
162
|
+
yIn: top,
|
|
163
|
+
wIn: geom.bodyWIn - geom.leftColWIn - geom.colGutterIn,
|
|
164
|
+
hIn: geom.bodyHIn - top,
|
|
165
|
+
};
|
|
166
|
+
} else {
|
|
167
|
+
const startY = placed.length > 0 ? textEnd + geom.gapIn : top;
|
|
168
|
+
region = { xIn: 0, yIn: startY, wIn: geom.bodyWIn, hIn: Math.max(0, geom.bodyHIn - startY) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 3. Fit images into the region.
|
|
172
|
+
const { boxes, used } = fitImages(chunk.images.slice(ii), settings.imageLayout, region, geom);
|
|
173
|
+
ii += used;
|
|
174
|
+
if (boxes.some((b) => b.yIn + b.hIn > region.yIn + region.hIn + 0.01)) {
|
|
175
|
+
warnings.push("An image overflows the page and is clipped.");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Progress guard: unreachable with sane geometry (a text-free page always
|
|
179
|
+
// gets the full region, and both fitters place ≥1 there), but a config
|
|
180
|
+
// mistake must not hang the browser.
|
|
181
|
+
if (placed.length === 0 && used === 0) {
|
|
182
|
+
warnings.push("Pagination could not place remaining content.");
|
|
183
|
+
pages.push({
|
|
184
|
+
id: `${chunk.id}#${pages.length}`,
|
|
185
|
+
chunkId: chunk.id,
|
|
186
|
+
title: cont ? `${chunk.title} Cont.` : chunk.title,
|
|
187
|
+
cont,
|
|
188
|
+
textBlocks: placed,
|
|
189
|
+
textRegion,
|
|
190
|
+
imageBoxes: boxes,
|
|
191
|
+
warnings,
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pages.push({
|
|
197
|
+
id: `${chunk.id}#${pages.length}`,
|
|
198
|
+
chunkId: chunk.id,
|
|
199
|
+
title: cont ? `${chunk.title} Cont.` : chunk.title,
|
|
200
|
+
cont,
|
|
201
|
+
textBlocks: placed,
|
|
202
|
+
textRegion,
|
|
203
|
+
imageBoxes: boxes,
|
|
204
|
+
warnings,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return pages;
|
|
209
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
imageLayout: z
|
|
16
|
+
.discriminatedUnion("mode", [
|
|
17
|
+
z.object({
|
|
18
|
+
mode: z.literal("grid"),
|
|
19
|
+
rows: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(2),
|
|
20
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(4),
|
|
21
|
+
}),
|
|
22
|
+
z.object({
|
|
23
|
+
mode: z.literal("masonry"),
|
|
24
|
+
flow: z.enum(["rows", "columns"]).default("rows"),
|
|
25
|
+
columns: z
|
|
26
|
+
.union([z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)])
|
|
27
|
+
.default(3),
|
|
28
|
+
}),
|
|
29
|
+
])
|
|
30
|
+
.default({ mode: "masonry", flow: "rows", columns: 3 }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type SectionSettings = z.infer<typeof sectionSettingsSchema>;
|
|
34
|
+
|
|
35
|
+
const DEFAULTS: SectionSettings = sectionSettingsSchema.parse({});
|
|
36
|
+
|
|
37
|
+
// Overlay lookup: unknown/stale/malformed entries fall back to defaults —
|
|
38
|
+
// settings for a deleted portal section must be inert, never fatal.
|
|
39
|
+
export function settingsFor(
|
|
40
|
+
all: Record<string, unknown> | undefined,
|
|
41
|
+
chunkId: string,
|
|
42
|
+
): SectionSettings {
|
|
43
|
+
const raw = all?.[chunkId];
|
|
44
|
+
const parsed = sectionSettingsSchema.safeParse(raw ?? {});
|
|
45
|
+
const s = parsed.success ? parsed.data : DEFAULTS;
|
|
46
|
+
if (s.textPlacement === "top" && s.imagePlacement === "right") {
|
|
47
|
+
return { ...s, imagePlacement: "fullWidth" };
|
|
48
|
+
}
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const documentMetaSchema = z.object({
|
|
53
|
+
client: z.string().default(""),
|
|
54
|
+
project: z.string().default(""),
|
|
55
|
+
dateLabel: z.string().default(""),
|
|
56
|
+
});
|
|
57
|
+
const documentCoverSchema = z.object({
|
|
58
|
+
bgColor: z.string().default("#333333"),
|
|
59
|
+
accentColor: z.string().default("#EB9500"),
|
|
60
|
+
title: z.string().default(""),
|
|
61
|
+
label: z.string().default("BRAND GUIDELINES"),
|
|
62
|
+
date: z.string().default(""),
|
|
63
|
+
// Media-library image id shown between masthead and title lockup; null = solid cover.
|
|
64
|
+
imageId: z.string().nullable().default(null),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const documentOptionsSchema = z.object({
|
|
68
|
+
enabled: z.boolean().default(false),
|
|
69
|
+
// `.default({})` doesn't typecheck even though `{}` parses fine at
|
|
70
|
+
// runtime — zod v4 infers the default's type from the object schema's
|
|
71
|
+
// required input shape, not its post-default output shape.
|
|
72
|
+
meta: documentMetaSchema.default(() => documentMetaSchema.parse({})),
|
|
73
|
+
cover: documentCoverSchema.default(() => documentCoverSchema.parse({})),
|
|
74
|
+
sections: z.record(z.string(), sectionSettingsSchema).default({}),
|
|
75
|
+
});
|
|
76
|
+
export type DocumentOptions = z.infer<typeof documentOptionsSchema>;
|
|
77
|
+
export type DocCover = DocumentOptions["cover"];
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
// Breadcrumb metadata shown in the page chrome. Single definition — the
|
|
11
|
+
// harness carried this shape in two places (deck/DeckPage.tsx's `DeckMeta`
|
|
12
|
+
// and lib/deck/schema.ts's zod-inferred `DeckMeta`); the engine only needs
|
|
13
|
+
// the plain type, not a schema, so it lives here once.
|
|
14
|
+
export interface DocMeta {
|
|
15
|
+
client: string;
|
|
16
|
+
project: string;
|
|
17
|
+
dateLabel: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DocSectionInput {
|
|
21
|
+
id: string;
|
|
22
|
+
type: string;
|
|
23
|
+
content: Record<string, unknown>;
|
|
24
|
+
options?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One displayable image, dimensions + pre-chosen repo filenames baked in by
|
|
28
|
+
// the (scaffolding) fetch layer so the engine never touches the manifest.
|
|
29
|
+
export interface DocImage {
|
|
30
|
+
imageId: string;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
alt: string;
|
|
34
|
+
previewFile: string; // e.g. "1080.webp" — editor variant
|
|
35
|
+
printFile: string; // e.g. "1920.webp" — print/PDF variant
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TextBlock =
|
|
39
|
+
| { kind: "heading"; level: 2 | 3; text: string }
|
|
40
|
+
| { kind: "prose"; html: string };
|
|
41
|
+
|
|
42
|
+
// A link-heading chunk: everything between one link_heading and the next.
|
|
43
|
+
export interface Chunk {
|
|
44
|
+
id: string; // link_heading section id, or PREAMBLE_ID
|
|
45
|
+
title: string;
|
|
46
|
+
textBlocks: TextBlock[];
|
|
47
|
+
images: DocImage[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DocSourceContent {
|
|
51
|
+
pageTitle: string;
|
|
52
|
+
sections: DocSectionInput[];
|
|
53
|
+
images: Record<string, DocImage>;
|
|
54
|
+
}
|
|
@@ -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";
|