Skip to content

Commit 265f535

Browse files
committed
feat: enhance pagination logic and improve layout handling in cover letter and resume templates
1 parent 2affc47 commit 265f535

3 files changed

Lines changed: 279 additions & 77 deletions

File tree

apps/studio/features/resume/editor/ResumePagedPreview.tsx

Lines changed: 146 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,47 @@ interface ResumePreviewPage {
1313
content: string;
1414
}
1515

16+
function getSectionItemsContainer(section: HTMLElement): HTMLElement | null {
17+
let itemsContainer =
18+
section.tagName.toLowerCase() === "section" && section.children.length >= 2
19+
? (section.children[1] as HTMLElement)
20+
: null;
21+
22+
while (
23+
itemsContainer &&
24+
itemsContainer.children.length === 1 &&
25+
itemsContainer.children[0] instanceof HTMLElement &&
26+
itemsContainer.children[0].tagName.toLowerCase() === "div"
27+
) {
28+
itemsContainer = itemsContainer.children[0] as HTMLElement;
29+
}
30+
31+
return itemsContainer;
32+
}
33+
34+
function createSectionFragment(
35+
section: HTMLElement,
36+
items: HTMLElement[],
37+
includeHeader: boolean,
38+
): HTMLElement {
39+
const sectionClone = section.cloneNode(true) as HTMLElement;
40+
const clonedItemsContainer = getSectionItemsContainer(sectionClone);
41+
42+
if (clonedItemsContainer) {
43+
clonedItemsContainer.innerHTML = "";
44+
45+
items.forEach((item) => {
46+
clonedItemsContainer.appendChild(item.cloneNode(true));
47+
});
48+
}
49+
50+
if (!includeHeader && sectionClone.children.length > 0) {
51+
sectionClone.removeChild(sectionClone.children[0]);
52+
}
53+
54+
return sectionClone;
55+
}
56+
1657
export function ResumePagedPreview({ children }: { children: ReactNode }) {
1758
const measureRef = useRef<HTMLDivElement | null>(null);
1859
const [pages, setPages] = useState<ResumePreviewPage[]>([]);
@@ -30,43 +71,121 @@ export function ResumePagedPreview({ children }: { children: ReactNode }) {
3071
}
3172

3273
const computed = window.getComputedStyle(container);
33-
const paddingTop = Number.parseFloat(computed.paddingTop) || 0;
34-
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
35-
const usableHeight = RESUME_PAGE_HEIGHT_PX - paddingTop - paddingBottom;
74+
const nextPageStyle = {
75+
backgroundColor: computed.backgroundColor,
76+
color: computed.color,
77+
fontFamily: computed.fontFamily,
78+
fontSize: computed.fontSize,
79+
lineHeight: computed.lineHeight,
80+
padding: computed.padding,
81+
} satisfies CSSProperties;
82+
const probe = document.createElement("article");
83+
84+
probe.className = "resume-page-preview mx-auto overflow-hidden bg-white";
85+
Object.assign(probe.style, {
86+
...nextPageStyle,
87+
boxShadow: "none",
88+
height: `${RESUME_PAGE_HEIGHT_PX}px`,
89+
minHeight: `${RESUME_PAGE_HEIGHT_PX}px`,
90+
position: "absolute",
91+
width: `${RESUME_PAGE_WIDTH_PX}px`,
92+
});
93+
94+
measureRef.current.appendChild(probe);
95+
96+
const fitsPage = (elements: HTMLElement[]) => {
97+
probe.innerHTML = elements.map((element) => element.outerHTML).join("");
98+
99+
return probe.scrollHeight <= RESUME_PAGE_HEIGHT_PX + 1;
100+
};
101+
36102
const nextPages: ResumePreviewPage[] = [];
37-
let current: string[] = [];
38-
let usedHeight = 0;
103+
let current: HTMLElement[] = [];
104+
105+
const commitCurrentPage = () => {
106+
if (current.length === 0) {
107+
return;
108+
}
109+
110+
nextPages.push({
111+
content: current.map((element) => element.outerHTML).join(""),
112+
});
113+
current = [];
114+
};
115+
116+
const appendBlock = (block: HTMLElement) => {
117+
const candidate = [...current, block];
118+
119+
if (candidate.length === 1 || fitsPage(candidate)) {
120+
current = candidate;
121+
return;
122+
}
123+
124+
commitCurrentPage();
125+
current = [block];
126+
};
39127

40128
Array.from(container.children).forEach((child) => {
41-
const element = child as HTMLElement;
42-
const childStyle = window.getComputedStyle(element);
43-
const blockHeight =
44-
element.getBoundingClientRect().height +
45-
(Number.parseFloat(childStyle.marginTop) || 0) +
46-
(Number.parseFloat(childStyle.marginBottom) || 0);
47-
48-
if (current.length > 0 && usedHeight + blockHeight > usableHeight) {
49-
nextPages.push({ content: current.join("") });
50-
current = [];
51-
usedHeight = 0;
129+
if (!(child instanceof HTMLElement)) {
130+
return;
131+
}
132+
133+
const isSection = child.tagName.toLowerCase() === "section";
134+
const itemsContainer = getSectionItemsContainer(child);
135+
const items = itemsContainer
136+
? (Array.from(itemsContainer.children).filter(
137+
(item): item is HTMLElement => item instanceof HTMLElement,
138+
) as HTMLElement[])
139+
: [];
140+
141+
if (!isSection || items.length <= 1) {
142+
appendBlock(child);
143+
return;
52144
}
53145

54-
current.push(element.outerHTML);
55-
usedHeight += blockHeight;
146+
let itemIndex = 0;
147+
let includeHeader = true;
148+
149+
while (itemIndex < items.length) {
150+
let acceptedCount = 0;
151+
152+
for (let count = 1; itemIndex + count <= items.length; count += 1) {
153+
const fragment = createSectionFragment(
154+
child,
155+
items.slice(itemIndex, itemIndex + count),
156+
includeHeader,
157+
);
158+
const candidate = [...current, fragment];
159+
160+
if (fitsPage(candidate) || (current.length === 0 && count === 1)) {
161+
acceptedCount = count;
162+
} else {
163+
break;
164+
}
165+
}
166+
167+
if (acceptedCount === 0) {
168+
commitCurrentPage();
169+
continue;
170+
}
171+
172+
const acceptedFragment = createSectionFragment(
173+
child,
174+
items.slice(itemIndex, itemIndex + acceptedCount),
175+
includeHeader,
176+
);
177+
current = [...current, acceptedFragment];
178+
itemIndex += acceptedCount;
179+
includeHeader = false;
180+
}
56181
});
57182

58183
if (current.length > 0) {
59-
nextPages.push({ content: current.join("") });
184+
commitCurrentPage();
60185
}
61186

62-
const nextPageStyle = {
63-
backgroundColor: computed.backgroundColor,
64-
color: computed.color,
65-
fontFamily: computed.fontFamily,
66-
fontSize: computed.fontSize,
67-
lineHeight: computed.lineHeight,
68-
padding: computed.padding,
69-
} satisfies CSSProperties;
187+
probe.remove();
188+
70189
const resolvedPages = nextPages.length > 0 ? nextPages : [{ content: container.innerHTML }];
71190
const pageKey = resolvedPages.map((page) => page.content).join("");
72191
const styleKey = JSON.stringify(nextPageStyle);

apps/studio/templates/cover-letter/professional/web.tsx

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { escapeHtml } from "@/features/resume/services/resume-formatters";
2323
import { SOCIAL_ICON_SRC_BY_TYPE } from "@/templates/shared/social-icons";
2424

2525
const PAGE_HEIGHT = 1123;
26-
const BODY_TOP_GAP = 28;
2726
const PARAGRAPH_CHUNK_WORDS = 70;
2827

2928
type ProfessionalFlowItem =
@@ -167,31 +166,38 @@ function renderGroupedFlowItems(items: ProfessionalFlowItem[], accentColor: stri
167166

168167
function paginateMeasuredItems<T extends { id: string }>(
169168
items: T[],
170-
heights: Map<string, number>,
171-
firstLimit: number,
172-
nextLimit: number,
169+
fitsPage: (items: T[], pageIndex: number) => boolean,
173170
) {
174171
const pages: T[][] = [[]];
175172
let pageIndex = 0;
176-
let used = 0;
177173

178174
for (const item of items) {
179-
const height = Math.ceil(heights.get(item.id) ?? 0);
180-
const limit = pageIndex === 0 ? firstLimit : nextLimit;
175+
const candidate = [...pages[pageIndex], item];
181176

182-
if (pages[pageIndex].length > 0 && used + height > limit) {
177+
if (pages[pageIndex].length > 0 && !fitsPage(candidate, pageIndex)) {
183178
pages.push([]);
184179
pageIndex += 1;
185-
used = 0;
186180
}
187181

188182
pages[pageIndex].push(item);
189-
used += height;
190183
}
191184

192185
return pages.filter((page) => page.length > 0);
193186
}
194187

188+
function getProfessionalPageKey(pages: ProfessionalFlowItem[][]) {
189+
return pages.map((page) => page.map((item) => JSON.stringify(item)).join(",")).join("|");
190+
}
191+
192+
function fitsInsideBottomPadding(container: HTMLElement, content: HTMLElement) {
193+
const containerStyle = window.getComputedStyle(container);
194+
const paddingBottom = Number.parseFloat(containerStyle.paddingBottom) || 0;
195+
const containerBottom = container.getBoundingClientRect().bottom - paddingBottom;
196+
const contentBottom = content.getBoundingClientRect().bottom;
197+
198+
return contentBottom <= containerBottom + 1;
199+
}
200+
195201
function getProfessionalHtmlItemWeight(item: ProfessionalFlowItem) {
196202
if (item.type === "body-list" || item.type === "proof-list") {
197203
return 2 + item.items.reduce((total, listItem) => total + Math.ceil(listItem.length / 78), 0);
@@ -281,38 +287,70 @@ export function ProfessionalCoverLetterPreview({ content }: { content: CoverLett
281287
[flowContent, flowSenderName],
282288
);
283289
const [pages, setPages] = useState<ProfessionalFlowItem[][]>(() => [flowItems]);
290+
const measureRef = useRef<HTMLDivElement | null>(null);
284291
const firstPrefixRef = useRef<HTMLDivElement | null>(null);
285292
const nextPrefixRef = useRef<HTMLParagraphElement | null>(null);
286293
const itemRefs = useRef(new Map<string, HTMLDivElement>());
287294

288295
useLayoutEffect(() => {
289296
const frame = window.requestAnimationFrame(() => {
290-
const firstPrefixHeight = firstPrefixRef.current?.getBoundingClientRect().height ?? 0;
291-
const nextPrefixHeight = nextPrefixRef.current?.getBoundingClientRect().height ?? 0;
292-
const firstLimit = PAGE_HEIGHT - appearance.pageMargin * 2 - firstPrefixHeight - BODY_TOP_GAP;
293-
const nextLimit = PAGE_HEIGHT - appearance.pageMargin * 2 - nextPrefixHeight - BODY_TOP_GAP;
294-
const heights = new Map<string, number>();
295-
296-
flowItems.forEach((item) => {
297-
const node = itemRefs.current.get(item.id);
298-
heights.set(item.id, node?.getBoundingClientRect().height ?? 0);
297+
const probe = document.createElement("article");
298+
299+
probe.className = "mx-auto h-280.75 w-198.5 max-w-full overflow-hidden";
300+
Object.assign(probe.style, {
301+
backgroundColor: appearance.pageColor,
302+
color: appearance.textColor,
303+
fontFamily,
304+
padding: `${appearance.pageMargin}px`,
299305
});
306+
measureRef.current?.appendChild(probe);
307+
308+
const fitsPage = (items: ProfessionalFlowItem[], pageIndex: number) => {
309+
probe.innerHTML = "";
310+
311+
const prefix = pageIndex === 0 ? firstPrefixRef.current : nextPrefixRef.current;
312+
if (prefix) probe.appendChild(prefix.cloneNode(true));
313+
314+
const main = document.createElement("main");
315+
main.className = "mt-7 text-[15px] text-zinc-800";
316+
main.style.lineHeight = String(appearance.lineHeight);
317+
main.style.setProperty("--paragraph-gap", `${appearance.paragraphSpacing}px`);
318+
319+
items.forEach((item) => {
320+
const node = itemRefs.current.get(item.id);
321+
if (node) main.appendChild(node.cloneNode(true));
322+
});
323+
324+
probe.appendChild(main);
325+
326+
return probe.scrollHeight <= PAGE_HEIGHT + 1 && fitsInsideBottomPadding(probe, main);
327+
};
300328

301-
const nextPages = paginateMeasuredItems(flowItems, heights, firstLimit, nextLimit);
302-
const nextKey = nextPages.map((page) => page.map((item) => item.id).join(",")).join("|");
329+
const nextPages = paginateMeasuredItems(flowItems, fitsPage);
330+
probe.remove();
331+
const nextKey = getProfessionalPageKey(nextPages);
303332

304333
setPages((current) => {
305-
const currentKey = current.map((page) => page.map((item) => item.id).join(",")).join("|");
334+
const currentKey = getProfessionalPageKey(current);
306335
return currentKey === nextKey ? current : nextPages;
307336
});
308337
});
309338

310339
return () => window.cancelAnimationFrame(frame);
311-
}, [appearance.lineHeight, appearance.pageMargin, appearance.paragraphSpacing, flowItems]);
340+
}, [
341+
appearance.lineHeight,
342+
appearance.pageColor,
343+
appearance.pageMargin,
344+
appearance.paragraphSpacing,
345+
appearance.textColor,
346+
flowItems,
347+
fontFamily,
348+
]);
312349

313350
return (
314351
<div className="grid gap-6">
315352
<div
353+
ref={measureRef}
316354
aria-hidden="true"
317355
className="pointer-events-none absolute opacity-0"
318356
style={{ left: -10000, top: 0, width: 794 }}

0 commit comments

Comments
 (0)