mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat(editor): export active markdown to PDF (#882)
Adds File > Export as PDF... menu item (Cmd+Shift+E) and an overflow menu entry that renders the active markdown preview through a sandboxed Electron BrowserWindow and writes it to disk via printToPDF. - New main-side IPC handler (src/main/ipc/export.ts) and html-to-pdf helper that loads a CSP-locked HTML document in a sandboxed, context- isolated window with javascript enabled only for image-ready polling. - Renderer helpers clone the rendered markdown subtree, inline all computed styles through a curated allowlist, and ship the resulting HTML fragment over IPC. - Ref-counted listener registration so split-pane layouts install exactly one IPC subscription and survive panel churn.
This commit is contained in:
parent
8ea1f2ee33
commit
3a58a829f9
13 changed files with 635 additions and 3 deletions
59
src/main/ipc/export.ts
Normal file
59
src/main/ipc/export.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { ExportTimeoutError, htmlToPdf } from '../lib/html-to-pdf'
|
||||
|
||||
export type ExportHtmlToPdfArgs = {
|
||||
html: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type ExportHtmlToPdfResult =
|
||||
| { success: true; filePath: string }
|
||||
| { success: false; cancelled?: boolean; error?: string }
|
||||
|
||||
export function registerExportHandlers(): void {
|
||||
ipcMain.removeHandler('export:html-to-pdf')
|
||||
ipcMain.handle(
|
||||
'export:html-to-pdf',
|
||||
async (event, args: ExportHtmlToPdfArgs): Promise<ExportHtmlToPdfResult> => {
|
||||
const { html, title } = args
|
||||
if (!html.trim()) {
|
||||
return { success: false, error: 'No content to export' }
|
||||
}
|
||||
|
||||
try {
|
||||
const pdfBuffer = await htmlToPdf(html)
|
||||
|
||||
// Why: sanitize to keep the suggested filename legal on every platform.
|
||||
// Windows forbids /\:*?"<>| in filenames; truncate to keep the OS save
|
||||
// dialog stable when titles are pathologically long.
|
||||
const sanitizedTitle = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100) || 'export'
|
||||
const defaultFilename = `${sanitizedTitle}.pdf`
|
||||
|
||||
const parent = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
||||
const dialogOptions = {
|
||||
defaultPath: defaultFilename,
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }]
|
||||
}
|
||||
const { canceled, filePath } = parent
|
||||
? await dialog.showSaveDialog(parent, dialogOptions)
|
||||
: await dialog.showSaveDialog(dialogOptions)
|
||||
|
||||
if (canceled || !filePath) {
|
||||
return { success: false, cancelled: true }
|
||||
}
|
||||
|
||||
await writeFile(filePath, pdfBuffer)
|
||||
return { success: true, filePath }
|
||||
} catch (error) {
|
||||
if (error instanceof ExportTimeoutError) {
|
||||
return { success: false, error: 'Export timed out' }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to export PDF'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -22,7 +22,8 @@ const {
|
|||
registerBrowserHandlersMock,
|
||||
setTrustedBrowserRendererWebContentsIdMock,
|
||||
registerFilesystemWatcherHandlersMock,
|
||||
registerAppHandlersMock
|
||||
registerAppHandlersMock,
|
||||
registerExportHandlersMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerCliHandlersMock: vi.fn(),
|
||||
registerPreflightHandlersMock: vi.fn(),
|
||||
|
|
@ -45,7 +46,8 @@ const {
|
|||
registerBrowserHandlersMock: vi.fn(),
|
||||
setTrustedBrowserRendererWebContentsIdMock: vi.fn(),
|
||||
registerFilesystemWatcherHandlersMock: vi.fn(),
|
||||
registerAppHandlersMock: vi.fn()
|
||||
registerAppHandlersMock: vi.fn(),
|
||||
registerExportHandlersMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./cli', () => ({
|
||||
|
|
@ -72,6 +74,10 @@ vi.mock('./feedback', () => ({
|
|||
registerFeedbackHandlers: registerFeedbackHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./export', () => ({
|
||||
registerExportHandlers: registerExportHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./stats', () => ({
|
||||
registerStatsHandlers: registerStatsHandlersMock
|
||||
}))
|
||||
|
|
@ -156,6 +162,7 @@ describe('registerCoreHandlers', () => {
|
|||
setTrustedBrowserRendererWebContentsIdMock.mockReset()
|
||||
registerFilesystemWatcherHandlersMock.mockReset()
|
||||
registerAppHandlersMock.mockReset()
|
||||
registerExportHandlersMock.mockReset()
|
||||
})
|
||||
|
||||
it('passes the store through to handler registrars that need it', () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { registerClaudeUsageHandlers } from './claude-usage'
|
|||
import { registerCodexUsageHandlers } from './codex-usage'
|
||||
import { registerGitHubHandlers } from './github'
|
||||
import { registerFeedbackHandlers } from './feedback'
|
||||
import { registerExportHandlers } from './export'
|
||||
import { registerStatsHandlers } from './stats'
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import { registerRuntimeHandlers } from './runtime'
|
||||
|
|
@ -63,6 +64,7 @@ export function registerCoreHandlers(
|
|||
registerRateLimitHandlers(rateLimits)
|
||||
registerGitHubHandlers(store, stats)
|
||||
registerFeedbackHandlers()
|
||||
registerExportHandlers()
|
||||
registerStatsHandlers(stats)
|
||||
registerNotificationHandlers(store)
|
||||
registerSettingsHandlers(store)
|
||||
|
|
|
|||
98
src/main/lib/html-to-pdf.ts
Normal file
98
src/main/lib/html-to-pdf.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { app, BrowserWindow } from 'electron'
|
||||
import { writeFile, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export class ExportTimeoutError extends Error {
|
||||
constructor(message = 'Export timed out') {
|
||||
super(message)
|
||||
this.name = 'ExportTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
const EXPORT_TIMEOUT_MS = 60_000
|
||||
|
||||
// Why: injected into the hidden export window so printToPDF does not fire while
|
||||
// <img> elements are still fetching. printToPDF renders whatever is painted at
|
||||
// the moment it runs; without this gate, remote images and Mermaid SVGs loaded
|
||||
// via <img> can be missing from the output.
|
||||
const WAIT_FOR_IMAGES_SCRIPT = `
|
||||
new Promise((resolve) => {
|
||||
const imgs = Array.from(document.images || [])
|
||||
if (imgs.length === 0) { resolve(); return }
|
||||
let remaining = imgs.length
|
||||
const done = () => { remaining -= 1; if (remaining <= 0) resolve() }
|
||||
imgs.forEach((img) => {
|
||||
if (img.complete) { done(); return }
|
||||
img.addEventListener('load', done, { once: true })
|
||||
img.addEventListener('error', done, { once: true })
|
||||
})
|
||||
})
|
||||
`
|
||||
|
||||
export async function htmlToPdf(html: string): Promise<Buffer> {
|
||||
const tempDir = app.getPath('temp')
|
||||
const tempPath = path.join(tempDir, `orca-export-${randomUUID()}.html`)
|
||||
await writeFile(tempPath, html, 'utf-8')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// Why: image-wait needs to run a short script inside the export page, and
|
||||
// the exported renderer DOM may already embed scripts/SVGs (e.g. Mermaid)
|
||||
// that need JS to paint correctly. The window stays sandboxed and
|
||||
// isolated so this is safe.
|
||||
javascript: true
|
||||
}
|
||||
})
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
try {
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
win.webContents.once('did-finish-load', () => resolve())
|
||||
win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => {
|
||||
reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`))
|
||||
})
|
||||
})
|
||||
|
||||
await win.loadFile(tempPath)
|
||||
await loadPromise
|
||||
|
||||
const renderAndPrint = (async (): Promise<Buffer> => {
|
||||
await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true)
|
||||
return win.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
pageSize: 'A4',
|
||||
margins: {
|
||||
top: 0.75,
|
||||
bottom: 0.75,
|
||||
left: 0.75,
|
||||
right: 0.75
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
return await Promise.race([renderAndPrint, timeoutPromise])
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (!win.isDestroyed()) {
|
||||
win.destroy()
|
||||
}
|
||||
try {
|
||||
await unlink(tempPath)
|
||||
} catch {
|
||||
// Why: best-effort cleanup — losing the temp file should not surface
|
||||
// as a user-facing export failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,26 @@ export function registerAppMenu({
|
|||
{ role: 'quit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Export as PDF...',
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click: () => {
|
||||
// Why: fire a one-way event into the focused renderer. The renderer
|
||||
// owns the knowledge of whether a markdown surface is active and
|
||||
// what DOM to extract — when no markdown surface is active this is
|
||||
// a silent no-op on that side (see design doc §4 "Renderer UI
|
||||
// trigger"). Keeping this as a send (not an invoke) avoids main
|
||||
// needing to reason about surface state. Using
|
||||
// BrowserWindow.getFocusedWindow() rather than the menu's
|
||||
// focusedWindow param avoids the BaseWindow typing gap.
|
||||
BrowserWindow.getFocusedWindow()?.webContents.send('export:requestPdf')
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
|
|
|
|||
11
src/preload/api-types.d.ts
vendored
11
src/preload/api-types.d.ts
vendored
|
|
@ -171,6 +171,15 @@ export type PreflightApi = {
|
|||
refreshAgents: () => Promise<RefreshAgentsResult>
|
||||
}
|
||||
|
||||
export type ExportApi = {
|
||||
htmlToPdf: (args: {
|
||||
html: string
|
||||
title: string
|
||||
}) => Promise<
|
||||
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
|
||||
>
|
||||
}
|
||||
|
||||
export type StatsApi = {
|
||||
getSummary: () => Promise<StatsSummary>
|
||||
}
|
||||
|
|
@ -330,6 +339,7 @@ export type PreloadApi = {
|
|||
githubEmail: string | null
|
||||
}) => Promise<{ ok: true } | { ok: false; status: number | null; error: string }>
|
||||
}
|
||||
export: ExportApi
|
||||
gh: {
|
||||
viewer: () => Promise<GitHubViewer | null>
|
||||
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
|
||||
|
|
@ -602,6 +612,7 @@ export type PreloadApi = {
|
|||
onCloseActiveTab: (callback: () => void) => () => void
|
||||
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void
|
||||
onToggleStatusBar: (callback: () => void) => () => void
|
||||
onExportPdfRequested: (callback: () => void) => () => void
|
||||
onActivateWorktree: (
|
||||
callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void
|
||||
) => () => void
|
||||
|
|
|
|||
|
|
@ -336,6 +336,15 @@ const api = {
|
|||
ipcRenderer.invoke('feedback:submit', args)
|
||||
},
|
||||
|
||||
export: {
|
||||
htmlToPdf: (args: {
|
||||
html: string
|
||||
title: string
|
||||
}): Promise<
|
||||
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
|
||||
> => ipcRenderer.invoke('export:html-to-pdf', args)
|
||||
},
|
||||
|
||||
gh: {
|
||||
viewer: (): Promise<unknown> => ipcRenderer.invoke('gh:viewer'),
|
||||
|
||||
|
|
@ -1090,6 +1099,11 @@ const api = {
|
|||
ipcRenderer.on('ui:toggleStatusBar', listener)
|
||||
return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener)
|
||||
},
|
||||
onExportPdfRequested: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('export:requestPdf', listener)
|
||||
return () => ipcRenderer.removeListener('export:requestPdf', listener)
|
||||
},
|
||||
onActivateWorktree: (
|
||||
callback: (data: {
|
||||
repoId: string
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ across multiple components. Autosave now lives in a smaller headless controller
|
|||
so hidden editor UI no longer participates in shutdown. */
|
||||
import React, { useCallback, useEffect, useRef, useState, Suspense } from 'react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { Columns2, Copy, ExternalLink, FileText, Rows2 } from 'lucide-react'
|
||||
import { Columns2, Copy, ExternalLink, FileText, MoreHorizontal, Rows2 } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
type EditorPathMutationTarget
|
||||
} from './editor-autosave'
|
||||
import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
|
||||
import { exportActiveMarkdownToPdf } from './export-active-markdown'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
|
@ -68,6 +69,34 @@ type DiffContent = GitDiffResult
|
|||
const inFlightFileReads = new Map<string, Promise<FileContent>>()
|
||||
const inFlightDiffReads = new Map<string, Promise<DiffContent>>()
|
||||
|
||||
// Why: the "File → Export as PDF..." menu IPC fans out to every EditorPanel
|
||||
// instance, and split-pane layouts mount N panels concurrently. Without a
|
||||
// guard, a single menu click would spawn N concurrent exports — each racing
|
||||
// its own save dialog, toast, and printToPDF — producing duplicate output
|
||||
// files and confusing UX. This module-level ref-counted singleton installs
|
||||
// exactly one IPC subscription the first time any panel mounts, and tears
|
||||
// it down only when the last panel unmounts. A simple "first mounter wins"
|
||||
// counter would go dead if the first-mounting panel unmounted while others
|
||||
// were still mounted — survivors never re-subscribed and the menu silently
|
||||
// stopped working. The singleton pattern avoids that handoff bug entirely.
|
||||
let exportPdfListenerOwners = 0
|
||||
let exportPdfListenerUnsubscribe: (() => void) | null = null
|
||||
function acquireExportPdfListener(): () => void {
|
||||
exportPdfListenerOwners += 1
|
||||
if (exportPdfListenerOwners === 1) {
|
||||
exportPdfListenerUnsubscribe = window.api.ui.onExportPdfRequested(() => {
|
||||
void exportActiveMarkdownToPdf()
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
exportPdfListenerOwners -= 1
|
||||
if (exportPdfListenerOwners === 0 && exportPdfListenerUnsubscribe) {
|
||||
exportPdfListenerUnsubscribe()
|
||||
exportPdfListenerUnsubscribe = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inFlightReadKey(connectionId: string | undefined, filePath: string): string {
|
||||
return `${connectionId ?? ''}::${filePath}`
|
||||
}
|
||||
|
|
@ -151,6 +180,18 @@ function EditorPanelInner({
|
|||
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
}, [])
|
||||
|
||||
// Why: the system "File → Export as PDF..." menu item sends a one-way IPC
|
||||
// event that reaches whichever renderer has focus. The EditorPanel is the
|
||||
// natural owner of the active markdown surface, so the listener lives here
|
||||
// and delegates to the shared export helper. Both entry points (menu and
|
||||
// overflow button) funnel through exportActiveMarkdownToPdf so toasts and
|
||||
// no-op gating stay consistent.
|
||||
// Why (guard): split-pane layouts mount multiple EditorPanelInner instances.
|
||||
// We ref-count via `acquireExportPdfListener` so exactly one IPC subscription
|
||||
// exists regardless of how many panels are mounted — and it survives panel
|
||||
// churn as long as at least one panel is still mounted.
|
||||
useEffect(() => acquireExportPdfListener(), [])
|
||||
|
||||
// Why: keepCurrentModel / keepCurrent*Model retain Monaco models after unmount
|
||||
// so undo history survives tab switches. When a tab is *closed*, the user has
|
||||
// signalled they're done with the file — dispose the models to reclaim memory
|
||||
|
|
@ -804,6 +845,37 @@ function EditorPanelInner({
|
|||
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
|
||||
/>
|
||||
)}
|
||||
{hasViewModeToggle && isMarkdown && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
// Why: the item is disabled (not hidden) only in source/Monaco
|
||||
// mode, which has no document DOM to export. We intentionally
|
||||
// don't poll the DOM (canExportActiveMarkdown) at render time:
|
||||
// the Radix content renders in a Portal and the lookup can
|
||||
// race with the active surface's paint, producing a stuck
|
||||
// disabled state. exportActiveMarkdownToPdf is a safe no-op
|
||||
// when no subtree is found.
|
||||
disabled={mdViewMode === 'source'}
|
||||
onSelect={() => {
|
||||
void exportActiveMarkdownToPdf()
|
||||
}}
|
||||
>
|
||||
Export as PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={loadingFallback}>
|
||||
|
|
|
|||
39
src/renderer/src/components/editor/export-active-markdown.ts
Normal file
39
src/renderer/src/components/editor/export-active-markdown.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { toast } from 'sonner'
|
||||
import { getActiveMarkdownExportPayload } from './markdown-export-extract'
|
||||
|
||||
/**
|
||||
* Export the currently-active markdown document to PDF via the main-process
|
||||
* IPC bridge. Silent no-op when no markdown surface is active — the menu
|
||||
* item and overflow action can both share this entry point.
|
||||
*/
|
||||
export async function exportActiveMarkdownToPdf(): Promise<void> {
|
||||
const payload = getActiveMarkdownExportPayload()
|
||||
if (!payload) {
|
||||
// Why: design doc §5 — menu-triggered export with no markdown surface is
|
||||
// a silent no-op. The overflow-menu item is disabled in that case so we
|
||||
// only reach this branch for stray menu shortcuts.
|
||||
return
|
||||
}
|
||||
|
||||
const toastId = toast.loading('Exporting PDF...')
|
||||
try {
|
||||
const result = await window.api.export.htmlToPdf({
|
||||
html: payload.html,
|
||||
title: payload.title
|
||||
})
|
||||
if (result.success) {
|
||||
toast.success(`Exported to ${result.filePath}`, { id: toastId })
|
||||
return
|
||||
}
|
||||
if (result.cancelled) {
|
||||
// Why: user pressed Cancel in the save dialog — clear the loading toast
|
||||
// without surfacing an error.
|
||||
toast.dismiss(toastId)
|
||||
return
|
||||
}
|
||||
toast.error(result.error ?? 'Failed to export PDF', { id: toastId })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to export PDF'
|
||||
toast.error(message, { id: toastId })
|
||||
}
|
||||
}
|
||||
143
src/renderer/src/components/editor/export-css.ts
Normal file
143
src/renderer/src/components/editor/export-css.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Why: this stylesheet targets the *exported* PDF document, not the live Orca
|
||||
// pane. In-app CSS assumes sticky UI chrome, hover affordances, and app-shell
|
||||
// spacing that would look wrong when flattened to paper. Keeping export CSS
|
||||
// separate also means a future UI refactor can move live classes without
|
||||
// silently breaking PDF output.
|
||||
export const EXPORT_CSS = `
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color: #1f2328;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.orca-export-root {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.orca-export-root h1,
|
||||
.orca-export-root h2,
|
||||
.orca-export-root h3,
|
||||
.orca-export-root h4,
|
||||
.orca-export-root h5,
|
||||
.orca-export-root h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.orca-export-root h1 { font-size: 1.9em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
|
||||
.orca-export-root h2 { font-size: 1.5em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
|
||||
.orca-export-root h3 { font-size: 1.25em; }
|
||||
.orca-export-root h4 { font-size: 1em; }
|
||||
|
||||
.orca-export-root p,
|
||||
.orca-export-root blockquote,
|
||||
.orca-export-root ul,
|
||||
.orca-export-root ol,
|
||||
.orca-export-root pre,
|
||||
.orca-export-root table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.orca-export-root a {
|
||||
color: #0969da;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.orca-export-root blockquote {
|
||||
padding: 0 1em;
|
||||
color: #57606a;
|
||||
border-left: 0.25em solid #d0d7de;
|
||||
}
|
||||
|
||||
.orca-export-root code,
|
||||
.orca-export-root pre {
|
||||
font-family: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.orca-export-root code {
|
||||
background: #f6f8fa;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.orca-export-root pre {
|
||||
background: #f6f8fa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.orca-export-root pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.orca-export-root table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.orca-export-root th,
|
||||
.orca-export-root td {
|
||||
border: 1px solid #d0d7de;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.orca-export-root th { background: #f6f8fa; }
|
||||
|
||||
.orca-export-root img,
|
||||
.orca-export-root svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.orca-export-root ul,
|
||||
.orca-export-root ol { padding-left: 2em; }
|
||||
|
||||
.orca-export-root li { margin: 0.25em 0; }
|
||||
|
||||
.orca-export-root input[type="checkbox"] {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
|
||||
.orca-export-root hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #d0d7de;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
/* Why: the export subtree selection already excludes the big chrome (toolbar,
|
||||
search bar, etc.), but in-document affordances like the code-copy button
|
||||
can still leak. Hide the well-known offenders as a belt-and-suspenders
|
||||
defense on top of DOM scrubbing. */
|
||||
.code-block-copy-btn,
|
||||
.markdown-preview-search,
|
||||
.rich-markdown-toolbar,
|
||||
[data-orca-export-hide="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.code-block-wrapper { position: static !important; }
|
||||
|
||||
@media print {
|
||||
pre, code, table, img, svg { page-break-inside: avoid; }
|
||||
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; }
|
||||
}
|
||||
`
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { buildMarkdownExportHtml } from './markdown-export-html'
|
||||
|
||||
export type MarkdownExportPayload = {
|
||||
title: string
|
||||
html: string
|
||||
}
|
||||
|
||||
// Why: the export subtree is the smallest DOM that represents the rendered
|
||||
// document. Preview mode uses `.markdown-body` (the .markdown-preview wrapper
|
||||
// also contains the search bar chrome), and rich mode uses `.ProseMirror`
|
||||
// (the surrounding .rich-markdown-editor-shell contains the toolbar, search
|
||||
// bar, link bubble, and slash menu as siblings).
|
||||
const DOCUMENT_SUBTREE_SELECTOR = '.ProseMirror, .markdown-body'
|
||||
|
||||
// Why: even after picking the smallest subtree, a few in-document UI leaks
|
||||
// can remain. The design doc lists these by name and treats the cloned-scrub
|
||||
// pass as a belt-and-suspenders defense so PDF output never shows copy
|
||||
// buttons, per-block search highlights, or other transient affordances.
|
||||
const UI_ONLY_SELECTORS = [
|
||||
'.code-block-copy-btn',
|
||||
'.markdown-preview-search',
|
||||
'[class*="rich-markdown-search"]',
|
||||
'[data-orca-export-hide="true"]'
|
||||
]
|
||||
|
||||
function basenameWithoutExt(filePath: string): string {
|
||||
const base = filePath.split(/[\\/]/).pop() ?? filePath
|
||||
const dot = base.lastIndexOf('.')
|
||||
return dot > 0 ? base.slice(0, dot) : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the active markdown document DOM subtree. v1 uses a scoped query
|
||||
* over the whole document: there is only one active markdown surface at a
|
||||
* time, and both preview and rich modes paint a uniquely-classed container.
|
||||
* If multi-pane split view ever makes multiple surfaces visible at once,
|
||||
* this contract must be revisited (see design doc §4).
|
||||
*/
|
||||
function findActiveDocumentSubtree(): Element | null {
|
||||
return document.querySelector(DOCUMENT_SUBTREE_SELECTOR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a clean, self-contained HTML export payload from the active
|
||||
* markdown surface. Returns null when no markdown document is active or the
|
||||
* surface is in a mode (Monaco source) that does not render a document DOM.
|
||||
*/
|
||||
export function getActiveMarkdownExportPayload(): MarkdownExportPayload | null {
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeTabType !== 'editor') {
|
||||
return null
|
||||
}
|
||||
const activeFile = state.openFiles.find((f) => f.id === state.activeFileId)
|
||||
if (!activeFile || activeFile.mode !== 'edit') {
|
||||
return null
|
||||
}
|
||||
const language = detectLanguage(activeFile.filePath)
|
||||
if (language !== 'markdown') {
|
||||
return null
|
||||
}
|
||||
|
||||
const subtree = findActiveDocumentSubtree()
|
||||
if (!subtree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const clone = subtree.cloneNode(true) as Element
|
||||
for (const selector of UI_ONLY_SELECTORS) {
|
||||
for (const node of clone.querySelectorAll(selector)) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const renderedHtml = clone.innerHTML.trim()
|
||||
if (!renderedHtml) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath)
|
||||
const html = buildMarkdownExportHtml({ title, renderedHtml })
|
||||
return { title, html }
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMarkdownExportHtml } from './markdown-export-html'
|
||||
|
||||
describe('buildMarkdownExportHtml', () => {
|
||||
it('wraps rendered html in a complete standalone document', () => {
|
||||
const html = buildMarkdownExportHtml({
|
||||
title: 'Hello',
|
||||
renderedHtml: '<h1>Hello</h1><p>world</p>'
|
||||
})
|
||||
expect(html.startsWith('<!DOCTYPE html>')).toBe(true)
|
||||
expect(html).toContain('<meta charset="utf-8"')
|
||||
expect(html).toContain('<title>Hello</title>')
|
||||
expect(html).toContain('<h1>Hello</h1><p>world</p>')
|
||||
expect(html).toContain('class="orca-export-root"')
|
||||
expect(html).toContain('<style>')
|
||||
})
|
||||
|
||||
it('escapes HTML-unsafe characters in the title', () => {
|
||||
const html = buildMarkdownExportHtml({
|
||||
title: '<script>alert(1)</script>',
|
||||
renderedHtml: '<p>x</p>'
|
||||
})
|
||||
expect(html).toContain('<title><script>alert(1)</script></title>')
|
||||
expect(html).not.toContain('<title><script>')
|
||||
})
|
||||
|
||||
it('falls back to "Untitled" when the title is empty', () => {
|
||||
const html = buildMarkdownExportHtml({ title: '', renderedHtml: '<p>x</p>' })
|
||||
expect(html).toContain('<title>Untitled</title>')
|
||||
})
|
||||
})
|
||||
52
src/renderer/src/components/editor/markdown-export-html.ts
Normal file
52
src/renderer/src/components/editor/markdown-export-html.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { EXPORT_CSS } from './export-css'
|
||||
|
||||
type BuildMarkdownExportHtmlArgs = {
|
||||
title: string
|
||||
renderedHtml: string
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a rendered markdown fragment in a standalone HTML document suitable
|
||||
* for Electron `webContents.printToPDF()`.
|
||||
*
|
||||
* The result is intentionally self-contained (inline CSS, no external links
|
||||
* except whatever the rendered fragment already references) so that loading
|
||||
* it from a temp file produces a stable paint regardless of the caller's
|
||||
* working directory.
|
||||
*/
|
||||
export function buildMarkdownExportHtml(args: BuildMarkdownExportHtmlArgs): string {
|
||||
const title = escapeHtml(args.title || 'Untitled')
|
||||
// Why (CSP): the generated HTML is loaded in an Electron BrowserWindow with
|
||||
// `javascript: true` (required for printToPDF layout). Without a CSP, any
|
||||
// <script> tag that leaked into the cloned rendered subtree — e.g. from a
|
||||
// malicious markdown paste or a compromised upstream renderer — would
|
||||
// execute with renderer privileges during the export. Forbidding script-src
|
||||
// entirely (no 'default-src' fallback to scripts) closes this hole while
|
||||
// still allowing inline styles (for the <style> block and element-level
|
||||
// style attributes the renderer emits), images from common schemes, and
|
||||
// data/https fonts.
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: https: http: file:; style-src 'unsafe-inline'; font-src data: https:;" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<style>${EXPORT_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="orca-export-root">
|
||||
${args.renderedHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
Loading…
Reference in a new issue