diff --git a/apps/electron/layers/main/src/export/index.ts b/apps/electron/layers/main/src/export/index.ts new file mode 100644 index 0000000000..57fadbe7e9 --- /dev/null +++ b/apps/electron/layers/main/src/export/index.ts @@ -0,0 +1,10 @@ +import type { NamespaceHandlers } from '../type'; +import { savePDFFileAs } from './pdf'; + +export const exportHandlers = { + savePDFFileAs: async (_, title: string) => { + return savePDFFileAs(title); + }, +} satisfies NamespaceHandlers; + +export * from './pdf'; diff --git a/apps/electron/layers/main/src/export/pdf.ts b/apps/electron/layers/main/src/export/pdf.ts new file mode 100644 index 0000000000..1e1dba5ac2 --- /dev/null +++ b/apps/electron/layers/main/src/export/pdf.ts @@ -0,0 +1,61 @@ +import { BrowserWindow, dialog, shell } from 'electron'; +import fs from 'fs-extra'; + +import { logger } from '../logger'; +import type { ErrorMessage } from './utils'; +import { getFakedResult } from './utils'; + +interface SavePDFFileResult { + filePath?: string; + canceled?: boolean; + error?: ErrorMessage; +} + +/** + * This function is called when the user clicks the "Export to PDF" button in the electron. + * + * It will just copy the file to the given path + */ +export async function savePDFFileAs( + pageTitle: string +): Promise { + try { + const ret = + getFakedResult() ?? + (await dialog.showSaveDialog({ + properties: ['showOverwriteConfirmation'], + title: 'Save PDF', + showsTagField: false, + buttonLabel: 'Save', + defaultPath: `${pageTitle}.pdf`, + message: 'Save Page as a PDF file', + })); + const filePath = ret.filePath; + if (ret.canceled || !filePath) { + return { + canceled: true, + }; + } + + await BrowserWindow.getFocusedWindow() + ?.webContents.printToPDF({ + pageSize: 'A4', + printBackground: true, + landscape: false, + }) + .then(data => { + fs.writeFile(filePath, data, error => { + if (error) throw error; + logger.log(`Wrote PDF successfully to ${filePath}`); + }); + }); + + shell.openPath(filePath); + return { filePath }; + } catch (err) { + logger.error('savePDFFileAs', err); + return { + error: 'UNKNOWN_ERROR', + }; + } +} diff --git a/apps/electron/layers/main/src/export/utils.ts b/apps/electron/layers/main/src/export/utils.ts new file mode 100644 index 0000000000..ebd1336a4d --- /dev/null +++ b/apps/electron/layers/main/src/export/utils.ts @@ -0,0 +1,24 @@ +// provide a backdoor to set dialog path for testing in playwright +interface FakeDialogResult { + canceled?: boolean; + filePath?: string; + filePaths?: string[]; +} +// result will be used in the next call to showOpenDialog +// if it is being read once, it will be reset to undefined +let fakeDialogResult: FakeDialogResult | undefined = undefined; +export function getFakedResult() { + const result = fakeDialogResult; + fakeDialogResult = undefined; + return result; +} + +export function setFakeDialogResult(result: FakeDialogResult | undefined) { + fakeDialogResult = result; + // for convenience, we will fill filePaths with filePath if it is not set + if (result?.filePaths === undefined && result?.filePath !== undefined) { + result.filePaths = [result.filePath]; + } +} +const ErrorMessages = ['FILE_ALREADY_EXISTS', 'UNKNOWN_ERROR'] as const; +export type ErrorMessage = (typeof ErrorMessages)[number]; diff --git a/apps/electron/layers/main/src/handlers.ts b/apps/electron/layers/main/src/handlers.ts index 378a177d67..53f0165b78 100644 --- a/apps/electron/layers/main/src/handlers.ts +++ b/apps/electron/layers/main/src/handlers.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron'; import { dbHandlers } from './db'; import { dialogHandlers } from './dialog'; +import { exportHandlers } from './export'; import { getLogFilePath, logger, revealLogFile } from './logger'; import type { NamespaceHandlers } from './type'; import { uiHandlers } from './ui'; @@ -23,6 +24,7 @@ export const allHandlers = { debug: debugHandlers, dialog: dialogHandlers, ui: uiHandlers, + export: exportHandlers, updater: updaterHandlers, workspace: workspaceHandlers, } satisfies Record; diff --git a/apps/web/src/layouts/workspace-layout.tsx b/apps/web/src/layouts/workspace-layout.tsx index 1e0996db86..090bb8d72c 100644 --- a/apps/web/src/layouts/workspace-layout.tsx +++ b/apps/web/src/layouts/workspace-layout.tsx @@ -1,4 +1,5 @@ import { Content, displayFlex } from '@affine/component'; +import { AffineWatermark } from '@affine/component/affine-watermark'; import { appSidebarResizingAtom } from '@affine/component/app-sidebar'; import type { DraggableTitleCellData } from '@affine/component/page-list'; import { StyledTitleLink } from '@affine/component/page-list'; @@ -445,6 +446,7 @@ export const WorkspaceLayoutInner: FC = ({ children }) => { /> )} + diff --git a/packages/component/src/components/affine-watermark/index.css.ts b/packages/component/src/components/affine-watermark/index.css.ts new file mode 100644 index 0000000000..820a8342b3 --- /dev/null +++ b/packages/component/src/components/affine-watermark/index.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css'; + +export const waterMarkStyle = style({ + display: 'none', + '@media': { + print: { + position: 'absolute', + bottom: '0', + right: '20px', + zIndex: 100, + display: 'block', + width: 'auto', + filter: 'opacity(20%)', + }, + }, +}); + +export const linkStyle = style({ + textAlign: 'left', + color: 'black', + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + width: '200px', +}); +export const linkTextStyle = style({ + whiteSpace: 'nowrap', +}); +export const iconStyle = style({ + fontSize: '20px', +}); diff --git a/packages/component/src/components/affine-watermark/index.tsx b/packages/component/src/components/affine-watermark/index.tsx new file mode 100644 index 0000000000..680aa5b250 --- /dev/null +++ b/packages/component/src/components/affine-watermark/index.tsx @@ -0,0 +1,19 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import clsx from 'clsx'; + +import { linkStyle, linkTextStyle, waterMarkStyle } from './index.css'; +import { AffineLogoIcon } from './logo'; + +export const AffineWatermark = () => { + const t = useAFFiNEI18N(); + return ( + + ); +}; diff --git a/packages/component/src/components/affine-watermark/logo.tsx b/packages/component/src/components/affine-watermark/logo.tsx new file mode 100644 index 0000000000..a6bbd7afaf --- /dev/null +++ b/packages/component/src/components/affine-watermark/logo.tsx @@ -0,0 +1,22 @@ +export const AffineLogoIcon = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/component/src/components/page-list/operation-menu-items/export.tsx b/packages/component/src/components/page-list/operation-menu-items/export.tsx index 87bfc5f70d..b0379b5547 100644 --- a/packages/component/src/components/page-list/operation-menu-items/export.tsx +++ b/packages/component/src/components/page-list/operation-menu-items/export.tsx @@ -1,11 +1,14 @@ import { Menu, MenuItem } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { PageBlockModel } from '@blocksuite/blocks'; import { ContentParser } from '@blocksuite/blocks/content-parser'; import { ArrowRightSmallIcon, ExportIcon, ExportToHtmlIcon, ExportToMarkdownIcon, + ExportToPdfIcon, + ExportToPngIcon, } from '@blocksuite/icons'; import { useRef } from 'react'; @@ -14,7 +17,7 @@ import type { CommonMenuItemProps } from './types'; export const Export = ({ onSelect, onItemClick, -}: CommonMenuItemProps<{ type: 'markdown' | 'html' }>) => { +}: CommonMenuItemProps<{ type: 'markdown' | 'html' | 'pdf' | 'png' }>) => { const t = useAFFiNEI18N(); const contentParserRef = useRef(); return ( @@ -24,6 +27,29 @@ export const Export = ({ trigger="click" content={ <> + { + if (!contentParserRef.current) { + contentParserRef.current = new ContentParser( + globalThis.currentEditor!.page + ); + } + const result = await window.apis?.export.savePDFFileAs( + ( + globalThis.currentEditor!.page.root as PageBlockModel + ).title.toString() + ); + if (result !== undefined) { + return; + } + contentParserRef.current.exportPdf(); + onSelect?.({ type: 'pdf' }); + }} + icon={} + > + {t['Export to PDF']()} + { @@ -39,6 +65,21 @@ export const Export = ({ > {t['Export to HTML']()} + { + if (!contentParserRef.current) { + contentParserRef.current = new ContentParser( + globalThis.currentEditor!.page + ); + } + contentParserRef.current.exportPng(); + onSelect?.({ type: 'png' }); + }} + icon={} + > + {t['Export to PNG']()} + { diff --git a/packages/i18n/src/resources/en.json b/packages/i18n/src/resources/en.json index f7912e7001..094bc6455b 100644 --- a/packages/i18n/src/resources/en.json +++ b/packages/i18n/src/resources/en.json @@ -35,8 +35,11 @@ "Convert to ": "Convert to ", "Page": "Page", "Export": "Export", + "Export to PDF": "Export to PDF", "Export to HTML": "Export to HTML", + "Export to PNG": "Export to PNG", "Export to Markdown": "Export to Markdown", + "Created with": "Created with", "Delete": "Delete", "Title": "Title", "Untitled": "Untitled", diff --git a/tests/libs/utils.ts b/tests/libs/utils.ts index da48eeb11a..4e40549774 100644 --- a/tests/libs/utils.ts +++ b/tests/libs/utils.ts @@ -126,3 +126,16 @@ export async function getMetas(page: Page): Promise { () => globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas ?? [] ); } + +export async function waitForLogMessage( + page: Page, + log: string +): Promise { + return new Promise(resolve => { + page.on('console', msg => { + if (msg.type() === 'log' && msg.text() === log) { + resolve(true); + } + }); + }); +} diff --git a/tests/parallels/local-first-favorite-page.spec.ts b/tests/parallels/local-first-favorite-page.spec.ts index ef426da005..db770e8d5c 100644 --- a/tests/parallels/local-first-favorite-page.spec.ts +++ b/tests/parallels/local-first-favorite-page.spec.ts @@ -8,6 +8,7 @@ import { newPage, waitMarkdownImported, } from '../libs/page-logic'; +import { waitForLogMessage } from '../libs/utils'; import { assertCurrentWorkspaceFlavour } from '../libs/workspace'; test('New a page and open it ,then favorite it', async ({ page }) => { @@ -29,7 +30,7 @@ test('New a page and open it ,then favorite it', async ({ page }) => { await assertCurrentWorkspaceFlavour('local', page); }); -test('Export to html and markdown', async ({ page }) => { +test('Export to html, markdown and png', async ({ page }) => { await openHomePage(page); await waitMarkdownImported(page); { @@ -47,6 +48,31 @@ test('Export to html and markdown', async ({ page }) => { await page.getByTestId('export-to-html').click(); await downloadPromise; } + await page.waitForTimeout(50); + { + await clickPageMoreActions(page); + await page.getByTestId('export-menu').click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByTestId('export-to-png').click(); + await downloadPromise; + } +}); + +test('Export to pdf', async ({ page }) => { + const CheckedMessage = '[test] beforeprint event emitted'; + page.addInitScript(() => { + window.addEventListener('beforeprint', () => { + console.log(CheckedMessage); + }); + }); + await openHomePage(page); + await waitMarkdownImported(page); + { + await clickPageMoreActions(page); + await page.getByTestId('export-menu').click(); + await page.getByTestId('export-to-pdf').click(); + expect(waitForLogMessage(page, CheckedMessage)).toBeTruthy(); + } }); test('Cancel favorite', async ({ page }) => {