Match the editor's spacing in PDF/DOCX/ODT and fix the toolbar dropdown

Exports now use the same typography as the Full Book editor — Georgia
serif at 12pt, 1.7 line spacing, and a blank-line gap between paragraphs -
so the on-screen editor and every exported format look consistent. DOCX/
ODT also gain a chapter break before each chapter after the first.
This commit is contained in:
avi 2026-08-18 18:39:21 -05:00
commit 0df26d4ce6
6 changed files with 143 additions and 27 deletions

View file

@ -20,6 +20,7 @@ import {
Document,
ExternalHyperlink,
HeadingLevel,
LineRuleType,
NumberFormat,
Packer,
Paragraph,
@ -205,9 +206,15 @@ const defaultCtx: InlineCtx = {
underline: false,
};
const paraSpacing = { after: 160 };
const headingSpacing = { before: 240, after: 160 };
// Typography mirrors the Full Book editor: serif at 12pt, 1.7 line spacing,
// and one blank line (1.7 x the font size, 408 twips) between prose blocks so
// the exported document has the same airy rhythm as the on-screen editor.
const lineSpacing = { line: 408, lineRule: LineRuleType.AUTO };
const bodySpacing = { after: 408, ...lineSpacing };
const headingSpacing = { before: 240, after: 408, ...lineSpacing };
const listSpacing = { after: 60, ...lineSpacing };
const tableSpacing = { after: 40 };
const codeSpacing = { before: 120, after: 160, ...lineSpacing };
function hexColor(v: string): string {
return v.replace(/^#/, "").trim().toLowerCase();
@ -470,7 +477,7 @@ function htmlBlockToParagraph(html: string): Paragraph | null {
return new Paragraph({
alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined,
children: [new TextRun({ text })],
spacing: paraSpacing,
spacing: bodySpacing,
});
}
@ -487,7 +494,7 @@ function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { re
out.push(
new Paragraph({
children: runs,
spacing: paraSpacing,
spacing: listSpacing,
numbering: { reference: listCtx.reference, level: listCtx.level },
})
);
@ -495,7 +502,7 @@ function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { re
out.push(
new Paragraph({
children: runs,
spacing: paraSpacing,
spacing: listSpacing,
indent: { left: 720 * (listCtx.level + 1), hanging: 360 },
})
);
@ -589,7 +596,7 @@ function renderQuoted(tokens: import("markdown-it").Token[], isCallout: boolean)
indent: { left: 720 },
border: QUOTE_BORDER,
shading: isCallout ? CALLOUT_SHADING : undefined,
spacing: paraSpacing,
spacing: bodySpacing,
})
);
i += 3;
@ -632,7 +639,7 @@ function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] {
inlineTok && inlineTok.type === "inline"
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx)
: [];
out.push(new Paragraph({ children: runs, spacing: paraSpacing }));
out.push(new Paragraph({ children: runs, spacing: bodySpacing }));
i += 3;
} else if (t.type === "bullet_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
@ -662,7 +669,7 @@ function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] {
children: runs,
shading: { type: ShadingType.CLEAR, fill: "F2F2F2" },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } },
spacing: { before: 120, after: 160 },
spacing: codeSpacing,
})
);
i++;
@ -766,12 +773,13 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
}),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ italics: true, color: "666666", text: "A book compiled with Folio._" })],
children: [new TextRun({ italics: true, color: "666666", text: "A book compiled with Folio." })],
spacing: { after: 360 },
}),
];
for (const ch of chapters) {
for (let ci = 0; ci < chapters.length; ci++) {
const ch = chapters[ci];
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
@ -791,14 +799,28 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
}
}
const markdown = hasOwnHeading ? body : body ? `# ${ch.title}\n\n${body}` : `# ${ch.title}`;
if (markdown) blocks.push(...renderBlocks(md.parse(markdown, {})));
if (markdown) {
// The editor leaves a 2.5rem + 1.5rem gap (about 48pt) before each
// chapter, shown as a thin rule on the chapter's first line. Mirror a
// similar break (~320 twips + a leading line) before chapters after the
// first so the manuscript keeps the same rhythm on paper as on screen.
if (ci > 0) {
blocks.push(
new Paragraph({
children: [],
spacing: { before: 320, after: 0 },
})
);
}
blocks.push(...renderBlocks(md.parse(markdown, {})));
}
}
const doc = new Document({
styles: {
default: {
document: {
run: { font: "Georgia", size: 22 },
run: { font: "Georgia", size: 24 },
},
},
},

View file

@ -441,7 +441,16 @@ export function initBook(api: FolioAPI) {
void handleSpecial(item);
});
// Bind toolbar dropdown toggles.
const customTextColor = toolbar.querySelector<HTMLInputElement>("#customTextColor");
customTextColor?.addEventListener("change", () => {
toggleTextColor(customTextColor.value)(editorView);
editorView.focus();
closeDropdowns();
});
// Bind toolbar dropdown toggles. The toolbar itself is a horizontal scroll
// container (overflow-x: auto), which would clip an absolutely-positioned
// menu, so an open menu is pinned to the viewport under its toggle instead.
toolbar.querySelectorAll<HTMLButtonElement>("button[data-dropdown]").forEach((toggle) => {
const menu = document.getElementById(toggle.dataset.dropdown ?? "");
if (!menu) return;
@ -452,10 +461,21 @@ export function initBook(api: FolioAPI) {
if (!wasOpen) {
menu.classList.remove("hidden");
toggle.classList.add("open");
const rect = toggle.getBoundingClientRect();
const menuWidth = menu.offsetWidth;
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8));
menu.style.position = "fixed";
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${left}px`;
}
});
});
// A pinned menu goes stale if the page scrolls or the window resizes, so
// any of those closes the dropdowns.
window.addEventListener("scroll", closeDropdowns, true);
window.addEventListener("resize", closeDropdowns);
headingSelect.addEventListener("change", () => {
setHeading(Number(headingSelect.value))(editorView);
editorView.focus();

View file

@ -603,11 +603,11 @@ html, body {
display: none !important;
}
#printBook {
#printBook {
display: block !important;
font-family: var(--font-serif);
font-size: 12pt;
line-height: 1.6;
line-height: 1.7;
color: #000;
background: #fff;
}
@ -629,14 +629,15 @@ html, body {
#printBook h4,
#printBook h5,
#printBook h6 {
margin: 0 0 0.7em;
line-height: 1.3;
margin: 0 0 1.7em;
line-height: 1.35;
color: #000;
}
#printBook h1 { font-size: 1.7em; }
#printBook h2 { font-size: 1.45em; }
#printBook h3 { font-size: 1.25em; }
#printBook h4 { font-size: 1.1em; }
#printBook h5, #printBook h6 { font-size: 1em; }
#printBook p,
#printBook ul,
@ -644,9 +645,10 @@ html, body {
#printBook blockquote,
#printBook pre,
#printBook table {
margin: 0 0 0.8em;
margin: 0 0 1.7em;
}
#printBook ul, #printBook ol { padding-left: 1.5em; }
#printBook li { margin: 0; }
#printBook blockquote {
padding-left: 1em;
border-left: 3px solid #999;
@ -677,7 +679,7 @@ html, body {
border-left: 4px solid #3b82f6;
background: #f0f6ff;
padding: 0.6em 1em;
margin: 0 0 0.8em;
margin: 0 0 1.7em;
}
#printBook .callout-title {
font-weight: 700;
@ -884,6 +886,30 @@ html, body {
outline-offset: 1px;
}
.custom-color-row {
flex-basis: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px 6px;
cursor: pointer;
}
.custom-color-row input[type="color"] {
width: 26px;
height: 22px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
cursor: pointer;
}
.custom-color-row span {
font-size: 0.82rem;
color: var(--color-text);
}
.color-menu .dropdown-sep {
flex-basis: 100%;
}

View file

@ -152,6 +152,10 @@
<button class="color-swatch" data-cmd="color:#78716c" title="Gray" style="background:#78716c"></button>
<button class="color-swatch" data-cmd="color:#18181b" title="Black" style="background:#18181b"></button>
<div class="dropdown-sep"></div>
<label class="custom-color-row" title="Pick any color">
<input type="color" id="customTextColor" value="#3b82f6" />
<span>Custom&#8230;</span>
</label>
<button class="dropdown-item" data-cmd="removecolor" title="Remove text color">Remove color</button>
</div>
</div>