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) => ( - + ))} ); -} \ 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() { ); } 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.