diff --git a/src/main/index.js b/src/main/index.js
index be25d27..f71217a 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -2249,6 +2249,27 @@ function setTheme(theme) {
}
// IPC handlers
+ipcMain.on('app:quit', () => {
+ app.quit();
+});
+
+ipcMain.on('app:open-external', (_event, url) => {
+ if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
+ shell.openExternal(url).catch((e) => console.error('[app:open-external]', e));
+ }
+});
+
+ipcMain.handle('app:show-save-dialog', async (_event, args) => {
+ const opts = {
+ title: args?.title ?? 'Save file',
+ defaultPath: args?.defaultPath,
+ filters: args?.filters,
+ };
+ const result = await dialog.showSaveDialog(mainWindow, opts);
+ if (result.canceled || !result.filePath) return null;
+ return result.filePath;
+});
+
ipcMain.on('save-file', (event, { path, content }) => {
fs.writeFileSync(path, content, 'utf-8');
currentFile = path;
diff --git a/src/preload.js b/src/preload.js
index fd56ce4..b532f30 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -98,6 +98,7 @@ const ALLOWED_SEND_CHANNELS = [
'list-directory',
'read-file',
'write-file',
+ 'write-buffer',
'delete-file',
'ensure-directory',
'path-exists',
@@ -129,6 +130,11 @@ const ALLOWED_SEND_CHANNELS = [
'menu-open',
'export',
+ // App lifecycle
+ 'app:quit',
+ 'app:open-external',
+ 'app:show-save-dialog',
+
// Git diff
'git-diff',
@@ -221,6 +227,9 @@ const ALLOWED_RECEIVE_CHANNELS = [
'load-template-menu',
'toggle-sidebar-panel',
'toggle-bottom-panel',
+ 'print-preview',
+ 'print-preview-styled',
+ 'clear-recent-files',
// File dialog / directory listing
'list-directory',
@@ -345,7 +354,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Print Operations
print: {
- doPrint: (options) => ipcRenderer.send('do-print', options)
+ doPrint: (options) => ipcRenderer.send('do-print', options),
+ show: (payload) => ipcRenderer.send('do-print', payload),
},
// Export Operations
@@ -423,7 +433,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
toGif: (data) => ipcRenderer.send('video-gif', data)
},
- getAppVersion: () => ipcRenderer.invoke('get-app-version')
+ getAppVersion: () => ipcRenderer.invoke('get-app-version'),
+
+ app: {
+ quit: () => ipcRenderer.send('app:quit'),
+ openExternal: (url) => ipcRenderer.send('app:open-external', url),
+ showSaveDialog: (args) => ipcRenderer.invoke('app:show-save-dialog', args),
+ }
});
// Log successful preload initialization
diff --git a/src/renderer/components/editor/CodeMirrorEditor.tsx b/src/renderer/components/editor/CodeMirrorEditor.tsx
index 68c1a99..5bb43aa 100644
--- a/src/renderer/components/editor/CodeMirrorEditor.tsx
+++ b/src/renderer/components/editor/CodeMirrorEditor.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
@@ -10,6 +10,7 @@ import { useTheme } from 'next-themes';
import { lightTheme, lightHighlight } from './themes/light';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
+import { setActiveView } from '@/lib/editor-commands';
import { Minimap } from './Minimap';
interface Props {
@@ -27,6 +28,11 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
const updateContent = useEditorStore((s) => s.updateContent);
const setCursor = useEditorStore((s) => s.setCursor);
const minimap = useSettingsStore((s) => s.minimap);
+ const editorFontSize = useSettingsStore((s) => s.editorFontSize);
+ // Live scroll metrics for the minimap. `0..1` ratios; defaults match
+ // Minimap's static defaults so it renders before any scroll event.
+ const [scrollRatio, setScrollRatio] = useState(0);
+ const [visibleRatio, setVisibleRatio] = useState(1);
useEffect(() => {
if (!ref.current) return;
@@ -58,11 +64,27 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
onCursorChange?.(lineNo, col);
}
}),
+ EditorView.theme({
+ '&': { fontSize: `${editorFontSize}px` },
+ }),
+ EditorView.domEventHandlers({
+ scroll(_event, view) {
+ const el = view.scrollDOM;
+ const denom = el.scrollHeight - el.clientHeight;
+ setScrollRatio(denom > 0 ? el.scrollTop / denom : 0);
+ setVisibleRatio(
+ el.clientHeight > 0 ? Math.min(1, el.clientHeight / el.scrollHeight) : 1,
+ );
+ return false;
+ },
+ }),
],
});
const view = new EditorView({ state, parent: ref.current });
viewRef.current = view;
+ setActiveView(view);
return () => {
+ setActiveView(null);
view.destroy();
viewRef.current = null;
};
@@ -82,7 +104,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
return (
- {minimap &&
}
+ {minimap &&
}
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/components/layout/Breadcrumb.tsx b/src/renderer/components/layout/Breadcrumb.tsx
index f3c8e91..bc2fd00 100644
--- a/src/renderer/components/layout/Breadcrumb.tsx
+++ b/src/renderer/components/layout/Breadcrumb.tsx
@@ -1,22 +1,15 @@
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
-
-function extractHeadings(content: string): { level: number; text: string }[] {
- const lines = content.split('\n');
- const headings: { level: number; text: string }[] = [];
- for (const line of lines) {
- const m = line.match(/^(#{1,6})\s+(.+)$/);
- if (m) headings.push({ level: m[1].length, text: m[2].trim() });
- }
- return headings;
-}
+import { useCommandStore } from '@/stores/command-store';
+import { extractHeadings } from '@/lib/headings';
export function Breadcrumb() {
const activeTabId = useFileStore((s) => s.activeTabId);
const openTabs = useFileStore((s) => s.openTabs);
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
+ const dispatch = useCommandStore((s) => s.dispatch);
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
@@ -26,13 +19,17 @@ export function Breadcrumb() {
aria-label="File path"
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
>
- {tab ? tab.title : 'No file selected'}
+ {tab ? tab.title : 'No file selected'}
{headings.map((h, i) => (
-
+ dispatch('editor.gotoHeading', h.line)}
+ className="flex items-center gap-1 hover:text-foreground"
+ >
›
{'#'.repeat(h.level)} {h.text}
-
+
))}
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/components/layout/Toolbar.tsx b/src/renderer/components/layout/Toolbar.tsx
index b82449d..9f58cb9 100644
--- a/src/renderer/components/layout/Toolbar.tsx
+++ b/src/renderer/components/layout/Toolbar.tsx
@@ -1,4 +1,16 @@
-import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon, PanelLeft, PanelRight, Save, FolderOpen, FileText } from 'lucide-react';
+import {
+ Bold,
+ Italic,
+ List,
+ ListOrdered,
+ Code,
+ Link as LinkIcon,
+ PanelLeft,
+ PanelRight,
+ Save,
+ FolderOpen,
+ FileText,
+} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCommandStore } from '@/stores/command-store';
import { useAppStore } from '@/stores/app-store';
@@ -66,12 +78,60 @@ export function Toolbar() {
-
-
-
-
-
-
+ dispatch('editor.bold')}
+ >
+
+
+ dispatch('editor.italic')}
+ >
+
+
+ dispatch('editor.list.unordered')}
+ >
+
+
+ dispatch('editor.list.ordered')}
+ >
+
+
+ dispatch('editor.code')}
+ >
+
+
+ dispatch('editor.link')}
+ >
+
+
);
}
diff --git a/src/renderer/components/modals/ExportDocxDialog.tsx b/src/renderer/components/modals/ExportDocxDialog.tsx
index 8a6e371..0347452 100644
--- a/src/renderer/components/modals/ExportDocxDialog.tsx
+++ b/src/renderer/components/modals/ExportDocxDialog.tsx
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source';
+import { generateDocx } from '@/lib/docx-export';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter';
@@ -23,19 +24,27 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
if (!source) { setError('No file open.'); return; }
setSubmitting(true);
setError(null);
- const result = await ipc.export.docx({
- inputPath: source.path,
- outputPath: source.path.replace(/\.md$/, '.docx'),
- template,
- renderTablesAsAscii: ascii,
- } as any);
- if (!result.ok) {
- toast.error(`Export failed: ${result.error.message}`);
- setError(result.error.message);
- setSubmitting(false);
- } else {
- toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
+ try {
+ const blob = await generateDocx({ source: source.source, title: source.title });
+ const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') });
+ if (!saveResult?.ok || !saveResult.data) {
+ setSubmitting(false);
+ return;
+ }
+ const buffer = new Uint8Array(await blob.arrayBuffer());
+ const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
+ if (!writeResult.ok) {
+ setError(writeResult.error.message);
+ setSubmitting(false);
+ return;
+ }
+ toast.success(`Exported ${source.title} to ${saveResult.data}`);
closeModal();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ toast.error(`Export failed: ${msg}`);
+ setError(msg);
+ setSubmitting(false);
}
};
@@ -58,7 +67,8 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
- Bundled with the app. Standard is the default; Modern adds a colored cover page.
+ The renderer-side export produces the same document for all three
+ template choices; the option is preserved for future stylesheets.
@@ -75,4 +85,4 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/components/modals/ExportHtmlDialog.tsx b/src/renderer/components/modals/ExportHtmlDialog.tsx
index 7fa31f7..c633698 100644
--- a/src/renderer/components/modals/ExportHtmlDialog.tsx
+++ b/src/renderer/components/modals/ExportHtmlDialog.tsx
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source';
+import { generateHtml } from '@/lib/html-export';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter';
@@ -24,20 +25,33 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
if (!source) { setError('No file open.'); return; }
setSubmitting(true);
setError(null);
- const result = await ipc.export.html({
- inputPath: source.path,
- outputPath: source.path.replace(/\.md$/, '.html'),
- standalone,
- highlightStyle: highlight,
- renderTablesAsAscii: ascii,
- } as any);
- if (!result.ok) {
- toast.error(`Export failed: ${result.error.message}`);
- setError(result.error.message);
- setSubmitting(false);
- } else {
- toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
+ try {
+ const html = generateHtml({
+ source: source.source,
+ title: source.title,
+ standalone,
+ highlightStyle: highlight,
+ renderTablesAsAscii: ascii,
+ });
+ const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as HTML', defaultPath: source.path.replace(/\.md$/, '.html') });
+ if (!saveResult?.ok || !saveResult.data) {
+ setSubmitting(false);
+ return;
+ }
+ const buffer = new TextEncoder().encode(html);
+ const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
+ if (!writeResult.ok) {
+ setError(writeResult.error.message);
+ setSubmitting(false);
+ return;
+ }
+ toast.success(`Exported ${source.title} to ${saveResult.data}`);
closeModal();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ toast.error(`Export failed: ${msg}`);
+ setError(msg);
+ setSubmitting(false);
}
};
@@ -79,4 +93,4 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/components/modals/ExportPdfDialog.tsx b/src/renderer/components/modals/ExportPdfDialog.tsx
index 4b7fffd..f4e991d 100644
--- a/src/renderer/components/modals/ExportPdfDialog.tsx
+++ b/src/renderer/components/modals/ExportPdfDialog.tsx
@@ -6,9 +6,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source';
-import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter';
+import { ipc } from '@/lib/ipc';
+import { generateHtml } from '@/lib/html-export';
const MARGIN_MAP = {
normal: { top: 25, right: 25, bottom: 25, left: 25 },
@@ -25,7 +26,6 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
const [ascii, setAscii] = useState(renderTablesAsAscii);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
-
const source = useExportSource();
const handleSubmit = async () => {
@@ -35,22 +35,37 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
}
setSubmitting(true);
setError(null);
- const result = await ipc.export.pdf({
- inputPath: source.path,
- outputPath: source.path.replace(/\.md$/, '.pdf'),
- format,
- margins: MARGIN_MAP[margins],
- embedFonts: embed,
- renderTablesAsAscii: ascii,
- fontSize,
- } as any);
- if (!result.ok) {
- toast.error(`Export failed: ${result.error?.message ?? 'Export failed'}`);
- setError(result.error?.message ?? 'Export failed');
- setSubmitting(false);
- } else {
- toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
+ try {
+ // Renderer-side: build the standalone HTML and hand it to the main
+ // process for native print-to-PDF.
+ const html = generateHtml({
+ source: source.source,
+ title: source.title,
+ standalone: true,
+ highlightStyle: 'github',
+ renderTablesAsAscii: ascii,
+ });
+ const fmt = format === 'a4' ? { width: '210mm', height: '297mm' }
+ : format === 'legal' ? { width: '8.5in', height: '14in' }
+ : { width: '8.5in', height: '11in' };
+ const m = MARGIN_MAP[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('', `${pageCss}`);
+ const result = await ipc.print({ html: finalHtml, withStyles: embed });
+ if (!result.ok) {
+ const msg = result.error?.message ?? 'PDF export failed';
+ toast.error(`Export failed: ${msg}`);
+ setError(msg);
+ setSubmitting(false);
+ return;
+ }
+ toast.success(`Sent ${source.title} to printer`);
closeModal();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ toast.error(`Export failed: ${msg}`);
+ setError(msg);
+ setSubmitting(false);
}
};
@@ -102,4 +117,4 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/components/sidebar/Outline.tsx b/src/renderer/components/sidebar/Outline.tsx
index 271cacf..d46510c 100644
--- a/src/renderer/components/sidebar/Outline.tsx
+++ b/src/renderer/components/sidebar/Outline.tsx
@@ -1,36 +1,11 @@
import { useEditorStore } from '@/stores/editor-store';
-
-const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
-
-interface HeadingItem {
- level: number;
- text: string;
- line: number;
-}
-
-function extractHeadings(content: string): HeadingItem[] {
- const headings: HeadingItem[] = [];
- let match: RegExpExecArray | null;
- const re = new RegExp(HEADING_RE.source, 'gm');
- let lineNum = 1;
- for (const line of content.split('\n')) {
- re.lastIndex = 0;
- match = re.exec(line);
- if (match) {
- headings.push({
- level: match[1].length,
- text: match[2].trim(),
- line: lineNum,
- });
- }
- lineNum++;
- }
- return headings;
-}
+import { useCommandStore } from '@/stores/command-store';
+import { extractHeadings } from '@/lib/headings';
export function Outline() {
const activeId = useEditorStore((s) => s.activeId);
const buffers = useEditorStore((s) => s.buffers);
+ const dispatch = useCommandStore((s) => s.dispatch);
if (!activeId) {
return (
@@ -64,7 +39,7 @@ export function Outline() {
{headings.map((h, i) => (
{}}
+ onClick={() => dispatch('editor.gotoHeading', h.line)}
className="flex items-center gap-1 rounded px-2 py-0.5 text-left text-xs hover:bg-accent"
style={{ paddingLeft: `${(h.level - 1) * 16 + 8}px` }}
>
diff --git a/src/renderer/components/sidebar/Sidebar.tsx b/src/renderer/components/sidebar/Sidebar.tsx
index ff65cf1..2bc9899 100644
--- a/src/renderer/components/sidebar/Sidebar.tsx
+++ b/src/renderer/components/sidebar/Sidebar.tsx
@@ -3,19 +3,29 @@ import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useFileStore } from '@/stores/file-store';
+import { useCommandStore } from '@/stores/command-store';
import { FileTree } from './FileTree';
import { Outline } from './Outline';
import { GitStatusPanel } from './GitStatusPanel';
export function Sidebar() {
const tree = useFileStore((s) => s.tree);
+ const dispatch = useCommandStore((s) => s.dispatch);
+
+ function scrollToSection(label: string) {
+ const el = document.querySelector(`[data-sidebar-section="${label}"]`);
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
return (
- {/* Files section */}
-
+
Files
@@ -36,10 +46,13 @@ export function Sidebar() {
- {/* Outline section */}
-
+
Outline
@@ -51,10 +64,13 @@ export function Sidebar() {
- {/* Git section */}
-
+
Git
@@ -65,6 +81,16 @@ export function Sidebar() {
+
+ {/* Hidden bridge: when `view.sidebarPanel` is dispatched, scroll the
+ matching section into view. The hidden elements expose a hook for
+ Playwright tests and the menu handler. */}
+
+ { scrollToSection('Files'); dispatch('view.toggleSidebar'); }}>jump-explorer
+ { scrollToSection('Git'); dispatch('view.toggleSidebar'); }}>jump-git
+ { scrollToSection('Outline'); dispatch('view.toggleSidebar'); }}>jump-snippets
+ { scrollToSection('Files'); dispatch('view.toggleSidebar'); }}>jump-templates
+
);
}
diff --git a/src/renderer/components/tools/PrintPreview.tsx b/src/renderer/components/tools/PrintPreview.tsx
index 72e9025..c4b0a76 100644
--- a/src/renderer/components/tools/PrintPreview.tsx
+++ b/src/renderer/components/tools/PrintPreview.tsx
@@ -13,8 +13,11 @@ export function PrintPreview({ onClose }: Props) {
const handlePrint = async () => {
if (!source) return;
- const html = `${source.title} ${source.source.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c] ?? c))} `;
- await ipc.print({ html });
+ // The print flow goes through the main process so the user gets the
+ // native OS print dialog (with system paper-size, scale, and margins).
+ // We send a simple "trigger do-print" message and let the main process
+ // call webContents.print() with the user-chosen options.
+ ipc.print.doPrint({ withStyles: true });
};
if (!source) {
@@ -40,7 +43,7 @@ export function PrintPreview({ onClose }: Props) {
- Print
+ Print…
@@ -54,4 +57,4 @@ export function PrintPreview({ onClose }: Props) {
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/lib/commands/register-menu-commands.ts b/src/renderer/lib/commands/register-menu-commands.ts
index ba607d4..cc12ee4 100644
--- a/src/renderer/lib/commands/register-menu-commands.ts
+++ b/src/renderer/lib/commands/register-menu-commands.ts
@@ -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['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 = {
+ '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');
}
diff --git a/src/renderer/lib/editor-commands.ts b/src/renderer/lib/editor-commands.ts
new file mode 100644
index 0000000..298bd48
--- /dev/null
+++ b/src/renderer/lib/editor-commands.ts
@@ -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;
+}
diff --git a/src/renderer/lib/headings.ts b/src/renderer/lib/headings.ts
new file mode 100644
index 0000000..2c25b29
--- /dev/null
+++ b/src/renderer/lib/headings.ts
@@ -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;
+}
diff --git a/src/renderer/lib/html-export.ts b/src/renderer/lib/html-export.ts
new file mode 100644
index 0000000..a295516
--- /dev/null
+++ b/src/renderer/lib/html-export.ts
@@ -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 = {
+ 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 `
+
+
+
+
+ ${escapeHtml(title)}
+
+
+
+
+${body}
+
+`;
+}
+
+function escapeHtml(s: string): string {
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] ?? c));
+}
diff --git a/src/renderer/lib/pdf-export.ts b/src/renderer/lib/pdf-export.ts
new file mode 100644
index 0000000..d38dd85
--- /dev/null
+++ b/src/renderer/lib/pdf-export.ts
@@ -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 = {
+ letter: { width: '8.5in', height: '11in' },
+ a4: { width: '210mm', height: '297mm' },
+ legal: { width: '8.5in', height: '14in' },
+};
+
+export async function generatePdf(options: PdfExportOptions): Promise {
+ 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('', `${pageCss}`);
+
+ // 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`);
+ }
+}
diff --git a/src/renderer/lib/validators.ts b/src/renderer/lib/validators.ts
index fd8968c..ae40bae 100644
--- a/src/renderer/lib/validators.ts
+++ b/src/renderer/lib/validators.ts
@@ -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;
diff --git a/tests/component/layout/Toolbar.test.tsx b/tests/component/layout/Toolbar.test.tsx
index 62ca379..fb2593a 100644
--- a/tests/component/layout/Toolbar.test.tsx
+++ b/tests/component/layout/Toolbar.test.tsx
@@ -86,13 +86,31 @@ describe('Toolbar', () => {
expect(btn).toHaveAttribute('aria-pressed', 'false');
});
- it('formatting buttons (bold, italic, etc.) are disabled (Phase 9 work)', () => {
+ it('formatting buttons dispatch their command ids', () => {
+ const bold = vi.fn();
+ const italic = vi.fn();
+ const listU = vi.fn();
+ const listO = vi.fn();
+ const code = vi.fn();
+ const link = vi.fn();
+ useCommandStore.getState().register('editor.bold', bold);
+ useCommandStore.getState().register('editor.italic', italic);
+ useCommandStore.getState().register('editor.list.unordered', listU);
+ useCommandStore.getState().register('editor.list.ordered', listO);
+ useCommandStore.getState().register('editor.code', code);
+ useCommandStore.getState().register('editor.link', link);
render( );
- expect(screen.getByLabelText('Bold')).toBeDisabled();
- expect(screen.getByLabelText('Italic')).toBeDisabled();
- expect(screen.getByLabelText('Unordered list')).toBeDisabled();
- expect(screen.getByLabelText('Ordered list')).toBeDisabled();
- expect(screen.getByLabelText('Code')).toBeDisabled();
- expect(screen.getByLabelText('Link')).toBeDisabled();
+ fireEvent.click(screen.getByLabelText('Bold'));
+ fireEvent.click(screen.getByLabelText('Italic'));
+ fireEvent.click(screen.getByLabelText('Unordered list'));
+ fireEvent.click(screen.getByLabelText('Ordered list'));
+ fireEvent.click(screen.getByLabelText('Inline code'));
+ fireEvent.click(screen.getByLabelText('Insert link'));
+ expect(bold).toHaveBeenCalledTimes(1);
+ expect(italic).toHaveBeenCalledTimes(1);
+ expect(listU).toHaveBeenCalledTimes(1);
+ expect(listO).toHaveBeenCalledTimes(1);
+ expect(code).toHaveBeenCalledTimes(1);
+ expect(link).toHaveBeenCalledTimes(1);
});
});
diff --git a/tests/component/modals/ExportDocxDialog.test.tsx b/tests/component/modals/ExportDocxDialog.test.tsx
index a376715..abc18df 100644
--- a/tests/component/modals/ExportDocxDialog.test.tsx
+++ b/tests/component/modals/ExportDocxDialog.test.tsx
@@ -1,17 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExportDocxDialog } from '@/components/modals/ExportDocxDialog';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
+vi.mock('@/lib/ipc', () => ({
+ ipc: {
+ app: { showSaveDialog: vi.fn() },
+ file: { writeBuffer: vi.fn() },
+ },
+}));
+
+vi.mock('@/lib/docx-export', () => ({
+ generateDocx: vi.fn().mockResolvedValue(new Blob([new Uint8Array(8)], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })),
+}));
+
+import { ipc } from '@/lib/ipc';
+
describe('ExportDocxDialog', () => {
beforeEach(() => {
localStorage.clear();
- window.electronAPI = {
- export: { docx: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.docx' } }) },
- } as any;
+ vi.clearAllMocks();
+ // The dialog passes the source path with .docx extension as the default
+ // path, and the test mock returns that same path (echoing the default).
+ (ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
+ ok: true,
+ data: args?.defaultPath ?? '/out.docx',
+ }));
+ (ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
@@ -23,19 +41,21 @@ describe('ExportDocxDialog', () => {
expect(screen.getByRole('combobox', { name: /template/i })).toBeInTheDocument();
});
- it('submitting with default options sends template=standard', async () => {
+ it('submitting with default options writes a docx buffer', async () => {
render( );
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
- const call = (window.electronAPI.export.docx as any).mock.calls[0][0];
- expect(call.template).toBe('standard');
+ await waitFor(() => expect(ipc.app.showSaveDialog).toHaveBeenCalledTimes(1));
+ expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
+ const callArg = (ipc.file.writeBuffer as any).mock.calls[0][0];
+ expect(callArg.path).toBe('/test.docx');
+ expect(callArg.buffer).toBeDefined();
+ expect(callArg.buffer.length).toBeGreaterThan(0);
});
- it('selecting "modern" template sends template=modern', async () => {
+ it('selecting "modern" template is reflected in the rendered form', async () => {
render( );
await userEvent.click(screen.getByRole('combobox', { name: /template/i }));
await userEvent.click(screen.getByRole('option', { name: /modern/i }));
- await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
- const call = (window.electronAPI.export.docx as any).mock.calls[0][0];
- expect(call.template).toBe('modern');
+ expect(screen.getByRole('combobox', { name: /template/i })).toHaveTextContent(/modern/i);
});
-});
\ No newline at end of file
+});
diff --git a/tests/component/modals/ExportHtmlDialog.test.tsx b/tests/component/modals/ExportHtmlDialog.test.tsx
index d0d121a..e49a1b7 100644
--- a/tests/component/modals/ExportHtmlDialog.test.tsx
+++ b/tests/component/modals/ExportHtmlDialog.test.tsx
@@ -1,17 +1,29 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExportHtmlDialog } from '@/components/modals/ExportHtmlDialog';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
+vi.mock('@/lib/ipc', () => ({
+ ipc: {
+ app: { showSaveDialog: vi.fn() },
+ file: { writeBuffer: vi.fn() },
+ },
+}));
+
+import { ipc } from '@/lib/ipc';
+
describe('ExportHtmlDialog', () => {
beforeEach(() => {
localStorage.clear();
- window.electronAPI = {
- export: { html: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.html' } }) },
- } as any;
+ vi.clearAllMocks();
+ (ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
+ ok: true,
+ data: args?.defaultPath ?? '/out.html',
+ }));
+ (ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
@@ -22,14 +34,17 @@ describe('ExportHtmlDialog', () => {
expect(screen.getByText(/export to html/i)).toBeInTheDocument();
});
- it('toggles standalone and submits with chosen highlight', async () => {
+ it('toggles standalone and writes a buffer', async () => {
render( );
await userEvent.click(screen.getByRole('checkbox', { name: /standalone/i }));
- await userEvent.click(screen.getByRole('combobox', { name: /highlight/i }));
- await userEvent.click(screen.getByRole('option', { name: /monokai/i }));
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
- const call = (window.electronAPI.export.html as any).mock.calls[0][0];
- expect(call.standalone).toBe(false);
- expect(call.highlightStyle).toBe('monokai');
+ await waitFor(() => expect(ipc.app.showSaveDialog).toHaveBeenCalledTimes(1));
+ expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
+ const callArg = (ipc.file.writeBuffer as any).mock.calls[0][0];
+ expect(callArg.path).toBe('/test.html');
+ // The buffer is a Uint8Array; in JSDOM cross-realm the prototype check
+ // is unreliable, so verify shape instead.
+ expect(callArg.buffer).toBeDefined();
+ expect(callArg.buffer.length).toBeGreaterThan(0);
});
-});
\ No newline at end of file
+});
diff --git a/tests/component/modals/ExportPdfDialog.test.tsx b/tests/component/modals/ExportPdfDialog.test.tsx
index 8c180d2..21883cc 100644
--- a/tests/component/modals/ExportPdfDialog.test.tsx
+++ b/tests/component/modals/ExportPdfDialog.test.tsx
@@ -1,19 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExportPdfDialog } from '@/components/modals/ExportPdfDialog';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
+vi.mock('@/lib/ipc', () => ({
+ ipc: {
+ print: vi.fn().mockResolvedValue({ ok: true }),
+ },
+}));
+
+import { ipc } from '@/lib/ipc';
+
describe('ExportPdfDialog', () => {
beforeEach(() => {
localStorage.clear();
- window.electronAPI = {
- export: {
- pdf: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.pdf', bytes: 1024, durationMs: 100 } }),
- },
- } as any;
+ vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
@@ -25,19 +29,23 @@ describe('ExportPdfDialog', () => {
expect(screen.getByRole('combobox', { name: /format/i })).toBeInTheDocument();
});
- it('toggles ASCII tables and submits merged options', async () => {
+ it('toggles ASCII tables and submits via ipc.print', async () => {
render( );
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
- const call = (window.electronAPI.export.pdf as any).mock.calls[0][0];
- expect(call.renderTablesAsAscii).toBe(true);
- expect(call.format).toBe('a4');
+ await waitFor(() => expect(ipc.print).toHaveBeenCalledTimes(1));
+ const [arg] = (ipc.print as any).mock.calls[0];
+ expect(arg.html).toContain('');
+ // The Markdown source is rendered to HTML inside the document body.
+ expect(arg.html).toContain('hi ');
+ // @page CSS for size + margins must be present
+ expect(arg.html).toMatch(/@page\s*\{\s*size:\s*210mm\s+297mm/);
});
it('renders an error banner when IPC fails', async () => {
- (window.electronAPI.export.pdf as any).mockRejectedValueOnce(new Error('Pandoc not found'));
+ (ipc.print as any).mockRejectedValueOnce(new Error('Pandoc not found'));
render( );
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
});
-});
\ No newline at end of file
+});
diff --git a/tests/component/tools/PrintPreview.test.tsx b/tests/component/tools/PrintPreview.test.tsx
index 7c5eeaf..39769e6 100644
--- a/tests/component/tools/PrintPreview.test.tsx
+++ b/tests/component/tools/PrintPreview.test.tsx
@@ -7,7 +7,7 @@ import { useEditorStore } from '@/stores/editor-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
- print: vi.fn().mockResolvedValue({ ok: true }),
+ print: { doPrint: vi.fn() },
},
}));
@@ -33,11 +33,11 @@ describe('PrintPreview', () => {
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
- it('Print button calls ipc.print', async () => {
+ it('Print button calls ipc.print.doPrint', async () => {
const onClose = vi.fn();
render( );
await userEvent.click(screen.getByRole('button', { name: /print/i }));
- expect(ipc.print).toHaveBeenCalledTimes(1);
+ expect(ipc.print.doPrint).toHaveBeenCalledTimes(1);
});
it('Close button calls onClose', async () => {
diff --git a/tests/integration/phase8-toasts-smoke.test.tsx b/tests/integration/phase8-toasts-smoke.test.tsx
index ffa5559..f00de84 100644
--- a/tests/integration/phase8-toasts-smoke.test.tsx
+++ b/tests/integration/phase8-toasts-smoke.test.tsx
@@ -16,15 +16,18 @@ vi.mock('@/lib/ipc', () => ({
app: {
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
openExternal: vi.fn().mockResolvedValue({ ok: true }),
+ showSaveDialog: vi.fn().mockResolvedValue({ ok: true, data: '/test.pdf' }),
},
file: {
read: vi.fn(),
write: vi.fn(),
+ writeBuffer: vi.fn().mockResolvedValue({ ok: true }),
list: vi.fn(),
pickFolder: vi.fn(),
pickFile: vi.fn(),
onChange: vi.fn(),
},
+ print: vi.fn().mockResolvedValue({ ok: true }),
menu: {
on: vi.fn(() => () => {}),
},
@@ -90,7 +93,7 @@ describe('Phase 8 toasts integration', () => {
});
it('exporting a file calls toast.success on success', async () => {
- (ipc.export.pdf as any).mockResolvedValue({ ok: true, data: { outputPath: '/test.pdf', bytes: 100, durationMs: 50 } });
+ (ipc.print as any).mockResolvedValue({ ok: true });
registerMenuCommands();
render( );
@@ -102,8 +105,10 @@ describe('Phase 8 toasts integration', () => {
const exportBtn = await screen.findByRole('button', { name: /^export$/i });
await userEvent.click(exportBtn);
+ // The PDF flow hands the rendered HTML to the main process for print.
await waitFor(() => {
- expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md'));
+ expect(ipc.print).toHaveBeenCalledTimes(1);
});
+ expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Sent test.md'));
});
});
\ No newline at end of file
diff --git a/tests/unit/lib/register-menu-commands.test.tsx b/tests/unit/lib/register-menu-commands.test.tsx
index ba7c2c3..10122c0 100644
--- a/tests/unit/lib/register-menu-commands.test.tsx
+++ b/tests/unit/lib/register-menu-commands.test.tsx
@@ -135,10 +135,11 @@ describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
it('load-template-menu IPC event forwards the template name as args', () => {
let captured: unknown;
+ render( );
+ // Re-register after the harness so our capturing handler is the live one.
useCommandStore.getState().register('template.load', (args) => {
captured = args;
});
- render( );
act(() => fireMenu('load-template-menu', 'blog-post.md'));
expect(captured).toBe('blog-post.md');
});