From 6afa4a3311d6de5b943750a79a82eff080ec9189 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Sun, 11 Oct 2026 19:19:52 -0700 Subject: [PATCH] Add top-level heading page breaks --- CHANGELOG.md | 1 + README.md | 2 +- docs/PARITY_MATRIX.md | 2 +- src/App.tsx | 14 +++++++++--- src/components/PageSetupDialog.tsx | 6 ++++- src/lib/markdown.ts | 4 +++- tests/markdown.test.ts | 9 ++++++++ tests/page-setup-dialog.test.ts | 35 ++++++++++++++++++++++++++++++ 8 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 tests/page-setup-dialog.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b620b9..7fd6429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Replacement-workflow reliability and file navigation. - Hardened Electron local-image preview/export reads against pathname replacement: image bytes are now read from a bound file descriptor only after its filesystem identity and containment are validated, with symlink and size checks retained. - Added renderer-only file-library exclusion globs for Files, Articles and Open Quickly. Patterns are stored as preference text only, never as filesystem authority; the active/revealed document remains visible and Search in Folder continues to search the full authorized folder. - Added a built-in export appearance preference that can match the editor or force Light/Dark for TypeOpen HTML, PDF, PNG and print rendering while keeping custom editor CSS/templates out of the export pipeline. +- Added an opt-in Page Setup rule that starts direct top-level H1 headings after the first on new printed pages. The rule is print-only for PDF/Print and browser-print of styled HTML; PNG and style-free HTML remain unchanged. ## 0.1.6 — 2026-10-10 diff --git a/README.md b/README.md index 518ab68..2356b0a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ This project is an independent clean-room implementation. It does not contain Ty - Private unsaved-buffer recovery plus native external-file conflict detection and **Reload from Disk**; recovered buffers reopen without filesystem write authority and require an explicit Save As, stale recovery is cleared immediately once all documents are clean, and clean saved tabs can be restored only through an explicit identity-revalidated session action. - Light, dark and system appearance settings plus explicitly selected local custom CSS themes with external-resource/executable CSS blocked. - Built-in export appearance can match the editor or force Light/Dark for TypeOpen HTML, PDF, PNG and print rendering without injecting custom editor CSS. -- Page Setup for Letter/A4/Legal/A5/Tabloid, portrait/landscape orientation and per-side margins used by PDF export and printing. +- Page Setup for Letter/A4/Legal/A5/Tabloid, portrait/landscape orientation, per-side margins and optional new-page starts for top-level H1 headings after the first. The heading rule is print-only, so PNG and onscreen HTML stay continuous. - Compact right-click TypeOpen commands plus a searchable `/` command palette for common Markdown blocks. - PDF, HTML, style-free HTML and PNG export, plus **Export with Previous** to repeat the current document's last successful export format during the app session while still choosing the destination normally. - DOCX, ODT, RTF, EPUB, LaTeX and MediaWiki export when Pandoc is installed. diff --git a/docs/PARITY_MATRIX.md b/docs/PARITY_MATRIX.md index 50c0575..fc75343 100644 --- a/docs/PARITY_MATRIX.md +++ b/docs/PARITY_MATRIX.md @@ -77,7 +77,7 @@ These are intentionally tracked even when they are not part of the current imple | Later | Multi-window / Open in New Window | Multi-tab model exists; the native app still centers on one main editing window | | Later | Advanced file library: pinned/recent locations and grouping | Native management, name/modified/creation-date sorting, a host-enforced hidden-file toggle and renderer-only relative exclusion globs exist. Persisted folder locations remain deferred because they must not imply restored filesystem authority | | Later | Advanced image management: configurable asset roots/copy rules, copy/move/rename/delete image assets, syntax switching, zoom/open actions | Safe local image insert/preview and adjacent `assets/` storage exist. Folder-tree image assets are kept out of the editable-document grant path; Electron preview/export reads bind validated image identity to the opened descriptor rather than reopening a pathname. Configurable write roots/copy rules remain deferred pending stronger destination-directory semantics; cloud/PicGo/PicList integration is intentionally deferred | -| Later | Export profiles: headers/footers, heading page breaks, custom export CSS/templates, destination/post-export actions and richer Pandoc arguments | Core native/Pandoc export plus **Export with Previous** and built-in Match Editor/Light/Dark appearance selection exist. Previous export history is per-document/session-only and destinations are never silently reused; custom CSS/templates and overwrite/destination reuse remain deferred pending explicit safety/authority models | +| Later | Export profiles: headers/footers, richer heading-level page-break rules, custom export CSS/templates, destination/post-export actions and richer Pandoc arguments | Core native/Pandoc export plus **Export with Previous**, built-in Match Editor/Light/Dark appearance selection and an opt-in print-only rule that starts top-level H1 headings after the first on new pages exist. Previous export history is per-document/session-only and destinations are never silently reused; custom CSS/templates and overwrite/destination reuse remain deferred pending explicit safety/authority models | | Later | Custom shortcuts, richer spellcheck language controls, separate light/dark custom themes and launch behavior preferences | Not required for core Markdown replacement but retained as parity backlog | | Deliberately different | Typora licensing/activation/server/update mechanisms | Not a parity target. TypeOpen remains open source and uses GitHub/Homebrew distribution instead | diff --git a/src/App.tsx b/src/App.tsx index 12a8655..3ae16bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,7 +50,14 @@ const DEFAULT_PREFS: TypeOpenPreferences = { function loadPageSetup(): PageSetupState { try { const raw = localStorage.getItem(PAGE_SETUP_KEY) - return raw ? { ...DEFAULT_PAGE_SETUP, ...JSON.parse(raw), margins: { ...DEFAULT_PAGE_SETUP.margins, ...JSON.parse(raw).margins } } : DEFAULT_PAGE_SETUP + if (!raw) return DEFAULT_PAGE_SETUP + const parsed = JSON.parse(raw) + return { + ...DEFAULT_PAGE_SETUP, + ...parsed, + breakBeforeH1: parsed?.breakBeforeH1 === true, + margins: { ...DEFAULT_PAGE_SETUP.margins, ...parsed?.margins } + } } catch { return DEFAULT_PAGE_SETUP } @@ -716,9 +723,10 @@ export default function App() { title: filePath ? extensionless(filePath) : documentTitle(latest), includeOutline: false, theme: exportTheme, - maxWidth: preferences.contentWidth + maxWidth: preferences.contentWidth, + breakBeforeH1: pageSetup.breakBeforeH1 }) - }, [filePath, markdown, preferences.contentWidth, preferences.exportAppearance, preferences.theme]) + }, [filePath, markdown, pageSetup.breakBeforeH1, preferences.contentWidth, preferences.exportAppearance, preferences.theme]) const exportDocument = useCallback(async (format: ExportFormat) => { const documentId = activeDocumentIdRef.current diff --git a/src/components/PageSetupDialog.tsx b/src/components/PageSetupDialog.tsx index 590141b..fb5b780 100644 --- a/src/components/PageSetupDialog.tsx +++ b/src/components/PageSetupDialog.tsx @@ -4,6 +4,7 @@ export interface PageSetupState { paperSize: 'Letter' | 'A4' | 'Legal' | 'A5' | 'Tabloid' orientation: 'portrait' | 'landscape' margins: { top: number; right: number; bottom: number; left: number } + breakBeforeH1: boolean } interface Props { @@ -16,7 +17,8 @@ interface Props { export const DEFAULT_PAGE_SETUP: PageSetupState = { paperSize: 'Letter', orientation: 'portrait', - margins: { top: 0.5, right: 0.5, bottom: 0.5, left: 0.5 } + margins: { top: 0.5, right: 0.5, bottom: 0.5, left: 0.5 }, + breakBeforeH1: false } export function PageSetupDialog({ visible, value, onChange, onClose }: Props) { @@ -37,6 +39,8 @@ export function PageSetupDialog({ visible, value, onChange, onClose }: Props) { Margins (inches) {(['top', 'right', 'bottom', 'left'] as const).map((side) => )} + +

When enabled, every top-level H1 after the first starts on a new printed page. H2–H6 are unchanged.

diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 541de70..e744651 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -28,6 +28,7 @@ export interface ExportOptions { includeOutline?: boolean theme?: 'light' | 'dark' maxWidth?: number + breakBeforeH1?: boolean } export function slugifyHeading(value: string): string { @@ -335,6 +336,7 @@ export async function createExportHtml(markdown: string, options: ExportOptions const maxWidth = Math.max(560, Math.min(1200, options.maxWidth || 860)) const dark = options.theme === 'dark' const outlineMarkup = options.includeOutline ? outlineHtml(outline) : '' + const headingPageBreakCss = options.breakBeforeH1 ? 'main > h1:not(:first-of-type){break-before:page;page-break-before:always}' : '' return ` @@ -344,7 +346,7 @@ export async function createExportHtml(markdown: string, options: ExportOptions ${title}
${outlineMarkup}
${cleanBody.replace(/^
|<\/main>$/g, '')}
diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts index b912344..97c0be3 100644 --- a/tests/markdown.test.ts +++ b/tests/markdown.test.ts @@ -57,6 +57,15 @@ describe('markdown document helpers', () => { it('leaves page geometry to Electron Page Setup instead of emitting a conflicting @page rule', async () => { const html = await createExportHtml('# Print') expect(html).not.toContain('@page') + expect(html).not.toContain('break-before:page') + }) + + it('can start top-level H1 headings after the first on new printed pages only', async () => { + const html = await createExportHtml('# First\n\n## Child\n\n# Second\n', { breakBeforeH1: true }) + expect(html).toContain('@media print{') + expect(html).toContain('main > h1:not(:first-of-type){break-before:page;page-break-before:always}') + expect(html).not.toContain('h2:not(:first-of-type)') + expect(html).not.toContain('@page') }) it('exports GitHub alerts as styled callouts without exposing the marker', async () => { diff --git a/tests/page-setup-dialog.test.ts b/tests/page-setup-dialog.test.ts new file mode 100644 index 0000000..72c6943 --- /dev/null +++ b/tests/page-setup-dialog.test.ts @@ -0,0 +1,35 @@ +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' +import { DEFAULT_PAGE_SETUP, PageSetupDialog } from '../src/components/PageSetupDialog' + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +describe('Page Setup dialog', () => { + it('keeps heading page breaks off by default and exposes an explicit toggle', async () => { + expect(DEFAULT_PAGE_SETUP.breakBeforeH1).toBe(false) + + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + const onChange = vi.fn() + await act(async () => { + root.render(createElement(PageSetupDialog, { + visible: true, + value: DEFAULT_PAGE_SETUP, + onChange, + onClose: () => undefined + })) + }) + + const toggle = Array.from(host.querySelectorAll('input[type="checkbox"]')) + .find((input) => input.parentElement?.textContent?.includes('Start top-level headings')) + if (!toggle) throw new Error('Heading page-break toggle not found') + expect(toggle.checked).toBe(false) + await act(async () => toggle.click()) + expect(onChange).toHaveBeenCalledWith({ ...DEFAULT_PAGE_SETUP, breakBeforeH1: true }) + + await act(async () => root.unmount()) + host.remove() + }) +})