@drawnagency/primitives 0.1.79 → 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-RWMSDDQM.js → chunk-DKXISNJ2.js} +79 -26
- package/dist/{chunk-RSITF24B.js → chunk-EVNEMJZ6.js} +9 -3
- package/dist/{chunk-DR22HM44.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-FRTJNS5F.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-4XXLH5CL.js → chunk-ZEXR7JYQ.js} +19 -12
- package/dist/components/brandguide/Colors.d.ts +2 -1
- package/dist/components/brandguide/Colors.d.ts.map +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/EditableGrid.d.ts +11 -1
- package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
- package/dist/components/primitives/gridDrop.d.ts +18 -0
- package/dist/components/primitives/gridDrop.d.ts.map +1 -0
- package/dist/components/primitives/index.js +7 -6
- package/dist/components/primitives/useEditableCollection.d.ts +1 -0
- package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- 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/EditorContext.d.ts +2 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts +44 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/HistoryToolbar.d.ts +8 -1
- package/dist/components/shell/HistoryToolbar.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/lib/registry.d.ts +3 -0
- package/dist/lib/registry.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/brandguide/Colors.tsx +15 -5
- package/src/components/editor/PagesModal.tsx +16 -2
- package/src/components/primitives/EditableGrid.tsx +86 -24
- package/src/components/primitives/gridDrop.ts +26 -0
- package/src/components/primitives/useEditableCollection.ts +16 -1
- package/src/components/sections/Colors/index.tsx +2 -1
- 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/EditorContext.tsx +6 -1
- package/src/components/shell/EditorShell.tsx +171 -10
- package/src/components/shell/HistoryToolbar.tsx +24 -2
- 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/lib/registry.ts +3 -0
- package/src/schemas/site-config.ts +22 -7
- /package/dist/{chunk-4YEOTFVU.js → chunk-YBVK254P.js} +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DocImage, DocMeta } from "../engine/types";
|
|
2
|
+
import type { DerivedPage } from "../engine/paginate";
|
|
3
|
+
import { DocPage } from "./DocPage";
|
|
4
|
+
|
|
5
|
+
export interface DocHost {
|
|
6
|
+
imageSrc: (image: DocImage, variant: "preview" | "print") => string;
|
|
7
|
+
variant: "preview" | "print";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const inch = (v: number) => `${v}in`;
|
|
11
|
+
|
|
12
|
+
export function DocSectionPage({ page, meta, host }: { page: DerivedPage; meta: DocMeta; host: DocHost }) {
|
|
13
|
+
return (
|
|
14
|
+
<DocPage meta={meta}>
|
|
15
|
+
<h2 className="pdoc-title">{page.title}</h2>
|
|
16
|
+
{page.textRegion ? (
|
|
17
|
+
<div
|
|
18
|
+
className="pdoc-text"
|
|
19
|
+
style={{ left: inch(page.textRegion.xIn), top: inch(page.textRegion.yIn), width: inch(page.textRegion.wIn) }}
|
|
20
|
+
>
|
|
21
|
+
{page.textBlocks.map((b, i) => {
|
|
22
|
+
const spacing = i > 0 ? { marginTop: inch(0.15) } : undefined;
|
|
23
|
+
if (b.kind === "heading") {
|
|
24
|
+
return b.level === 2 ? (
|
|
25
|
+
<h3 key={i} className="pdoc-h2" style={spacing}>{b.text}</h3>
|
|
26
|
+
) : (
|
|
27
|
+
<h4 key={i} className="pdoc-h3" style={spacing}>{b.text}</h4>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return <div key={i} className="pdoc-prose" style={spacing} dangerouslySetInnerHTML={{ __html: b.html }} />;
|
|
31
|
+
})}
|
|
32
|
+
</div>
|
|
33
|
+
) : null}
|
|
34
|
+
{page.imageBoxes.map((box, i) => (
|
|
35
|
+
<div
|
|
36
|
+
key={`${box.image.imageId}-${i}`}
|
|
37
|
+
className="pdoc-img"
|
|
38
|
+
style={{ left: inch(box.xIn), top: inch(box.yIn), width: inch(box.wIn), height: inch(box.hIn) }}
|
|
39
|
+
>
|
|
40
|
+
<img src={host.imageSrc(box.image, host.variant)} alt={box.image.alt} style={{ objectFit: box.fit }} />
|
|
41
|
+
</div>
|
|
42
|
+
))}
|
|
43
|
+
</DocPage>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import type { DocumentOptions } from "../engine/settings";
|
|
3
|
+
import { settingsFor } from "../engine/settings";
|
|
4
|
+
import type { Chunk, DocImage, DocSourceContent } from "../engine/types";
|
|
5
|
+
import { DocCoverPage } from "./DocCoverPage";
|
|
6
|
+
import { DocRail, entryId, type RailEntry } from "./DocRail";
|
|
7
|
+
import { DocSectionPage, type DocHost } from "./DocSectionPage";
|
|
8
|
+
import { useDocPages } from "./useDocPages";
|
|
9
|
+
|
|
10
|
+
// 17in @96dpi = 1632×1056; the canvas scales the real page to fit its width.
|
|
11
|
+
const PAGE_W = 1632;
|
|
12
|
+
const PAGE_H = 1056;
|
|
13
|
+
|
|
14
|
+
export interface DocViewerProps {
|
|
15
|
+
source: DocSourceContent | null;
|
|
16
|
+
document: DocumentOptions; // { enabled, meta, cover, sections } — PageSchema.document shape
|
|
17
|
+
host: DocHost;
|
|
18
|
+
mode: "present" | "edit";
|
|
19
|
+
// edit mode only:
|
|
20
|
+
selectedChunkId?: string | null;
|
|
21
|
+
onSelectChunk?: (chunkId: string | null) => void; // null = cover selected
|
|
22
|
+
onClose?: () => void; // present mode: close control
|
|
23
|
+
// Optional pagination-state hook (present mode unaffected — the present route
|
|
24
|
+
// never passes it). Fires, identity-guarded, whenever the internal useDocPages
|
|
25
|
+
// output changes, so a host (the edit overlay) can reuse this single pass for
|
|
26
|
+
// its inspector titles/warnings instead of running a second useDocPages.
|
|
27
|
+
onDocState?: (state: { chunks: Chunk[]; warningsByChunk: Record<string, string[]> }) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Rail-entry id → the chunk it belongs to (null for the cover).
|
|
31
|
+
function chunkIdOf(id: string): string | null {
|
|
32
|
+
if (id === "cover") return null;
|
|
33
|
+
if (id.startsWith("ghost:")) return id.slice("ghost:".length);
|
|
34
|
+
if (id.includes("#")) return id.slice(0, id.lastIndexOf("#"));
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function DocViewer({
|
|
39
|
+
source,
|
|
40
|
+
document: doc,
|
|
41
|
+
host,
|
|
42
|
+
mode,
|
|
43
|
+
selectedChunkId,
|
|
44
|
+
onSelectChunk,
|
|
45
|
+
onClose,
|
|
46
|
+
onDocState,
|
|
47
|
+
}: DocViewerProps) {
|
|
48
|
+
const { chunks, pages, measuring, warningsByChunk } = useDocPages({ source, sections: doc.sections });
|
|
49
|
+
const [activeId, setActiveId] = useState<string>("cover");
|
|
50
|
+
const [railOpen, setRailOpen] = useState(true);
|
|
51
|
+
|
|
52
|
+
// Resolve the cover's media-library image once here; missing id or missing
|
|
53
|
+
// source entry (deleted image) both degrade to null → solid cover.
|
|
54
|
+
const coverImage: DocImage | null = doc.cover.imageId ? (source?.images[doc.cover.imageId] ?? null) : null;
|
|
55
|
+
|
|
56
|
+
// Publish the pagination output to an optional host, guarded so it only fires
|
|
57
|
+
// when the memoized chunks/warnings identities actually change (and reading
|
|
58
|
+
// onDocState via a ref so an unstable callback identity can't cause a loop).
|
|
59
|
+
const onDocStateRef = useRef(onDocState);
|
|
60
|
+
onDocStateRef.current = onDocState;
|
|
61
|
+
const lastEmitRef = useRef<{ chunks: Chunk[]; warningsByChunk: Record<string, string[]> } | null>(null);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (lastEmitRef.current?.chunks === chunks && lastEmitRef.current?.warningsByChunk === warningsByChunk) return;
|
|
64
|
+
lastEmitRef.current = { chunks, warningsByChunk };
|
|
65
|
+
onDocStateRef.current?.({ chunks, warningsByChunk });
|
|
66
|
+
}, [chunks, warningsByChunk]);
|
|
67
|
+
|
|
68
|
+
// Present mode: excluded/empty chunks are simply absent. Edit mode: they
|
|
69
|
+
// surface as ghost entries (excluded takes priority over empty).
|
|
70
|
+
const entries: RailEntry[] = useMemo(() => {
|
|
71
|
+
const list: RailEntry[] = [{ kind: "cover" }];
|
|
72
|
+
for (const chunk of chunks) {
|
|
73
|
+
const s = settingsFor(doc.sections, chunk.id);
|
|
74
|
+
const empty = chunk.textBlocks.length === 0 && chunk.images.length === 0;
|
|
75
|
+
if (s.excluded || empty) {
|
|
76
|
+
if (mode === "edit") {
|
|
77
|
+
list.push({ kind: "ghost", chunkId: chunk.id, title: chunk.title, reason: s.excluded ? "excluded" : "empty" });
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
for (const page of pages.filter((p) => p.chunkId === chunk.id)) list.push({ kind: "page", page });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return list;
|
|
84
|
+
}, [chunks, pages, doc.sections, mode]);
|
|
85
|
+
|
|
86
|
+
const selectEntry = useCallback(
|
|
87
|
+
(id: string) => {
|
|
88
|
+
setActiveId(id);
|
|
89
|
+
if (mode === "edit") onSelectChunk?.(chunkIdOf(id));
|
|
90
|
+
},
|
|
91
|
+
[mode, onSelectChunk],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Stale active-entry guard: if the active entry vanished (chunk excluded, page
|
|
95
|
+
// count shrank), re-point to the matching ghost or the cover. Guarded on
|
|
96
|
+
// !measuring so transient empty-`pages` frames during layout don't churn it.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (measuring) return;
|
|
99
|
+
if (entries.some((e) => entryId(e) === activeId)) return;
|
|
100
|
+
const chunkId = chunkIdOf(activeId);
|
|
101
|
+
const ghost = entries.find((e) => e.kind === "ghost" && e.chunkId === chunkId);
|
|
102
|
+
setActiveId(ghost ? entryId(ghost) : "cover");
|
|
103
|
+
}, [entries, activeId, measuring]);
|
|
104
|
+
|
|
105
|
+
// Controlled selection (edit): follow an externally-driven selectedChunkId.
|
|
106
|
+
// Undefined means uncontrolled — the viewer owns selection outright.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (mode !== "edit" || selectedChunkId === undefined) return;
|
|
109
|
+
if (chunkIdOf(activeId) === selectedChunkId) return;
|
|
110
|
+
if (selectedChunkId === null) {
|
|
111
|
+
setActiveId("cover");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const match = entries.find(
|
|
115
|
+
(e) =>
|
|
116
|
+
(e.kind === "page" && e.page.chunkId === selectedChunkId) ||
|
|
117
|
+
(e.kind === "ghost" && e.chunkId === selectedChunkId),
|
|
118
|
+
);
|
|
119
|
+
if (match) setActiveId(entryId(match));
|
|
120
|
+
}, [selectedChunkId, mode, entries, activeId]);
|
|
121
|
+
|
|
122
|
+
// Arrow-key paging on `window`. Ignores events targeting form fields, clamps
|
|
123
|
+
// at the ends, preventDefault only when it consumes the key, listener removed
|
|
124
|
+
// on unmount. A ref keeps the handler reading the live active id without
|
|
125
|
+
// resubscribing on every step.
|
|
126
|
+
const activeIdRef = useRef(activeId);
|
|
127
|
+
activeIdRef.current = activeId;
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const onKey = (e: KeyboardEvent) => {
|
|
130
|
+
const t = e.target as HTMLElement | null;
|
|
131
|
+
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
|
|
132
|
+
let dir = 0;
|
|
133
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") dir = 1;
|
|
134
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") dir = -1;
|
|
135
|
+
else return;
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
const ids = entries.map(entryId);
|
|
138
|
+
const cur = Math.max(0, ids.indexOf(activeIdRef.current));
|
|
139
|
+
const next = Math.min(ids.length - 1, Math.max(0, cur + dir));
|
|
140
|
+
if (ids[next] !== activeIdRef.current) selectEntry(ids[next]);
|
|
141
|
+
};
|
|
142
|
+
window.addEventListener("keydown", onKey);
|
|
143
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
144
|
+
}, [entries, selectEntry]);
|
|
145
|
+
|
|
146
|
+
const active = entries.find((e) => entryId(e) === activeId) ?? null;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
// min-w-0 (here and on the rail/canvas row): without it these flex items
|
|
150
|
+
// refuse to shrink below their content width, and opening the rail shoves
|
|
151
|
+
// the overlay's inspector off the right edge instead of narrowing the
|
|
152
|
+
// canvas.
|
|
153
|
+
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
|
154
|
+
{mode === "present" ? (
|
|
155
|
+
<div className="flex items-center justify-between border-b border-base-200 bg-base px-4 py-2">
|
|
156
|
+
<span className="text-sm font-medium text-base-contrast">{source?.pageTitle ?? "Document"}</span>
|
|
157
|
+
{onClose ? (
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
aria-label="Close"
|
|
161
|
+
onClick={onClose}
|
|
162
|
+
className="rounded border border-base-200 px-3 py-1 text-xs font-medium text-base-contrast hover:bg-base-accent/30"
|
|
163
|
+
>
|
|
164
|
+
✕
|
|
165
|
+
</button>
|
|
166
|
+
) : null}
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
<div className="flex min-h-0 min-w-0 flex-1">
|
|
170
|
+
{railOpen ? (
|
|
171
|
+
<DocRail
|
|
172
|
+
entries={entries}
|
|
173
|
+
selectedId={activeId}
|
|
174
|
+
meta={doc.meta}
|
|
175
|
+
cover={doc.cover}
|
|
176
|
+
coverImage={coverImage}
|
|
177
|
+
host={host}
|
|
178
|
+
onSelect={selectEntry}
|
|
179
|
+
onCollapse={() => setRailOpen(false)}
|
|
180
|
+
/>
|
|
181
|
+
) : (
|
|
182
|
+
<div className="flex w-10 shrink-0 flex-col border-r border-neutral-200 bg-neutral-50">
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
aria-label="Expand page rail"
|
|
186
|
+
onClick={() => setRailOpen(true)}
|
|
187
|
+
className="px-2 py-3 text-lg leading-none text-neutral-500 hover:text-neutral-800"
|
|
188
|
+
>
|
|
189
|
+
›
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
<DocCanvas
|
|
194
|
+
active={active}
|
|
195
|
+
cover={doc.cover}
|
|
196
|
+
coverImage={coverImage}
|
|
197
|
+
meta={doc.meta}
|
|
198
|
+
host={host}
|
|
199
|
+
measuring={measuring}
|
|
200
|
+
noContent={chunks.length === 0}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function DocCanvas({
|
|
208
|
+
active,
|
|
209
|
+
cover,
|
|
210
|
+
coverImage,
|
|
211
|
+
meta,
|
|
212
|
+
host,
|
|
213
|
+
measuring,
|
|
214
|
+
noContent,
|
|
215
|
+
}: {
|
|
216
|
+
active: RailEntry | null;
|
|
217
|
+
cover: DocumentOptions["cover"];
|
|
218
|
+
coverImage: DocImage | null;
|
|
219
|
+
meta: DocumentOptions["meta"];
|
|
220
|
+
host: DocHost;
|
|
221
|
+
measuring: boolean;
|
|
222
|
+
noContent: boolean;
|
|
223
|
+
}) {
|
|
224
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
225
|
+
const [scale, setScale] = useState(0.4);
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const el = containerRef.current;
|
|
228
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
229
|
+
const observer = new ResizeObserver(() => {
|
|
230
|
+
const available = el.clientWidth - 48;
|
|
231
|
+
setScale(Math.min(0.75, Math.max(0.15, available / PAGE_W)));
|
|
232
|
+
});
|
|
233
|
+
observer.observe(el);
|
|
234
|
+
return () => observer.disconnect();
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
const note = (text: string) => <p className="mt-24 text-sm text-neutral-400">{text}</p>;
|
|
238
|
+
|
|
239
|
+
let body: ReactNode;
|
|
240
|
+
if (active?.kind === "cover") {
|
|
241
|
+
body = noContent ? (
|
|
242
|
+
<div className="flex flex-col items-center gap-4">
|
|
243
|
+
<Scaled scale={scale}>
|
|
244
|
+
<DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
|
|
245
|
+
</Scaled>
|
|
246
|
+
<p className="text-sm text-neutral-400">This page has no displayable content.</p>
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
<Scaled scale={scale}>
|
|
250
|
+
<DocCoverPage cover={cover} meta={meta} image={coverImage} host={host} />
|
|
251
|
+
</Scaled>
|
|
252
|
+
);
|
|
253
|
+
} else if (active?.kind === "page") {
|
|
254
|
+
body = (
|
|
255
|
+
<Scaled scale={scale}>
|
|
256
|
+
<DocSectionPage page={active.page} meta={meta} host={host} />
|
|
257
|
+
</Scaled>
|
|
258
|
+
);
|
|
259
|
+
} else if (active?.kind === "ghost") {
|
|
260
|
+
body = note(active.reason === "excluded" ? "Excluded — re-include from the inspector" : "No displayable content");
|
|
261
|
+
} else {
|
|
262
|
+
// Active id resolved to nothing — its page is still being measured.
|
|
263
|
+
body = measuring ? note("Laying out…") : note("This page has no displayable content.");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div
|
|
268
|
+
ref={containerRef}
|
|
269
|
+
data-testid="doc-canvas"
|
|
270
|
+
className="flex flex-1 items-start justify-center overflow-auto bg-neutral-100 p-6"
|
|
271
|
+
>
|
|
272
|
+
{body}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function Scaled({ scale, children }: { scale: number; children: ReactNode }) {
|
|
278
|
+
return (
|
|
279
|
+
<div className="shrink-0 overflow-hidden shadow-lg" style={{ width: PAGE_W * scale, height: PAGE_H * scale }}>
|
|
280
|
+
<div className="origin-top-left" style={{ transform: `scale(${scale})`, width: PAGE_W, height: PAGE_H }}>
|
|
281
|
+
{children}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Drawn script wordmark. Path data copied verbatim from
|
|
2
|
+
// ~/drawn/frontend/src/components/shell/Logo.astro — do not edit paths by hand.
|
|
3
|
+
export function Wordmark({ className }: { className?: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
viewBox="0 0 360 154.474"
|
|
7
|
+
fill="currentColor"
|
|
8
|
+
role="img"
|
|
9
|
+
aria-label="Drawn"
|
|
10
|
+
className={className}
|
|
11
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
12
|
+
>
|
|
13
|
+
<path d="M93.211 18.436c-2.177.868 2.024 1.375 6.374 5.14 3.913 3.392 5.542 6.526 8.126 5.794l.084-.019c15.002-4.018 30.605-4.343 45.642-3.985 37.577.895 76.5 13.868 127.052 10.736 52.074-3.226 64.063-33.293 64.423-35.34.335-1.903-4.473.223-6.71 1.341-2.461 1.23-12.375 22.056-56.817 24.605-54.58 3.13-76.949-13.42-127.948-16.105-24.157-1.272-44.308 1.912-60.225 7.832ZM353.301 104.05c-3.497-.813-8.22-.393-14.355 3.083a42.817 42.817 0 0 0-1.464.863l-.646.396c-.153.094-.302.192-.452.286-.185.121-.375.239-.56.36-.158.1-.315.202-.47.305-.18.119-.36.234-.539.354-.177.118-.353.235-.53.35l-.557.373c-.113.076-.23.15-.344.225-2.209 1.475-4.596 3.046-7.903 4.822l-.002-.001c-.178.093-.354.189-.534.285-.25.132-.504.264-.764.398-.28.145-.574.292-.87.442-.268.133-.537.269-.817.403-.32.16-.653.319-.988.477-.282.136-.561.27-.857.407a124.187 124.187 0 0 1-4.258 1.882c-22.372 9.426-46.637 12.894-83.75 9.349-37.286-3.562-76.22-13.87-126.593-10.74-51.887 3.228-63.837 33.296-64.195 35.346-.336 1.898 4.455-.225 6.685-1.345 2.45-1.228 12.332-22.055 56.612-24.603 54.385-3.132 84.076 13.469 131.52 17.447 34.494 2.89 59.914-3.453 77.117-14.422 3.42-2.183 6.945-4.758 10.342-7.353.093-.067.176-.134.264-.202.111-.085.217-.168.329-.25.563-.434 1.122-.87 1.68-1.302.098-.077.2-.152.3-.23 1.027-.8 2.038-1.593 3.021-2.37 2.518-1.98 4.866-3.835 6.939-5.395l.823-.614.382-.28c.344-.251.675-.49.999-.718a42.728 42.728 0 0 1 1.547-1.058c.07-.047.14-.084.206-.13.31-.197.608-.379.89-.544.094-.054.189-.11.281-.16a12.3 12.3 0 0 1 .933-.486c4.427-2.017 8.968-2.27 9.22 1.837.124 2.074-1.999 4.029-5.691 4.449-2.565.292-6.454.442-9.699-.803-.026-.012-.048-.012-.077-.023-.5-.17-1.841 1.036-2.762 2-.922.966-2.05 1.673-1.084 2.163 4.29 2.29 8.832 3.056 11.611 3.137 8.07.221 14.336-1.887 15.567-8.04.93-4.653-1.559-9.227-6.507-10.371Z" />
|
|
14
|
+
<path d="M340.048 55.675c-3.094-2.81-8.125-2.34-11.568-2.069-5.92.466-11.54 3.713-15.45 6.582-.477-3.394-1.684-5.828-3.908-6.64-6.393-2.34-11.45 2.035-16.42 5.387-1.95 1.254-4.322-4.012-5.878-4.895-1.55-.882-4.995-1.347-7.803.126-4.404 2.305-7.465 5.91-7.21 8.136.257 2.297 4.149 4.28 4.065 6.879-.13 4.019-2.04 7.87-4.668 13.225-1.998 4.068-4.108 7.762-6.38 10.711-1.537 1.954-3.631 4.144-6.278 5.748-.514.277-1.04.496-1.57.685-3.299.82-4.712-3.26-3.843-8.882 1.006-6.444 9.235-29.756 9.738-30.966.502-1.21-.382-1.121-2.396-1.49-2.015-.37-6.724-.041-8.45.241-1.727.279-5.21 15.477-7.354 22.253-2.143 6.778-5.96 14.898-8.942 17.284-2.568 2.058-4.896 3.11-7.137 2.31-6.262-2.243-2.25-19.315 1.117-28.63 3.801-10.515 9.353-20.009 6.934-19.91-5.368.224-11.976 2.01-13.28 4.058-2.33 3.673-8.074 21.308-9.999 26.677-1.072 2.989-1.506 6.316-1.412 9.579-1.124.544-6.17 6.036-12.888 5.767-7.49-.303 4.697-29.98 5.369-31.32.67-1.342-11.354-1.567-12.026-.222-.668 1.34-6.037 31.487-20.409 31.487-10.457 0-5.145-25.45 7.604-34.614 10.985-7.9 25.36-2.188 26.924-2.862 1.565-.668 4.17-9.668 4.17-10.786s-4.698-1.119-18.792-.222c-14.09.893-21.92 7.38-30.195 20.69-8.278 13.31-10.068 26.73-19.412 26.73-9.22 0-1.169-18.788 3.082-27.738 4.248-8.946 6.937-17.895 5.592-19.907-1.343-2.014-16.332-.46-17.673-1.58-1.344-1.119 1.121-3.12.45-4.913-.67-1.787-10.515-1.787-14.205 0-1.887.916-2.185 3.287-2.021 5.893 1.254 7.163 1.232 14.979-.318 22.603.064.302.197.384.438.138 2.586-2.63 8.876-11.227 9.55-12.12.669-.897 8.836-.71 9.239.043.645 1.203-6.796 17.224-8.723 22.59-3.61 10.057-2.573 16.894 2.973 20.234 4.967 2.996 9.777 2.584 16.73-1.624 6.977-4.222 9.599-6.526 9.599-6.526s0 14.087 15.437 14.087c11.406 0 14.089-8.048 14.089-8.048s1.797 10.698 12.414 9.087c6.176-.937 11.652-9.47 13.41-12.763 1.045 6.066 3.97 11.25 8.174 12.625 5.887 1.922 13.06.388 19.818-4.645a15.195 15.195 0 0 0 2.495-2.362c2.356 5.703 5.865 9.538 9.681 10.008 19.478 2.399 28.458-19.807 33.299-34.217.993-2.955 1.71-5.846 2.654-9.364.268-1.007 1.183-2.504 2.994-2.939 4.188-.933 7.848-1.282 8.583.371 3.784 8.503-13.171 43.36-10.747 43.36 3.3 0 10.047-1.037 12.48-4.353 4.145-5.64 10.948-25.812 11.45-39.149 2.636-2.101 5.458-3.664 8.179-3.883 9.395-.757 6.167 14.162 3.22 22.537-4.906 13.966-3.351 22.982.632 28.735.709.9 1.775-.014 3.49-.917 1.718-.906 2.7-1.413 2.527-2.26-.011-.062-.019-.12-.029-.183-1.323-5.592 3.407-13.519 7.318-24.085 3.762-10.158 7.001-24.749 1.465-29.782ZM46.09 89.63c1.282-2.736 2.616-5.56 4.002-8.468C56.528 67.684 66.09 53.946 75.049 46.32a80.636 80.636 0 0 1 14.747-9.98c.07-.044.148-.092.221-.132 1.173-.631 1.17-1.394-.013-3.301-1.354-2.185-3.87-4.955-8.296-7.144-1.703-.842-3.224-.362-4.618.396-4.817 2.932-9.012 6.161-12.569 9.6C46.448 53.23 37.23 76.32 33.231 88.318l-1.407 4.22c-.406 1.22-.8 2.397-1.185 3.546l-.149.437c-.38 1.13-.748 2.214-1.11 3.27-.008.02-.014.043-.022.061-.993 2.89-1.932 5.525-2.846 7.885h-.01c-.22.568-.44 1.153-.66 1.689l-.006.015a79.329 79.329 0 0 1-1.105 2.609 51.44 51.44 0 0 1-1.022 2.16l-.106.22c-2.926 5.805-5.963 8.77-10.193 8.77-8.5 0-6.377-11.156 6.71-15.516a18.611 18.611 0 0 1 3.791-.83c.064-.01.118-.024.188-.035 1.761-.268 1.54-1.49 2.059-3.246.376-1.273 1.282-2.543-.193-2.404a57.899 57.899 0 0 0-6.293 1.147c-23.152 5.477-24.366 25.123-11.446 28.413 9.656 2.457 17.26-3.099 23.006-11.475.24-.356.493-.743.75-1.154.084-.132.173-.28.26-.42.177-.29.354-.584.54-.901.102-.175.208-.362.313-.545.176-.305.355-.623.537-.95.11-.198.22-.393.33-.598.198-.364.4-.74.604-1.122.098-.188.197-.368.294-.557.304-.578.614-1.173.93-1.796.028-.052.055-.112.083-.164l.337-.668c1.406-2.791 2.94-5.988 4.603-9.504 1.64-3.475 3.4-7.248 5.277-11.245Z" />
|
|
15
|
+
<path d="M79.206 111.375c35.531-11.52 39.87-52.054 28.828-74.15a34.694 34.694 0 0 0-2.573-4.302c-4.379-6.306-10.605-10.865-17.802-14.027-5.346-2.35-11.22-3.94-17.254-4.914C43.94 9.708 6.14 18.749 5.74 39.95 5.354 60.374 30.548 57.9 32.533 58.025c2.247.14 1.678-1.678-.67-4.028-2.35-2.347-7.946-7.147-7.382-15.433.844-12.415 17.78-22.482 44.612-18.03 3.758.625 6.956 1.438 9.75 2.425 8.204 2.907 12.703 7.418 16.072 13.73a55.714 55.714 0 0 1 2.154 4.559c8.11 19.313-4.926 52.567-24.438 59.737-14.466 5.317-21.625 3.08-29.713 1.281-1.304-.287-1.495 1.185-2.543 3.446-1.047 2.264-2.092 3.906-1.052 4.293 9.395 3.44 20.77 7.563 39.883 1.37Z" />
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState } from "react";
|
|
2
|
+
import { DOC_GEOM } from "../engine/paginate";
|
|
3
|
+
import type { SectionSettings } from "../engine/settings";
|
|
4
|
+
import type { Chunk, TextBlock } from "../engine/types";
|
|
5
|
+
|
|
6
|
+
const PX_PER_IN = 96;
|
|
7
|
+
|
|
8
|
+
function widthInFor(settings: SectionSettings | undefined): number {
|
|
9
|
+
return settings?.textPlacement === "leftColumn" ? DOC_GEOM.leftColWIn : DOC_GEOM.bodyWIn;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function blockClassName(block: TextBlock): string {
|
|
13
|
+
if (block.kind === "heading") return block.level === 2 ? "pdoc-h2" : "pdoc-h3";
|
|
14
|
+
return "pdoc-prose";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Content that affects a block's measured height, serialized so the
|
|
18
|
+
// re-measure effect can key on structural equality rather than object
|
|
19
|
+
// identity (chunks are re-derived on every harvest() call). JSON-encoded —
|
|
20
|
+
// hand-joined delimiters can occur verbatim in content, letting two different
|
|
21
|
+
// block lists collide and the re-measure effect never fire.
|
|
22
|
+
function signatureFor(chunks: Chunk[], settingsMap: Record<string, SectionSettings>): string {
|
|
23
|
+
return JSON.stringify(
|
|
24
|
+
chunks.map((c) => [
|
|
25
|
+
c.id,
|
|
26
|
+
widthInFor(settingsMap[c.id]),
|
|
27
|
+
c.textBlocks.map((b) => (b.kind === "heading" ? ["h", b.level, b.text] : ["p", b.html])),
|
|
28
|
+
]),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Measures every text block of every chunk by rendering it into a hidden,
|
|
33
|
+
// fixed-position container at the chunk's real text width with the real
|
|
34
|
+
// pdoc-* classes (see document.css `.pdoc-measure`) — plain DOM nodes, not a
|
|
35
|
+
// React subtree, since measurement needs no reconciliation or events, only
|
|
36
|
+
// the browser's own layout of the real markup shape. Blocks get no CSS
|
|
37
|
+
// margins (pagination owns inter-block spacing via `gapIn`), so a wrapper's
|
|
38
|
+
// offsetHeight is the whole truth.
|
|
39
|
+
export function useTextMeasurements(
|
|
40
|
+
chunks: Chunk[],
|
|
41
|
+
settingsMap: Record<string, SectionSettings>,
|
|
42
|
+
): { heights: Record<string, number[]> | null } {
|
|
43
|
+
const [heights, setHeights] = useState<Record<string, number[]> | null>(null);
|
|
44
|
+
const [fontsReady, setFontsReady] = useState(false);
|
|
45
|
+
|
|
46
|
+
// jsdom (tests) has no document.fonts — optional-chain so this resolves
|
|
47
|
+
// immediately there instead of hanging measurement forever.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
const markReady = () => {
|
|
51
|
+
if (!cancelled) setFontsReady(true);
|
|
52
|
+
};
|
|
53
|
+
// A rejected `fonts.ready` (e.g. a font failing to load) must not wedge
|
|
54
|
+
// `measuring` true forever — fall back to treating fonts as ready so
|
|
55
|
+
// measurement still runs with whatever's loaded.
|
|
56
|
+
Promise.resolve(document.fonts?.ready).then(markReady).catch(markReady);
|
|
57
|
+
return () => {
|
|
58
|
+
cancelled = true;
|
|
59
|
+
};
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const signature = signatureFor(chunks, settingsMap);
|
|
63
|
+
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
if (!fontsReady) return;
|
|
66
|
+
const containers: HTMLDivElement[] = [];
|
|
67
|
+
const next: Record<string, number[]> = {};
|
|
68
|
+
|
|
69
|
+
for (const chunk of chunks) {
|
|
70
|
+
if (chunk.textBlocks.length === 0) {
|
|
71
|
+
next[chunk.id] = [];
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const container = document.createElement("div");
|
|
75
|
+
container.className = "pdoc-measure";
|
|
76
|
+
container.style.width = `${widthInFor(settingsMap[chunk.id])}in`;
|
|
77
|
+
document.body.appendChild(container);
|
|
78
|
+
containers.push(container);
|
|
79
|
+
|
|
80
|
+
const measured: number[] = [];
|
|
81
|
+
for (const block of chunk.textBlocks) {
|
|
82
|
+
const wrapper = document.createElement("div");
|
|
83
|
+
wrapper.className = blockClassName(block);
|
|
84
|
+
if (block.kind === "heading") {
|
|
85
|
+
wrapper.textContent = block.text;
|
|
86
|
+
} else {
|
|
87
|
+
wrapper.innerHTML = block.html;
|
|
88
|
+
}
|
|
89
|
+
container.appendChild(wrapper);
|
|
90
|
+
measured.push(wrapper.offsetHeight / PX_PER_IN);
|
|
91
|
+
}
|
|
92
|
+
next[chunk.id] = measured;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setHeights(next);
|
|
96
|
+
return () => {
|
|
97
|
+
for (const container of containers) container.remove();
|
|
98
|
+
};
|
|
99
|
+
// Re-measure only when chunk/width content actually changes, or once
|
|
100
|
+
// fonts finish loading (font swap changes heights).
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [signature, fontsReady]);
|
|
103
|
+
|
|
104
|
+
return { heights };
|
|
105
|
+
}
|
|
@@ -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
|
+
}
|