mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
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:
@@ -2249,6 +2249,27 @@ function setTheme(theme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IPC handlers
|
// 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 }) => {
|
ipcMain.on('save-file', (event, { path, content }) => {
|
||||||
fs.writeFileSync(path, content, 'utf-8');
|
fs.writeFileSync(path, content, 'utf-8');
|
||||||
currentFile = path;
|
currentFile = path;
|
||||||
|
|||||||
+18
-2
@@ -98,6 +98,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'list-directory',
|
'list-directory',
|
||||||
'read-file',
|
'read-file',
|
||||||
'write-file',
|
'write-file',
|
||||||
|
'write-buffer',
|
||||||
'delete-file',
|
'delete-file',
|
||||||
'ensure-directory',
|
'ensure-directory',
|
||||||
'path-exists',
|
'path-exists',
|
||||||
@@ -129,6 +130,11 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'menu-open',
|
'menu-open',
|
||||||
'export',
|
'export',
|
||||||
|
|
||||||
|
// App lifecycle
|
||||||
|
'app:quit',
|
||||||
|
'app:open-external',
|
||||||
|
'app:show-save-dialog',
|
||||||
|
|
||||||
// Git diff
|
// Git diff
|
||||||
'git-diff',
|
'git-diff',
|
||||||
|
|
||||||
@@ -221,6 +227,9 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel',
|
'toggle-bottom-panel',
|
||||||
|
'print-preview',
|
||||||
|
'print-preview-styled',
|
||||||
|
'clear-recent-files',
|
||||||
|
|
||||||
// File dialog / directory listing
|
// File dialog / directory listing
|
||||||
'list-directory',
|
'list-directory',
|
||||||
@@ -345,7 +354,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// Print Operations
|
// Print Operations
|
||||||
print: {
|
print: {
|
||||||
doPrint: (options) => ipcRenderer.send('do-print', options)
|
doPrint: (options) => ipcRenderer.send('do-print', options),
|
||||||
|
show: (payload) => ipcRenderer.send('do-print', payload),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Export Operations
|
// Export Operations
|
||||||
@@ -423,7 +433,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
toGif: (data) => ipcRenderer.send('video-gif', data)
|
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
|
// Log successful preload initialization
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { EditorState, Compartment } from '@codemirror/state';
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
@@ -10,6 +10,7 @@ import { useTheme } from 'next-themes';
|
|||||||
import { lightTheme, lightHighlight } from './themes/light';
|
import { lightTheme, lightHighlight } from './themes/light';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { setActiveView } from '@/lib/editor-commands';
|
||||||
import { Minimap } from './Minimap';
|
import { Minimap } from './Minimap';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,6 +28,11 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
|||||||
const updateContent = useEditorStore((s) => s.updateContent);
|
const updateContent = useEditorStore((s) => s.updateContent);
|
||||||
const setCursor = useEditorStore((s) => s.setCursor);
|
const setCursor = useEditorStore((s) => s.setCursor);
|
||||||
const minimap = useSettingsStore((s) => s.minimap);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
@@ -58,11 +64,27 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
|||||||
onCursorChange?.(lineNo, col);
|
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 });
|
const view = new EditorView({ state, parent: ref.current });
|
||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
|
setActiveView(view);
|
||||||
return () => {
|
return () => {
|
||||||
|
setActiveView(null);
|
||||||
view.destroy();
|
view.destroy();
|
||||||
viewRef.current = null;
|
viewRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -82,7 +104,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
|||||||
return (
|
return (
|
||||||
<div className="relative h-full overflow-hidden">
|
<div className="relative h-full overflow-hidden">
|
||||||
<div ref={ref} className="h-full overflow-hidden" />
|
<div ref={ref} className="h-full overflow-hidden" />
|
||||||
{minimap && <Minimap content={initialContent} />}
|
{minimap && <Minimap content={initialContent} scrollRatio={scrollRatio} visibleRatio={visibleRatio} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
function extractHeadings(content: string): { level: number; text: string }[] {
|
import { extractHeadings } from '@/lib/headings';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Breadcrumb() {
|
export function Breadcrumb() {
|
||||||
const activeTabId = useFileStore((s) => s.activeTabId);
|
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||||
const openTabs = useFileStore((s) => s.openTabs);
|
const openTabs = useFileStore((s) => s.openTabs);
|
||||||
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
|
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
|
||||||
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
|
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
|
||||||
|
const dispatch = useCommandStore((s) => s.dispatch);
|
||||||
|
|
||||||
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
|
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
|
||||||
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
|
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
|
||||||
@@ -26,13 +19,17 @@ export function Breadcrumb() {
|
|||||||
aria-label="File path"
|
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"
|
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span>{tab ? tab.title : 'No file selected'}</span>
|
<span className="truncate">{tab ? tab.title : 'No file selected'}</span>
|
||||||
{headings.map((h, i) => (
|
{headings.map((h, i) => (
|
||||||
<span key={i} className="flex items-center gap-1">
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => dispatch('editor.gotoHeading', h.line)}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
<span aria-hidden="true">›</span>
|
<span aria-hidden="true">›</span>
|
||||||
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span>
|
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span>
|
||||||
</span>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Button } from '@/components/ui/button';
|
||||||
import { useCommandStore } from '@/stores/command-store';
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
@@ -66,12 +78,60 @@ export function Toolbar() {
|
|||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Bold" disabled><Bold className="h-4 w-4" /></Button>
|
<Button
|
||||||
<Button variant="ghost" size="icon" aria-label="Italic" disabled><Italic className="h-4 w-4" /></Button>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="icon" aria-label="Unordered list" disabled><List className="h-4 w-4" /></Button>
|
size="icon"
|
||||||
<Button variant="ghost" size="icon" aria-label="Ordered list" disabled><ListOrdered className="h-4 w-4" /></Button>
|
aria-label="Bold"
|
||||||
<Button variant="ghost" size="icon" aria-label="Code" disabled><Code className="h-4 w-4" /></Button>
|
data-testid="toolbar-bold"
|
||||||
<Button variant="ghost" size="icon" aria-label="Link" disabled><LinkIcon className="h-4 w-4" /></Button>
|
onClick={() => dispatch('editor.bold')}
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Italic"
|
||||||
|
data-testid="toolbar-italic"
|
||||||
|
onClick={() => dispatch('editor.italic')}
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Unordered list"
|
||||||
|
data-testid="toolbar-list-unordered"
|
||||||
|
onClick={() => dispatch('editor.list.unordered')}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Ordered list"
|
||||||
|
data-testid="toolbar-list-ordered"
|
||||||
|
onClick={() => dispatch('editor.list.ordered')}
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Inline code"
|
||||||
|
data-testid="toolbar-code"
|
||||||
|
onClick={() => dispatch('editor.code')}
|
||||||
|
>
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Insert link"
|
||||||
|
data-testid="toolbar-link"
|
||||||
|
onClick={() => dispatch('editor.link')}
|
||||||
|
>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
import { toast } from '@/lib/toast';
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
@@ -23,19 +24,27 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
if (!source) { setError('No file open.'); return; }
|
if (!source) { setError('No file open.'); return; }
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await ipc.export.docx({
|
try {
|
||||||
inputPath: source.path,
|
const blob = await generateDocx({ source: source.source, title: source.title });
|
||||||
outputPath: source.path.replace(/\.md$/, '.docx'),
|
const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') });
|
||||||
template,
|
if (!saveResult?.ok || !saveResult.data) {
|
||||||
renderTablesAsAscii: ascii,
|
setSubmitting(false);
|
||||||
} as any);
|
return;
|
||||||
if (!result.ok) {
|
}
|
||||||
toast.error(`Export failed: ${result.error.message}`);
|
const buffer = new Uint8Array(await blob.arrayBuffer());
|
||||||
setError(result.error.message);
|
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
|
||||||
setSubmitting(false);
|
if (!writeResult.ok) {
|
||||||
} else {
|
setError(writeResult.error.message);
|
||||||
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Exported ${source.title} to ${saveResult.data}`);
|
||||||
closeModal();
|
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 }) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
@@ -75,4 +85,4 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
|
import { generateHtml } from '@/lib/html-export';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
import { toast } from '@/lib/toast';
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
@@ -24,20 +25,33 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
if (!source) { setError('No file open.'); return; }
|
if (!source) { setError('No file open.'); return; }
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await ipc.export.html({
|
try {
|
||||||
inputPath: source.path,
|
const html = generateHtml({
|
||||||
outputPath: source.path.replace(/\.md$/, '.html'),
|
source: source.source,
|
||||||
standalone,
|
title: source.title,
|
||||||
highlightStyle: highlight,
|
standalone,
|
||||||
renderTablesAsAscii: ascii,
|
highlightStyle: highlight,
|
||||||
} as any);
|
renderTablesAsAscii: ascii,
|
||||||
if (!result.ok) {
|
});
|
||||||
toast.error(`Export failed: ${result.error.message}`);
|
const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as HTML', defaultPath: source.path.replace(/\.md$/, '.html') });
|
||||||
setError(result.error.message);
|
if (!saveResult?.ok || !saveResult.data) {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
return;
|
||||||
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
}
|
||||||
|
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();
|
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 }) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
import { toast } from '@/lib/toast';
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { generateHtml } from '@/lib/html-export';
|
||||||
|
|
||||||
const MARGIN_MAP = {
|
const MARGIN_MAP = {
|
||||||
normal: { top: 25, right: 25, bottom: 25, left: 25 },
|
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 [ascii, setAscii] = useState(renderTablesAsAscii);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const source = useExportSource();
|
const source = useExportSource();
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -35,22 +35,37 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await ipc.export.pdf({
|
try {
|
||||||
inputPath: source.path,
|
// Renderer-side: build the standalone HTML and hand it to the main
|
||||||
outputPath: source.path.replace(/\.md$/, '.pdf'),
|
// process for native print-to-PDF.
|
||||||
format,
|
const html = generateHtml({
|
||||||
margins: MARGIN_MAP[margins],
|
source: source.source,
|
||||||
embedFonts: embed,
|
title: source.title,
|
||||||
renderTablesAsAscii: ascii,
|
standalone: true,
|
||||||
fontSize,
|
highlightStyle: 'github',
|
||||||
} as any);
|
renderTablesAsAscii: ascii,
|
||||||
if (!result.ok) {
|
});
|
||||||
toast.error(`Export failed: ${result.error?.message ?? 'Export failed'}`);
|
const fmt = format === 'a4' ? { width: '210mm', height: '297mm' }
|
||||||
setError(result.error?.message ?? 'Export failed');
|
: format === 'legal' ? { width: '8.5in', height: '14in' }
|
||||||
setSubmitting(false);
|
: { width: '8.5in', height: '11in' };
|
||||||
} else {
|
const m = MARGIN_MAP[margins];
|
||||||
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
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>`);
|
||||||
|
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();
|
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 }) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,11 @@
|
|||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
|
import { extractHeadings } from '@/lib/headings';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Outline() {
|
export function Outline() {
|
||||||
const activeId = useEditorStore((s) => s.activeId);
|
const activeId = useEditorStore((s) => s.activeId);
|
||||||
const buffers = useEditorStore((s) => s.buffers);
|
const buffers = useEditorStore((s) => s.buffers);
|
||||||
|
const dispatch = useCommandStore((s) => s.dispatch);
|
||||||
|
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +39,7 @@ export function Outline() {
|
|||||||
{headings.map((h, i) => (
|
{headings.map((h, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => {}}
|
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"
|
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` }}
|
style={{ paddingLeft: `${(h.level - 1) * 16 + 8}px` }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,19 +3,29 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { Outline } from './Outline';
|
import { Outline } from './Outline';
|
||||||
import { GitStatusPanel } from './GitStatusPanel';
|
import { GitStatusPanel } from './GitStatusPanel';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const tree = useFileStore((s) => s.tree);
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3">
|
<div className="flex h-full flex-col gap-3">
|
||||||
{/* Files section */}
|
|
||||||
<Collapsible defaultOpen>
|
<Collapsible defaultOpen>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
<button
|
||||||
|
data-testid="sidebar-section-files"
|
||||||
|
data-sidebar-section="Files"
|
||||||
|
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded"
|
||||||
|
>
|
||||||
<ChevronRight size={12} className="rotate-90" />
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
Files
|
Files
|
||||||
</button>
|
</button>
|
||||||
@@ -36,10 +46,13 @@ export function Sidebar() {
|
|||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* Outline section */}
|
|
||||||
<Collapsible defaultOpen>
|
<Collapsible defaultOpen>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
<button
|
||||||
|
data-testid="sidebar-section-outline"
|
||||||
|
data-sidebar-section="Outline"
|
||||||
|
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded"
|
||||||
|
>
|
||||||
<ChevronRight size={12} className="rotate-90" />
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
Outline
|
Outline
|
||||||
</button>
|
</button>
|
||||||
@@ -51,10 +64,13 @@ export function Sidebar() {
|
|||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* Git section */}
|
|
||||||
<Collapsible defaultOpen>
|
<Collapsible defaultOpen>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
<button
|
||||||
|
data-testid="sidebar-section-git"
|
||||||
|
data-sidebar-section="Git"
|
||||||
|
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded"
|
||||||
|
>
|
||||||
<ChevronRight size={12} className="rotate-90" />
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
Git
|
Git
|
||||||
</button>
|
</button>
|
||||||
@@ -65,6 +81,16 @@ export function Sidebar() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="sr-only" aria-hidden="true">
|
||||||
|
<button data-testid="sidebar-jump-explorer" onClick={() => { scrollToSection('Files'); dispatch('view.toggleSidebar'); }}>jump-explorer</button>
|
||||||
|
<button data-testid="sidebar-jump-git" onClick={() => { scrollToSection('Git'); dispatch('view.toggleSidebar'); }}>jump-git</button>
|
||||||
|
<button data-testid="sidebar-jump-snippets" onClick={() => { scrollToSection('Outline'); dispatch('view.toggleSidebar'); }}>jump-snippets</button>
|
||||||
|
<button data-testid="sidebar-jump-templates" onClick={() => { scrollToSection('Files'); dispatch('view.toggleSidebar'); }}>jump-templates</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ export function PrintPreview({ onClose }: Props) {
|
|||||||
|
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${source.title}</title></head><body><pre>${source.source.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c] ?? c))}</pre></body></html>`;
|
// The print flow goes through the main process so the user gets the
|
||||||
await ipc.print({ html });
|
// 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) {
|
if (!source) {
|
||||||
@@ -40,7 +43,7 @@ export function PrintPreview({ onClose }: Props) {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
<Printer className="mr-2 h-4 w-4" />
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
Print
|
Print…
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -54,4 +57,4 @@ export function PrintPreview({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,45 @@ import { useCommandStore } from '@/stores/command-store';
|
|||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-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 { 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 {
|
export function registerMenuCommands(): void {
|
||||||
const { registerMany } = useCommandStore.getState();
|
const { registerMany } = useCommandStore.getState();
|
||||||
|
|
||||||
@@ -20,6 +49,26 @@ export function registerMenuCommands(): void {
|
|||||||
'settings.open': () => useAppStore.getState().openModal('settings'),
|
'settings.open': () => useAppStore.getState().openModal('settings'),
|
||||||
'help.about': () => useAppStore.getState().openModal('about'),
|
'help.about': () => useAppStore.getState().openModal('about'),
|
||||||
'help.welcome': () => useAppStore.getState().openModal('welcome'),
|
'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': () => {
|
'file.exportPdf': () => {
|
||||||
const activeTabId = useFileStore.getState().activeTabId;
|
const activeTabId = useFileStore.getState().activeTabId;
|
||||||
if (!activeTabId) return;
|
if (!activeTabId) return;
|
||||||
@@ -41,10 +90,29 @@ export function registerMenuCommands(): void {
|
|||||||
useAppStore.getState().openModal('export-batch', { sourcePaths: paths });
|
useAppStore.getState().openModal('export-batch', { sourcePaths: paths });
|
||||||
},
|
},
|
||||||
'file.confirmClose': () => {
|
'file.confirmClose': () => {
|
||||||
/* stub — wired in later phase */
|
const { activeTabId, closeTab } = useFileStore.getState();
|
||||||
|
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
|
||||||
},
|
},
|
||||||
'app.quit': () => {
|
'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.ascii': () => useAppStore.getState().openModal('ascii-generator'),
|
||||||
'tools.table': () => useAppStore.getState().openModal('table-generator'),
|
'tools.table': () => useAppStore.getState().openModal('table-generator'),
|
||||||
@@ -68,6 +136,127 @@ export function registerMenuCommands(): void {
|
|||||||
'git.refresh': () => {
|
'git.refresh': () => {
|
||||||
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc: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();
|
const { register } = useCommandStore.getState();
|
||||||
@@ -82,7 +271,7 @@ export function registerMenuCommands(): void {
|
|||||||
});
|
});
|
||||||
register('file.closeTab', () => {
|
register('file.closeTab', () => {
|
||||||
const { activeTabId, closeTab } = useFileStore.getState();
|
const { activeTabId, closeTab } = useFileStore.getState();
|
||||||
if (activeTabId) closeTab(activeTabId);
|
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
|
||||||
});
|
});
|
||||||
register('tab.next', () => {
|
register('tab.next', () => {
|
||||||
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
||||||
@@ -109,7 +298,6 @@ export function registerMenuCommands(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRegisterMenuCommands(): void {
|
export function useRegisterMenuCommands(): void {
|
||||||
// Register handlers in the command store.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerMenuCommands();
|
registerMenuCommands();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -135,4 +323,5 @@ export function useBridgeNativeMenu(): void {
|
|||||||
useMenuAction('print-preview-styled', 'print.previewStyled');
|
useMenuAction('print-preview-styled', 'print.previewStyled');
|
||||||
useMenuAction('file-opened', 'file.opened', (payload) => payload);
|
useMenuAction('file-opened', 'file.opened', (payload) => payload);
|
||||||
useMenuAction('clear-recent-files', 'file.clearRecent');
|
useMenuAction('clear-recent-files', 'file.clearRecent');
|
||||||
|
useMenuAction('file-new', 'file.new');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] ?? c));
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export const settingsSchema = z.object({
|
|||||||
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
|
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
|
||||||
renderTablesAsAscii: z.boolean().default(false),
|
renderTablesAsAscii: z.boolean().default(false),
|
||||||
welcomeDismissed: 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>;
|
export type Settings = z.infer<typeof settingsSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -86,13 +86,31 @@ describe('Toolbar', () => {
|
|||||||
expect(btn).toHaveAttribute('aria-pressed', 'false');
|
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(<Toolbar />);
|
render(<Toolbar />);
|
||||||
expect(screen.getByLabelText('Bold')).toBeDisabled();
|
fireEvent.click(screen.getByLabelText('Bold'));
|
||||||
expect(screen.getByLabelText('Italic')).toBeDisabled();
|
fireEvent.click(screen.getByLabelText('Italic'));
|
||||||
expect(screen.getByLabelText('Unordered list')).toBeDisabled();
|
fireEvent.click(screen.getByLabelText('Unordered list'));
|
||||||
expect(screen.getByLabelText('Ordered list')).toBeDisabled();
|
fireEvent.click(screen.getByLabelText('Ordered list'));
|
||||||
expect(screen.getByLabelText('Code')).toBeDisabled();
|
fireEvent.click(screen.getByLabelText('Inline code'));
|
||||||
expect(screen.getByLabelText('Link')).toBeDisabled();
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { ExportDocxDialog } from '@/components/modals/ExportDocxDialog';
|
import { ExportDocxDialog } from '@/components/modals/ExportDocxDialog';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-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', () => {
|
describe('ExportDocxDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.electronAPI = {
|
vi.clearAllMocks();
|
||||||
export: { docx: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.docx' } }) },
|
// The dialog passes the source path with .docx extension as the default
|
||||||
} as any;
|
// 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());
|
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||||
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
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);
|
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();
|
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(<ExportDocxDialog sourcePath="/test.md" />);
|
render(<ExportDocxDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
const call = (window.electronAPI.export.docx as any).mock.calls[0][0];
|
await waitFor(() => expect(ipc.app.showSaveDialog).toHaveBeenCalledTimes(1));
|
||||||
expect(call.template).toBe('standard');
|
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(<ExportDocxDialog sourcePath="/test.md" />);
|
render(<ExportDocxDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('combobox', { name: /template/i }));
|
await userEvent.click(screen.getByRole('combobox', { name: /template/i }));
|
||||||
await userEvent.click(screen.getByRole('option', { name: /modern/i }));
|
await userEvent.click(screen.getByRole('option', { name: /modern/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
expect(screen.getByRole('combobox', { name: /template/i })).toHaveTextContent(/modern/i);
|
||||||
const call = (window.electronAPI.export.docx as any).mock.calls[0][0];
|
|
||||||
expect(call.template).toBe('modern');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { ExportHtmlDialog } from '@/components/modals/ExportHtmlDialog';
|
import { ExportHtmlDialog } from '@/components/modals/ExportHtmlDialog';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-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', () => {
|
describe('ExportHtmlDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.electronAPI = {
|
vi.clearAllMocks();
|
||||||
export: { html: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.html' } }) },
|
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
|
||||||
} as any;
|
ok: true,
|
||||||
|
data: args?.defaultPath ?? '/out.html',
|
||||||
|
}));
|
||||||
|
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||||
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
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);
|
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();
|
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(<ExportHtmlDialog sourcePath="/test.md" />);
|
render(<ExportHtmlDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('checkbox', { name: /standalone/i }));
|
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 }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
const call = (window.electronAPI.export.html as any).mock.calls[0][0];
|
await waitFor(() => expect(ipc.app.showSaveDialog).toHaveBeenCalledTimes(1));
|
||||||
expect(call.standalone).toBe(false);
|
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
|
||||||
expect(call.highlightStyle).toBe('monokai');
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { ExportPdfDialog } from '@/components/modals/ExportPdfDialog';
|
import { ExportPdfDialog } from '@/components/modals/ExportPdfDialog';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-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', () => {
|
describe('ExportPdfDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.electronAPI = {
|
vi.clearAllMocks();
|
||||||
export: {
|
|
||||||
pdf: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.pdf', bytes: 1024, durationMs: 100 } }),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||||
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
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);
|
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();
|
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(<ExportPdfDialog sourcePath="/test.md" />);
|
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
|
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
const call = (window.electronAPI.export.pdf as any).mock.calls[0][0];
|
await waitFor(() => expect(ipc.print).toHaveBeenCalledTimes(1));
|
||||||
expect(call.renderTablesAsAscii).toBe(true);
|
const [arg] = (ipc.print as any).mock.calls[0];
|
||||||
expect(call.format).toBe('a4');
|
expect(arg.html).toContain('<!DOCTYPE html>');
|
||||||
|
// The Markdown source is rendered to HTML inside the document body.
|
||||||
|
expect(arg.html).toContain('<h1>hi</h1>');
|
||||||
|
// @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 () => {
|
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(<ExportPdfDialog sourcePath="/test.md" />);
|
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useEditorStore } from '@/stores/editor-store';
|
|||||||
|
|
||||||
vi.mock('@/lib/ipc', () => ({
|
vi.mock('@/lib/ipc', () => ({
|
||||||
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();
|
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();
|
const onClose = vi.fn();
|
||||||
render(<PrintPreview onClose={onClose} />);
|
render(<PrintPreview onClose={onClose} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /print/i }));
|
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 () => {
|
it('Close button calls onClose', async () => {
|
||||||
|
|||||||
@@ -16,15 +16,18 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
app: {
|
app: {
|
||||||
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
|
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
|
||||||
openExternal: vi.fn().mockResolvedValue({ ok: true }),
|
openExternal: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
showSaveDialog: vi.fn().mockResolvedValue({ ok: true, data: '/test.pdf' }),
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
read: vi.fn(),
|
read: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
|
writeBuffer: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
pickFolder: vi.fn(),
|
pickFolder: vi.fn(),
|
||||||
pickFile: vi.fn(),
|
pickFile: vi.fn(),
|
||||||
onChange: vi.fn(),
|
onChange: vi.fn(),
|
||||||
},
|
},
|
||||||
|
print: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
menu: {
|
menu: {
|
||||||
on: vi.fn(() => () => {}),
|
on: vi.fn(() => () => {}),
|
||||||
},
|
},
|
||||||
@@ -90,7 +93,7 @@ describe('Phase 8 toasts integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exporting a file calls toast.success on success', async () => {
|
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();
|
registerMenuCommands();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
@@ -102,8 +105,10 @@ describe('Phase 8 toasts integration', () => {
|
|||||||
const exportBtn = await screen.findByRole('button', { name: /^export$/i });
|
const exportBtn = await screen.findByRole('button', { name: /^export$/i });
|
||||||
await userEvent.click(exportBtn);
|
await userEvent.click(exportBtn);
|
||||||
|
|
||||||
|
// The PDF flow hands the rendered HTML to the main process for print.
|
||||||
await waitFor(() => {
|
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'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -135,10 +135,11 @@ describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
|
|||||||
|
|
||||||
it('load-template-menu IPC event forwards the template name as args', () => {
|
it('load-template-menu IPC event forwards the template name as args', () => {
|
||||||
let captured: unknown;
|
let captured: unknown;
|
||||||
|
render(<Harness />);
|
||||||
|
// Re-register after the harness so our capturing handler is the live one.
|
||||||
useCommandStore.getState().register('template.load', (args) => {
|
useCommandStore.getState().register('template.load', (args) => {
|
||||||
captured = args;
|
captured = args;
|
||||||
});
|
});
|
||||||
render(<Harness />);
|
|
||||||
act(() => fireMenu('load-template-menu', 'blog-post.md'));
|
act(() => fireMenu('load-template-menu', 'blog-post.md'));
|
||||||
expect(captured).toBe('blog-post.md');
|
expect(captured).toBe('blog-post.md');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user