Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/PARITY_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
14 changes: 11 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/components/PageSetupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -37,6 +39,8 @@ export function PageSetupDialog({ visible, value, onChange, onClose }: Props) {
<legend>Margins (inches)</legend>
{(['top', 'right', 'bottom', 'left'] as const).map((side) => <label key={side}>{side[0].toUpperCase() + side.slice(1)}<input type="number" min="0" max="3" step="0.1" value={value.margins[side]} onChange={(event) => patchMargin(side, event.target.value)} /></label>)}
</fieldset>
<label className="switch-row"><input type="checkbox" checked={value.breakBeforeH1} onChange={(event) => onChange({ ...value, breakBeforeH1: event.target.checked })} /> Start top-level headings on new pages</label>
<p className="preference-note">When enabled, every top-level H1 after the first starts on a new printed page. H2–H6 are unchanged.</p>
</div>
<footer><button onClick={onClose}>Done</button></footer>
</section>
Expand Down
4 changes: 3 additions & 1 deletion src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ExportOptions {
includeOutline?: boolean
theme?: 'light' | 'dark'
maxWidth?: number
breakBeforeH1?: boolean
}

export function slugifyHeading(value: string): string {
Expand Down Expand Up @@ -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 `<!doctype html>
<html lang="en">
Expand All @@ -344,7 +346,7 @@ export async function createExportHtml(markdown: string, options: ExportOptions
<title>${title}</title>
<style>
:root{color-scheme:${dark ? 'dark' : 'light'};font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:${dark ? '#171717' : '#fff'};color:${dark ? '#e9e9e9' : '#242424'}}
body{margin:0;padding:48px 28px;line-height:1.65}.typeopen-export-layout{max-width:${maxWidth + (options.includeOutline ? 260 : 0)}px;margin:0 auto;display:flex;gap:44px;align-items:flex-start}main{max-width:${maxWidth}px;min-width:0;flex:1}h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.5em 0 .6em}h1{font-size:2.1rem;border-bottom:1px solid ${dark ? '#333' : '#e4e4e4'};padding-bottom:.35em}h2{font-size:1.55rem}h3{font-size:1.28rem}a{color:${dark ? '#7bb7ff' : '#1769aa'}}blockquote{margin:1.2em 0;padding:.2em 1em;border-left:3px solid ${dark ? '#555' : '#ccc'};color:${dark ? '#bbb' : '#5c5c5c'}}.typeopen-github-alert{padding:.65em 1em .75em;background:${dark ? '#202020' : '#f7f7f7'};border-left-color:${dark ? '#7bb7ff' : '#1769aa'};color:inherit;border-radius:0 6px 6px 0}.typeopen-github-alert-label{display:block;margin-bottom:.35em;color:${dark ? '#7bb7ff' : '#1769aa'};font-size:.78em;letter-spacing:.045em;text-transform:uppercase}.typeopen-github-alert>p:first-of-type{margin-top:0}.typeopen-github-alert>p:last-child{margin-bottom:0}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:${dark ? '#262626' : '#f4f4f4'};padding:.15em .35em;border-radius:4px}pre{overflow:auto;background:${dark ? '#111' : '#f6f7f8'};padding:16px;border-radius:7px}pre code{background:none;padding:0}table{border-collapse:collapse;width:100%;margin:1.3em 0}th,td{border:1px solid ${dark ? '#444' : '#d8d8d8'};padding:.55em .75em}th{background:${dark ? '#242424' : '#f6f6f6'}}img,svg{max-width:100%;height:auto}.typeopen-math-block{text-align:center;overflow:auto;margin:1.4em 0}.typeopen-diagram{text-align:center;margin:1.5em 0}.typeopen-export-outline{width:220px;position:sticky;top:24px;font-size:.9rem}.typeopen-export-outline ul{list-style:none;padding:0}.typeopen-export-outline li{margin:.35em 0}.typeopen-export-outline .level-2{padding-left:12px}.typeopen-export-outline .level-3,.typeopen-export-outline .level-4,.typeopen-export-outline .level-5,.typeopen-export-outline .level-6{padding-left:24px}.typeopen-export-outline a{text-decoration:none;color:inherit}@media(max-width:900px){.typeopen-export-outline{display:none}.typeopen-export-layout{display:block}}@media print{body{padding:0}.typeopen-export-outline{display:none}}
body{margin:0;padding:48px 28px;line-height:1.65}.typeopen-export-layout{max-width:${maxWidth + (options.includeOutline ? 260 : 0)}px;margin:0 auto;display:flex;gap:44px;align-items:flex-start}main{max-width:${maxWidth}px;min-width:0;flex:1}h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.5em 0 .6em}h1{font-size:2.1rem;border-bottom:1px solid ${dark ? '#333' : '#e4e4e4'};padding-bottom:.35em}h2{font-size:1.55rem}h3{font-size:1.28rem}a{color:${dark ? '#7bb7ff' : '#1769aa'}}blockquote{margin:1.2em 0;padding:.2em 1em;border-left:3px solid ${dark ? '#555' : '#ccc'};color:${dark ? '#bbb' : '#5c5c5c'}}.typeopen-github-alert{padding:.65em 1em .75em;background:${dark ? '#202020' : '#f7f7f7'};border-left-color:${dark ? '#7bb7ff' : '#1769aa'};color:inherit;border-radius:0 6px 6px 0}.typeopen-github-alert-label{display:block;margin-bottom:.35em;color:${dark ? '#7bb7ff' : '#1769aa'};font-size:.78em;letter-spacing:.045em;text-transform:uppercase}.typeopen-github-alert>p:first-of-type{margin-top:0}.typeopen-github-alert>p:last-child{margin-bottom:0}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:${dark ? '#262626' : '#f4f4f4'};padding:.15em .35em;border-radius:4px}pre{overflow:auto;background:${dark ? '#111' : '#f6f7f8'};padding:16px;border-radius:7px}pre code{background:none;padding:0}table{border-collapse:collapse;width:100%;margin:1.3em 0}th,td{border:1px solid ${dark ? '#444' : '#d8d8d8'};padding:.55em .75em}th{background:${dark ? '#242424' : '#f6f6f6'}}img,svg{max-width:100%;height:auto}.typeopen-math-block{text-align:center;overflow:auto;margin:1.4em 0}.typeopen-diagram{text-align:center;margin:1.5em 0}.typeopen-export-outline{width:220px;position:sticky;top:24px;font-size:.9rem}.typeopen-export-outline ul{list-style:none;padding:0}.typeopen-export-outline li{margin:.35em 0}.typeopen-export-outline .level-2{padding-left:12px}.typeopen-export-outline .level-3,.typeopen-export-outline .level-4,.typeopen-export-outline .level-5,.typeopen-export-outline .level-6{padding-left:24px}.typeopen-export-outline a{text-decoration:none;color:inherit}@media(max-width:900px){.typeopen-export-outline{display:none}.typeopen-export-layout{display:block}}@media print{body{padding:0}.typeopen-export-outline{display:none}${headingPageBreakCss}}
</style>
</head>
<body><div class="typeopen-export-layout">${outlineMarkup}<main>${cleanBody.replace(/^<main>|<\/main>$/g, '')}</main></div></body>
Expand Down
9 changes: 9 additions & 0 deletions tests/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
35 changes: 35 additions & 0 deletions tests/page-setup-dialog.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>('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()
})
})
Loading