✨ standard manuscript info and cover page
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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, '<').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 = `
|
||||
<div class="cover-page">
|
||||
<div class="cover-top">
|
||||
<div class="cover-contact">${contactLines.map(esc).join('<br>')}</div>
|
||||
<div class="cover-wordcount">${esc(wordCountText)}</div>
|
||||
</div>
|
||||
<div class="cover-title-block">
|
||||
<p class="cover-title">${esc(projectName)}</p>
|
||||
${authorByline ? `<p class="cover-by">by</p><p class="cover-byline">${esc(authorByline)}</p>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
// 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 = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${pageCSS}</style></head><body>${section.bodyHtml}</body></html>`
|
||||
const css = section.css ?? pageCSS
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${css}</style></head><body>${section.bodyHtml}</body></html>`
|
||||
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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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<void> => {
|
||||
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
|
||||
/>
|
||||
<p className="settings-hint">Used for AI features. Changes take effect immediately.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider"><span>Manuscript</span></div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-author-name">Author Name</label>
|
||||
<input
|
||||
id="settings-author-name"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={authorName}
|
||||
onChange={(e) => setAuthorName(e.target.value)}
|
||||
placeholder="Jane Smith"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="settings-hint">Legal name used on manuscript cover pages and PDF headers.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-pen-name">Pen Name / Byline</label>
|
||||
<input
|
||||
id="settings-pen-name"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={penName}
|
||||
onChange={(e) => setPenName(e.target.value)}
|
||||
placeholder="J. Smith"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="settings-hint">Name shown as author (e.g. "by…"). Defaults to Author Name if blank.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-author-address">Address</label>
|
||||
<textarea
|
||||
id="settings-author-address"
|
||||
className="settings-textarea"
|
||||
value={authorAddress}
|
||||
onChange={(e) => setAuthorAddress(e.target.value)}
|
||||
placeholder={"123 Example Street\nCity, Country"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-two-col">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-author-email">Email</label>
|
||||
<input
|
||||
id="settings-author-email"
|
||||
className="settings-input"
|
||||
type="email"
|
||||
value={authorEmail}
|
||||
onChange={(e) => setAuthorEmail(e.target.value)}
|
||||
placeholder="jane@example.com"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-author-phone">Phone</label>
|
||||
<input
|
||||
id="settings-author-phone"
|
||||
className="settings-input"
|
||||
type="tel"
|
||||
value={authorPhone}
|
||||
onChange={(e) => setAuthorPhone(e.target.value)}
|
||||
placeholder="+1 555 000 0000"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-footer">
|
||||
|
||||
@@ -2,6 +2,12 @@ export interface GlobalConfig {
|
||||
apiKey?: string
|
||||
projectPath?: string
|
||||
projectTitle?: string
|
||||
// Manuscript metadata
|
||||
authorName?: string
|
||||
penName?: string
|
||||
authorAddress?: string
|
||||
authorEmail?: string
|
||||
authorPhone?: string
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user