From 685cf30d049184ca066d6a8b875c6e5366c31d1d Mon Sep 17 00:00:00 2001 From: Jorben Date: Thu, 5 Mar 2026 11:32:00 +0800 Subject: [PATCH 1/2] feat(cloud-preview): improve cloud preview integration --- .../infrastructure/services/CloudService.ts | 110 +++++++++++++++- .../services/__tests__/CloudService.test.ts | 26 ++++ src/main/ipc/__tests__/handlers.test.ts | 1 + .../handlers/__tests__/file.handler.test.ts | 67 +++++++++- src/main/ipc/handlers/file.handler.ts | 44 ++++++- src/preload/electron.d.ts | 1 + src/preload/index.ts | 2 + src/renderer/App.css | 19 +++ src/renderer/electron.d.ts | 8 ++ src/renderer/locales/ar-SA/cloud-preview.json | 9 ++ src/renderer/locales/ar-SA/settings.json | 8 ++ src/renderer/locales/en-US/cloud-preview.json | 9 ++ src/renderer/locales/en-US/settings.json | 8 ++ src/renderer/locales/fa-IR/cloud-preview.json | 9 ++ src/renderer/locales/fa-IR/settings.json | 8 ++ src/renderer/locales/ja-JP/cloud-preview.json | 9 ++ src/renderer/locales/ja-JP/settings.json | 8 ++ src/renderer/locales/ru-RU/cloud-preview.json | 9 ++ src/renderer/locales/ru-RU/settings.json | 8 ++ src/renderer/locales/zh-CN/cloud-preview.json | 9 ++ src/renderer/locales/zh-CN/settings.json | 8 ++ src/renderer/pages/CloudPreview.tsx | 120 ++++++++++++++++-- src/renderer/pages/Preview.tsx | 62 ++++++++- .../pages/__tests__/CloudPreview.test.tsx | 112 +++++++++++++++- src/renderer/pages/__tests__/Preview.test.tsx | 49 +++++++ src/shared/ipc/channels.ts | 1 + tests/setup.renderer.ts | 9 ++ 27 files changed, 711 insertions(+), 22 deletions(-) diff --git a/src/core/infrastructure/services/CloudService.ts b/src/core/infrastructure/services/CloudService.ts index a9865de..414b7aa 100644 --- a/src/core/infrastructure/services/CloudService.ts +++ b/src/core/infrastructure/services/CloudService.ts @@ -37,6 +37,109 @@ class CloudService { private constructor() {} + private extractDownloadFileName(contentDisposition: string, fallback: string): string { + const rfc5987Name = this.parseRFC5987Filename(contentDisposition); + if (rfc5987Name) { + return this.sanitizeDownloadFileName(rfc5987Name, fallback); + } + + const plainName = this.parsePlainFilename(contentDisposition); + if (!plainName) { + return this.sanitizeDownloadFileName(fallback, fallback); + } + + const repairedName = this.tryRepairUtf8Mojibake(plainName); + return this.sanitizeDownloadFileName(repairedName || plainName, fallback); + } + + private parseRFC5987Filename(contentDisposition: string): string | null { + const match = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i); + if (!match) return null; + + const rawValue = match[1]?.trim(); + if (!rawValue) return null; + + const unquoted = rawValue.replace(/^"(.*)"$/, '$1'); + const parts = unquoted.match(/^([^']*)'[^']*'(.*)$/); + if (!parts) return null; + + const charset = (parts[1] || 'utf-8').trim().toLowerCase(); + const encodedValue = parts[2] || ''; + + try { + if (charset === 'utf-8' || charset === 'utf8') { + return decodeURIComponent(encodedValue); + } + + const bytes = this.percentDecodeToBytes(encodedValue); + if (charset === 'iso-8859-1' || charset === 'latin1') { + return Buffer.from(bytes).toString('latin1'); + } + return Buffer.from(bytes).toString('utf8'); + } catch { + return null; + } + } + + private parsePlainFilename(contentDisposition: string): string | null { + const match = contentDisposition.match(/filename\s*=\s*("(?:\\.|[^"])*"|[^;]+)/i); + if (!match) return null; + + let value = match[1]?.trim(); + if (!value) return null; + + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1).replace(/\\"/g, '"'); + } + + return value; + } + + private percentDecodeToBytes(input: string): number[] { + const bytes: number[] = []; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (ch === '%' && i + 2 < input.length) { + const hex = input.slice(i + 1, i + 3); + const parsed = Number.parseInt(hex, 16); + if (!Number.isNaN(parsed)) { + bytes.push(parsed); + i += 2; + continue; + } + } + bytes.push(input.charCodeAt(i)); + } + return bytes; + } + + private tryRepairUtf8Mojibake(input: string): string | null { + const hasCjk = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(input); + if (hasCjk) return null; + + const latinSupplementCount = Array.from(input).filter((ch) => { + const code = ch.charCodeAt(0); + return code >= 0x00c0 && code <= 0x00ff; + }).length; + if (latinSupplementCount < 2) return null; + + const repaired = Buffer.from(input, 'latin1').toString('utf8'); + if (!repaired) return null; + + const repairedHasCjk = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(repaired); + const roundTrip = Buffer.from(repaired, 'utf8').toString('latin1') === input; + if (repairedHasCjk && roundTrip) { + return repaired; + } + return null; + } + + private sanitizeDownloadFileName(input: string, fallback: string): string { + // Sanitize: extract basename and strip control/reserved characters + // eslint-disable-next-line no-control-regex + return path.basename(input).replace(/[\u0000-\u001f<>:"|?*]/g, '_') || fallback; + } + private normalizeCheckoutStatus(data: any): PaymentCheckoutStatusApiResponse | null { if (!data || typeof data !== 'object') { return null; @@ -454,11 +557,8 @@ class CloudService { } const contentDisposition = res.headers.get('Content-Disposition') || ''; - const match = contentDisposition.match(/filename="?([^";\n]+)"?/); - const rawName = match ? match[1] : `task-${id}.pdf`; - // Sanitize: extract basename and strip control/reserved characters - // eslint-disable-next-line no-control-regex - const fileName = path.basename(rawName).replace(/[\u0000-\u001f<>:"|?*]/g, '_') || `task-${id}.pdf`; + const fallbackName = `task-${id}.pdf`; + const fileName = this.extractDownloadFileName(contentDisposition, fallbackName); const buffer = await res.arrayBuffer(); return { success: true, data: { buffer, fileName } }; diff --git a/src/core/infrastructure/services/__tests__/CloudService.test.ts b/src/core/infrastructure/services/__tests__/CloudService.test.ts index eb07263..ee79000 100644 --- a/src/core/infrastructure/services/__tests__/CloudService.test.ts +++ b/src/core/infrastructure/services/__tests__/CloudService.test.ts @@ -283,6 +283,32 @@ describe('CloudService', () => { expect(result.data?.fileName).toBe('task-task-xyz.pdf') }) + it('downloadPdf decodes RFC5987 filename* for non-english names', async () => { + const cloudService = (await import('../CloudService.js')).default + const response = makeJsonResponse(200, {}) + response.headers.get.mockReturnValue( + "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%80%E6%9C%AF%E6%89%8B%E5%86%8C.pdf", + ) + response.arrayBuffer.mockResolvedValue(new Uint8Array([1]).buffer) + mockAuthManager.fetchWithAuth.mockResolvedValueOnce(response) + + const result = await cloudService.downloadPdf('task-cn') + expect(result.data?.fileName).toBe('中文技术手册.pdf') + }) + + it('downloadPdf repairs common UTF-8 mojibake in filename', async () => { + const cloudService = (await import('../CloudService.js')).default + const response = makeJsonResponse(200, {}) + const original = '中文手册2.0.pdf' + const mojibake = Buffer.from(original, 'utf8').toString('latin1') + response.headers.get.mockReturnValue(`attachment; filename="${mojibake}"`) + response.arrayBuffer.mockResolvedValue(new Uint8Array([1]).buffer) + mockAuthManager.fetchWithAuth.mockResolvedValueOnce(response) + + const result = await cloudService.downloadPdf('task-mojibake') + expect(result.data?.fileName).toBe(original) + }) + it('downloadPdf/getPageImage return error on non-OK response', async () => { const cloudService = (await import('../CloudService.js')).default mockAuthManager.fetchWithAuth diff --git a/src/main/ipc/__tests__/handlers.test.ts b/src/main/ipc/__tests__/handlers.test.ts index d08ecbb..1a82d74 100644 --- a/src/main/ipc/__tests__/handlers.test.ts +++ b/src/main/ipc/__tests__/handlers.test.ts @@ -152,6 +152,7 @@ vi.mock('../../../shared/ipc/channels.js', () => ({ FILE: { GET_IMAGE_PATH: 'file:getImagePath', DOWNLOAD_MARKDOWN: 'file:downloadMarkdown', + COPY_IMAGE_TO_CLIPBOARD: 'file:copyImageToClipboard', SELECT_DIALOG: 'file:selectDialog', UPLOAD: 'file:upload', UPLOAD_FILE_CONTENT: 'file:uploadFileContent', diff --git a/src/main/ipc/handlers/__tests__/file.handler.test.ts b/src/main/ipc/handlers/__tests__/file.handler.test.ts index 757ca48..412ea83 100644 --- a/src/main/ipc/handlers/__tests__/file.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/file.handler.test.ts @@ -14,6 +14,16 @@ const mockDialog = { showSaveDialog: vi.fn() } +const mockClipboard = { + writeImage: vi.fn(), +} + +const mockNativeImage = { + createFromPath: vi.fn(), + createFromDataURL: vi.fn(), + createFromBuffer: vi.fn(), +} + const mockFs = { existsSync: vi.fn(), mkdirSync: vi.fn(), @@ -36,7 +46,9 @@ const mockIpcMain = { // Mock modules vi.mock('electron', () => ({ ipcMain: mockIpcMain, - dialog: mockDialog + dialog: mockDialog, + clipboard: mockClipboard, + nativeImage: mockNativeImage, })) vi.mock('path', () => ({ @@ -66,6 +78,7 @@ vi.mock('../../../../shared/ipc/channels.js', () => ({ FILE: { GET_IMAGE_PATH: 'file:getImagePath', DOWNLOAD_MARKDOWN: 'file:downloadMarkdown', + COPY_IMAGE_TO_CLIPBOARD: 'file:copyImageToClipboard', SELECT_DIALOG: 'file:selectDialog', UPLOAD: 'file:upload', UPLOAD_FILE_CONTENT: 'file:uploadFileContent' @@ -87,11 +100,63 @@ describe('File Handler', () => { mockFileLogic.getUploadDir.mockReturnValue('/uploads') mockFs.statSync.mockReturnValue({ size: 1024 }) mockFs.existsSync.mockReturnValue(true) + const fakeImage = { isEmpty: vi.fn(() => false) } + mockNativeImage.createFromPath.mockReturnValue(fakeImage) + mockNativeImage.createFromDataURL.mockReturnValue(fakeImage) + mockNativeImage.createFromBuffer.mockReturnValue(fakeImage) const { registerFileHandlers } = await import('../file.handler.js') registerFileHandlers() }) + describe('file:copyImageToClipboard', () => { + it('should copy image from local path successfully', async () => { + const handler = handlers.get('file:copyImageToClipboard') + const result = await handler!({}, '/tmp/page.png') + + expect(result).toEqual({ + success: true, + data: { copied: true }, + }) + expect(mockNativeImage.createFromPath).toHaveBeenCalledWith('/tmp/page.png') + expect(mockClipboard.writeImage).toHaveBeenCalled() + }) + + it('should copy image from data URL successfully', async () => { + const handler = handlers.get('file:copyImageToClipboard') + const result = await handler!({}, 'data:image/png;base64,abcd') + + expect(result.success).toBe(true) + expect(mockNativeImage.createFromDataURL).toHaveBeenCalledWith('data:image/png;base64,abcd') + expect(mockClipboard.writeImage).toHaveBeenCalled() + }) + + it('should return error when image source is missing', async () => { + const handler = handlers.get('file:copyImageToClipboard') + const result = await handler!({}, '') + + expect(result).toEqual({ + success: false, + error: 'Image source is required', + }) + }) + + it('should return error when image is empty', async () => { + mockNativeImage.createFromPath.mockReturnValueOnce({ + isEmpty: vi.fn(() => true), + }) + + const handler = handlers.get('file:copyImageToClipboard') + const result = await handler!({}, '/tmp/empty.png') + + expect(result).toEqual({ + success: false, + error: 'Image data is empty or invalid', + }) + expect(mockClipboard.writeImage).not.toHaveBeenCalled() + }) + }) + describe('file:getImagePath', () => { it('should return image path and exists status', async () => { mockFs.existsSync.mockReturnValue(true) diff --git a/src/main/ipc/handlers/file.handler.ts b/src/main/ipc/handlers/file.handler.ts index 9b02227..27641e8 100644 --- a/src/main/ipc/handlers/file.handler.ts +++ b/src/main/ipc/handlers/file.handler.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog } from "electron"; +import { ipcMain, dialog, clipboard, nativeImage } from "electron"; import path from "path"; import fs from "fs"; import taskRepository from "../../../core/domain/repositories/TaskRepository.js"; @@ -7,6 +7,23 @@ import { ImagePathUtil } from "../../../core/infrastructure/adapters/split/index import { IPC_CHANNELS } from "../../../shared/ipc/channels.js"; import type { IpcResponse } from "../../../shared/ipc/responses.js"; +async function createImageFromSource(imageSource: string) { + if (imageSource.startsWith("data:image/")) { + return nativeImage.createFromDataURL(imageSource); + } + + if (imageSource.startsWith("http://") || imageSource.startsWith("https://")) { + const response = await fetch(imageSource); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + return nativeImage.createFromBuffer(buffer); + } + + return nativeImage.createFromPath(imageSource); +} + /** * Register all file-related IPC handlers */ @@ -96,6 +113,31 @@ export function registerFileHandlers() { } ); + /** + * Copy image to clipboard + */ + ipcMain.handle( + IPC_CHANNELS.FILE.COPY_IMAGE_TO_CLIPBOARD, + async (_, imageSource: string): Promise => { + try { + if (!imageSource) { + return { success: false, error: "Image source is required" }; + } + + const image = await createImageFromSource(imageSource); + if (image.isEmpty()) { + return { success: false, error: "Image data is empty or invalid" }; + } + + clipboard.writeImage(image); + return { success: true, data: { copied: true } }; + } catch (error: any) { + console.error("[IPC] file:copyImageToClipboard error:", error); + return { success: false, error: error.message || "Failed to copy image" }; + } + } + ); + /** * File selection dialog * @param allowOffice - If true, includes Office file types in the filter diff --git a/src/preload/electron.d.ts b/src/preload/electron.d.ts index 2e31b71..0bf72e4 100644 --- a/src/preload/electron.d.ts +++ b/src/preload/electron.d.ts @@ -99,6 +99,7 @@ interface WindowAPI { uploadFileContent: (taskId: string, fileName: string, fileBuffer: ArrayBuffer) => Promise; getImagePath: (taskId: string, page: number) => Promise; downloadMarkdown: (taskId: string) => Promise; + copyImageToClipboard: (imageSource: string) => Promise; }; completion: { markImagedown: (providerId: number, modelId: string, url: string) => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index c103dcd..8dcc0ec 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,8 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("file:getImagePath", taskId, page), downloadMarkdown: (taskId: string) => ipcRenderer.invoke("file:downloadMarkdown", taskId), + copyImageToClipboard: (imageSource: string) => + ipcRenderer.invoke("file:copyImageToClipboard", imageSource), }, // ==================== Completion APIs ==================== diff --git a/src/renderer/App.css b/src/renderer/App.css index 9f4c5c1..8ff5f90 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -145,3 +145,22 @@ .ant-splitter-panel:last-child { overflow: auto !important; } + +/* Floating copy button for preview panels */ +.preview-floating-action { + position: absolute; + top: 10px; + right: 10px; + z-index: 10; + opacity: 0.65; + transition: opacity 0.2s ease, background-color 0.2s ease; + background-color: rgba(255, 255, 255, 0.72); + border-color: rgba(0, 0, 0, 0.15); +} + +.preview-floating-action:hover, +.preview-floating-action:focus, +.preview-floating-action:focus-visible { + opacity: 1; + background-color: rgba(255, 255, 255, 0.92); +} diff --git a/src/renderer/electron.d.ts b/src/renderer/electron.d.ts index f74a487..35684bd 100644 --- a/src/renderer/electron.d.ts +++ b/src/renderer/electron.d.ts @@ -224,6 +224,11 @@ interface ElectronAPI { taskId: string, filePath: string, ) => Promise>; + uploadFileContent: ( + taskId: string, + fileName: string, + fileBuffer: ArrayBuffer, + ) => Promise>; getImagePath: ( taskId: string, page: number, @@ -231,6 +236,9 @@ interface ElectronAPI { downloadMarkdown: ( taskId: string, ) => Promise>; + copyImageToClipboard: ( + imageSource: string, + ) => Promise>; }; completion: { diff --git a/src/renderer/locales/ar-SA/cloud-preview.json b/src/renderer/locales/ar-SA/cloud-preview.json index 12a97fc..54e24f1 100644 --- a/src/renderer/locales/ar-SA/cloud-preview.json +++ b/src/renderer/locales/ar-SA/cloud-preview.json @@ -1,6 +1,7 @@ { "back": "رجوع", "fetch_task_failed": "فشل في جلب المهمة", + "download": "تحميل", "download_md": "تحميل MD", "download_pdf": "تحميل PDF", "download_success": "تم التحميل بنجاح", @@ -31,6 +32,14 @@ "page_label": "صفحة {{page}} / {{total}}", "regenerate": "إعادة إنشاء", "regenerate_tooltip": "إعادة محاولة تحويل هذه الصفحة", + "copy_markdown": "نسخ Markdown", + "copy_markdown_tooltip": "نسخ Markdown للصفحة الحالية", + "copy_markdown_success": "تم نسخ Markdown", + "copy_markdown_failed": "فشل نسخ Markdown", + "copy_image": "نسخ الصورة", + "copy_image_tooltip": "نسخ صورة الصفحة الحالية", + "copy_image_success": "تم نسخ الصورة", + "copy_image_failed": "فشل نسخ الصورة", "page_status": { "pending": "في الانتظار", "processing": "قيد المعالجة", diff --git a/src/renderer/locales/ar-SA/settings.json b/src/renderer/locales/ar-SA/settings.json index 64fa160..f7e2adf 100644 --- a/src/renderer/locales/ar-SA/settings.json +++ b/src/renderer/locales/ar-SA/settings.json @@ -36,6 +36,14 @@ "more_actions": "المزيد من الإجراءات", "regenerate": "إعادة إنشاء", "regenerate_tooltip": "إعادة إنشاء الصفحة الحالية", + "copy_markdown": "نسخ Markdown", + "copy_markdown_tooltip": "نسخ Markdown للصفحة الحالية", + "copy_markdown_success": "تم نسخ Markdown", + "copy_markdown_failed": "فشل نسخ Markdown", + "copy_image": "نسخ الصورة", + "copy_image_tooltip": "نسخ صورة الصفحة الحالية", + "copy_image_success": "تم نسخ الصورة", + "copy_image_failed": "فشل نسخ الصورة", "confirm_delete": "تأكيد الحذف", "confirm_delete_content": "هل أنت متأكد من حذف هذه المهمة؟ لا يمكن التراجع عن هذا الإجراء.", "delete_success": "تم الحذف بنجاح", diff --git a/src/renderer/locales/en-US/cloud-preview.json b/src/renderer/locales/en-US/cloud-preview.json index b7ef74a..38a9a3a 100644 --- a/src/renderer/locales/en-US/cloud-preview.json +++ b/src/renderer/locales/en-US/cloud-preview.json @@ -1,6 +1,7 @@ { "back": "Back", "fetch_task_failed": "Failed to fetch task", + "download": "Download", "download_md": "Download MD", "download_pdf": "Download PDF", "download_success": "Downloaded successfully", @@ -31,6 +32,14 @@ "page_label": "Page {{page}} / {{total}}", "regenerate": "Regenerate", "regenerate_tooltip": "Retry conversion for this page", + "copy_markdown": "Copy Markdown", + "copy_markdown_tooltip": "Copy current page markdown", + "copy_markdown_success": "Markdown copied", + "copy_markdown_failed": "Failed to copy markdown", + "copy_image": "Copy Image", + "copy_image_tooltip": "Copy current page image", + "copy_image_success": "Image copied", + "copy_image_failed": "Failed to copy image", "page_status": { "pending": "Pending", "processing": "Processing", diff --git a/src/renderer/locales/en-US/settings.json b/src/renderer/locales/en-US/settings.json index 101be47..b5aae34 100644 --- a/src/renderer/locales/en-US/settings.json +++ b/src/renderer/locales/en-US/settings.json @@ -35,6 +35,14 @@ "more_actions": "More Actions", "regenerate": "Regenerate", "regenerate_tooltip": "Regenerate current page", + "copy_markdown": "Copy Markdown", + "copy_markdown_tooltip": "Copy current page markdown", + "copy_markdown_success": "Markdown copied", + "copy_markdown_failed": "Failed to copy markdown", + "copy_image": "Copy Image", + "copy_image_tooltip": "Copy current page image", + "copy_image_success": "Image copied", + "copy_image_failed": "Failed to copy image", "confirm_delete": "Confirm Delete", "confirm_delete_content": "Are you sure you want to delete this task? This action cannot be undone.", "delete_success": "Deleted successfully", diff --git a/src/renderer/locales/fa-IR/cloud-preview.json b/src/renderer/locales/fa-IR/cloud-preview.json index d79ee94..2ef9939 100644 --- a/src/renderer/locales/fa-IR/cloud-preview.json +++ b/src/renderer/locales/fa-IR/cloud-preview.json @@ -1,6 +1,7 @@ { "back": "بازگشت", "fetch_task_failed": "دریافت وظیفه ناموفق بود", + "download": "دانلود", "download_md": "دانلود MD", "download_pdf": "دانلود PDF", "download_success": "دانلود موفق", @@ -31,6 +32,14 @@ "page_label": "صفحه {{page}} / {{total}}", "regenerate": "بازتولید", "regenerate_tooltip": "تلاش مجدد تبدیل این صفحه", + "copy_markdown": "کپی Markdown", + "copy_markdown_tooltip": "کپی Markdown صفحه فعلی", + "copy_markdown_success": "Markdown کپی شد", + "copy_markdown_failed": "کپی Markdown ناموفق بود", + "copy_image": "کپی تصویر", + "copy_image_tooltip": "کپی تصویر صفحه فعلی", + "copy_image_success": "تصویر کپی شد", + "copy_image_failed": "کپی تصویر ناموفق بود", "page_status": { "pending": "در انتظار", "processing": "در حال پردازش", diff --git a/src/renderer/locales/fa-IR/settings.json b/src/renderer/locales/fa-IR/settings.json index 8272870..dd491b6 100644 --- a/src/renderer/locales/fa-IR/settings.json +++ b/src/renderer/locales/fa-IR/settings.json @@ -36,6 +36,14 @@ "more_actions": "عملیات بیشتر", "regenerate": "بازتولید", "regenerate_tooltip": "بازتولید صفحه فعلی", + "copy_markdown": "کپی Markdown", + "copy_markdown_tooltip": "کپی Markdown صفحه فعلی", + "copy_markdown_success": "Markdown کپی شد", + "copy_markdown_failed": "کپی Markdown ناموفق بود", + "copy_image": "کپی تصویر", + "copy_image_tooltip": "کپی تصویر صفحه فعلی", + "copy_image_success": "تصویر کپی شد", + "copy_image_failed": "کپی تصویر ناموفق بود", "confirm_delete": "تأیید حذف", "confirm_delete_content": "آیا مطمئن هستید که می‌خواهید این وظیفه را حذف کنید؟ این عمل قابل بازگشت نیست.", "delete_success": "با موفقیت حذف شد", diff --git a/src/renderer/locales/ja-JP/cloud-preview.json b/src/renderer/locales/ja-JP/cloud-preview.json index c042059..0cb4090 100644 --- a/src/renderer/locales/ja-JP/cloud-preview.json +++ b/src/renderer/locales/ja-JP/cloud-preview.json @@ -1,6 +1,7 @@ { "back": "戻る", "fetch_task_failed": "タスクの取得に失敗しました", + "download": "ダウンロード", "download_md": "MD ダウンロード", "download_pdf": "PDF ダウンロード", "download_success": "ダウンロード成功", @@ -31,6 +32,14 @@ "page_label": "ページ {{page}} / {{total}}", "regenerate": "再生成", "regenerate_tooltip": "このページの変換を再試行", + "copy_markdown": "Markdownをコピー", + "copy_markdown_tooltip": "現在のページのMarkdownをコピー", + "copy_markdown_success": "Markdownをコピーしました", + "copy_markdown_failed": "Markdownのコピーに失敗しました", + "copy_image": "画像をコピー", + "copy_image_tooltip": "現在のページの画像をコピー", + "copy_image_success": "画像をコピーしました", + "copy_image_failed": "画像のコピーに失敗しました", "page_status": { "pending": "待機中", "processing": "処理中", diff --git a/src/renderer/locales/ja-JP/settings.json b/src/renderer/locales/ja-JP/settings.json index f593c5c..f90825c 100644 --- a/src/renderer/locales/ja-JP/settings.json +++ b/src/renderer/locales/ja-JP/settings.json @@ -36,6 +36,14 @@ "more_actions": "その他の操作", "regenerate": "再生成", "regenerate_tooltip": "現在のページを再生成", + "copy_markdown": "Markdownをコピー", + "copy_markdown_tooltip": "現在のページのMarkdownをコピー", + "copy_markdown_success": "Markdownをコピーしました", + "copy_markdown_failed": "Markdownのコピーに失敗しました", + "copy_image": "画像をコピー", + "copy_image_tooltip": "現在のページの画像をコピー", + "copy_image_success": "画像をコピーしました", + "copy_image_failed": "画像のコピーに失敗しました", "confirm_delete": "削除の確認", "confirm_delete_content": "このタスクを削除してもよろしいですか?この操作は元に戻せません。", "delete_success": "削除しました", diff --git a/src/renderer/locales/ru-RU/cloud-preview.json b/src/renderer/locales/ru-RU/cloud-preview.json index 1d4a169..73d8d65 100644 --- a/src/renderer/locales/ru-RU/cloud-preview.json +++ b/src/renderer/locales/ru-RU/cloud-preview.json @@ -1,6 +1,7 @@ { "back": "Назад", "fetch_task_failed": "Не удалось загрузить задачу", + "download": "Скачать", "download_md": "Скачать MD", "download_pdf": "Скачать PDF", "download_success": "Загрузка завершена", @@ -31,6 +32,14 @@ "page_label": "Страница {{page}} / {{total}}", "regenerate": "Перегенерировать", "regenerate_tooltip": "Повторить конвертацию этой страницы", + "copy_markdown": "Копировать Markdown", + "copy_markdown_tooltip": "Скопировать Markdown текущей страницы", + "copy_markdown_success": "Markdown скопирован", + "copy_markdown_failed": "Не удалось скопировать Markdown", + "copy_image": "Копировать изображение", + "copy_image_tooltip": "Скопировать изображение текущей страницы", + "copy_image_success": "Изображение скопировано", + "copy_image_failed": "Не удалось скопировать изображение", "page_status": { "pending": "В ожидании", "processing": "Обработка", diff --git a/src/renderer/locales/ru-RU/settings.json b/src/renderer/locales/ru-RU/settings.json index a696afa..7d57006 100644 --- a/src/renderer/locales/ru-RU/settings.json +++ b/src/renderer/locales/ru-RU/settings.json @@ -36,6 +36,14 @@ "more_actions": "Действия", "regenerate": "Перегенерировать", "regenerate_tooltip": "Перегенерировать текущую страницу", + "copy_markdown": "Копировать Markdown", + "copy_markdown_tooltip": "Скопировать Markdown текущей страницы", + "copy_markdown_success": "Markdown скопирован", + "copy_markdown_failed": "Не удалось скопировать Markdown", + "copy_image": "Копировать изображение", + "copy_image_tooltip": "Скопировать изображение текущей страницы", + "copy_image_success": "Изображение скопировано", + "copy_image_failed": "Не удалось скопировать изображение", "confirm_delete": "Подтвердите удаление", "confirm_delete_content": "Вы уверены, что хотите удалить эту задачу? Это действие необратимо.", "delete_success": "Успешно удалено", diff --git a/src/renderer/locales/zh-CN/cloud-preview.json b/src/renderer/locales/zh-CN/cloud-preview.json index f5988b4..4da316c 100644 --- a/src/renderer/locales/zh-CN/cloud-preview.json +++ b/src/renderer/locales/zh-CN/cloud-preview.json @@ -1,6 +1,7 @@ { "back": "返回", "fetch_task_failed": "获取任务失败", + "download": "下载", "download_md": "下载 MD", "download_pdf": "下载 PDF", "download_success": "下载成功", @@ -31,6 +32,14 @@ "page_label": "第 {{page}} 页 / 共 {{total}} 页", "regenerate": "重新生成", "regenerate_tooltip": "重试此页面的转换", + "copy_markdown": "复制 Markdown", + "copy_markdown_tooltip": "复制当前页 Markdown", + "copy_markdown_success": "Markdown 已复制", + "copy_markdown_failed": "复制 Markdown 失败", + "copy_image": "复制图片", + "copy_image_tooltip": "复制当前页图片", + "copy_image_success": "图片已复制", + "copy_image_failed": "复制图片失败", "page_status": { "pending": "待处理", "processing": "处理中", diff --git a/src/renderer/locales/zh-CN/settings.json b/src/renderer/locales/zh-CN/settings.json index 659fa3b..c3879e0 100644 --- a/src/renderer/locales/zh-CN/settings.json +++ b/src/renderer/locales/zh-CN/settings.json @@ -35,6 +35,14 @@ "more_actions": "更多操作", "regenerate": "重新生成", "regenerate_tooltip": "重新生成当前页", + "copy_markdown": "复制 Markdown", + "copy_markdown_tooltip": "复制当前页 Markdown", + "copy_markdown_success": "Markdown 已复制", + "copy_markdown_failed": "复制 Markdown 失败", + "copy_image": "复制图片", + "copy_image_tooltip": "复制当前页图片", + "copy_image_success": "图片已复制", + "copy_image_failed": "复制图片失败", "confirm_delete": "确认删除", "confirm_delete_content": "确定要删除此任务吗?此操作不可恢复。", "delete_success": "删除成功", diff --git a/src/renderer/pages/CloudPreview.tsx b/src/renderer/pages/CloudPreview.tsx index 188f3c8..730e58c 100644 --- a/src/renderer/pages/CloudPreview.tsx +++ b/src/renderer/pages/CloudPreview.tsx @@ -3,9 +3,12 @@ import { CheckCircleFilled, ClockCircleFilled, CloseCircleFilled, + CopyOutlined, DeleteOutlined, + DownloadOutlined, DownOutlined, FileMarkdownOutlined, + FilePdfOutlined, LoadingOutlined, ReloadOutlined, StopOutlined, @@ -310,7 +313,7 @@ const CloudPreview: React.FC = () => { }, [id]); // Download result as markdown - const handleDownloadResult = async () => { + const handleDownloadMarkdown = async () => { if (!id || !cloudContext) return; setDownloading(true); @@ -336,6 +339,25 @@ const CloudPreview: React.FC = () => { } }; + // Download generated PDF + const handleDownloadPdf = async () => { + if (!id || !cloudContext) return; + + setDownloading(true); + try { + const result = await cloudContext.downloadResult(id); + if (result.success) { + message.success(t('download_success')); + } else { + message.error(result.error || t('download_failed')); + } + } catch { + message.error(t('download_failed')); + } finally { + setDownloading(false); + } + }; + // Cancel task const handleCancel = async () => { if (!id || !cloudContext) return; @@ -450,6 +472,35 @@ const CloudPreview: React.FC = () => { }); }; + // Copy current page markdown + const handleCopyMarkdown = async () => { + const markdown = currentPageData?.markdown || ''; + if (!markdown) return; + + try { + await navigator.clipboard.writeText(markdown); + message.success(t('copy_markdown_success')); + } catch { + message.error(t('copy_markdown_failed')); + } + }; + + // Copy current page image to system clipboard + const handleCopyImage = async () => { + if (!imageUrl) return; + + try { + const result = await window.api.file.copyImageToClipboard(imageUrl); + if (result.success) { + message.success(t('copy_image_success')); + } else { + message.error(result.error || t('copy_image_failed')); + } + } catch { + message.error(t('copy_image_failed')); + } + }; + // Delete task const handleDelete = async () => { if (!id || !cloudContext) return; @@ -511,6 +562,21 @@ const CloudPreview: React.FC = () => { const pageStatusInfo = getPageStatusInfo(); const totalPages = task?.page_count || 0; const progress = task ? (task.status === 6 ? 100 : totalPages > 0 ? Math.round(((task.pages_completed || 0) / totalPages) * 100) : 0) : 0; + const canDownload = task?.status === 6 && !downloading; + const downloadMenuItems: MenuProps['items'] = [ + { + key: 'download_md', + icon: , + label: t('download_md'), + onClick: handleDownloadMarkdown, + }, + { + key: 'download_pdf', + icon: , + label: t('download_pdf'), + onClick: handleDownloadPdf, + }, + ]; return ( @@ -554,16 +620,21 @@ const CloudPreview: React.FC = () => { - {/* Download Markdown */} - + + {/* Action dropdown */} {(() => { @@ -700,8 +771,20 @@ const CloudPreview: React.FC = () => { justifyContent: "center", alignItems: "center", overflow: "hidden", + position: "relative", }} > + +