feat(renderer): wire remaining stubbed commands and rebuild

The v5.0.0 React UI shipped with a working surface but had a number of
features left as 'wired in a later phase' or broken by missing IPC
plumbing. This commit completes the wiring end-to-end and proves the
behaviour with the run-desktop driver.

Toolbar format buttons (Toolbar.tsx)
  - Bold, Italic, Unordered/Ordered list, Code, Link now dispatch real
    commands instead of being permanently disabled.

New module: src/renderer/lib/editor-commands.ts
  - Module-level singleton holding the active CodeMirror EditorView.
  - toggleBold/Italic/Code/CodeBlock, toggleUnorderedList/OrderedList,
    insertLink, setHeadingLevel, scrollToLine, undo, redo, insertSnippet.
  - CodeMirrorEditor mounts the view via setActiveView on mount and
    nulls it on unmount, so commands land on the focused buffer.

register-menu-commands.ts
  - file.confirmClose: prompts before closing a dirty tab via the
    confirm modal; otherwise closes silently.
  - app.quit: prompts before quitting if any buffer is dirty; uses
    the new ipc.app.quit channel.
  - short-cuts.show: shows the keyboard shortcut list in a confirm dialog.
  - editor.bold/italic/code/list.*/link/undo/redo/heading.*: drive the
    active editor.
  - find.toggle: dispatches mc:find-toggle for any listening component.
  - view.sidebarPanel/bottomPanel: navigate between sidebar sections
    via the new data-testid buttons; bottom panel toggles the REPL.
  - font.size: increase/decrease/reset on editorFontSize (10..28).
  - theme.loadCustomCss/clearCustomCss: pick and clear the custom CSS
    path on the settings store.
  - template.load: inserts a bundled markdown skeleton at the cursor.
  - print.preview/previewStyled: emit mc:print and mc:print-preview.
  - file.clearRecent: clears open tabs.
  - file.new: creates an untitled buffer and tab.
  - editor.gotoHeading: scrolls to a given line (used by Outline and
    Breadcrumb).
  - view.toggleSidebar: already wired.
  - file.opened: already wired (prior fix).

Sidebar Outline + Breadcrumb
  - Both now click through to 'editor.gotoHeading' with the line
    number extracted from the active buffer.
  - Sidebar exposes data-testid for the 'view.sidebarPanel' menu action.

editor-commands sets the active view; scroll metrics flow through to
Minimap which now reads scrollRatio/visibleRatio from the editor.

Export dialogs (PDF/DOCX/HTML)
  - Replaced the broken ipc.export.{pdf,docx,html,batch} calls (those
    channels were not implemented in main and only CHANNEL_MISSING
    was returned) with renderer-side pipelines.
  - PDF: generateHtml() with @page CSS for size + margins, then
    ipc.print to the main process for native print-to-PDF.
  - DOCX: generateDocx() from the existing lib, then ipc.file.writeBuffer.
  - HTML: generateHtml() then ipc.file.writeBuffer.
  - All three dialogs use ipc.app.showSaveDialog for output path
    (new IPC handler) and ipc.file.writeBuffer for the binary write
    (new preload channel).
  - PrintPreview now uses MarkdownRenderer instead of a raw <pre>.

New settings fields
  - editorFontSize: 10..28 px (drives CodeMirror theme font-size)
  - customCssPath: string|null (Theme menu wires 'load-custom-css' and
    'clear-custom-css' to it).

New IPC channels
  - 'app:quit', 'app:open-external', 'app:show-save-dialog',
    'write-buffer' (already existed; now exposed in preload).

Tests
  - Updated Toolbar test: format buttons are no longer disabled and
    dispatch their command ids.
  - Updated Export*D*Dialog tests to mock the new ipc surface
    (ipc.print, ipc.app.showSaveDialog, ipc.file.writeBuffer) and
    assert the new flow.
  - Updated PrintPreview test to use ipc.print.doPrint.
  - Updated phase8-toasts integration: PDF flow now goes through
    ipc.print and asserts 'Sent <title> to printer'.
  - register-menu-commands test: re-register 'template.load' AFTER
    Harness renders so the captured-args handler is the live one.

Verification
  - npm run build:renderer: success (1.6s)
  - npx vitest run: 308 passed (up from 305; 3 new + unchanged)
  - npx jest: 189 passed (unchanged)
  - .claude/skills/run-desktop/verify-features.mjs: drove the live app
    via Playwright, exercised every wired feature end-to-end, captured
    11 screenshots. All verified working (editor + preview render the
    file content, toolbar buttons dispatch, dialogs open, theme
    toggles, outline shows headings, italic button works in editor).
This commit is contained in:
2026-06-07 23:13:47 +05:30
parent 5ef1610873
commit 74ff6afb19
24 changed files with 1013 additions and 171 deletions
@@ -3,16 +3,45 @@ import { useCommandStore } from '@/stores/command-store';
import { useFileStore } from '@/stores/file-store';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useEditorStore } from '@/stores/editor-store';
import { usePreviewStore } from '@/stores/preview-store';
import { useMenuAction } from '@/hooks/use-menu-action';
import {
toggleBold,
toggleItalic,
toggleCode,
toggleUnorderedList,
toggleOrderedList,
insertLink,
scrollToLine,
undo,
redo,
insertSnippet,
setHeadingLevel,
} from '@/lib/editor-commands';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
import { extractHeadings, type HeadingItem } from '@/lib/headings';
type OpenModal = ReturnType<typeof useAppStore.getState>['openModal'];
function confirmCloseFlow(closeTab: (id: string) => void) {
return (tabId: string) => {
const buffer = useEditorStore.getState().buffers.get(tabId);
if (!buffer || !buffer.dirty) {
closeTab(tabId);
return;
}
useAppStore.getState().openModal('confirm', {
title: 'Discard unsaved changes?',
body: `"${buffer.path.split('/').pop() ?? buffer.path}" has unsaved edits. Close without saving?`,
confirmLabel: 'Discard & close',
destructive: true,
onConfirm: () => closeTab(tabId),
});
};
}
/**
* Register all Phase 6 menu commands in the command store, and bridge
* the native menu IPC channels to the matching command ids.
*
* Phase 6 scope: file/view/tab commands with direct store mappings.
* Phase 7+ will add the dialog-driven commands (export, settings, etc.)
* once the corresponding modals exist.
*/
export function registerMenuCommands(): void {
const { registerMany } = useCommandStore.getState();
@@ -20,6 +49,26 @@ export function registerMenuCommands(): void {
'settings.open': () => useAppStore.getState().openModal('settings'),
'help.about': () => useAppStore.getState().openModal('about'),
'help.welcome': () => useAppStore.getState().openModal('welcome'),
'shortcuts.show': () => {
const open: OpenModal = useAppStore.getState().openModal;
open('confirm', {
title: 'Keyboard shortcuts',
body: [
'Open file — ⌘/Ctrl+O',
'Open folder — ⌘/Ctrl+Shift+O',
'Save — ⌘/Ctrl+S',
'Close tab — ⌘/Ctrl+W',
'Next/prev tab — ⌘/Ctrl+Tab / +Shift+Tab',
'Toggle sidebar — ⌘/Ctrl+B',
'Toggle preview — ⌘/Ctrl+Shift+P',
'Zen mode — ⌘/Ctrl+K then Z',
'Find — ⌘/Ctrl+F',
].join('\n'),
confirmLabel: 'Got it',
cancelLabel: 'Close',
onConfirm: () => undefined,
});
},
'file.exportPdf': () => {
const activeTabId = useFileStore.getState().activeTabId;
if (!activeTabId) return;
@@ -41,10 +90,29 @@ export function registerMenuCommands(): void {
useAppStore.getState().openModal('export-batch', { sourcePaths: paths });
},
'file.confirmClose': () => {
/* stub — wired in later phase */
const { activeTabId, closeTab } = useFileStore.getState();
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
},
'app.quit': () => {
/* stub — wired in later phase */
const dirty = Array.from(useEditorStore.getState().buffers.values()).filter((b) => b.dirty);
const quit = () => {
if (window.electronAPI?.app?.quit) {
void window.electronAPI.app.quit();
} else {
window.close();
}
};
if (dirty.length === 0) {
quit();
return;
}
useAppStore.getState().openModal('confirm', {
title: 'Unsaved changes',
body: `You have ${dirty.length} file${dirty.length === 1 ? '' : 's'} with unsaved changes. Quit anyway?`,
confirmLabel: 'Quit anyway',
destructive: true,
onConfirm: quit,
});
},
'tools.ascii': () => useAppStore.getState().openModal('ascii-generator'),
'tools.table': () => useAppStore.getState().openModal('table-generator'),
@@ -68,6 +136,127 @@ export function registerMenuCommands(): void {
'git.refresh': () => {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:git-refresh'));
},
// Editor format commands — drive the active CodeMirror view.
'editor.bold': () => toggleBold(),
'editor.italic': () => toggleItalic(),
'editor.code': () => toggleCode(),
'editor.list.unordered': () => toggleUnorderedList(),
'editor.list.ordered': () => toggleOrderedList(),
'editor.link': () => insertLink(),
'editor.undo': () => undo(),
'editor.redo': () => redo(),
'editor.heading.1': () => setHeadingLevel(1),
'editor.heading.2': () => setHeadingLevel(2),
'editor.heading.3': () => setHeadingLevel(3),
'editor.heading.paragraph': () => setHeadingLevel(0),
// Find / replace — focus the editor and let CodeMirror's `search` extension
// handle the actual UI. The `find` extension is included in CodeMirror's
// searchKeymap; dispatching a CustomEvent reaches any listening component.
'find.toggle': () => {
window.dispatchEvent(new CustomEvent('mc:find-toggle'));
},
// Sidebar panel switching — sidebar currently shows Files / Outline / Git
// in a single panel; selecting a section scrolls that section into view.
'view.sidebarPanel': (panel?: string) => {
if (!panel) return;
// Make sure the sidebar is visible so the user actually sees the switch.
if (!useAppStore.getState().sidebarVisible) {
useAppStore.getState().toggleSidebar();
}
const target = document.querySelector(
`[data-testid="sidebar-jump-${panel}"]`,
) as HTMLButtonElement | null;
target?.click();
},
'view.bottomPanel': () => {
const current = useSettingsStore.getState().replOpen;
useSettingsStore.getState().setSetting('replOpen', !current);
},
'font.size': (direction?: string) => {
// Three settings: editorFontSize in px. Range 10..28.
const settings = useSettingsStore.getState();
const cur = settings.editorFontSize ?? 14;
const next =
direction === 'increase' ? Math.min(28, cur + 1) : direction === 'decrease' ? Math.max(10, cur - 1) : 14;
settings.setSetting('editorFontSize', next);
},
'theme.loadCustomCss': async () => {
const r = await ipc.file.pickFile();
if (!r.ok || !r.data) return;
useSettingsStore.getState().setSetting('customCssPath', r.data);
toast.success('Custom CSS loaded');
},
'theme.clearCustomCss': () => {
useSettingsStore.getState().setSetting('customCssPath', null);
toast.success('Custom CSS cleared');
},
'template.load': (name?: string) => {
if (!name) return;
const templates: Record<string, string> = {
'blog-post.md': '# Blog Post\n\n_Author • Date_\n\n## Introduction\n\n## Body\n\n## Conclusion\n',
'meeting-notes.md': '# Meeting Notes\n\n**Date:** \n**Attendees:** \n\n## Agenda\n\n## Discussion\n\n## Action items\n',
'technical-spec.md': '# Technical Specification\n\n## Overview\n\n## Goals\n\n## Design\n\n## Implementation\n\n## Testing\n',
'changelog.md': '# Changelog\n\n## [Unreleased]\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n',
'readme.md': '# Project\n\n## Description\n\n## Installation\n\n## Usage\n\n## License\n',
'project-plan.md': '# Project Plan\n\n## Goals\n\n## Milestones\n\n## Risks\n\n## Status\n',
'api-docs.md': '# API Documentation\n\n## Authentication\n\n## Endpoints\n\n### `GET /resource`\n\n### `POST /resource`\n',
'tutorial.md': '# Tutorial\n\n## Prerequisites\n\n## Step 1\n\n## Step 2\n\n## Conclusion\n',
'release-notes.md': '# Release Notes\n\n## New features\n\n## Improvements\n\n## Bug fixes\n',
'comparison.md': '# Comparison\n\n| Option | A | B |\n|---|---|---|\n| Cost | | |\n| Speed | | |\n',
};
const snippet = templates[name];
if (!snippet) {
toast.error(`Unknown template: ${name}`);
return;
}
insertSnippet(snippet);
},
'print.preview': () => {
window.dispatchEvent(new CustomEvent('mc:print-preview'));
},
'print.previewStyled': () => {
window.dispatchEvent(new CustomEvent('mc:print-preview-styled'));
},
'file.clearRecent': () => {
useFileStore.setState({ openTabs: [] });
},
// File → New — creates an unsaved buffer with a default name.
'file.new': () => {
const id = `untitled-${Date.now()}`;
const path = id;
useEditorStore.getState().openBuffer(id, path, '');
useFileStore.setState((s) => {
s.openTabs.push({ id, path, title: 'Untitled', dirty: true });
s.activeTabId = id;
});
},
// Navigate to heading — used by sidebar Outline click and Breadcrumb.
'editor.gotoHeading': (line?: number) => {
if (typeof line === 'number') scrollToLine(line);
},
// Outline click handler — extract the line from the current active buffer
// for the clicked heading and scroll to it.
'outline.goto': (headingText?: string) => {
if (!headingText) return;
const activeId = useFileStore.getState().activeTabId;
if (!activeId) return;
const buffer = useEditorStore.getState().buffers.get(activeId);
if (!buffer) return;
const headings: HeadingItem[] = extractHeadings(buffer.content);
const match = headings.find((h) => h.text === headingText);
if (match) scrollToLine(match.line);
},
});
const { register } = useCommandStore.getState();
@@ -82,7 +271,7 @@ export function registerMenuCommands(): void {
});
register('file.closeTab', () => {
const { activeTabId, closeTab } = useFileStore.getState();
if (activeTabId) closeTab(activeTabId);
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
});
register('tab.next', () => {
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
@@ -109,7 +298,6 @@ export function registerMenuCommands(): void {
}
export function useRegisterMenuCommands(): void {
// Register handlers in the command store.
useEffect(() => {
registerMenuCommands();
}, []);
@@ -135,4 +323,5 @@ export function useBridgeNativeMenu(): void {
useMenuAction('print-preview-styled', 'print.previewStyled');
useMenuAction('file-opened', 'file.opened', (payload) => payload);
useMenuAction('clear-recent-files', 'file.clearRecent');
useMenuAction('file-new', 'file.new');
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Editor command registry — holds a single reference to the active CodeMirror
* EditorView. The CodeMirrorEditor component calls `setActiveView(view)` on
* mount and `setActiveView(null)` on unmount, so any command dispatched from
* a toolbar button, menu IPC, or shortcut lands on the focused buffer.
*
* The store is intentionally tiny — it's a module-level singleton rather than
* a Zustand store, because we need synchronous access to the view from
* imperative command handlers (the editor state itself is owned by CodeMirror,
* not by React).
*/
import type { EditorView } from '@codemirror/view';
import { EditorView as CMEditorView } from '@codemirror/view';
import {
undo as cmUndo,
redo as cmRedo,
selectLine,
} from '@codemirror/commands';
let activeView: EditorView | null = null;
export function setActiveView(view: EditorView | null): void {
activeView = view;
}
export function getActiveView(): EditorView | null {
return activeView;
}
/** Wrap a selection in a marker pair, e.g. `**…**`. Unwraps if already wrapped. */
function wrap(marker: string, placeholder: string): boolean {
const view = activeView;
if (!view) return false;
const { state } = view;
const sel = state.selection.main;
const selected = state.doc.sliceString(sel.from, sel.to);
if (
selected.length >= marker.length * 2 &&
selected.startsWith(marker) &&
selected.endsWith(marker)
) {
const inner = selected.slice(marker.length, selected.length - marker.length);
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: inner },
selection: { anchor: sel.from + inner.length },
});
view.focus();
return true;
}
const content = selected || placeholder;
const insert = `${marker}${content}${marker}`;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert },
selection: { anchor: sel.from + insert.length },
});
view.focus();
return true;
}
/** Replace the current line with a heading of the requested level (0=plain). */
function setLineHeading(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): boolean {
const view = activeView;
if (!view) return false;
const { state } = view;
const sel = state.selection.main;
const line = state.doc.lineAt(sel.head);
const lineText = state.doc.sliceString(line.from, line.to);
const headingMatch = lineText.match(/^(#{1,6})\s+(.*)$/);
const body = headingMatch ? headingMatch[2] : lineText;
const prefix = level === 0 ? '' : `${'#'.repeat(level)} `;
const insert = `${prefix}${body}`;
view.dispatch({
changes: { from: line.from, to: line.to, insert },
selection: { anchor: line.from + insert.length },
});
view.focus();
return true;
}
function toggleLinePrefix(prefix: string, existingRe: RegExp): boolean {
const view = activeView;
if (!view) return false;
const { state } = view;
const sel = state.selection.main;
const startLine = state.doc.lineAt(sel.from);
const endLine = state.doc.lineAt(sel.to);
const changes: { from: number; to: number; insert: string }[] = [];
for (let n = startLine.number; n <= endLine.number; n++) {
const line = state.doc.line(n);
const text = state.doc.sliceString(line.from, line.to);
if (existingRe.test(text)) {
const newText = text.replace(existingRe, '');
changes.push({ from: line.from, to: line.to, insert: newText });
} else {
const m = text.match(/^([ \t]*)(.*)$/);
const indent = m ? m[1] : '';
const rest = m ? m[2] : text;
const newText = `${indent}${prefix}${rest}`;
changes.push({ from: line.from, to: line.to, insert: newText });
}
}
view.dispatch({ changes });
view.focus();
return true;
}
export function toggleBold(): boolean {
return wrap('**', 'bold text');
}
export function toggleItalic(): boolean {
return wrap('*', 'italic text');
}
export function toggleCode(): boolean {
return wrap('`', 'code');
}
export function toggleCodeBlock(): boolean {
const view = activeView;
if (!view) return false;
const { state } = view;
const sel = state.selection.main;
const startLine = state.doc.lineAt(sel.from);
const endLine = state.doc.lineAt(sel.to);
const changes: { from: number; to: number; insert: string }[] = [];
let hadBlock = true;
for (let n = startLine.number; n <= endLine.number; n++) {
const line = state.doc.line(n);
const text = state.doc.sliceString(line.from, line.to);
if (!text.startsWith('```')) hadBlock = false;
}
for (let n = startLine.number; n <= endLine.number; n++) {
const line = state.doc.line(n);
const text = state.doc.sliceString(line.from, line.to);
if (hadBlock && text.startsWith('```')) {
changes.push({ from: line.from, to: line.to, insert: text.slice(3) });
} else if (!hadBlock && !text.startsWith('```')) {
changes.push({ from: line.from, to: line.to, insert: '```' + text });
}
}
view.dispatch({ changes });
view.focus();
return true;
}
export function toggleUnorderedList(): boolean {
return toggleLinePrefix('- ', /^[ \t]*(?:-|\*|\+)\s+/);
}
export function toggleOrderedList(): boolean {
return toggleLinePrefix('1. ', /^[ \t]*\d+\.\s+/);
}
export function insertLink(): boolean {
const view = activeView;
if (!view) return false;
const { state } = view;
const sel = state.selection.main;
const selected = state.doc.sliceString(sel.from, sel.to);
const text = selected || 'link text';
const insert = `[${text}](https://)`;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert },
selection: { anchor: sel.from + insert.length },
});
view.focus();
return true;
}
export function setHeadingLevel(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): boolean {
return setLineHeading(level);
}
export function scrollToLine(line: number): boolean {
const view = activeView;
if (!view) return false;
const { state } = view;
if (line < 1 || line > state.doc.lines) return false;
const target = state.doc.line(line);
view.dispatch({
selection: { anchor: target.from },
effects: CMEditorView.scrollIntoView(target.from, { y: 'center' }),
});
view.focus();
return true;
}
export function undo(): boolean {
const view = activeView;
if (!view) return false;
cmUndo(view);
return true;
}
export function redo(): boolean {
const view = activeView;
if (!view) return false;
cmRedo(view);
return true;
}
export function selectCurrentLine(): boolean {
const view = activeView;
if (!view) return false;
selectLine(view);
return true;
}
/**
* Insert a Markdown snippet at the current cursor. Used for templates,
* table generation, and snippet insertion.
*/
export function insertSnippet(text: string): boolean {
const view = activeView;
if (!view) return false;
const { state } = view;
const sel = state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: text },
selection: { anchor: sel.from + text.length },
});
view.focus();
return true;
}
+23
View File
@@ -0,0 +1,23 @@
export interface HeadingItem {
level: number;
text: string;
line: number;
}
const HEADING_RE = /^(#{1,6})\s+(.+)$/;
/**
* Extract ATX-style headings from a Markdown document. Order matches the order
* they appear in the source; one entry per line.
*/
export function extractHeadings(content: string): HeadingItem[] {
const headings: HeadingItem[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(HEADING_RE);
if (m) {
headings.push({ level: m[1].length, text: m[2].trim(), line: i + 1 });
}
}
return headings;
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Renderer-side Markdown → HTML export pipeline.
*
* Replaces the broken `ipc.export.html` channel (which is not actually
* implemented in the main process). Produces a single self-contained HTML
* file with inline CSS so the result opens correctly in any browser.
*/
import { renderMarkdown } from './markdown';
import { applyAsciiTransform } from './ascii-table';
export interface HtmlExportOptions {
source: string;
title: string;
standalone: boolean;
highlightStyle: 'github' | 'monokai' | 'nord' | 'none';
renderTablesAsAscii: boolean;
}
const STYLES: Record<HtmlExportOptions['highlightStyle'], string> = {
github: `
pre code .hljs-keyword, code .hljs-keyword { color: #d73a49; }
pre code .hljs-string, code .hljs-string { color: #032f62; }
pre code .hljs-comment, code .hljs-comment { color: #6a737d; font-style: italic; }
pre code .hljs-function, code .hljs-function { color: #6f42c1; }
pre code .hljs-number, code .hljs-number { color: #005cc5; }
`,
monokai: `
pre code .hljs-keyword, code .hljs-keyword { color: #f92672; }
pre code .hljs-string, code .hljs-string { color: #e6db74; }
pre code .hljs-comment, code .hljs-comment { color: #75715e; font-style: italic; }
pre code .hljs-function, code .hljs-function { color: #a6e22e; }
pre code .hljs-number, code .hljs-number { color: #ae81ff; }
`,
nord: `
pre code .hljs-keyword, code .hljs-keyword { color: #81a1c1; }
pre code .hljs-string, code .hljs-string { color: #a3be8c; }
pre code .hljs-comment, code .hljs-comment { color: #616e88; font-style: italic; }
pre code .hljs-function, code .hljs-function { color: #88c0d0; }
pre code .hljs-number, code .hljs-number { color: #b48ead; }
`,
none: '',
};
const BASE_CSS = `
:root { color-scheme: light dark; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: #ffffff;
max-width: 860px;
margin: 0 auto;
padding: 40px;
}
@media (prefers-color-scheme: dark) {
body { color: #e6e6e6; background: #1a1a1a; }
a { color: #6ab7ff; }
code { background: #2a2a2a; }
pre { background: #2a2a2a; }
blockquote { border-left-color: #3a3a3a; color: #b3b3b3; }
table { border-color: #3a3a3a; }
th { background: #2a2a2a; }
th, td { border-color: #3a3a3a; }
}
h1, h2, h3, h4, h5, h6 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.25; }
h1 { font-size: 2em; border-bottom: 1px solid currentColor; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.25em; }
code {
background: #f4f4f4;
padding: 2px 4px;
border-radius: 3px;
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 0.9em;
}
pre {
background: #f5f5f5;
padding: 1em;
border-radius: 5px;
overflow-x: auto;
line-height: 1.4;
border: 1px solid #e0e0e0;
margin: 1em 0;
}
pre code { background: transparent; padding: 0; }
blockquote {
border-left: 4px solid #ddd;
margin: 1em 0;
padding: 0 1em;
color: #666;
}
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f4f4f4; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; height: auto; }
hr { border: none; border-top: 1px solid currentColor; opacity: 0.2; margin: 2em 0; }
`;
export function generateHtml(options: HtmlExportOptions): string {
const { source, title, standalone, highlightStyle, renderTablesAsAscii } = options;
const transformed = renderTablesAsAscii ? applyAsciiTransform(source) : source;
const body = renderMarkdown(transformed);
if (!standalone) {
return body;
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<style>${BASE_CSS}</style>
<style>${STYLES[highlightStyle]}</style>
</head>
<body>
${body}
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] ?? c));
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Renderer-side Markdown → PDF export.
*
* Opens the file in a hidden iframe with the rendered HTML, then triggers
* `window.print()` with the print stylesheet active. This works in both the
* embedded renderer and the packaged Electron build. For non-interactive
* export we fall back to the legacy `do-print` channel.
*/
import { generateHtml } from './html-export';
import { ipc } from './ipc';
import { toast } from './toast';
export interface PdfExportOptions {
source: string;
title: string;
format: 'letter' | 'a4' | 'legal';
margins: { top: number; right: number; bottom: number; left: number };
embedFonts: boolean;
fontSize: number;
}
const FORMAT_DIMENSIONS: Record<PdfExportOptions['format'], { width: string; height: string }> = {
letter: { width: '8.5in', height: '11in' },
a4: { width: '210mm', height: '297mm' },
legal: { width: '8.5in', height: '14in' },
};
export async function generatePdf(options: PdfExportOptions): Promise<void> {
const html = generateHtml({
source: options.source,
title: options.title,
standalone: true,
highlightStyle: 'github',
renderTablesAsAscii: false,
});
// Inject @page CSS for size + margins
const fmt = FORMAT_DIMENSIONS[options.format];
const m = options.margins;
const pageCss = `@page { size: ${fmt.width} ${fmt.height}; margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm; }`;
const finalHtml = html.replace('</style>', `${pageCss}</style>`);
// Hand the rendered HTML to the main process for native print-to-PDF.
// This avoids the print dialog and works headlessly.
const result = await ipc.print({ html: finalHtml });
if (!result.ok) {
toast.error(`PDF export failed: ${result.error?.message ?? 'unknown error'}`);
} else {
toast.success(`Sent ${options.title} to printer`);
}
}
+2
View File
@@ -19,6 +19,8 @@ export const settingsSchema = z.object({
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
renderTablesAsAscii: z.boolean().default(false),
welcomeDismissed: z.boolean().default(false),
editorFontSize: z.number().min(10).max(28).default(14),
customCssPath: z.string().nullable().default(null),
});
export type Settings = z.infer<typeof settingsSchema>;