Add DOCX and ODT export for the Full Book view
The main process now assembles the manuscript from the chapter files and renders it to a Word document with the docx library, using the same markdown-it plugin set as the on-screen renderer (highlight, callouts, wikilinks). ODT is converted from that DOCX with LibreOffice running headless, keeping the two formats consistent; a clear error is returned when LibreOffice is missing. The Full Book header's single Export PDF button becomes an Export menu (PDF/DOCX/ODT) wired to the File menu. Tests: new run-office-export-test.mjs verifies DOCX/ODT structure, chapter order and formatting; the e2e scenario exports through the real IPC chain and checks magic bytes, extensions and the status notices.
This commit is contained in:
parent
ad0329bbf9
commit
6679294231
12 changed files with 1403 additions and 15 deletions
|
|
@ -23,6 +23,7 @@ import {
|
|||
setChapterContent,
|
||||
} from "./chapters.js";
|
||||
import { combineMarkdown, buildZip } from "./export.js";
|
||||
import { buildDocx, buildOdt } from "./office-export.js";
|
||||
import { getSetting, setSetting } from "./settings.js";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
|
@ -358,6 +359,60 @@ ipcMain.handle("folio:exportPdf", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
// Export the manuscript as a DOCX. Built entirely in the main process from the
|
||||
// chapter files, so it does not depend on which view is on screen. The e2e
|
||||
// harness overrides the save dialog with FOLIO_TEST_DOCX.
|
||||
ipcMain.handle("folio:exportDocx", async () => {
|
||||
try {
|
||||
const bp = requireBook();
|
||||
let filePath: string;
|
||||
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_DOCX) {
|
||||
filePath = process.env.FOLIO_TEST_DOCX;
|
||||
} else {
|
||||
const { canceled, filePath: chosen } = await showSaveDialog({
|
||||
title: "Export Full Book as DOCX",
|
||||
defaultPath: `${currentBookTitle()}.docx`,
|
||||
filters: [{ name: "Word Document", extensions: ["docx"] }],
|
||||
});
|
||||
if (canceled || !chosen) return { canceled: true } as const;
|
||||
filePath = chosen.endsWith(".docx") ? chosen : `${chosen}.docx`;
|
||||
}
|
||||
const buf = await buildDocx(bp);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
const docxOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
|
||||
return { ok: true, filePath, bytes: buf.length, docxOk };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Export the manuscript as an ODT via LibreOffice (converted from the same
|
||||
// DOCX so both exports stay consistent). Fails with a helpful message when
|
||||
// LibreOffice is not installed.
|
||||
ipcMain.handle("folio:exportOdt", async () => {
|
||||
try {
|
||||
const bp = requireBook();
|
||||
let filePath: string;
|
||||
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_ODT) {
|
||||
filePath = process.env.FOLIO_TEST_ODT;
|
||||
} else {
|
||||
const { canceled, filePath: chosen } = await showSaveDialog({
|
||||
title: "Export Full Book as ODT",
|
||||
defaultPath: `${currentBookTitle()}.odt`,
|
||||
filters: [{ name: "OpenDocument Text", extensions: ["odt"] }],
|
||||
});
|
||||
if (canceled || !chosen) return { canceled: true } as const;
|
||||
filePath = chosen.endsWith(".odt") ? chosen : `${chosen}.odt`;
|
||||
}
|
||||
const buf = await buildOdt(bp);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
const odtOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
|
||||
return { ok: true, filePath, bytes: buf.length, odtOk };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
|
||||
try {
|
||||
setChapterContent(requireBook(), id, content);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,16 @@ export function buildMenu(
|
|||
enabled: !!getState().bookPath,
|
||||
click: () => getWindow()?.webContents.send("folio:export-pdf-requested"),
|
||||
},
|
||||
{
|
||||
label: "Export Full Book (DOCX)…",
|
||||
enabled: !!getState().bookPath,
|
||||
click: () => getWindow()?.webContents.send("folio:export-docx-requested"),
|
||||
},
|
||||
{
|
||||
label: "Export Full Book (ODT)…",
|
||||
enabled: !!getState().bookPath,
|
||||
click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Close",
|
||||
|
|
|
|||
865
src/main/office-export.ts
Normal file
865
src/main/office-export.ts
Normal file
|
|
@ -0,0 +1,865 @@
|
|||
// Full Book export to DOCX (and ODT) entirely in the main process.
|
||||
//
|
||||
// DOCX is produced with the `docx` library: the assembled manuscript is parsed
|
||||
// with markdown-it (same plugin set as the on-screen renderer) and the token
|
||||
// stream is converted into Word paragraphs, tables and runs. ODT is converted
|
||||
// from the generated DOCX with LibreOffice running headless, which keeps the
|
||||
// two formats consistent without hand-writing another office XML vocabulary.
|
||||
// Both exports read the chapter files straight off disk, so the manuscript is
|
||||
// always complete regardless of which view is on screen.
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { MarkdownIt as MarkdownItInstance, StateCore, StateInline } from "markdown-it";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
NumberFormat,
|
||||
Packer,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import { loadBook } from "./project.js";
|
||||
import { listChaptersInOrder } from "./chapters.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MONO_FONT = "Consolas";
|
||||
|
||||
interface WikilinkTarget {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// ==text== -> <mark>text</mark> (mirrors src/renderer/print-format.ts)
|
||||
function highlightPlugin(md: MarkdownItInstance) {
|
||||
const parse = (state: StateInline, silent: boolean): boolean => {
|
||||
const src = state.src;
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
if (start + 2 > max) return false;
|
||||
if (src.charCodeAt(start) !== 0x3d || src.charCodeAt(start + 1) !== 0x3d) return false;
|
||||
let end = -1;
|
||||
for (let i = start + 2; i < max - 1; i++) {
|
||||
const ch = src.charCodeAt(i);
|
||||
if (ch === 0x0a) return false;
|
||||
if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end === -1) return false;
|
||||
const content = src.slice(start + 2, end);
|
||||
if (!content.trim()) return false;
|
||||
if (silent) {
|
||||
state.pos = end + 2;
|
||||
return true;
|
||||
}
|
||||
state.push("highlight_open", "mark", 1);
|
||||
state.push("text", "", 0).content = content;
|
||||
state.push("highlight_close", "mark", -1);
|
||||
state.pos = end + 2;
|
||||
return true;
|
||||
};
|
||||
md.inline.ruler.before("emphasis", "highlight", parse);
|
||||
}
|
||||
|
||||
// [[Title]] / [[Target|Alias]] -> links (mirrors src/renderer/wikilinks.ts)
|
||||
function wikilinkPlugin(md: MarkdownItInstance, chapters: WikilinkTarget[]) {
|
||||
const byTitle = new Map(chapters.map((c) => [String(c.title).toLowerCase(), c.id]));
|
||||
const byId = new Set(chapters.map((c) => String(c.id)));
|
||||
const parse = (state: StateInline, silent: boolean): boolean => {
|
||||
const src = state.src;
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
if (start + 2 > max) return false;
|
||||
if (src.charCodeAt(start) !== 0x5b || src.charCodeAt(start + 1) !== 0x5b) return false;
|
||||
let end = -1;
|
||||
for (let i = start + 2; i < max - 1; i++) {
|
||||
const ch = src.charCodeAt(i);
|
||||
if (ch === 0x5b) return false;
|
||||
if (ch === 0x0a) return false;
|
||||
if (ch === 0x5d && src.charCodeAt(i + 1) === 0x5d) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end === -1) return false;
|
||||
const raw = src.slice(start + 2, end).trim();
|
||||
if (!raw) return false;
|
||||
let target = raw;
|
||||
let label = raw;
|
||||
const pipe = raw.indexOf("|");
|
||||
if (pipe >= 0) {
|
||||
target = raw.slice(0, pipe).trim();
|
||||
const alias = raw.slice(pipe + 1).trim();
|
||||
label = alias.length ? alias : target;
|
||||
}
|
||||
if (!target) return false;
|
||||
if (silent) {
|
||||
state.pos = end + 2;
|
||||
return true;
|
||||
}
|
||||
const key = target.toLowerCase();
|
||||
const id = byTitle.has(key) ? byTitle.get(key)! : byId.has(target) ? target : null;
|
||||
const open = state.push("link_open", "a", 1);
|
||||
open.attrs = [
|
||||
["href", id !== null ? `folio:chapter/${encodeURIComponent(id)}` : "#"],
|
||||
["class", id !== null ? "wikilink internal" : "wikilink missing"],
|
||||
];
|
||||
const text = state.push("text", "", 0);
|
||||
text.content = label;
|
||||
state.push("link_close", "a", -1);
|
||||
state.pos = end + 2;
|
||||
return true;
|
||||
};
|
||||
md.inline.ruler.before("link", "wikilink", parse);
|
||||
}
|
||||
|
||||
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/;
|
||||
|
||||
// > [!note] Title ... -> styled callout blocks (mirrors src/renderer/print-format.ts)
|
||||
function calloutPlugin(md: MarkdownItInstance) {
|
||||
md.core.ruler.after("inline", "callout", (state: StateCore) => {
|
||||
const tokens = state.tokens;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const open = tokens[i];
|
||||
if (open.type !== "blockquote_open") continue;
|
||||
let depth = 1;
|
||||
let closeIdx = -1;
|
||||
for (let j = i + 1; j < tokens.length; j++) {
|
||||
const t = tokens[j];
|
||||
if (t.type === "blockquote_open") depth += 1;
|
||||
else if (t.type === "blockquote_close") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
closeIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (closeIdx === -1) continue;
|
||||
const paraOpen = tokens[i + 1];
|
||||
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
|
||||
const inline = tokens[i + 2];
|
||||
if (!inline || inline.type !== "inline" || !inline.children) continue;
|
||||
const first = inline.children[0];
|
||||
if (!first || first.type !== "text") continue;
|
||||
const m = CALLOUT_RE.exec(first.content);
|
||||
if (!m) continue;
|
||||
|
||||
open.type = "callout_open";
|
||||
open.attrs = [["class", `callout callout-${m[1].toLowerCase()}`]];
|
||||
const close = tokens[closeIdx];
|
||||
close.type = "callout_close";
|
||||
close.tag = "div";
|
||||
|
||||
first.content = "";
|
||||
inline.children = inline.children.filter((c) => !(c.type === "text" && c.content === ""));
|
||||
if (inline.children[0]?.type === "softbreak") inline.children.shift();
|
||||
|
||||
const title = m[2].trim();
|
||||
if (title) {
|
||||
const titleOpen = new state.Token("paragraph_open", "p", 1);
|
||||
titleOpen.attrs = [["class", "callout-title"]];
|
||||
const titleInline = new state.Token("inline", "", 0);
|
||||
titleInline.content = title;
|
||||
const titleText = new state.Token("text", "", 0);
|
||||
titleText.content = title;
|
||||
titleInline.children = [titleText];
|
||||
const titleClose = new state.Token("paragraph_close", "p", -1);
|
||||
tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type BlockEl = Paragraph | Table;
|
||||
|
||||
interface InlineCtx {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
strike: boolean;
|
||||
code: boolean;
|
||||
underline: boolean;
|
||||
highlight?: "yellow";
|
||||
color?: string;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
const defaultCtx: InlineCtx = {
|
||||
bold: false,
|
||||
italic: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
underline: false,
|
||||
};
|
||||
|
||||
const paraSpacing = { after: 160 };
|
||||
const headingSpacing = { before: 240, after: 160 };
|
||||
const tableSpacing = { after: 40 };
|
||||
|
||||
function hexColor(v: string): string {
|
||||
return v.replace(/^#/, "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function makeRun(ctx: InlineCtx, text: string): TextRun {
|
||||
const lineBreak = text === "\n";
|
||||
return new TextRun({
|
||||
text: lineBreak ? "" : text,
|
||||
bold: ctx.bold || undefined,
|
||||
italics: ctx.italic || undefined,
|
||||
strike: ctx.strike || undefined,
|
||||
underline: ctx.underline ? {} : undefined,
|
||||
highlight: ctx.highlight,
|
||||
color: ctx.color,
|
||||
shading: ctx.bg ? { type: ShadingType.CLEAR, fill: ctx.bg } : undefined,
|
||||
font: ctx.code ? MONO_FONT : undefined,
|
||||
break: lineBreak ? 1 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function decodeEntities(s: string): string {
|
||||
return s
|
||||
.replace(/ /g, "\u00a0")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_m, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_m, dec: string) => String.fromCodePoint(parseInt(dec, 10)));
|
||||
}
|
||||
|
||||
// Convert a raw inline HTML fragment (toolbar's <u>, <span style=...>, etc.)
|
||||
// into runs. The caller keeps one mutable state across the whole inline token
|
||||
// list, so formatting opened in one html_inline token (e.g. "<u>") still
|
||||
// applies to the plain text tokens that follow until its closing tag.
|
||||
function htmlInlineRuns(html: string, state: { ctx: InlineCtx; stack: Partial<InlineCtx>[] }): TextRun[] {
|
||||
const out: TextRun[] = [];
|
||||
const { ctx, stack } = state;
|
||||
let textBuf = "";
|
||||
let i = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (textBuf) {
|
||||
out.push(makeRun(ctx, textBuf));
|
||||
textBuf = "";
|
||||
}
|
||||
};
|
||||
const snapshot = (): Partial<InlineCtx> => ({
|
||||
bold: ctx.bold,
|
||||
italic: ctx.italic,
|
||||
strike: ctx.strike,
|
||||
code: ctx.code,
|
||||
underline: ctx.underline,
|
||||
highlight: ctx.highlight,
|
||||
color: ctx.color,
|
||||
bg: ctx.bg,
|
||||
});
|
||||
const restore = (snap: Partial<InlineCtx>) => {
|
||||
Object.assign(ctx, snap);
|
||||
};
|
||||
|
||||
while (i < html.length) {
|
||||
const lt = html.indexOf("<", i);
|
||||
if (lt === -1) {
|
||||
textBuf += decodeEntities(html.slice(i));
|
||||
break;
|
||||
}
|
||||
const gt = html.indexOf(">", lt);
|
||||
if (gt === -1) {
|
||||
textBuf += decodeEntities(html.slice(i));
|
||||
break;
|
||||
}
|
||||
if (lt > i) textBuf += decodeEntities(html.slice(i, lt));
|
||||
const tag = html.slice(lt + 1, gt);
|
||||
const nameMatch = /^\s*\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(tag);
|
||||
const name = nameMatch?.[1].toLowerCase() ?? "";
|
||||
const isClose = /^\s*\//.test(tag);
|
||||
const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1] ?? "";
|
||||
|
||||
if (!name) {
|
||||
textBuf += "<" + tag + ">";
|
||||
} else if (name === "br") {
|
||||
flush();
|
||||
out.push(makeRun(ctx, "\n"));
|
||||
} else if (name === "span") {
|
||||
flush();
|
||||
if (!isClose) {
|
||||
stack.push(snapshot());
|
||||
for (const decl of styleAttr.split(";")) {
|
||||
const [prop, ...rest] = decl.split(":");
|
||||
const k = prop.trim().toLowerCase();
|
||||
const v = rest.join(":").trim();
|
||||
if (k === "color" && v) ctx.color = hexColor(v);
|
||||
else if (k === "background-color" && v) ctx.bg = hexColor(v);
|
||||
else if (k === "text-decoration" && v.includes("underline")) ctx.underline = true;
|
||||
}
|
||||
} else {
|
||||
const snap = stack.pop();
|
||||
if (snap) restore(snap);
|
||||
}
|
||||
} else if (name === "u") {
|
||||
flush();
|
||||
if (!isClose) {
|
||||
stack.push(snapshot());
|
||||
ctx.underline = true;
|
||||
} else {
|
||||
const snap = stack.pop();
|
||||
if (snap) restore(snap);
|
||||
}
|
||||
} else if (name === "del" || name === "s") {
|
||||
flush();
|
||||
if (!isClose) {
|
||||
stack.push(snapshot());
|
||||
ctx.strike = true;
|
||||
} else {
|
||||
const snap = stack.pop();
|
||||
if (snap) restore(snap);
|
||||
}
|
||||
} else if (name === "mark") {
|
||||
flush();
|
||||
if (!isClose) {
|
||||
stack.push(snapshot());
|
||||
ctx.highlight = "yellow";
|
||||
} else {
|
||||
const snap = stack.pop();
|
||||
if (snap) restore(snap);
|
||||
}
|
||||
} else if (name === "strong" || name === "b") {
|
||||
flush();
|
||||
if (!isClose) {
|
||||
stack.push(snapshot());
|
||||
ctx.bold = true;
|
||||
} else {
|
||||
const snap = stack.pop();
|
||||
if (snap) restore(snap);
|
||||
}
|
||||
} else if (name === "em" || name === "i") {
|
||||
flush();
|
||||
if (!isClose) {
|
||||
stack.push(snapshot());
|
||||
ctx.italic = true;
|
||||
} else {
|
||||
const snap = stack.pop();
|
||||
if (snap) restore(snap);
|
||||
}
|
||||
} else if (name === "code") {
|
||||
flush();
|
||||
if (!isClose) {
|
||||
stack.push(snapshot());
|
||||
ctx.code = true;
|
||||
} else {
|
||||
const snap = stack.pop();
|
||||
if (snap) restore(snap);
|
||||
}
|
||||
}
|
||||
// Unknown tags are dropped; their inner text is kept.
|
||||
i = gt + 1;
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Convert inline token children into runs. Links become ExternalHyperlink
|
||||
// unless they are wikilinks (rendered as plain label text, since the export
|
||||
// has no in-app chapter URLs).
|
||||
function inlineRuns(tokens: import("markdown-it").Token[], ctx: InlineCtx): (TextRun | ExternalHyperlink)[] {
|
||||
const out: (TextRun | ExternalHyperlink)[] = [];
|
||||
let i = 0;
|
||||
const htmlState = { ctx: { ...ctx }, stack: [] as Partial<InlineCtx>[] };
|
||||
|
||||
const collect = (openType: string, closeType: string): { inner: import("markdown-it").Token[] } => {
|
||||
i++;
|
||||
let depth = 1;
|
||||
const inner: import("markdown-it").Token[] = [];
|
||||
while (i < tokens.length && depth) {
|
||||
const t = tokens[i];
|
||||
if (t.type === openType) depth++;
|
||||
else if (t.type === closeType) depth--;
|
||||
if (depth) inner.push(t);
|
||||
i++;
|
||||
}
|
||||
return { inner };
|
||||
};
|
||||
|
||||
while (i < tokens.length) {
|
||||
const t = tokens[i];
|
||||
if (t.type === "text") {
|
||||
out.push(makeRun(htmlState.ctx, t.content));
|
||||
i++;
|
||||
} else if (t.type === "softbreak" || t.type === "hardbreak") {
|
||||
out.push(makeRun(htmlState.ctx, "\n"));
|
||||
i++;
|
||||
} else if (t.type === "code") {
|
||||
out.push(makeRun({ ...htmlState.ctx, code: true }, t.content));
|
||||
i++;
|
||||
} else if (t.type === "image") {
|
||||
const alt = String(t.attrGet("alt") ?? t.attrGet("src") ?? "");
|
||||
if (alt) out.push(makeRun({ ...htmlState.ctx, italic: true }, alt));
|
||||
i++;
|
||||
} else if (t.type === "strong_open") {
|
||||
out.push(...inlineRuns(collect("strong_open", "strong_close").inner, { ...htmlState.ctx, bold: true }));
|
||||
} else if (t.type === "em_open") {
|
||||
out.push(...inlineRuns(collect("em_open", "em_close").inner, { ...htmlState.ctx, italic: true }));
|
||||
} else if (t.type === "s_open") {
|
||||
out.push(...inlineRuns(collect("s_open", "s_close").inner, { ...htmlState.ctx, strike: true }));
|
||||
} else if (t.type === "highlight_open") {
|
||||
out.push(
|
||||
...inlineRuns(collect("highlight_open", "highlight_close").inner, {
|
||||
...htmlState.ctx,
|
||||
highlight: htmlState.ctx.highlight ?? "yellow",
|
||||
})
|
||||
);
|
||||
} else if (t.type === "link_open") {
|
||||
const href = String(t.attrGet("href") ?? "");
|
||||
const isWikilink = String(t.attrGet("class") ?? "").includes("wikilink");
|
||||
const children = inlineRuns(collect("link_open", "link_close").inner, htmlState.ctx);
|
||||
if (isWikilink || href === "#" || href.startsWith("folio:chapter/")) {
|
||||
out.push(...children);
|
||||
} else if (href && children.length) {
|
||||
out.push(new ExternalHyperlink({ link: href, children }));
|
||||
} else {
|
||||
out.push(...children);
|
||||
}
|
||||
} else if (t.type === "html_inline") {
|
||||
out.push(...htmlInlineRuns(t.content, htmlState));
|
||||
i++;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function headingLevel(level: number) {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return HeadingLevel.HEADING_1;
|
||||
case 2:
|
||||
return HeadingLevel.HEADING_2;
|
||||
case 3:
|
||||
return HeadingLevel.HEADING_3;
|
||||
case 4:
|
||||
return HeadingLevel.HEADING_4;
|
||||
case 5:
|
||||
return HeadingLevel.HEADING_5;
|
||||
case 6:
|
||||
return HeadingLevel.HEADING_6;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function htmlBlockToParagraph(html: string): Paragraph | null {
|
||||
const alignMatch = /align\s*=\s*["'](left|center|right|justify)["']/i.exec(html);
|
||||
const align = alignMatch?.[1].toLowerCase();
|
||||
const text = decodeEntities(html.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
|
||||
if (!text) return null;
|
||||
return new Paragraph({
|
||||
alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined,
|
||||
children: [new TextRun({ text })],
|
||||
spacing: paraSpacing,
|
||||
});
|
||||
}
|
||||
|
||||
function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { reference: string; level: number }): BlockEl[] {
|
||||
const out: BlockEl[] = [];
|
||||
let i = 0;
|
||||
let first = true;
|
||||
while (i < itemTokens.length) {
|
||||
const t = itemTokens[i];
|
||||
if (t.type === "paragraph_open") {
|
||||
const inlineTok = itemTokens[i + 1];
|
||||
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
|
||||
if (first) {
|
||||
out.push(
|
||||
new Paragraph({
|
||||
children: runs,
|
||||
spacing: paraSpacing,
|
||||
numbering: { reference: listCtx.reference, level: listCtx.level },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
new Paragraph({
|
||||
children: runs,
|
||||
spacing: paraSpacing,
|
||||
indent: { left: 720 * (listCtx.level + 1), hanging: 360 },
|
||||
})
|
||||
);
|
||||
}
|
||||
first = false;
|
||||
i += 3;
|
||||
} else if (t.type === "bullet_list_open" || t.type === "ordered_list_open") {
|
||||
const nested = {
|
||||
reference: t.type === "bullet_list_open" ? "bullets" : "numbers",
|
||||
level: listCtx.level + 1,
|
||||
};
|
||||
const { blocks, next } = renderList(itemTokens, i, nested);
|
||||
out.push(...blocks);
|
||||
i = next;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderList(tokens: import("markdown-it").Token[], start: number, listCtx: { reference: string; level: number }): { blocks: BlockEl[]; next: number } {
|
||||
const blocks: BlockEl[] = [];
|
||||
let i = start + 1;
|
||||
let depth = 1;
|
||||
while (i < tokens.length && depth) {
|
||||
const t = tokens[i];
|
||||
if (t.type === "bullet_list_open" || t.type === "ordered_list_open") depth++;
|
||||
else if (t.type === "bullet_list_close" || t.type === "ordered_list_close") depth--;
|
||||
if (depth && t.type === "list_item_open") {
|
||||
let j = i + 1;
|
||||
let itemDepth = 1;
|
||||
const itemTokens: import("markdown-it").Token[] = [];
|
||||
while (j < tokens.length && itemDepth) {
|
||||
const it = tokens[j];
|
||||
if (it.type === "list_item_open") itemDepth++;
|
||||
else if (it.type === "list_item_close") itemDepth--;
|
||||
if (itemDepth) itemTokens.push(it);
|
||||
j++;
|
||||
}
|
||||
blocks.push(...renderListItem(itemTokens, listCtx));
|
||||
i = j;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { blocks, next: i };
|
||||
}
|
||||
|
||||
// Collect the tokens inside a balanced open/close pair and return the index
|
||||
// just past the closing token.
|
||||
function collectMatching(
|
||||
tokens: import("markdown-it").Token[],
|
||||
start: number,
|
||||
openTypes: string[],
|
||||
closeTypes: string[]
|
||||
): { inner: import("markdown-it").Token[]; next: number } {
|
||||
let i = start + 1;
|
||||
let depth = 1;
|
||||
const inner: import("markdown-it").Token[] = [];
|
||||
while (i < tokens.length && depth) {
|
||||
const t = tokens[i];
|
||||
if (openTypes.includes(t.type)) depth++;
|
||||
else if (closeTypes.includes(t.type)) depth--;
|
||||
if (depth) inner.push(t);
|
||||
i++;
|
||||
}
|
||||
return { inner, next: i };
|
||||
}
|
||||
|
||||
const QUOTE_BORDER = { left: { style: BorderStyle.SINGLE, size: 16, color: "888888", space: 8 } };
|
||||
const CALLOUT_SHADING = { type: ShadingType.CLEAR, fill: "F5F2ED" };
|
||||
|
||||
// Render blockquote / callout content. Every paragraph gets the quote indent
|
||||
// and left border; callouts are additionally shaded.
|
||||
function renderQuoted(tokens: import("markdown-it").Token[], isCallout: boolean): BlockEl[] {
|
||||
const out: BlockEl[] = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const t = tokens[i];
|
||||
if (t.type === "paragraph_open") {
|
||||
const isTitle = String(t.attrGet("class") ?? "").includes("callout-title");
|
||||
const inlineTok = tokens[i + 1];
|
||||
const runs =
|
||||
inlineTok && inlineTok.type === "inline"
|
||||
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx)
|
||||
: [];
|
||||
out.push(
|
||||
new Paragraph({
|
||||
children: runs,
|
||||
indent: { left: 720 },
|
||||
border: QUOTE_BORDER,
|
||||
shading: isCallout ? CALLOUT_SHADING : undefined,
|
||||
spacing: paraSpacing,
|
||||
})
|
||||
);
|
||||
i += 3;
|
||||
} else if (t.type === "bullet_list_open") {
|
||||
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
|
||||
out.push(...blocks);
|
||||
i = next;
|
||||
} else if (t.type === "ordered_list_open") {
|
||||
const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 });
|
||||
out.push(...blocks);
|
||||
i = next;
|
||||
} else if (t.type === "blockquote_open" || t.type === "callout_open") {
|
||||
const nested = t.type === "callout_open";
|
||||
const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]);
|
||||
out.push(...renderQuoted(inner, isCallout || nested));
|
||||
i = next;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Render a run of block-level tokens (a whole chapter or a blockquote body).
|
||||
function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] {
|
||||
const out: BlockEl[] = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const t = tokens[i];
|
||||
if (t.type === "heading_open") {
|
||||
const level = Number(t.tag.slice(1));
|
||||
const inlineTok = tokens[i + 1];
|
||||
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
|
||||
out.push(new Paragraph({ heading: headingLevel(level), children: runs, spacing: headingSpacing }));
|
||||
i += 3;
|
||||
} else if (t.type === "paragraph_open") {
|
||||
const isTitle = String(t.attrGet("class") ?? "").includes("callout-title");
|
||||
const inlineTok = tokens[i + 1];
|
||||
const runs =
|
||||
inlineTok && inlineTok.type === "inline"
|
||||
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx)
|
||||
: [];
|
||||
out.push(new Paragraph({ children: runs, spacing: paraSpacing }));
|
||||
i += 3;
|
||||
} else if (t.type === "bullet_list_open") {
|
||||
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
|
||||
out.push(...blocks);
|
||||
i = next;
|
||||
} else if (t.type === "ordered_list_open") {
|
||||
const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 });
|
||||
out.push(...blocks);
|
||||
i = next;
|
||||
} else if (t.type === "blockquote_open" || t.type === "callout_open") {
|
||||
const isCallout = t.type === "callout_open";
|
||||
const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]);
|
||||
out.push(...renderQuoted(inner, isCallout));
|
||||
i = next;
|
||||
} else if (t.type === "fence" || t.type === "code_block") {
|
||||
const lines = t.content.replace(/\n$/, "").split("\n");
|
||||
const runs = lines.map(
|
||||
(line, idx) =>
|
||||
new TextRun({
|
||||
text: line,
|
||||
font: MONO_FONT,
|
||||
break: idx < lines.length - 1 ? 1 : undefined,
|
||||
})
|
||||
);
|
||||
out.push(
|
||||
new Paragraph({
|
||||
children: runs,
|
||||
shading: { type: ShadingType.CLEAR, fill: "F2F2F2" },
|
||||
border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } },
|
||||
spacing: { before: 120, after: 160 },
|
||||
})
|
||||
);
|
||||
i++;
|
||||
} else if (t.type === "hr") {
|
||||
out.push(
|
||||
new Paragraph({
|
||||
children: [],
|
||||
spacing: { before: 120, after: 120 },
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: "999999", space: 1 } },
|
||||
})
|
||||
);
|
||||
i++;
|
||||
} else if (t.type === "table_open") {
|
||||
const { inner, next } = collectMatching(tokens, i, ["table_open"], ["table_close"]);
|
||||
const rows: TableRow[] = [];
|
||||
let cells: TableCell[] = [];
|
||||
for (let k = 0; k < inner.length; k++) {
|
||||
const tt = inner[k];
|
||||
if (tt.type === "tr_open") cells = [];
|
||||
else if (tt.type === "th_open" || tt.type === "td_open") {
|
||||
const inlineTok = inner[k + 1];
|
||||
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
|
||||
cells.push(
|
||||
new TableCell({
|
||||
children: [new Paragraph({ children: runs, spacing: tableSpacing })],
|
||||
shading: tt.type === "th_open" ? { type: ShadingType.CLEAR, fill: "EFEFEF" } : undefined,
|
||||
})
|
||||
);
|
||||
} else if (tt.type === "tr_close") rows.push(new TableRow({ children: cells }));
|
||||
}
|
||||
out.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows }));
|
||||
i = next;
|
||||
} else if (t.type === "html_block") {
|
||||
const p = htmlBlockToParagraph(t.content);
|
||||
if (p) out.push(p);
|
||||
i++;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Assemble the manuscript exactly the way the Full Book view does: the book
|
||||
// title on top, then each chapter in order, prepending "# Title" only for
|
||||
// chapters that do not carry their own heading. The title page note mirrors
|
||||
// combineMarkdown so all exports stay consistent.
|
||||
function assembleBookMarkdown(bookPath: string): string {
|
||||
const meta = loadBook(bookPath);
|
||||
const parts: string[] = [`# ${meta.title}`, "", "_A book compiled with Folio._"];
|
||||
for (const ch of listChaptersInOrder(meta)) {
|
||||
let content = "";
|
||||
try {
|
||||
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
|
||||
} catch {
|
||||
content = "";
|
||||
}
|
||||
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
|
||||
const t = ch.title.trim().toLowerCase();
|
||||
let hasOwnHeading = !t;
|
||||
if (t) {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
||||
if (m && m[1].trim().toLowerCase() === t) {
|
||||
hasOwnHeading = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const block: string[] = [];
|
||||
if (!hasOwnHeading) {
|
||||
block.push(`# ${ch.title}`);
|
||||
if (body) {
|
||||
block.push("");
|
||||
block.push(body);
|
||||
}
|
||||
} else if (body) {
|
||||
block.push(body);
|
||||
}
|
||||
if (block.length) parts.push("", block.join("\n"));
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function buildDocx(bookPath: string): Promise<Buffer> {
|
||||
const meta = loadBook(bookPath);
|
||||
const chapters = listChaptersInOrder(meta);
|
||||
const targets: WikilinkTarget[] = chapters.map((c) => ({ id: c.id, title: c.title }));
|
||||
|
||||
const md = new MarkdownIt({ html: true });
|
||||
md.use(wikilinkPlugin, targets);
|
||||
md.use(highlightPlugin);
|
||||
md.use(calloutPlugin);
|
||||
|
||||
const blocks: BlockEl[] = [
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: meta.title })],
|
||||
spacing: { after: 120 },
|
||||
}),
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ italics: true, color: "666666", text: "A book compiled with Folio._" })],
|
||||
spacing: { after: 360 },
|
||||
}),
|
||||
];
|
||||
|
||||
for (const ch of chapters) {
|
||||
let content = "";
|
||||
try {
|
||||
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
|
||||
} catch {
|
||||
content = "";
|
||||
}
|
||||
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
|
||||
const t = ch.title.trim().toLowerCase();
|
||||
let hasOwnHeading = !t;
|
||||
if (t) {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
||||
if (m && m[1].trim().toLowerCase() === t) {
|
||||
hasOwnHeading = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const markdown = hasOwnHeading ? body : body ? `# ${ch.title}\n\n${body}` : `# ${ch.title}`;
|
||||
if (markdown) blocks.push(...renderBlocks(md.parse(markdown, {})));
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: { font: "Georgia", size: 22 },
|
||||
},
|
||||
},
|
||||
},
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "bullets",
|
||||
levels: [0, 1, 2, 3, 4, 5].map((level) => ({
|
||||
level,
|
||||
format: NumberFormat.BULLET,
|
||||
text: "\u2022",
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
|
||||
})),
|
||||
},
|
||||
{
|
||||
reference: "numbers",
|
||||
levels: [0, 1, 2, 3, 4, 5].map((level) => ({
|
||||
level,
|
||||
format: NumberFormat.DECIMAL,
|
||||
text: `%${level + 1}.`,
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children: blocks }],
|
||||
});
|
||||
|
||||
return Packer.toBuffer(doc);
|
||||
}
|
||||
|
||||
// Convert the generated DOCX to ODT with LibreOffice running headless. This
|
||||
// keeps the two exports consistent and produces a spec-compliant ODT without
|
||||
// hand-writing a second XML vocabulary.
|
||||
export async function buildOdt(bookPath: string): Promise<Buffer> {
|
||||
const docxBuf = await buildDocx(bookPath);
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-odt-"));
|
||||
try {
|
||||
const tmpDocx = path.join(tmpDir, "book.docx");
|
||||
const outDir = path.join(tmpDir, "out");
|
||||
fs.writeFileSync(tmpDocx, docxBuf);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
try {
|
||||
await execFileAsync("soffice", ["--headless", "--convert-to", "odt", "--outdir", outDir, tmpDocx], {
|
||||
timeout: 60_000,
|
||||
});
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
throw new Error(
|
||||
"LibreOffice is not installed, so ODT export is unavailable. Install LibreOffice (or use the PDF or DOCX export instead)."
|
||||
);
|
||||
}
|
||||
throw new Error(`LibreOffice could not convert the document: ${(e as Error).message}`);
|
||||
}
|
||||
const odtPath = path.join(outDir, "book.odt");
|
||||
if (!fs.existsSync(odtPath)) throw new Error("LibreOffice did not produce an ODT file.");
|
||||
return fs.readFileSync(odtPath);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
|
||||
exportZip: () => ipcRenderer.invoke("folio:exportZip"),
|
||||
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
|
||||
exportDocx: () => ipcRenderer.invoke("folio:exportDocx"),
|
||||
exportOdt: () => ipcRenderer.invoke("folio:exportOdt"),
|
||||
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
|
||||
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
|
||||
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
|
||||
|
|
@ -57,6 +59,10 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
ipcRenderer.on("folio:export-zip-requested", () => cb()),
|
||||
onExportPdfRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:export-pdf-requested", () => cb()),
|
||||
onExportDocxRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:export-docx-requested", () => cb()),
|
||||
onExportOdtRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:export-odt-requested", () => cb()),
|
||||
onUndoRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:undo-requested", () => cb()),
|
||||
onRedoRequested: (cb: () => void) =>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ export type FolioAPI = {
|
|||
exportMarkdown: () => Promise<any>;
|
||||
exportZip: () => Promise<any>;
|
||||
exportPdf: () => Promise<any>;
|
||||
exportDocx: () => Promise<any>;
|
||||
exportOdt: () => Promise<any>;
|
||||
importAttachment: () => Promise<any>;
|
||||
copyText: (text: string) => Promise<any>;
|
||||
getClipboardText: () => Promise<string>;
|
||||
|
|
@ -72,6 +74,8 @@ export type FolioAPI = {
|
|||
onExportMarkdownRequested: (cb: () => void) => void;
|
||||
onExportZipRequested: (cb: () => void) => void;
|
||||
onExportPdfRequested: (cb: () => void) => void;
|
||||
onExportDocxRequested: (cb: () => void) => void;
|
||||
onExportOdtRequested: (cb: () => void) => void;
|
||||
onUndoRequested: (cb: () => void) => void;
|
||||
onRedoRequested: (cb: () => void) => void;
|
||||
onSelectAllRequested: (cb: () => void) => void;
|
||||
|
|
@ -124,7 +128,9 @@ export function initBook(api: FolioAPI) {
|
|||
const printBook = $("printBook");
|
||||
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
|
||||
const fullBookStatus = $("fullBookStatus");
|
||||
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement;
|
||||
const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
|
||||
const exportMenu = $("exportMenu");
|
||||
const exportDropdown = $("exportDropdown");
|
||||
const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement;
|
||||
const fbNextBtn = $("fbNextBtn") as HTMLButtonElement;
|
||||
const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement;
|
||||
|
|
@ -794,6 +800,28 @@ export function initBook(api: FolioAPI) {
|
|||
else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`);
|
||||
}
|
||||
|
||||
async function exportDocx() {
|
||||
if (!currentBook) return;
|
||||
const res = await api.exportDocx();
|
||||
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
|
||||
else if (res && "canceled" in res) fullBookNotice("Export canceled");
|
||||
else if (res && "filePath" in res) fullBookNotice(`DOCX saved to ${basename(res.filePath)}`);
|
||||
}
|
||||
|
||||
async function exportOdt() {
|
||||
if (!currentBook) return;
|
||||
const res = await api.exportOdt();
|
||||
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
|
||||
else if (res && "canceled" in res) fullBookNotice("Export canceled");
|
||||
else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`);
|
||||
}
|
||||
|
||||
async function exportFromMenu(kind: string) {
|
||||
if (kind === "pdf") await exportPdf();
|
||||
else if (kind === "docx") await exportDocx();
|
||||
else if (kind === "odt") await exportOdt();
|
||||
}
|
||||
|
||||
function basename(p: string): string {
|
||||
return p.split(/[/\\]/).pop() || p;
|
||||
}
|
||||
|
|
@ -1005,7 +1033,30 @@ export function initBook(api: FolioAPI) {
|
|||
const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value);
|
||||
if (idx >= 0) jumpToChapter(idx);
|
||||
});
|
||||
exportPdfBtn.addEventListener("click", () => void exportPdf());
|
||||
function closeExportMenu() {
|
||||
exportMenu.classList.add("hidden");
|
||||
exportMenuToggle.classList.remove("open");
|
||||
}
|
||||
|
||||
exportMenuToggle.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const wasOpen = !exportMenu.classList.contains("hidden");
|
||||
closeExportMenu();
|
||||
if (!wasOpen) exportMenu.classList.remove("hidden");
|
||||
exportMenuToggle.classList.toggle("open", wasOpen);
|
||||
});
|
||||
|
||||
exportMenu.addEventListener("click", (e) => {
|
||||
const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null;
|
||||
if (!item) return;
|
||||
closeExportMenu();
|
||||
void exportFromMenu(item.dataset.export ?? "");
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (exportDropdown && !(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu();
|
||||
});
|
||||
|
||||
copyAllBtn.addEventListener("click", async () => {
|
||||
const text = editorText(fullBookEditor);
|
||||
if (!text) {
|
||||
|
|
@ -1038,6 +1089,8 @@ export function initBook(api: FolioAPI) {
|
|||
api.onExportMarkdownRequested(() => void exportMarkdown());
|
||||
api.onExportZipRequested(() => void exportZip());
|
||||
api.onExportPdfRequested(() => void exportPdf());
|
||||
api.onExportDocxRequested(() => void exportDocx());
|
||||
api.onExportOdtRequested(() => void exportOdt());
|
||||
api.onUndoRequested(() => {
|
||||
if (editorView.hasFocus) undo(editorView);
|
||||
else document.execCommand("undo");
|
||||
|
|
|
|||
|
|
@ -888,6 +888,14 @@ html, body {
|
|||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* Export dropdown in the Full Book header sits flush against the window's
|
||||
right edge, so its menu opens toward the left instead of overflowing. */
|
||||
.export-dropdown .dropdown-menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.color-menu .dropdown-item {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,7 +181,14 @@
|
|||
<h2 class="chapter-title">Full Book</h2>
|
||||
<div class="editor-actions">
|
||||
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
|
||||
<button class="button" id="exportPdfBtn" title="Export the entire Full Book as a PDF">Export PDF</button>
|
||||
<div class="toolbar-dropdown export-dropdown" id="exportDropdown">
|
||||
<button class="button dropdown-toggle" id="exportMenuToggle" title="Export the entire Full Book">Export ▾</button>
|
||||
<div class="dropdown-menu hidden" id="exportMenu">
|
||||
<button class="dropdown-item" data-export="pdf">PDF</button>
|
||||
<button class="dropdown-item" data-export="docx">DOCX</button>
|
||||
<button class="dropdown-item" data-export="odt">ODT</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
|
||||
<span class="save-state" id="fullBookStatus"></span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue