mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
parent
1400e94c68
commit
d0a48d7f51
8 changed files with 521 additions and 3 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"@x/shared": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
|
|
|
|||
7
apps/x/apps/main/src/html-to-docx.d.ts
vendored
Normal file
7
apps/x/apps/main/src/html-to-docx.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
declare module 'html-to-docx' {
|
||||
export default function htmlToDocx(
|
||||
htmlString: string,
|
||||
headerHTMLString?: string,
|
||||
options?: Record<string, unknown>,
|
||||
): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ipcMain, BrowserWindow, shell } from 'electron';
|
||||
import { ipcMain, BrowserWindow, shell, dialog } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
|
@ -41,6 +41,71 @@ import { search } from '@x/core/dist/search/search.js';
|
|||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
|
||||
/**
|
||||
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||
*/
|
||||
function markdownToHtml(markdown: string, title: string): string {
|
||||
// Simple markdown to HTML conversion for export purposes
|
||||
let html = markdown
|
||||
// Resolve wiki links [[Folder/Note Name]] or [[Folder/Note Name|Display]] to plain text
|
||||
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, _path, display) => display.trim())
|
||||
.replace(/\[\[([^\]]+)\]\]/g, (_match, linkPath: string) => {
|
||||
// Use the last segment (filename) as the display name
|
||||
const segments = linkPath.trim().split('/')
|
||||
return segments[segments.length - 1]
|
||||
})
|
||||
// Escape HTML entities (but preserve markdown syntax)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Headings (must come before other processing)
|
||||
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
|
||||
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
|
||||
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
|
||||
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
|
||||
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
|
||||
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
|
||||
|
||||
// Bold and italic
|
||||
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr>')
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
|
||||
// Paragraphs: wrap remaining lines that aren't already wrapped in HTML tags
|
||||
html = html.replace(/^(?!<[a-z/])((?!^\s*$).+)$/gm, '<p>$1</p>')
|
||||
|
||||
// Clean up consecutive list items into lists
|
||||
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; line-height: 1.6; font-size: 14px; }
|
||||
h1 { font-size: 1.8em; margin-top: 1em; } h2 { font-size: 1.4em; margin-top: 1em; } h3 { font-size: 1.2em; }
|
||||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
|
||||
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
|
||||
ul { padding-left: 1.5em; }
|
||||
a { color: #0066cc; }
|
||||
</style></head><body>${html}</body></html>`
|
||||
}
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
|
|
@ -567,6 +632,68 @@ export function setupIpcHandlers() {
|
|||
return search(args.query, args.limit, args.types);
|
||||
},
|
||||
// Inline task schedule classification
|
||||
'export:note': async (event, args) => {
|
||||
const { markdown, format, title } = args;
|
||||
const sanitizedTitle = title.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'Untitled';
|
||||
|
||||
const filterMap: Record<string, Electron.FileFilter[]> = {
|
||||
md: [{ name: 'Markdown', extensions: ['md'] }],
|
||||
pdf: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
docx: [{ name: 'Word Document', extensions: ['docx'] }],
|
||||
};
|
||||
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
defaultPath: `${sanitizedTitle}.${format}`,
|
||||
filters: filterMap[format],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const filePath = result.filePath;
|
||||
|
||||
if (format === 'md') {
|
||||
await fs.writeFile(filePath, markdown, 'utf8');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (format === 'pdf') {
|
||||
// Render markdown as HTML in a hidden window, then print to PDF
|
||||
const htmlContent = markdownToHtml(markdown, sanitizedTitle);
|
||||
const hiddenWin = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: { offscreen: true },
|
||||
});
|
||||
await hiddenWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
||||
// Small delay to ensure CSS/fonts render
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const pdfBuffer = await hiddenWin.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
pageSize: 'A4',
|
||||
});
|
||||
hiddenWin.destroy();
|
||||
await fs.writeFile(filePath, pdfBuffer);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (format === 'docx') {
|
||||
const htmlContent = markdownToHtml(markdown, sanitizedTitle);
|
||||
const { default: htmlToDocx } = await import('html-to-docx');
|
||||
const docxBuffer = await htmlToDocx(htmlContent, undefined, {
|
||||
table: { row: { cantSplit: true } },
|
||||
footer: false,
|
||||
header: false,
|
||||
});
|
||||
await fs.writeFile(filePath, Buffer.from(docxBuffer as ArrayBuffer));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unknown format' };
|
||||
},
|
||||
'inline-task:classifySchedule': async (_event, args) => {
|
||||
const schedule = await classifySchedule(args.instruction);
|
||||
return { schedule };
|
||||
|
|
|
|||
|
|
@ -3806,6 +3806,15 @@ function App() {
|
|||
}
|
||||
}}
|
||||
editable={!isViewingHistory}
|
||||
onExport={async (format) => {
|
||||
const markdown = tabContent
|
||||
const title = getBaseName(tab.path)
|
||||
try {
|
||||
await window.ipc.invoke('export:note', { markdown, format, title })
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,18 +25,30 @@ import {
|
|||
ExternalLinkIcon,
|
||||
Trash2Icon,
|
||||
ImageIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
editor: Editor | null
|
||||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||
onImageUpload?: (file: File) => Promise<void> | void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
editor,
|
||||
onSelectionHighlight,
|
||||
onImageUpload,
|
||||
onExport,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -341,6 +353,38 @@ export function EditorToolbar({
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Export */}
|
||||
{onExport && (
|
||||
<>
|
||||
<div className="separator" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Export"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onExport('md')}>
|
||||
<FileTextIcon className="size-4 mr-2" />
|
||||
Markdown (.md)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onExport('pdf')}>
|
||||
<FileIcon className="size-4 mr-2" />
|
||||
PDF (.pdf)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onExport('docx')}>
|
||||
<FileTypeIcon className="size-4 mr-2" />
|
||||
Word (.docx)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ interface MarkdownEditorProps {
|
|||
editable?: boolean
|
||||
frontmatter?: string | null
|
||||
onFrontmatterChange?: (raw: string | null) => void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -309,6 +310,7 @@ export function MarkdownEditor({
|
|||
editable = true,
|
||||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
onExport,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -1071,6 +1073,7 @@ export function MarkdownEditor({
|
|||
editor={editor}
|
||||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
/>
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue