mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
feat(renderer): Phase 9 foundation — settings, modal union, ipc, commands, docx-export
This commit is contained in:
Generated
+4
-4
@@ -42,7 +42,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.7.1",
|
||||||
"docx4js": "^2.0.1",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
@@ -9161,9 +9161,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/docx": {
|
"node_modules/docx": {
|
||||||
"version": "9.6.1",
|
"version": "9.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||||
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==",
|
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
|
|||||||
+1
-1
@@ -113,7 +113,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.7.1",
|
||||||
"docx4js": "^2.0.1",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { AboutDialog } from './AboutDialog';
|
import { AboutDialog } from './AboutDialog';
|
||||||
|
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
|
||||||
import { ConfirmDialog } from './ConfirmDialog';
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
import { ExportBatchDialog } from './ExportBatchDialog';
|
import { ExportBatchDialog } from './ExportBatchDialog';
|
||||||
import { ExportDocxDialog } from './ExportDocxDialog';
|
import { ExportDocxDialog } from './ExportDocxDialog';
|
||||||
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
||||||
import { ExportPdfDialog } from './ExportPdfDialog';
|
import { ExportPdfDialog } from './ExportPdfDialog';
|
||||||
|
import { FindInFilesDialog } from './FindInFilesDialog';
|
||||||
import { SettingsSheet } from './SettingsSheet';
|
import { SettingsSheet } from './SettingsSheet';
|
||||||
|
import { TableGeneratorDialog } from './TableGeneratorDialog';
|
||||||
import { WelcomeDialog } from './WelcomeDialog';
|
import { WelcomeDialog } from './WelcomeDialog';
|
||||||
|
import { WordExportDialog } from './WordExportDialog';
|
||||||
|
|
||||||
export function ModalLayer() {
|
export function ModalLayer() {
|
||||||
const modal = useAppStore((s) => s.modal);
|
const modal = useAppStore((s) => s.modal);
|
||||||
@@ -29,5 +33,13 @@ export function ModalLayer() {
|
|||||||
return <WelcomeDialog />;
|
return <WelcomeDialog />;
|
||||||
case 'confirm':
|
case 'confirm':
|
||||||
return <ConfirmDialog {...modal.props} />;
|
return <ConfirmDialog {...modal.props} />;
|
||||||
|
case 'export-word':
|
||||||
|
return <WordExportDialog sourcePath={modal.props.sourcePath} />;
|
||||||
|
case 'ascii-generator':
|
||||||
|
return <AsciiGeneratorDialog />;
|
||||||
|
case 'table-generator':
|
||||||
|
return <TableGeneratorDialog />;
|
||||||
|
case 'find-in-files':
|
||||||
|
return <FindInFilesDialog />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useCommandStore } from '@/stores/command-store';
|
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 { useMenuAction } from '@/hooks/use-menu-action';
|
import { useMenuAction } from '@/hooks/use-menu-action';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +46,28 @@ export function registerMenuCommands(): void {
|
|||||||
'app.quit': () => {
|
'app.quit': () => {
|
||||||
/* stub — wired in later phase */
|
/* stub — wired in later phase */
|
||||||
},
|
},
|
||||||
|
'tools.ascii': () => useAppStore.getState().openModal('ascii-generator'),
|
||||||
|
'tools.table': () => useAppStore.getState().openModal('table-generator'),
|
||||||
|
'tools.findInFiles': () => useAppStore.getState().openModal('find-in-files'),
|
||||||
|
'tools.exportWord': () => {
|
||||||
|
const activeTabId = useFileStore.getState().activeTabId;
|
||||||
|
if (!activeTabId) return;
|
||||||
|
useAppStore.getState().openModal('export-word', { sourcePath: activeTabId });
|
||||||
|
},
|
||||||
|
'tools.repl': () => {
|
||||||
|
const current = useSettingsStore.getState().replOpen;
|
||||||
|
useSettingsStore.getState().setSetting('replOpen', !current);
|
||||||
|
},
|
||||||
|
'view.zenMode': () => {
|
||||||
|
const current = useAppStore.getState().zenMode;
|
||||||
|
useAppStore.getState().setZenMode(!current);
|
||||||
|
},
|
||||||
|
'file.print': () => {
|
||||||
|
/* stub — placeholder for print feature */
|
||||||
|
},
|
||||||
|
'git.refresh': () => {
|
||||||
|
/* stub — actual refresh is a useEffect in GitStatusPanel */
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register } = useCommandStore.getState();
|
const { register } = useCommandStore.getState();
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
|
||||||
|
import { applyAsciiTransform } from './ascii-table';
|
||||||
|
|
||||||
|
export interface DocxOptions {
|
||||||
|
source: string;
|
||||||
|
title?: string;
|
||||||
|
customTemplatePath?: string | null; // for future use
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDocx(options: DocxOptions): Promise<Blob> {
|
||||||
|
// Apply ASCII transform (convert markdown tables to preformatted)
|
||||||
|
const transformed = applyAsciiTransform(options.source);
|
||||||
|
|
||||||
|
// Parse the source into a list of Paragraphs
|
||||||
|
// For v1, simple line-by-line: each line is a paragraph
|
||||||
|
// Headings (# ## ###) get heading styles
|
||||||
|
const lines = transformed.split('\n');
|
||||||
|
const children = lines.map((line) => {
|
||||||
|
if (line.startsWith('### ')) return new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 });
|
||||||
|
if (line.startsWith('## ')) return new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 });
|
||||||
|
if (line.startsWith('# ')) return new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 });
|
||||||
|
if (line.startsWith('```')) return new Paragraph({ text: line, alignment: AlignmentType.CENTER });
|
||||||
|
return new Paragraph({ children: [new TextRun(line)] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
sections: [{ children }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Packer.toBlob(doc);
|
||||||
|
}
|
||||||
@@ -76,7 +76,15 @@ export const ipc = {
|
|||||||
}
|
}
|
||||||
return window.electronAPI.file.onChange(cb);
|
return window.electronAPI.file.onChange(cb);
|
||||||
},
|
},
|
||||||
|
search: (args: { rootPath: string; query: string; isRegex: boolean; caseSensitive: boolean }): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'search', args),
|
||||||
|
gitStatus: (args: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }> | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'gitStatus', args),
|
||||||
|
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'writeBuffer', args),
|
||||||
},
|
},
|
||||||
|
print: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('print', 'show', args),
|
||||||
export: {
|
export: {
|
||||||
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
safeCall('export', 'pdf', opts),
|
safeCall('export', 'pdf', opts),
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const settingsSchema = z.object({
|
|||||||
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
|
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
|
||||||
pdfEmbedFonts: z.boolean().default(true),
|
pdfEmbedFonts: z.boolean().default(true),
|
||||||
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
|
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
|
||||||
|
docxCustomTemplatePath: z.string().nullable().default(null),
|
||||||
|
replOpen: z.boolean().default(false),
|
||||||
|
breadcrumbSymbols: z.boolean().default(true),
|
||||||
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),
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export type ModalState =
|
|||||||
| { kind: 'settings' }
|
| { kind: 'settings' }
|
||||||
| { kind: 'about' }
|
| { kind: 'about' }
|
||||||
| { kind: 'welcome' }
|
| { kind: 'welcome' }
|
||||||
| { kind: 'confirm'; props: ConfirmProps };
|
| { kind: 'confirm'; props: ConfirmProps }
|
||||||
|
| { kind: 'export-word'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'ascii-generator' }
|
||||||
|
| { kind: 'table-generator' }
|
||||||
|
| { kind: 'find-in-files' };
|
||||||
|
|
||||||
export type ModalKind = ModalState['kind'];
|
export type ModalKind = ModalState['kind'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
describe('Phase 9 commands', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||||
|
useAppStore.setState({ modal: { kind: null } } as any);
|
||||||
|
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||||
|
// Use resetToDefaults to restore store methods (setSetting/resetToDefaults)
|
||||||
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.ascii opens ascii-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.ascii');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'ascii-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.table opens table-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.table');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'table-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.findInFiles opens find-in-files modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.findInFiles');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'find-in-files' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.exportWord opens export-word modal with active path', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.exportWord');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.repl toggles replOpen setting', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||||
|
useCommandStore.getState().dispatch('tools.repl');
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(true);
|
||||||
|
useCommandStore.getState().dispatch('tools.repl');
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
|
|
||||||
|
describe('generateDocx', () => {
|
||||||
|
it('returns a Blob for a simple markdown string', async () => {
|
||||||
|
const blob = await generateDocx({ source: '# Hello\n\nWorld' });
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts headings to docx heading styles', async () => {
|
||||||
|
const blob = await generateDocx({ source: '# H1\n## H2\n### H3' });
|
||||||
|
// The blob should be a valid zip-based docx; check size and MIME
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts markdown tables to preformatted (via applyAsciiTransform)', async () => {
|
||||||
|
const source = '| A | B |\n| - | - |\n| 1 | 2 |';
|
||||||
|
const blob = await generateDocx({ source });
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user