Show PDF export only in Markdown controls (#5548)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
0b3cfa7b46
commit
5429e659d4
|
|
@ -446,8 +446,6 @@ export function setupGuestShortcutForwarding(args: {
|
|||
renderer.send('ui:openTasks')
|
||||
} else if (action?.type === 'openSettings') {
|
||||
renderer.send('ui:openSettings')
|
||||
} else if (action?.type === 'exportPdf') {
|
||||
renderer.send('export:requestPdf')
|
||||
} else if (action?.type === 'forceReload') {
|
||||
renderer.reloadIgnoringCache()
|
||||
} else if (action?.type === 'jumpToWorktreeIndex') {
|
||||
|
|
|
|||
|
|
@ -189,12 +189,10 @@ describe('registerAppMenu', () => {
|
|||
expect(template.find((item) => item.label === 'Orca')).toBeUndefined()
|
||||
|
||||
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
|
||||
expect(fileLabels).not.toContain(`Export as PDF...\t${isMac ? '⌘⇧E' : 'Ctrl+Shift+E'}`)
|
||||
expect(fileLabels[0]).toBe(`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`)
|
||||
expect(fileLabels).toEqual(
|
||||
expect.arrayContaining([
|
||||
`Export as PDF...\t${isMac ? '⌘⇧E' : 'Ctrl+Shift+E'}`,
|
||||
`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`,
|
||||
'Exit'
|
||||
])
|
||||
expect.arrayContaining([`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`, 'Exit'])
|
||||
)
|
||||
|
||||
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
|
||||
|
|
@ -218,10 +216,8 @@ describe('registerAppMenu', () => {
|
|||
expect.arrayContaining(['Check for Updates...', `Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`])
|
||||
)
|
||||
// Why: on macOS File should NOT duplicate Settings/Exit — those live in
|
||||
// the system app menu, so only Export belongs under File.
|
||||
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
|
||||
expect(fileLabels).not.toContain(`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`)
|
||||
expect(fileLabels).not.toContain('Exit')
|
||||
// the system app menu. Without global Export, there is no File item left.
|
||||
expect(template.find((item) => item.label === 'File')).toBeUndefined()
|
||||
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
|
||||
expect(helpLabels).toEqual([
|
||||
'Report Crash...',
|
||||
|
|
|
|||
|
|
@ -120,21 +120,6 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
|||
click: (_menuItem, window) => onOpenCrashReport(window)
|
||||
}
|
||||
|
||||
const exportPdfItem: Electron.MenuItemConstructorOptions = {
|
||||
label: `${translateMain('menu.exportPdf', 'Export as PDF...')}\t${shortcutLabel('file.exportPdf')}`,
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the macOS app-menu (named after the app) is mandatory on darwin and
|
||||
// owns hide/hideOthers/unhide/services/quit roles that only make sense in
|
||||
// the system menu bar. On Windows/Linux that menu would render as a
|
||||
|
|
@ -159,19 +144,13 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
|||
|
||||
const fileMenu: Electron.MenuItemConstructorOptions = {
|
||||
label: translateMain('menu.file', 'File'),
|
||||
// Why: on Windows/Linux there is no app-named menu, so Settings and
|
||||
// Quit live under File — matching the common platform convention and
|
||||
// keeping all user-facing actions reachable from the in-window menu bar.
|
||||
submenu: [
|
||||
exportPdfItem,
|
||||
// Why: on Windows/Linux there is no app-named menu, so Settings and
|
||||
// Quit live under File — matching the common platform convention and
|
||||
// keeping all user-facing actions reachable from the in-window menu bar.
|
||||
...(isMac
|
||||
? []
|
||||
: ([
|
||||
{ type: 'separator' },
|
||||
settingsItem,
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit', label: translateMain('menu.exit', 'Exit') }
|
||||
] satisfies Electron.MenuItemConstructorOptions[]))
|
||||
settingsItem,
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit', label: translateMain('menu.exit', 'Exit') }
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +291,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
|||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac ? [macAppMenu] : []),
|
||||
fileMenu,
|
||||
...(isMac ? [] : [fileMenu]),
|
||||
editMenu,
|
||||
viewMenu,
|
||||
windowMenu,
|
||||
|
|
|
|||
|
|
@ -781,11 +781,6 @@ export function createMainWindow(
|
|||
return
|
||||
}
|
||||
|
||||
if (action.type === 'exportPdf') {
|
||||
mainWindow.webContents.send('export:requestPdf')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'forceReload') {
|
||||
opts?.onBeforeReload?.({
|
||||
ignoreCache: true,
|
||||
|
|
|
|||
|
|
@ -2339,7 +2339,6 @@ export type PreloadApi = {
|
|||
onCtrlTabKeyUp: (callback: () => void) => () => void
|
||||
onToggleStatusBar: (callback: () => void) => () => void
|
||||
onDictationKeyDown: (callback: () => void) => () => void
|
||||
onExportPdfRequested: (callback: () => void) => () => void
|
||||
onActivateWorktree: (
|
||||
callback: (data: {
|
||||
repoId: string
|
||||
|
|
|
|||
|
|
@ -2910,11 +2910,6 @@ 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)
|
||||
},
|
||||
onDictationKeyDown: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('ui:dictationKeyDown', listener)
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import {
|
|||
} from './ConflictComponents'
|
||||
import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor'
|
||||
import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types'
|
||||
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
|
||||
import { getMarkdownRenderMode } from './markdown-render-mode'
|
||||
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
|
||||
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
|
||||
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
|
||||
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
|
||||
import { useMarkdownDocuments } from './useMarkdownDocuments'
|
||||
|
|
@ -41,11 +41,6 @@ const MermaidViewer = lazy(() => import('./MermaidViewer'))
|
|||
const CsvViewer = lazy(() => import('./CsvViewer'))
|
||||
const IpynbViewer = lazy(() => import('./IpynbViewer'))
|
||||
|
||||
const richMarkdownSizeEncoder = new TextEncoder()
|
||||
// Why: encodeInto() with a pre-allocated buffer avoids creating a new
|
||||
// Uint8Array on every render, reducing GC pressure for large files.
|
||||
const richMarkdownSizeBuffer = new Uint8Array(RICH_MARKDOWN_MAX_SIZE_BYTES + 1)
|
||||
|
||||
export function getMarkdownSourceLineOffset(frontMatterRaw: string): number {
|
||||
return (frontMatterRaw.match(/\r\n|\r|\n/g) ?? []).length
|
||||
}
|
||||
|
|
@ -326,12 +321,7 @@ export function EditorContent({
|
|||
const currentContent = editBuffers[activeFile.id] ?? fc.content
|
||||
const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent)
|
||||
const renderMode = getMarkdownRenderMode({
|
||||
// Why: the threshold is defined in bytes because large pasted Unicode
|
||||
// documents can exceed ProseMirror's performance envelope long before
|
||||
// JS string length reaches the same numeric value.
|
||||
exceedsRichModeSizeLimit:
|
||||
richMarkdownSizeEncoder.encodeInto(currentContent, richMarkdownSizeBuffer).written >
|
||||
RICH_MARKDOWN_MAX_SIZE_BYTES,
|
||||
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(currentContent),
|
||||
hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null,
|
||||
viewMode: mdViewMode
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
|
|
@ -10,7 +10,6 @@ import { requestEditorFileSave } from './editor-autosave'
|
|||
import { exportActiveMarkdownToPdf } from './export-active-markdown'
|
||||
import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import { EditorPanelShell } from './EditorPanelShell'
|
||||
import { acquireExportPdfListener } from './editor-panel-export-pdf-listener'
|
||||
import { canUseChangesModeForFile } from './editor-panel-file-mode'
|
||||
import { getEditorPanelRenderModel } from './editor-panel-render-model'
|
||||
import { useClosedEditorTabCleanup } from './useClosedEditorTabCleanup'
|
||||
|
|
@ -112,7 +111,6 @@ function EditorPanelInner({
|
|||
handleRenameConfirm
|
||||
} = useUntitledFileRename({ openFiles, closeFile, openFile, clearUntitled })
|
||||
|
||||
useEffect(() => acquireExportPdfListener(), [])
|
||||
useClosedEditorTabCleanup(openFiles)
|
||||
useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview })
|
||||
|
||||
|
|
@ -225,6 +223,7 @@ function EditorPanelInner({
|
|||
const model = getEditorPanelRenderModel({
|
||||
activeFile,
|
||||
fileContents,
|
||||
editorDrafts,
|
||||
gitStatusByWorktree,
|
||||
gitBranchChangesByWorktree,
|
||||
markdownViewMode,
|
||||
|
|
@ -365,7 +364,9 @@ function EditorPanelInner({
|
|||
!isMarkdownFrontmatterVisible
|
||||
)
|
||||
}
|
||||
onExportMarkdownToPdf={() => void exportActiveMarkdownToPdf()}
|
||||
onExportMarkdownToPdf={() =>
|
||||
void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
|
||||
}
|
||||
onContentChange={handleContentChange}
|
||||
onContentChangeForFile={handleContentChangeForFile}
|
||||
onDirtyStateHint={handleDirtyStateHint}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo } from 'react'
|
||||
import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import EditorViewToggle, {
|
||||
CSV_VIEW_MODE_METADATA,
|
||||
|
|
@ -25,12 +25,12 @@ type EditorPanelHeaderProps = {
|
|||
hasEditorToggle: boolean
|
||||
availableEditorToggleModes: readonly EditorToggleValue[]
|
||||
effectiveToggleValue: EditorToggleValue
|
||||
mdViewMode: MarkdownViewMode
|
||||
hasViewModeToggle: boolean
|
||||
canOpenPreviewToSide: boolean
|
||||
canShowMarkdownPreview: boolean
|
||||
canShowMarkdownTableOfContents: boolean
|
||||
isMarkdownTableOfContentsDisabled: boolean
|
||||
shouldShowMarkdownExportAction: boolean
|
||||
canExportMarkdownToPdf: boolean
|
||||
showMarkdownTableOfContents: boolean
|
||||
canShowMarkdownFrontmatterToggle: boolean
|
||||
markdownFrontmatterVisible: boolean
|
||||
|
|
@ -59,12 +59,12 @@ export function EditorPanelHeader({
|
|||
hasEditorToggle,
|
||||
availableEditorToggleModes,
|
||||
effectiveToggleValue,
|
||||
mdViewMode,
|
||||
hasViewModeToggle,
|
||||
canOpenPreviewToSide,
|
||||
canShowMarkdownPreview,
|
||||
canShowMarkdownTableOfContents,
|
||||
isMarkdownTableOfContentsDisabled,
|
||||
shouldShowMarkdownExportAction,
|
||||
canExportMarkdownToPdf,
|
||||
showMarkdownTableOfContents,
|
||||
canShowMarkdownFrontmatterToggle,
|
||||
markdownFrontmatterVisible,
|
||||
|
|
@ -246,8 +246,8 @@ export function EditorPanelHeader({
|
|||
)}
|
||||
<EditorPanelMarkdownActionsMenu
|
||||
isMarkdown={isMarkdown}
|
||||
hasViewModeToggle={hasViewModeToggle}
|
||||
mdViewMode={mdViewMode}
|
||||
shouldShowMarkdownExportAction={shouldShowMarkdownExportAction}
|
||||
canExportMarkdownToPdf={canExportMarkdownToPdf}
|
||||
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
|
||||
markdownFrontmatterVisible={markdownFrontmatterVisible}
|
||||
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type React from 'react'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import type { MarkdownViewMode } from '@/store/slices/editor'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -12,8 +11,8 @@ import { translate } from '@/i18n/i18n'
|
|||
|
||||
type EditorPanelMarkdownActionsMenuProps = {
|
||||
isMarkdown: boolean
|
||||
hasViewModeToggle: boolean
|
||||
mdViewMode: MarkdownViewMode
|
||||
shouldShowMarkdownExportAction: boolean
|
||||
canExportMarkdownToPdf: boolean
|
||||
canShowMarkdownFrontmatterToggle: boolean
|
||||
markdownFrontmatterVisible: boolean
|
||||
onToggleMarkdownFrontmatter: () => void
|
||||
|
|
@ -22,14 +21,14 @@ type EditorPanelMarkdownActionsMenuProps = {
|
|||
|
||||
export function EditorPanelMarkdownActionsMenu({
|
||||
isMarkdown,
|
||||
hasViewModeToggle,
|
||||
mdViewMode,
|
||||
shouldShowMarkdownExportAction,
|
||||
canExportMarkdownToPdf,
|
||||
canShowMarkdownFrontmatterToggle,
|
||||
markdownFrontmatterVisible,
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf
|
||||
}: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null {
|
||||
if (!isMarkdown || (!hasViewModeToggle && !canShowMarkdownFrontmatterToggle)) {
|
||||
if (!isMarkdown || (!shouldShowMarkdownExportAction && !canShowMarkdownFrontmatterToggle)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -70,15 +69,13 @@ export function EditorPanelMarkdownActionsMenu({
|
|||
'Show front matter'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{hasViewModeToggle ? <DropdownMenuSeparator /> : null}
|
||||
{shouldShowMarkdownExportAction ? <DropdownMenuSeparator /> : null}
|
||||
</>
|
||||
) : null}
|
||||
{hasViewModeToggle ? (
|
||||
{shouldShowMarkdownExportAction ? (
|
||||
<DropdownMenuItem
|
||||
// Why: source/Monaco mode has no document DOM. Avoid polling the
|
||||
// portal-mounted menu; exportActiveMarkdownToPdf is a safe no-op
|
||||
// when no rendered markdown subtree is found.
|
||||
disabled={mdViewMode === 'source'}
|
||||
// Why: source/Monaco fallbacks have no rendered document DOM to export.
|
||||
disabled={!canExportMarkdownToPdf}
|
||||
onSelect={onExportMarkdownToPdf}
|
||||
>
|
||||
{translate(
|
||||
|
|
|
|||
|
|
@ -106,12 +106,12 @@ export function EditorPanelShell({
|
|||
hasEditorToggle={model.hasEditorToggle}
|
||||
availableEditorToggleModes={model.availableEditorToggleModes}
|
||||
effectiveToggleValue={model.effectiveToggleValue}
|
||||
mdViewMode={model.mdViewMode}
|
||||
hasViewModeToggle={model.hasViewModeToggle}
|
||||
canOpenPreviewToSide={model.canOpenPreviewToSide}
|
||||
canShowMarkdownPreview={model.canShowMarkdownPreview}
|
||||
canShowMarkdownTableOfContents={model.canShowMarkdownTableOfContents}
|
||||
isMarkdownTableOfContentsDisabled={model.isMarkdownTableOfContentsDisabled}
|
||||
shouldShowMarkdownExportAction={model.shouldShowMarkdownExportAction}
|
||||
canExportMarkdownToPdf={model.canExportMarkdownToPdf}
|
||||
showMarkdownTableOfContents={showMarkdownTableOfContents}
|
||||
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
|
||||
markdownFrontmatterVisible={markdownFrontmatterVisible}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import { exportActiveMarkdownToPdf } from './export-active-markdown'
|
||||
|
||||
// Why: the "File -> Export as PDF..." menu IPC fans out to every EditorPanel
|
||||
// instance, and split-pane layouts mount N panels concurrently. This ref-counted
|
||||
// singleton keeps exactly one renderer subscription alive while any panel exists.
|
||||
let exportPdfListenerOwners = 0
|
||||
let exportPdfListenerUnsubscribe: (() => void) | null = null
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
|
||||
import type { FileContent } from './editor-panel-content-types'
|
||||
import { getEditorPanelRenderModel } from './editor-panel-render-model'
|
||||
|
||||
function markdownFile(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: '/repo/README.md',
|
||||
filePath: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function textContent(overrides: Partial<FileContent> = {}): FileContent {
|
||||
return {
|
||||
content: '# Hello',
|
||||
isBinary: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderModel(args: {
|
||||
activeFile?: OpenFile
|
||||
fileContents?: Record<string, FileContent>
|
||||
editorDrafts?: Record<string, string>
|
||||
markdownViewMode?: Record<string, 'source' | 'rich' | 'preview'>
|
||||
isChangesMode?: boolean
|
||||
}) {
|
||||
return getEditorPanelRenderModel({
|
||||
activeFile: args.activeFile ?? markdownFile(),
|
||||
fileContents: args.fileContents ?? { '/repo/README.md': textContent() },
|
||||
editorDrafts: args.editorDrafts ?? {},
|
||||
gitStatusByWorktree: {},
|
||||
gitBranchChangesByWorktree: {},
|
||||
markdownViewMode: args.markdownViewMode ?? {},
|
||||
isChangesMode: args.isChangesMode ?? false
|
||||
})
|
||||
}
|
||||
|
||||
describe('getEditorPanelRenderModel markdown export affordance', () => {
|
||||
it('enables export for rendered markdown edit tabs', () => {
|
||||
expect(renderModel({}).canExportMarkdownToPdf).toBe(true)
|
||||
})
|
||||
|
||||
it('disables export when an inline markdown tab renders Changes mode', () => {
|
||||
expect(renderModel({ isChangesMode: true }).canExportMarkdownToPdf).toBe(false)
|
||||
})
|
||||
|
||||
it('uses unsaved drafts when resolving rich markdown fallback', () => {
|
||||
const model = renderModel({
|
||||
markdownViewMode: { '/repo/README.md': 'rich' },
|
||||
editorDrafts: { '/repo/README.md': '[example]: https://example.com' }
|
||||
})
|
||||
|
||||
expect(model.canExportMarkdownToPdf).toBe(false)
|
||||
})
|
||||
|
||||
it('disables rich export when a multibyte character crosses the byte limit', () => {
|
||||
const model = renderModel({
|
||||
markdownViewMode: { '/repo/README.md': 'rich' },
|
||||
editorDrafts: { '/repo/README.md': `${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES)}\u00e9` }
|
||||
})
|
||||
|
||||
expect(model.shouldShowMarkdownExportAction).toBe(true)
|
||||
expect(model.canExportMarkdownToPdf).toBe(false)
|
||||
})
|
||||
|
||||
it('disables edit export while content is still loading, even with a draft', () => {
|
||||
const model = renderModel({
|
||||
fileContents: {},
|
||||
editorDrafts: { '/repo/README.md': '# Draft' }
|
||||
})
|
||||
|
||||
expect(model.shouldShowMarkdownExportAction).toBe(true)
|
||||
expect(model.canExportMarkdownToPdf).toBe(false)
|
||||
})
|
||||
|
||||
it('enables export for loaded markdown preview tabs', () => {
|
||||
const preview = markdownFile({
|
||||
id: 'preview:/repo/README.md',
|
||||
mode: 'markdown-preview',
|
||||
markdownPreviewSourceFileId: '/repo/README.md'
|
||||
} as Partial<OpenFile>)
|
||||
|
||||
expect(
|
||||
renderModel({
|
||||
activeFile: preview,
|
||||
fileContents: { [preview.id]: textContent() }
|
||||
}).canExportMarkdownToPdf
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('disables preview export until rendered content can exist', () => {
|
||||
const preview = markdownFile({
|
||||
id: 'preview:/repo/README.md',
|
||||
mode: 'markdown-preview',
|
||||
markdownPreviewSourceFileId: '/repo/README.md'
|
||||
} as Partial<OpenFile>)
|
||||
|
||||
expect(renderModel({ activeFile: preview, fileContents: {} }).canExportMarkdownToPdf).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
renderModel({
|
||||
activeFile: preview,
|
||||
fileContents: { [preview.id]: textContent({ loadError: 'missing' }) }
|
||||
}).canExportMarkdownToPdf
|
||||
).toBe(false)
|
||||
expect(
|
||||
renderModel({
|
||||
activeFile: preview,
|
||||
fileContents: { [preview.id]: textContent({ isBinary: true }) }
|
||||
}).canExportMarkdownToPdf
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -12,12 +12,16 @@ import { getEditorHeaderOpenFileState } from './editor-header'
|
|||
import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import type { FileContent } from './editor-panel-content-types'
|
||||
import { canUseChangesModeForFile } from './editor-panel-file-mode'
|
||||
import { getMarkdownRenderMode } from './markdown-render-mode'
|
||||
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
|
||||
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
|
||||
|
||||
type StoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
type EditorPanelRenderModelParams = {
|
||||
activeFile: OpenFile
|
||||
fileContents: Record<string, FileContent>
|
||||
editorDrafts: StoreState['editorDrafts']
|
||||
gitStatusByWorktree: StoreState['gitStatusByWorktree']
|
||||
gitBranchChangesByWorktree: StoreState['gitBranchChangesByWorktree']
|
||||
markdownViewMode: StoreState['markdownViewMode']
|
||||
|
|
@ -27,6 +31,7 @@ type EditorPanelRenderModelParams = {
|
|||
export function getEditorPanelRenderModel({
|
||||
activeFile,
|
||||
fileContents,
|
||||
editorDrafts,
|
||||
gitStatusByWorktree,
|
||||
gitBranchChangesByWorktree,
|
||||
markdownViewMode,
|
||||
|
|
@ -98,6 +103,36 @@ export function getEditorPanelRenderModel({
|
|||
: hasViewModeToggle
|
||||
? mdViewMode
|
||||
: 'edit'
|
||||
const inlineMarkdownContent =
|
||||
activeFile.mode === 'edit'
|
||||
? (editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content ?? null)
|
||||
: null
|
||||
const shouldShowMarkdownExportAction =
|
||||
resolvedLanguage === 'markdown' &&
|
||||
(activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview')
|
||||
const inlineMarkdownRenderMode =
|
||||
activeFile.mode === 'edit' && inlineMarkdownContent !== null
|
||||
? getMarkdownRenderMode({
|
||||
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(inlineMarkdownContent),
|
||||
hasRichModeUnsupportedContent:
|
||||
getMarkdownRichModeUnsupportedMessage(inlineMarkdownContent) !== null,
|
||||
viewMode: mdViewMode
|
||||
})
|
||||
: null
|
||||
const canExportMarkdownToPdf =
|
||||
shouldShowMarkdownExportAction &&
|
||||
((activeFile.mode === 'markdown-preview' &&
|
||||
fileContents[activeFile.id] !== undefined &&
|
||||
fileContents[activeFile.id]?.isBinary !== true &&
|
||||
!fileContents[activeFile.id]?.loadError) ||
|
||||
(activeFile.mode === 'edit' &&
|
||||
fileContents[activeFile.id] !== undefined &&
|
||||
!isChangesMode &&
|
||||
inlineMarkdownRenderMode !== null &&
|
||||
inlineMarkdownRenderMode !== 'source' &&
|
||||
fileContents[activeFile.id]?.isBinary !== true &&
|
||||
!fileContents[activeFile.id]?.loadError &&
|
||||
activeFile.conflict?.conflictStatus !== 'unresolved'))
|
||||
return {
|
||||
isSingleDiff,
|
||||
isDiffSurface: isSingleDiff || isChangesMode,
|
||||
|
|
@ -120,6 +155,8 @@ export function getEditorPanelRenderModel({
|
|||
hasEditorToggle: availableEditorToggleModes.length > 1,
|
||||
effectiveToggleValue,
|
||||
isMarkdownTableOfContentsDisabled: hasViewModeToggle && mdViewMode === 'source',
|
||||
shouldShowMarkdownExportAction,
|
||||
canExportMarkdownToPdf,
|
||||
canShowMarkdownTableOfContents:
|
||||
resolvedLanguage === 'markdown' &&
|
||||
(hasViewModeToggle || activeFile.mode === 'markdown-preview'),
|
||||
|
|
|
|||
|
|
@ -3,16 +3,17 @@ import { getActiveMarkdownExportPayload } from './markdown-export-extract'
|
|||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* 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 the markdown document for a local editor panel through the existing
|
||||
* PDF bridge. Silent no-op when the panel no longer has rendered markdown.
|
||||
*/
|
||||
export async function exportActiveMarkdownToPdf(): Promise<void> {
|
||||
const payload = getActiveMarkdownExportPayload()
|
||||
export async function exportActiveMarkdownToPdf(options: {
|
||||
fileId: string
|
||||
root: ParentNode | null
|
||||
}): Promise<void> {
|
||||
const payload = getActiveMarkdownExportPayload(options)
|
||||
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.
|
||||
// Why: stale panel refs can survive a dropdown click; keep export defensive
|
||||
// even though the local Markdown menu disables unreachable states.
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,29 +31,28 @@ function basenameWithoutExt(filePath: string): string {
|
|||
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)
|
||||
function findDocumentSubtree(root: ParentNode): Element | null {
|
||||
return root.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
|
||||
* Extract a clean, self-contained HTML export payload from a panel-scoped
|
||||
* markdown surface. Returns null when the requested file is stale 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') {
|
||||
export function getActiveMarkdownExportPayload({
|
||||
fileId,
|
||||
root
|
||||
}: {
|
||||
fileId: string
|
||||
root: ParentNode | null
|
||||
}): MarkdownExportPayload | null {
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
const activeFile = state.openFiles.find((f) => f.id === state.activeFileId)
|
||||
if (!activeFile || activeFile.mode !== 'edit') {
|
||||
const state = useAppStore.getState()
|
||||
const activeFile = state.openFiles.find((f) => f.id === fileId)
|
||||
if (!activeFile || (activeFile.mode !== 'edit' && activeFile.mode !== 'markdown-preview')) {
|
||||
return null
|
||||
}
|
||||
const language = detectLanguage(activeFile.filePath)
|
||||
|
|
@ -61,7 +60,7 @@ export function getActiveMarkdownExportPayload(): MarkdownExportPayload | null {
|
|||
return null
|
||||
}
|
||||
|
||||
const subtree = findActiveDocumentSubtree()
|
||||
const subtree = findDocumentSubtree(root)
|
||||
if (!subtree) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
|
||||
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
|
||||
|
||||
describe('exceedsMarkdownRichModeSizeLimit', () => {
|
||||
it('allows markdown at the rich-mode byte limit', () => {
|
||||
expect(exceedsMarkdownRichModeSizeLimit('a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES))).toBe(false)
|
||||
})
|
||||
|
||||
it('detects markdown over the byte limit', () => {
|
||||
expect(exceedsMarkdownRichModeSizeLimit('a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('detects unread multibyte content at the byte boundary', () => {
|
||||
expect(
|
||||
exceedsMarkdownRichModeSizeLimit(`${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES)}\u00e9`)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
|
||||
|
||||
const richMarkdownSizeEncoder = new TextEncoder()
|
||||
// Why: rich-mode eligibility is checked during render-model work, so this
|
||||
// avoids allocating a large Uint8Array every time markdown content changes.
|
||||
const richMarkdownSizeBuffer = new Uint8Array(RICH_MARKDOWN_MAX_SIZE_BYTES + 1)
|
||||
|
||||
export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean {
|
||||
const probe = richMarkdownSizeEncoder.encodeInto(markdownContent, richMarkdownSizeBuffer)
|
||||
|
||||
// Why: encodeInto() never writes partial UTF-8 sequences. A multibyte
|
||||
// character can leave written at the exact limit while unread content remains.
|
||||
return probe.written > RICH_MARKDOWN_MAX_SIZE_BYTES || probe.read < markdownContent.length
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@
|
|||
"exploreOrca": "Explore Orca",
|
||||
"gettingStarted": "Getting Started with Orca",
|
||||
"reportCrash": "Report Crash...",
|
||||
"exportPdf": "Export as PDF...",
|
||||
"file": "File",
|
||||
"exit": "Exit",
|
||||
"edit": "Edit",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@
|
|||
"exploreOrca": "Explorar Orca",
|
||||
"gettingStarted": "Empezando con Orca",
|
||||
"reportCrash": "Informar fallo...",
|
||||
"exportPdf": "Exportar como PDF...",
|
||||
"file": "Archivo",
|
||||
"exit": "Salida",
|
||||
"edit": "Editar",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@
|
|||
"exploreOrca": "Orca を探索",
|
||||
"gettingStarted": "Orca を使い始める",
|
||||
"reportCrash": "クラッシュを報告...",
|
||||
"exportPdf": "PDF としてエクスポート...",
|
||||
"file": "ファイル",
|
||||
"exit": "終了",
|
||||
"edit": "編集",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@
|
|||
"exploreOrca": "Orca 둘러보기",
|
||||
"gettingStarted": "Orca 시작하기",
|
||||
"reportCrash": "크래시 신고...",
|
||||
"exportPdf": "PDF로 내보내기...",
|
||||
"file": "파일",
|
||||
"exit": "종료",
|
||||
"edit": "편집",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@
|
|||
"exploreOrca": "探索 Orca",
|
||||
"gettingStarted": "Orca 入门",
|
||||
"reportCrash": "发送错误报告...",
|
||||
"exportPdf": "导出为 PDF...",
|
||||
"file": "文件",
|
||||
"exit": "退出",
|
||||
"edit": "编辑",
|
||||
|
|
|
|||
|
|
@ -2015,7 +2015,6 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onCtrlTabKeyUp: () => noopUnsubscribe,
|
||||
onToggleStatusBar: () => noopUnsubscribe,
|
||||
onDictationKeyDown: () => noopUnsubscribe,
|
||||
onExportPdfRequested: () => noopUnsubscribe,
|
||||
onActivateWorktree: () => noopUnsubscribe,
|
||||
onCreateTerminal: () => noopUnsubscribe,
|
||||
onRequestTerminalCreate: () => noopUnsubscribe,
|
||||
|
|
|
|||
|
|
@ -437,11 +437,7 @@ describe('keybindings', () => {
|
|||
|
||||
expect(conflicts).toContainEqual({
|
||||
binding: 'Mod+Shift+E',
|
||||
actionIds: expect.arrayContaining([
|
||||
'file.exportPdf',
|
||||
'sidebar.explorer.toggle',
|
||||
'worktree.palette'
|
||||
])
|
||||
actionIds: expect.arrayContaining(['sidebar.explorer.toggle', 'worktree.palette'])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export type KeybindingActionId =
|
|||
| 'worktree.navigateDown'
|
||||
| 'app.settings'
|
||||
| 'app.forceReload'
|
||||
| 'file.exportPdf'
|
||||
| 'workspace.create'
|
||||
| 'workspace.rename'
|
||||
| 'workspace.delete'
|
||||
|
|
@ -202,15 +201,6 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
|
|||
defaultBindings: platformBindings(['Mod+Shift+R']),
|
||||
conflictGroup: 'menu'
|
||||
},
|
||||
{
|
||||
id: 'file.exportPdf',
|
||||
title: 'Export as PDF',
|
||||
group: 'Global',
|
||||
scope: 'global',
|
||||
searchKeywords: ['shortcut', 'export', 'pdf', 'markdown'],
|
||||
defaultBindings: platformBindings(['Mod+Shift+E']),
|
||||
conflictGroup: 'menu'
|
||||
},
|
||||
{
|
||||
id: 'worktree.palette',
|
||||
title: 'Switch worktree',
|
||||
|
|
|
|||
|
|
@ -160,13 +160,16 @@ describe('resolveWindowShortcutAction', () => {
|
|||
).toEqual({ type: 'jumpToTabIndex', index: 2 })
|
||||
})
|
||||
|
||||
it('routes menu-backed actions through the same window shortcut policy', () => {
|
||||
it('does not resolve the removed PDF export shortcut globally', () => {
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'KeyE', key: 'e', meta: true, control: false, alt: false, shift: true },
|
||||
'darwin'
|
||||
)
|
||||
).toEqual({ type: 'exportPdf' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('routes menu-backed actions through the same window shortcut policy', () => {
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: true },
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export type WindowShortcutInput = {
|
|||
export type WindowShortcutAction =
|
||||
| { type: 'zoom'; direction: 'in' | 'out' | 'reset' }
|
||||
| { type: 'openSettings' }
|
||||
| { type: 'exportPdf' }
|
||||
| { type: 'forceReload' }
|
||||
| { type: 'toggleWorktreePalette' }
|
||||
| { type: 'toggleFloatingTerminal' }
|
||||
|
|
@ -190,10 +189,6 @@ export function resolveWindowShortcutAction(
|
|||
return { type: 'openSettings' }
|
||||
}
|
||||
|
||||
if (actionMatches('file.exportPdf', input, platform, keybindings, options)) {
|
||||
return { type: 'exportPdf' }
|
||||
}
|
||||
|
||||
if (actionMatches('app.forceReload', input, platform, keybindings, options)) {
|
||||
return { type: 'forceReload' }
|
||||
}
|
||||
|
|
@ -280,8 +275,6 @@ export function getWindowShortcutActionId(action: WindowShortcutAction): Keybind
|
|||
: 'zoom.reset'
|
||||
case 'openSettings':
|
||||
return 'app.settings'
|
||||
case 'exportPdf':
|
||||
return 'file.exportPdf'
|
||||
case 'forceReload':
|
||||
return 'app.forceReload'
|
||||
case 'toggleWorktreePalette':
|
||||
|
|
|
|||
Loading…
Reference in New Issue