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 @@ -29,6 +29,7 @@ Replacement-workflow reliability and file navigation.
- Added **Export with Previous** as per-document, session-only export history. It remembers only the last successfully completed export format and reruns the normal save flow; cancelled/failed exports do not replace history, and output destinations are not persisted or silently reused.
- 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.

## 0.1.6 — 2026-10-10

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ This project is an independent clean-room implementation. It does not contain Ty
- Native macOS **Edit → Substitutions** controls for smart quotes, smart dashes and text replacement, delegated to WKWebView rather than implemented as Markdown-rewriting transforms.
- 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.
- 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.
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, export theme/CSS/templates, destination/post-export actions and richer Pandoc arguments | Core native/Pandoc export plus **Export with Previous** exist. Previous export history is per-document and session-only, stores only the last successful format, and always returns through the normal destination picker; overwrite/destination reuse remains intentionally deferred pending an explicit authority model |
| 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 | 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
12 changes: 10 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { buildSessionIntent } from './lib/sessionRestore'
import { codeFenceSnippet, effectiveCodeBlockLanguage, normalizeCodeBlockLanguage, type CodeBlockLanguage } from './lib/codeLanguages'
import { normalizeOutlineMaxLevel, normalizeOutlineMode } from './lib/outlinePreferences'
import { isExportFormat, rememberSuccessfulExport, type ExportFormat } from './lib/exportHistory'
import { normalizeExportAppearance, resolveExportTheme } from './lib/exportPreferences'
import { filterFileTreeByGlobs } from './lib/fileTreeFilters'

const EMPTY_DOCUMENT = ''
Expand All @@ -30,6 +31,7 @@ const DEFAULT_PREFS: TypeOpenPreferences = {
theme: 'system',
customThemeName: null,
customThemeCss: '',
exportAppearance: 'editor',
fontSize: 17,
contentWidth: 780,
spellCheck: true,
Expand Down Expand Up @@ -62,6 +64,7 @@ function loadPreferences(): TypeOpenPreferences {
return {
...DEFAULT_PREFS,
...parsed,
exportAppearance: normalizeExportAppearance(parsed?.exportAppearance),
defaultCodeLanguage: normalizeCodeBlockLanguage(parsed?.defaultCodeLanguage),
outlineMode: normalizeOutlineMode(parsed?.outlineMode),
outlineMaxLevel: normalizeOutlineMaxLevel(parsed?.outlineMaxLevel),
Expand Down Expand Up @@ -704,13 +707,18 @@ export default function App() {

const createOutputHtml = useCallback(async () => {
const latest = editorRef.current?.getMarkdown() ?? markdown
const exportTheme = resolveExportTheme(
preferences.exportAppearance,
preferences.theme,
window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
)
return createExportHtml(latest, {
title: filePath ? extensionless(filePath) : documentTitle(latest),
includeOutline: false,
theme: preferences.theme === 'dark' ? 'dark' : 'light',
theme: exportTheme,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate the export theme from live Mermaid previews

When the export appearance differs from the editor—for example, Dark export with a Light editor—passing this theme to createExportHtml calls the global mermaid.initialize(...) even if the document contains no diagram. DiagramCodeBlockEditor.tsx uses that same Mermaid singleton for subsequent preview renders without reinitializing it, so editing or adding a diagram after exporting renders it with the export palette rather than the editor palette. Render exports without mutating the shared configuration, or restore/reapply the editor configuration afterward.

Useful? React with 👍 / 👎.

maxWidth: preferences.contentWidth
})
}, [filePath, markdown, preferences.contentWidth, preferences.theme])
}, [filePath, markdown, preferences.contentWidth, preferences.exportAppearance, preferences.theme])

const exportDocument = useCallback(async (format: ExportFormat) => {
const documentId = activeDocumentIdRef.current
Expand Down
4 changes: 4 additions & 0 deletions src/components/PreferencesDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { CODE_BLOCK_LANGUAGES, type CodeBlockLanguage } from '../lib/codeLanguages'
import type { ExportAppearance } from '../lib/exportPreferences'
import type { OutlineMaxLevel, OutlineMode } from '../lib/outlinePreferences'

export interface TypeOpenPreferences {
theme: 'system' | 'light' | 'dark'
customThemeName: string | null
customThemeCss: string
exportAppearance: ExportAppearance
fontSize: number
contentWidth: number
spellCheck: boolean
Expand Down Expand Up @@ -55,12 +57,14 @@ export function PreferencesDialog({ visible, preferences, onChange, onClose, onC
</>}
{tab === 'appearance' && <>
<label>Theme<select value={preferences.theme} onChange={(event) => patch({ theme: event.target.value as TypeOpenPreferences['theme'] })}><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label>
<label>Export appearance<select value={preferences.exportAppearance} onChange={(event) => patch({ exportAppearance: event.target.value as ExportAppearance })}><option value="editor">Match editor</option><option value="light">Light</option><option value="dark">Dark</option></select></label>
<label>Editor font size <span>{preferences.fontSize}px</span><input type="range" min="13" max="24" value={preferences.fontSize} onChange={(event) => patch({ fontSize: Number(event.target.value) })} /></label>
<label>Content width <span>{preferences.contentWidth}px</span><input type="range" min="560" max="1050" step="10" value={preferences.contentWidth} onChange={(event) => patch({ contentWidth: Number(event.target.value) })} /></label>
<div className="custom-theme-row">
<button onClick={onChooseCustomTheme}>Choose Custom CSS…</button>
{preferences.customThemeName && <><span>{preferences.customThemeName}</span><button onClick={() => patch({ customThemeName: null, customThemeCss: '' })}>Remove</button></>}
</div>
<p className="preference-note">Export appearance applies to TypeOpen's HTML, PDF, PNG and print rendering. Custom editor CSS is not injected into exports.</p>
<p className="preference-note">Custom CSS is loaded only from a file you explicitly choose. External-resource CSS is rejected.</p>
</>}
{tab === 'editor' && <>
Expand Down
16 changes: 16 additions & 0 deletions src/lib/exportPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type ExportAppearance = 'editor' | 'light' | 'dark'
export type ExportTheme = 'light' | 'dark'

export function normalizeExportAppearance(value: unknown): ExportAppearance {
return value === 'light' || value === 'dark' || value === 'editor' ? value : 'editor'
}

export function resolveExportTheme(
appearance: ExportAppearance,
editorTheme: 'system' | 'light' | 'dark',
systemPrefersDark: boolean
): ExportTheme {
if (appearance === 'light' || appearance === 'dark') return appearance
if (editorTheme === 'light' || editorTheme === 'dark') return editorTheme
return systemPrefersDark ? 'dark' : 'light'
}
27 changes: 27 additions & 0 deletions tests/export-preferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { normalizeExportAppearance, resolveExportTheme } from '../src/lib/exportPreferences'

describe('export appearance preferences', () => {
it('normalizes persisted values to the allowlisted modes', () => {
expect(normalizeExportAppearance('editor')).toBe('editor')
expect(normalizeExportAppearance('light')).toBe('light')
expect(normalizeExportAppearance('dark')).toBe('dark')
expect(normalizeExportAppearance('custom')).toBe('editor')
expect(normalizeExportAppearance(null)).toBe('editor')
})

it('allows explicit export light/dark to override the editor appearance', () => {
expect(resolveExportTheme('light', 'dark', true)).toBe('light')
expect(resolveExportTheme('dark', 'light', false)).toBe('dark')
})

it('matches explicit editor light/dark appearance', () => {
expect(resolveExportTheme('editor', 'light', true)).toBe('light')
expect(resolveExportTheme('editor', 'dark', false)).toBe('dark')
})

it('resolves system editor appearance at export time', () => {
expect(resolveExportTheme('editor', 'system', false)).toBe('light')
expect(resolveExportTheme('editor', 'system', true)).toBe('dark')
})
})
35 changes: 35 additions & 0 deletions tests/preferences-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const preferences: TypeOpenPreferences = {
theme: 'system',
customThemeName: null,
customThemeCss: '',
exportAppearance: 'editor',
fontSize: 17,
contentWidth: 780,
spellCheck: true,
Expand Down Expand Up @@ -89,4 +90,38 @@ describe('Preferences dialog', () => {
await act(async () => root.unmount())
host.remove()
})

it('exposes a built-in export appearance preference', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const onChange = vi.fn()

await act(async () => {
root.render(createElement(PreferencesDialog, {
visible: true,
preferences,
onChange,
onClose: () => undefined,
onChooseCustomTheme: () => undefined
}))
})

const appearanceTab = Array.from(host.querySelectorAll<HTMLButtonElement>('.preferences-body nav button'))
.find((button) => button.textContent === 'Appearance')
if (!appearanceTab) throw new Error('Appearance preferences tab not found')
await act(async () => appearanceTab.click())

const select = Array.from(host.querySelectorAll<HTMLSelectElement>('select'))
.find((candidate) => candidate.parentElement?.textContent?.includes('Export appearance'))
if (!select) throw new Error('Export appearance selector not found')
await act(async () => {
select.value = 'dark'
select.dispatchEvent(new Event('change', { bubbles: true }))
})

expect(onChange).toHaveBeenCalledWith({ ...preferences, exportAppearance: 'dark' })
await act(async () => root.unmount())
host.remove()
})
})
Loading