From aa3f6b30c9e74a1cc29a3f192c8df60983f4204b Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Wed, 27 May 2026 18:53:50 +1000 Subject: [PATCH] :sparkles: standard manuscript info and cover page --- src/main/globalConfig.ts | 6 + src/main/ipcHandlers.ts | 114 +++++++++++++++--- src/renderer/components/Settings/Settings.css | 46 +++++++ .../components/Settings/SettingsDialog.tsx | 91 +++++++++++++- src/renderer/types/editor.ts | 6 + tsconfig.node.tsbuildinfo | 2 +- 6 files changed, 247 insertions(+), 18 deletions(-) diff --git a/src/main/globalConfig.ts b/src/main/globalConfig.ts index 307c52c..7a03ca0 100644 --- a/src/main/globalConfig.ts +++ b/src/main/globalConfig.ts @@ -8,6 +8,12 @@ export interface GlobalConfig { projectTitle?: string fontSize?: number theme?: 'dark' | 'light' + // Manuscript metadata + authorName?: string + penName?: string + authorAddress?: string + authorEmail?: string + authorPhone?: string } const CONFIG_DIR = join(homedir(), '.hohoff') diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index 9f46122..f1e390b 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -312,13 +312,89 @@ export function registerIpcHandlers(): void { const { marked } = await import('marked') const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib') const normalize = (s: string) => s.replace(/\r\n/g, '\n').replace(/\n(?!\n)/g, '\n\n') + const esc = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>') const projectTitle = projectName.toUpperCase() - // Build an ordered list of sections: part-divider pages + individual chapters. + // ── Cover page ────────────────────────────────────────────────────────────── + const cfg = readGlobalConfig() + const authorLegal = cfg.authorName?.trim() ?? '' + const authorByline = cfg.penName?.trim() || authorLegal + const authorAddr = cfg.authorAddress?.trim() ?? '' + const authorEmail = cfg.authorEmail?.trim() ?? '' + const authorPhone = cfg.authorPhone?.trim() ?? '' + + // Count words from docs already in memory, rounded to nearest 1,000 + const totalWords = docs.reduce((sum, doc) => { + return sum + (doc.content.trim() === '' ? 0 : doc.content.trim().split(/\s+/).length) + }, 0) + const roundTo = totalWords >= 5000 ? 1000 : 100 + const rounded = Math.round(totalWords / roundTo) * roundTo + const wordCountText = `~${rounded.toLocaleString('en-US')} words` + + // Author contact block (skip empty lines) + const contactLines = [ + authorLegal, + ...authorAddr.split('\n').map(l => l.trim()).filter(Boolean), + authorPhone, + authorEmail, + ].filter(Boolean) + + const coverCSS = ` + @page { size: letter; } + body { + font-family: "Courier New", Courier, monospace; + font-size: 12pt; + line-height: 1.5; + color: #000; + margin: 0; + padding: 0; + } + .cover-page { + display: flex; + flex-direction: column; + height: 9in; + } + .cover-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + } + .cover-contact { line-height: 1.5; } + .cover-wordcount { text-align: right; line-height: 1.5; } + .cover-title-block { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + line-height: 2; + } + .cover-title { font-size: 12pt; text-transform: uppercase; margin: 0; } + .cover-by { margin: 0; } + .cover-byline{ margin: 0; } + ` + const coverBodyHtml = ` +
+
+
${contactLines.map(esc).join('
')}
+
${esc(wordCountText)}
+
+
+

${esc(projectName)}

+ ${authorByline ? `

by

` : ''} +
+
` + + // Build an ordered list of sections: cover + part-divider pages + individual chapters. // Each section becomes its own PDF so its title can appear in the running header. - interface Section { title: string; bodyHtml: string } - const sections: Section[] = [] + // isCover=true sections receive no running header (standard manuscript practice). + interface Section { title: string; bodyHtml: string; css?: string; isCover?: boolean } + const sections: Section[] = [ + { title: '', bodyHtml: coverBodyHtml, css: coverCSS, isCover: true } + ] let currentPart: string | null = null for (const doc of docs) { @@ -343,8 +419,7 @@ export function registerIpcHandlers(): void { }) } - // CSS shared by every section — no page-break rules needed since each section - // is its own HTML document printed independently. + // CSS shared by chapter/part sections — cover page uses its own CSS (set above). const pageCSS = ` @page { size: letter; } body { font-family: "Courier New", Courier, monospace; font-size: 12pt; line-height: 2; color: #000; margin: 0; padding: 0; } @@ -363,12 +438,14 @@ export function registerIpcHandlers(): void { .part-title { font-family: "Courier New", Courier, monospace; font-size: 12pt; text-transform: uppercase; } ` - // Print each section to its own PDF buffer (no built-in header — we'll draw - // it ourselves with pdf-lib so the chapter title can vary per section). - const sectionPdfs: { title: string; buffer: Uint8Array }[] = [] + // Print each section to its own PDF buffer. Cover page uses its own CSS; + // chapters/parts use the shared pageCSS. No built-in browser header/footer — + // we stamp running headers ourselves with pdf-lib so the title varies per section. + const sectionPdfs: { title: string; buffer: Uint8Array; isCover: boolean }[] = [] for (const section of sections) { - const html = `${section.bodyHtml}` + const css = section.css ?? pageCSS + const html = `${section.bodyHtml}` const tempPath = join(tmpdir(), `hohoff-section-${Date.now()}-${Math.round(Math.random() * 1e9)}.html`) writeFileSync(tempPath, html, 'utf-8') const offscreen = new BrowserWindow({ show: false, webPreferences: { sandbox: false, contextIsolation: true } }) @@ -379,7 +456,7 @@ export function registerIpcHandlers(): void { displayHeaderFooter: false, margins: { marginType: 'custom', top: 1.0, bottom: 1.0, left: 1.0, right: 1.0 } }) - sectionPdfs.push({ title: section.title, buffer: buf }) + sectionPdfs.push({ title: section.title, buffer: buf, isCover: section.isCover ?? false }) } finally { offscreen.destroy() try { unlinkSync(tempPath) } catch { /* ignore */ } @@ -390,24 +467,29 @@ export function registerIpcHandlers(): void { // belongs to so we can stamp the correct running header on it. const mergedPdf = await PDFDocument.create() const courier = await mergedPdf.embedFont(StandardFonts.Courier) - const pageOwners: string[] = [] // parallel array: pageOwners[i] = section title for page i + // pageOwners[i] tracks the section title and whether the page is a cover page + const pageOwners: { title: string; isCover: boolean }[] = [] - for (const { title, buffer } of sectionPdfs) { + for (const { title, buffer, isCover } of sectionPdfs) { const srcPdf = await PDFDocument.load(buffer) const copied = await mergedPdf.copyPages(srcPdf, srcPdf.getPageIndices()) for (const page of copied) { mergedPdf.addPage(page) - pageOwners.push(title) + pageOwners.push({ title, isCover }) } } - // Draw the running header on every page: PROJECT TITLE / CHAPTER TITLE / absolute page # + // Stamp the running header on body pages only (cover page gets no header). + // Page numbers count from 1 starting with the first non-cover page. const allPages = mergedPdf.getPages() + let bodyPageNum = 0 for (let i = 0; i < allPages.length; i++) { + if (pageOwners[i].isCover) continue // no header/number on cover page + bodyPageNum++ const page = allPages[i] const { width, height } = page.getSize() - const chapterUpper = pageOwners[i].toUpperCase() - const headerText = `${projectTitle} / ${chapterUpper} / ${i + 1}` + const chapterUpper = pageOwners[i].title.toUpperCase() + const headerText = `${projectTitle} / ${chapterUpper} / ${bodyPageNum}` const fontSize = 11 const textWidth = courier.widthOfTextAtSize(headerText, fontSize) page.drawText(headerText, { diff --git a/src/renderer/components/Settings/Settings.css b/src/renderer/components/Settings/Settings.css index 81b09d5..ba52d22 100644 --- a/src/renderer/components/Settings/Settings.css +++ b/src/renderer/components/Settings/Settings.css @@ -62,6 +62,8 @@ display: flex; flex-direction: column; gap: 20px; + max-height: calc(80vh - 120px); + overflow-y: auto; } .settings-field { @@ -127,6 +129,50 @@ margin: 0; } +.settings-section-divider { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-top: 4px; +} + +.settings-section-divider::before, +.settings-section-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.settings-textarea { + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 13px; + padding: 7px 10px; + outline: none; + width: 100%; + resize: vertical; + min-height: 64px; +} + +.settings-textarea:focus { + border-color: var(--accent); +} + +.settings-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + .settings-hint--warn { color: var(--accent); } diff --git a/src/renderer/components/Settings/SettingsDialog.tsx b/src/renderer/components/Settings/SettingsDialog.tsx index aac7200..47b711c 100644 --- a/src/renderer/components/Settings/SettingsDialog.tsx +++ b/src/renderer/components/Settings/SettingsDialog.tsx @@ -12,6 +12,11 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J const [projectPath, setProjectPath] = useState('') const [projectTitle, setProjectTitle] = useState('') const [originalPath, setOriginalPath] = useState('') + const [authorName, setAuthorName] = useState('') + const [penName, setPenName] = useState('') + const [authorAddress, setAuthorAddress] = useState('') + const [authorEmail, setAuthorEmail] = useState('') + const [authorPhone, setAuthorPhone] = useState('') const [saved, setSaved] = useState(false) const overlayRef = useRef(null) @@ -21,6 +26,11 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J setProjectPath(cfg.projectPath ?? '') setProjectTitle(cfg.projectTitle ?? '') setOriginalPath(cfg.projectPath ?? '') + setAuthorName(cfg.authorName ?? '') + setPenName(cfg.penName ?? '') + setAuthorAddress(cfg.authorAddress ?? '') + setAuthorEmail(cfg.authorEmail ?? '') + setAuthorPhone(cfg.authorPhone ?? '') }) }, []) @@ -43,7 +53,16 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J } const handleSave = async (): Promise => { - await window.api.writeConfig({ apiKey: apiKey || undefined, projectPath: projectPath || undefined, projectTitle: projectTitle.trim() || undefined }) + await window.api.writeConfig({ + apiKey: apiKey || undefined, + projectPath: projectPath || undefined, + projectTitle: projectTitle.trim() || undefined, + authorName: authorName.trim() || undefined, + penName: penName.trim() || undefined, + authorAddress: authorAddress.trim() || undefined, + authorEmail: authorEmail.trim() || undefined, + authorPhone: authorPhone.trim() || undefined, + }) if (projectPath !== originalPath) onProjectChanged?.() setOriginalPath(projectPath) setSaved(true) @@ -109,6 +128,76 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J />

Used for AI features. Changes take effect immediately.

+ +
Manuscript
+ +
+ + setAuthorName(e.target.value)} + placeholder="Jane Smith" + spellCheck={false} + /> +

Legal name used on manuscript cover pages and PDF headers.

+
+ +
+ + setPenName(e.target.value)} + placeholder="J. Smith" + spellCheck={false} + /> +

Name shown as author (e.g. "by…"). Defaults to Author Name if blank.

+
+ +
+ +