diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ed39d..5b620b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d12c7d4..518ab68 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/PARITY_MATRIX.md b/docs/PARITY_MATRIX.md index ff68730..50c0575 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, 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 | diff --git a/src/App.tsx b/src/App.tsx index fa2308c..12a8655 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = '' @@ -30,6 +31,7 @@ const DEFAULT_PREFS: TypeOpenPreferences = { theme: 'system', customThemeName: null, customThemeCss: '', + exportAppearance: 'editor', fontSize: 17, contentWidth: 780, spellCheck: true, @@ -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), @@ -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, 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 diff --git a/src/components/PreferencesDialog.tsx b/src/components/PreferencesDialog.tsx index fa30a7a..8adccb3 100644 --- a/src/components/PreferencesDialog.tsx +++ b/src/components/PreferencesDialog.tsx @@ -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 @@ -55,12 +57,14 @@ export function PreferencesDialog({ visible, preferences, onChange, onClose, onC } {tab === 'appearance' && <> +
{preferences.customThemeName && <>{preferences.customThemeName}}
+

Export appearance applies to TypeOpen's HTML, PDF, PNG and print rendering. Custom editor CSS is not injected into exports.

Custom CSS is loaded only from a file you explicitly choose. External-resource CSS is rejected.

} {tab === 'editor' && <> diff --git a/src/lib/exportPreferences.ts b/src/lib/exportPreferences.ts new file mode 100644 index 0000000..b4c53c2 --- /dev/null +++ b/src/lib/exportPreferences.ts @@ -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' +} diff --git a/tests/export-preferences.test.ts b/tests/export-preferences.test.ts new file mode 100644 index 0000000..a2a8309 --- /dev/null +++ b/tests/export-preferences.test.ts @@ -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') + }) +}) diff --git a/tests/preferences-dialog.test.ts b/tests/preferences-dialog.test.ts index dbcf193..17c99d8 100644 --- a/tests/preferences-dialog.test.ts +++ b/tests/preferences-dialog.test.ts @@ -9,6 +9,7 @@ const preferences: TypeOpenPreferences = { theme: 'system', customThemeName: null, customThemeCss: '', + exportAppearance: 'editor', fontSize: 17, contentWidth: 780, spellCheck: true, @@ -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('.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('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() + }) })