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 @@ -31,6 +31,7 @@ Replacement-workflow reliability and file navigation.
- 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.
- Split custom editor CSS into independent Light and Dark slots. Existing single custom themes migrate to both slots, System appearance switches between them live, and custom editor CSS remains excluded from export rendering.

## 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 @@ -40,7 +40,7 @@ This project is an independent clean-room implementation. It does not contain Ty
- Literal/regex find and replace with case/whole-word controls, recursive folder search, spell checking, word/character stats and optional autosave.
- 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.
- Light, dark and system appearance settings plus independently selected local custom CSS for Light/Dark editor appearances; existing single custom themes migrate to both slots and System follows macOS appearance changes. External-resource/executable CSS remains 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, 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/PARITY_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Reference inspected locally with REA: Typora 1.14.10 build 7786 on macOS. Primar
| External file changes / Reload from Disk | static File/resources messages | Implemented in native runtime | Native file watchers mark conflicts, block autosave/normal Save, and offer Reload from Disk or Save As. Existing-file saves use macOS file coordination plus descriptor-bound revision checks; the retained Electron migration fallback does not provide live external-change watching |
| Tabs / multi-document model | static New Tab / document switching | Implemented | Renderer-owned tabs preserve per-document dirty state, Save All, close prompts and next/previous switching |
| Native window behavior | shipped macOS window/menu surfaces | Implemented | AppKit traffic lights/full-size titlebar, draggable inert titlebar region, persisted frame size/position, standard Window menu, fullscreen/minimize/zoom and dirty-close lifecycle |
| Custom themes | static theme preferences | Implemented | Built-in light/dark/system remain available; users can explicitly choose a local CSS file that is size-limited and rejected if it contains external-resource/executable CSS constructs. Reference themes/assets are intentionally not copied |
| Custom themes | static theme preferences | Implemented | Built-in light/dark/system remain available; users can choose independent local CSS for Light and Dark editor appearances. Existing single-theme preferences migrate to both slots, System switches live with macOS appearance, and selected CSS remains size-limited with external-resource/executable constructs rejected. Reference themes/assets are intentionally not copied |
| Page Setup | static File menu | Implemented | TypeOpen-owned dialog configures paper size, portrait/landscape orientation, and margins; settings map to `NSPrintInfo` for native PDF export and printing |
| Pixel-identical native UI | runtime capture blocked | Reference unknown | REA live native UI observation was denied Screen Recording and Accessibility permissions; TypeOpen uses an original UI |

Expand All @@ -78,7 +78,7 @@ These are intentionally tracked even when they are not part of the current imple
| 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, 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 |
| Later | Custom shortcuts, richer spellcheck language controls and launch behavior preferences | Separate Light/Dark custom editor themes are implemented with legacy single-theme migration and live System appearance switching; the remaining preferences are not required for core Markdown replacement but stay in the backlog |
| Deliberately different | Typora licensing/activation/server/update mechanisms | Not a parity target. TypeOpen remains open source and uses GitHub/Homebrew distribution instead |

## Evidence boundary
Expand Down
42 changes: 33 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { normalizeOutlineMaxLevel, normalizeOutlineMode } from './lib/outlinePre
import { isExportFormat, rememberSuccessfulExport, type ExportFormat } from './lib/exportHistory'
import { normalizeExportAppearance, resolveExportTheme } from './lib/exportPreferences'
import { filterFileTreeByGlobs } from './lib/fileTreeFilters'
import { normalizeCustomThemePreferences, observeSystemTheme, resolveCustomTheme, type CustomThemeAppearance } from './lib/customThemePreferences'

const EMPTY_DOCUMENT = ''
const PREF_KEY = 'typeopen.preferences.v1'
Expand All @@ -29,8 +30,10 @@ function navigationScrollBehavior(): ScrollBehavior {

const DEFAULT_PREFS: TypeOpenPreferences = {
theme: 'system',
customThemeName: null,
customThemeCss: '',
customLightThemeName: null,
customLightThemeCss: '',
customDarkThemeName: null,
customDarkThemeCss: '',
exportAppearance: 'editor',
fontSize: 17,
contentWidth: 780,
Expand Down Expand Up @@ -68,9 +71,19 @@ function loadPreferences(): TypeOpenPreferences {
const raw = localStorage.getItem(PREF_KEY)
if (!raw) return DEFAULT_PREFS
const parsed = JSON.parse(raw)
const {
customThemeName: _legacyCustomThemeName,
customThemeCss: _legacyCustomThemeCss,
customLightThemeName: _customLightThemeName,
customLightThemeCss: _customLightThemeCss,
customDarkThemeName: _customDarkThemeName,
customDarkThemeCss: _customDarkThemeCss,
...rest
} = parsed && typeof parsed === 'object' ? parsed : {}
return {
...DEFAULT_PREFS,
...parsed,
...rest,
...normalizeCustomThemePreferences(parsed),
exportAppearance: normalizeExportAppearance(parsed?.exportAppearance),
defaultCodeLanguage: normalizeCodeBlockLanguage(parsed?.defaultCodeLanguage),
outlineMode: normalizeOutlineMode(parsed?.outlineMode),
Expand Down Expand Up @@ -185,6 +198,7 @@ export default function App() {
const [preferencesVisible, setPreferencesVisible] = useState(false)
const [aboutVisible, setAboutVisible] = useState(false)
const [preferences, setPreferences] = useState<TypeOpenPreferences>(() => loadPreferences())
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false)
const [lastCodeLanguage, setLastCodeLanguage] = useState<CodeBlockLanguage | null>(null)
const [pageSetupVisible, setPageSetupVisible] = useState(false)
const [pageSetup, setPageSetup] = useState<PageSetupState>(() => loadPageSetup())
Expand Down Expand Up @@ -781,10 +795,13 @@ export default function App() {
}
}, [createOutputHtml, pageSetup, showToast])

const chooseCustomTheme = useCallback(async () => {
const chooseCustomTheme = useCallback(async (appearance: CustomThemeAppearance) => {
try {
const result = await window.typeopen.invoke<{ name: string; css: string } | null>('typeopen:choose-theme-css')
if (result) setPreferences((current) => ({ ...current, customThemeName: result.name, customThemeCss: result.css }))
if (!result) return
setPreferences((current) => appearance === 'dark'
? { ...current, customDarkThemeName: result.name, customDarkThemeCss: result.css }
: { ...current, customLightThemeName: result.name, customLightThemeCss: result.css })
} catch (error) {
showToast(error instanceof Error ? error.message : String(error))
}
Expand Down Expand Up @@ -984,8 +1001,14 @@ export default function App() {
}, [pageSetup])

useEffect(() => {
const query = window.matchMedia?.('(prefers-color-scheme: dark)')
return observeSystemTheme(query, setSystemPrefersDark)
}, [])

useEffect(() => {
const activeCustomTheme = resolveCustomTheme(preferences, preferences.theme, systemPrefersDark)
let style = document.getElementById('typeopen-custom-theme') as HTMLStyleElement | null
if (!preferences.customThemeCss) {
if (!activeCustomTheme.css) {
style?.remove()
return
}
Expand All @@ -994,9 +1017,10 @@ export default function App() {
style.id = 'typeopen-custom-theme'
document.head.append(style)
}
style.textContent = preferences.customThemeCss
style.dataset.appearance = activeCustomTheme.appearance
style.textContent = activeCustomTheme.css
return () => { style?.remove() }
}, [preferences.customThemeCss])
}, [preferences.customDarkThemeCss, preferences.customLightThemeCss, preferences.theme, systemPrefersDark])

useEffect(() => {
if (!recoveryLoaded) return
Expand Down Expand Up @@ -1233,7 +1257,7 @@ export default function App() {
onOpenLabs={() => void window.typeopen.invoke('typeopen:open-external', 'https://github.com/TriHerm')}
onOpenProject={() => void window.typeopen.invoke('typeopen:open-external', 'https://github.com/TriHerm/TypeOpen')}
/>
<PreferencesDialog visible={preferencesVisible} preferences={preferences} onChange={setPreferences} onClose={() => setPreferencesVisible(false)} onChooseCustomTheme={() => void chooseCustomTheme()} />
<PreferencesDialog visible={preferencesVisible} preferences={preferences} onChange={setPreferences} onClose={() => setPreferencesVisible(false)} onChooseCustomTheme={(appearance) => void chooseCustomTheme(appearance)} />
<FileEntryDialog visible={Boolean(fileEntryDialog)} mode={fileEntryDialog?.mode ?? 'create-file'} initialName={fileEntryDialog?.node?.name ?? ''} onClose={() => setFileEntryDialog(null)} onSubmit={(name) => void submitFolderEntryDialog(name)} />
<PageSetupDialog visible={pageSetupVisible} value={pageSetup} onChange={setPageSetup} onClose={() => setPageSetupVisible(false)} />
{toast && <div className="toast">{toast}</div>}
Expand Down
21 changes: 13 additions & 8 deletions src/components/PreferencesDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { CODE_BLOCK_LANGUAGES, type CodeBlockLanguage } from '../lib/codeLanguages'
import type { CustomThemeAppearance, CustomThemePreferences, EditorTheme } from '../lib/customThemePreferences'
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
export interface TypeOpenPreferences extends CustomThemePreferences {
theme: EditorTheme
exportAppearance: ExportAppearance
fontSize: number
contentWidth: number
Expand All @@ -29,7 +28,7 @@ interface Props {
preferences: TypeOpenPreferences
onChange: (next: TypeOpenPreferences) => void
onClose: () => void
onChooseCustomTheme: () => void
onChooseCustomTheme: (appearance: CustomThemeAppearance) => void
}

export function PreferencesDialog({ visible, preferences, onChange, onClose, onChooseCustomTheme }: Props) {
Expand Down Expand Up @@ -61,11 +60,17 @@ export function PreferencesDialog({ visible, preferences, onChange, onClose, onC
<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></>}
<strong>Light custom theme</strong>
<button onClick={() => onChooseCustomTheme('light')}>Choose CSS…</button>
{preferences.customLightThemeName && <><span>{preferences.customLightThemeName}</span><button onClick={() => patch({ customLightThemeName: null, customLightThemeCss: '' })}>Remove</button></>}
</div>
<div className="custom-theme-row">
<strong>Dark custom theme</strong>
<button onClick={() => onChooseCustomTheme('dark')}>Choose CSS…</button>
{preferences.customDarkThemeName && <><span>{preferences.customDarkThemeName}</span><button onClick={() => patch({ customDarkThemeName: null, customDarkThemeCss: '' })}>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>
<p className="preference-note">Light and Dark custom CSS are loaded only from files you explicitly choose. System appearance switches between the two automatically. External-resource CSS is rejected.</p>
</>}
{tab === 'editor' && <>
<label className="switch-row"><input type="checkbox" checked={preferences.spellCheck} onChange={(event) => patch({ spellCheck: event.target.checked })} /> Check spelling while typing</label>
Expand Down
70 changes: 70 additions & 0 deletions src/lib/customThemePreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
export type EditorTheme = 'system' | 'light' | 'dark'
export type CustomThemeAppearance = 'light' | 'dark'

export interface CustomThemePreferences {
customLightThemeName: string | null
customLightThemeCss: string
customDarkThemeName: string | null
customDarkThemeCss: string
}

export interface SystemThemeQuery {
matches: boolean
addEventListener?: (type: 'change', listener: () => void) => void
removeEventListener?: (type: 'change', listener: () => void) => void
}

function normalizeName(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.slice(0, 512) : null
}

function normalizeCss(value: unknown): string {
return typeof value === 'string' ? value : ''
}

export function normalizeCustomThemePreferences(value: unknown): CustomThemePreferences {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {}
const hasNewSchema = [
'customLightThemeName',
'customLightThemeCss',
'customDarkThemeName',
'customDarkThemeCss'
].some((key) => Object.prototype.hasOwnProperty.call(raw, key))

if (hasNewSchema) {
return {
customLightThemeName: normalizeName(raw.customLightThemeName),
customLightThemeCss: normalizeCss(raw.customLightThemeCss),
customDarkThemeName: normalizeName(raw.customDarkThemeName),
customDarkThemeCss: normalizeCss(raw.customDarkThemeCss)
}
}

const legacyName = normalizeName(raw.customThemeName)
const legacyCss = normalizeCss(raw.customThemeCss)
return {
customLightThemeName: legacyName,
customLightThemeCss: legacyCss,
customDarkThemeName: legacyName,
customDarkThemeCss: legacyCss
}
}

export function resolveCustomTheme(
preferences: CustomThemePreferences,
theme: EditorTheme,
systemPrefersDark: boolean
): { appearance: CustomThemeAppearance; name: string | null; css: string } {
const appearance: CustomThemeAppearance = theme === 'dark' || (theme === 'system' && systemPrefersDark) ? 'dark' : 'light'
return appearance === 'dark'
? { appearance, name: preferences.customDarkThemeName, css: preferences.customDarkThemeCss }
: { appearance, name: preferences.customLightThemeName, css: preferences.customLightThemeCss }
}

export function observeSystemTheme(query: SystemThemeQuery | null | undefined, onChange: (prefersDark: boolean) => void): () => void {
if (!query) return () => undefined
const update = () => onChange(query.matches)
update()
query.addEventListener?.('change', update)
return () => query.removeEventListener?.('change', update)
}
Loading
Loading