mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ feat(desktop): add system save dialog for markdown export (#11852)
- Add ShowSaveDialogParams/Result types to electron-client-ipc - Implement handleShowSaveDialog in LocalFileCtr using Electron dialog API - Add showSaveDialog method to localFileService - Create desktopExportService for Desktop-specific export logic - Use system file picker instead of hardcoded downloads path - Show toast with actions (open file / show in folder) after export - Add i18n keys for export dialog and actions
This commit is contained in:
@@ -15,11 +15,13 @@ import {
|
||||
OpenLocalFileParams,
|
||||
OpenLocalFolderParams,
|
||||
RenameLocalFileResult,
|
||||
ShowSaveDialogParams,
|
||||
ShowSaveDialogResult,
|
||||
WriteLocalFileParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
|
||||
import { createPatch } from 'diff';
|
||||
import { shell } from 'electron';
|
||||
import { dialog, shell } from 'electron';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats, constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
||||
@@ -78,6 +80,28 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleShowSaveDialog({
|
||||
defaultPath,
|
||||
filters,
|
||||
title,
|
||||
}: ShowSaveDialogParams): Promise<ShowSaveDialogResult> {
|
||||
logger.debug('Showing save dialog:', { defaultPath, filters, title });
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath,
|
||||
filters,
|
||||
title,
|
||||
});
|
||||
|
||||
logger.debug('Save dialog result:', { canceled: result.canceled, filePath: result.filePath });
|
||||
|
||||
return {
|
||||
canceled: result.canceled,
|
||||
filePath: result.filePath,
|
||||
};
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
|
||||
logger.debug('Starting batch file reading:', { count: paths.length });
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
"pageEditor.empty.importNotion": "Import from Notion",
|
||||
"pageEditor.empty.title": "Select a page to get started",
|
||||
"pageEditor.empty.uploadFiles": "Upload Files",
|
||||
"pageEditor.exportActions.openFile": "Open",
|
||||
"pageEditor.exportActions.showInFolder": "Show in Folder",
|
||||
"pageEditor.exportDialogTitle": "Export Page",
|
||||
"pageEditor.exportError": "Failed to export the page",
|
||||
"pageEditor.exportSuccess": "Page exported successfully",
|
||||
"pageEditor.linkCopied": "Link copied",
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
"pageEditor.empty.importNotion": "从 Notion 导入",
|
||||
"pageEditor.empty.title": "选择一个文稿以开始",
|
||||
"pageEditor.empty.uploadFiles": "上传文件",
|
||||
"pageEditor.exportActions.openFile": "打开",
|
||||
"pageEditor.exportActions.showInFolder": "在文件夹中显示",
|
||||
"pageEditor.exportDialogTitle": "导出页面",
|
||||
"pageEditor.exportError": "页面导出失败",
|
||||
"pageEditor.exportSuccess": "页面导出成功",
|
||||
"pageEditor.linkCopied": "链接已复制",
|
||||
|
||||
@@ -225,3 +225,30 @@ export interface EditLocalFileResult {
|
||||
replacements: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// Save Dialog types
|
||||
export interface ShowSaveDialogParams {
|
||||
/**
|
||||
* Default file name
|
||||
*/
|
||||
defaultPath?: string;
|
||||
/**
|
||||
* File type filters
|
||||
*/
|
||||
filters?: { extensions: string[]; name: string }[];
|
||||
/**
|
||||
* Dialog title
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ShowSaveDialogResult {
|
||||
/**
|
||||
* Whether the dialog was cancelled
|
||||
*/
|
||||
canceled: boolean;
|
||||
/**
|
||||
* The selected file path (undefined if cancelled)
|
||||
*/
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { type DropdownItem, Icon } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { cssVar, useResponsive } from 'antd-style';
|
||||
@@ -51,7 +52,7 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const handleExportMarkdown = async () => {
|
||||
const state = storeApi.getState();
|
||||
const { editor, title } = state;
|
||||
|
||||
@@ -59,16 +60,26 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
|
||||
try {
|
||||
const markdown = (editor.getDocument('markdown') as unknown as string) || '';
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'Untitled'}.md`;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success(t('pageEditor.exportSuccess'));
|
||||
const fileName = `${title || 'Untitled'}.md`;
|
||||
|
||||
if (isDesktop) {
|
||||
const { desktopExportService } = await import('@/services/electron/desktopExportService');
|
||||
await desktopExportService.exportMarkdown({
|
||||
content: markdown,
|
||||
fileName,
|
||||
});
|
||||
} else {
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success(t('pageEditor.exportSuccess'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to export markdown:', error);
|
||||
message.error(t('pageEditor.exportError'));
|
||||
|
||||
@@ -93,6 +93,9 @@ export default {
|
||||
'pageEditor.empty.importNotion': 'Import from Notion',
|
||||
'pageEditor.empty.title': 'Select a page to get started',
|
||||
'pageEditor.empty.uploadFiles': 'Upload Files',
|
||||
'pageEditor.exportActions.openFile': 'Open',
|
||||
'pageEditor.exportActions.showInFolder': 'Show in Folder',
|
||||
'pageEditor.exportDialogTitle': 'Export Page',
|
||||
'pageEditor.exportError': 'Failed to export the page',
|
||||
'pageEditor.exportSuccess': 'Page exported successfully',
|
||||
'pageEditor.linkCopied': 'Link copied',
|
||||
|
||||
61
src/services/electron/desktopExportService.tsx
Normal file
61
src/services/electron/desktopExportService.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { toast } from '@lobehub/ui';
|
||||
import i18next from 'i18next';
|
||||
|
||||
import { localFileService } from './localFileService';
|
||||
|
||||
export interface DesktopExportOptions {
|
||||
content: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface DesktopExportResult {
|
||||
canceled: boolean;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
class DesktopExportService {
|
||||
async exportMarkdown(options: DesktopExportOptions): Promise<DesktopExportResult> {
|
||||
const { content, fileName } = options;
|
||||
|
||||
const result = await localFileService.showSaveDialog({
|
||||
defaultPath: fileName,
|
||||
filters: [{ extensions: ['md'], name: 'Markdown' }],
|
||||
title: i18next.t('pageEditor.exportDialogTitle', { ns: 'file' }),
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { canceled: true };
|
||||
}
|
||||
|
||||
await localFileService.writeFile({
|
||||
content,
|
||||
path: result.filePath,
|
||||
});
|
||||
|
||||
this.showExportSuccessToast(result.filePath);
|
||||
|
||||
return { canceled: false, filePath: result.filePath };
|
||||
}
|
||||
|
||||
private showExportSuccessToast(filePath: string) {
|
||||
const t = i18next.t.bind(i18next);
|
||||
|
||||
toast.success({
|
||||
actions: [
|
||||
{
|
||||
label: t('pageEditor.exportActions.showInFolder', { ns: 'file' }),
|
||||
onClick: () => localFileService.openFileFolder(filePath),
|
||||
variant: 'text',
|
||||
},
|
||||
{
|
||||
label: t('pageEditor.exportActions.openFile', { ns: 'file' }),
|
||||
onClick: () => localFileService.openLocalFile({ path: filePath }),
|
||||
variant: 'primary',
|
||||
},
|
||||
],
|
||||
title: t('pageEditor.exportSuccess', { ns: 'file' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopExportService = new DesktopExportService();
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type RenameLocalFileParams,
|
||||
type RunCommandParams,
|
||||
type RunCommandResult,
|
||||
type ShowSaveDialogParams,
|
||||
type ShowSaveDialogResult,
|
||||
type WriteLocalFileParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
@@ -91,6 +93,11 @@ class LocalFileService {
|
||||
return ensureElectronIpc().localSystem.handleGlobFiles(params);
|
||||
}
|
||||
|
||||
// Dialog
|
||||
async showSaveDialog(params: ShowSaveDialogParams): Promise<ShowSaveDialogResult> {
|
||||
return ensureElectronIpc().localSystem.handleShowSaveDialog(params);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
async openLocalFileOrFolder(path: string, isDirectory: boolean) {
|
||||
if (isDirectory) {
|
||||
|
||||
Reference in New Issue
Block a user