mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
10
apps/electron/layers/main/src/export/index.ts
Normal file
10
apps/electron/layers/main/src/export/index.ts
Normal file
@@ -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';
|
||||
61
apps/electron/layers/main/src/export/pdf.ts
Normal file
61
apps/electron/layers/main/src/export/pdf.ts
Normal file
@@ -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<SavePDFFileResult> {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
24
apps/electron/layers/main/src/export/utils.ts
Normal file
24
apps/electron/layers/main/src/export/utils.ts
Normal file
@@ -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];
|
||||
@@ -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<string, NamespaceHandlers>;
|
||||
|
||||
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
/>
|
||||
)}
|
||||
</ToolContainer>
|
||||
<AffineWatermark />
|
||||
</MainContainer>
|
||||
</AppContainer>
|
||||
<PageListTitleCellDragOverlay />
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
19
packages/component/src/components/affine-watermark/index.tsx
Normal file
19
packages/component/src/components/affine-watermark/index.tsx
Normal file
@@ -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 (
|
||||
<div className={clsx()}>
|
||||
<div data-testid="affine-watermark" className={clsx(waterMarkStyle)}>
|
||||
<a className={linkStyle} href="https://affine.pro">
|
||||
<div className={linkTextStyle}>{t['Created with']()}</div>
|
||||
<AffineLogoIcon />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
packages/component/src/components/affine-watermark/logo.tsx
Normal file
22
packages/component/src/components/affine-watermark/logo.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export const AffineLogoIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
id="Layer_2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1459.61 470"
|
||||
>
|
||||
<g id="_6_export">
|
||||
<path
|
||||
id="logo-black"
|
||||
d="m404.14,350.34c-5.95-10.33-15.86-27.48-25.65-44.42-2.97-5.14-5.92-10.25-8.75-15.15-5.82-10.08-11.12-19.27-14.9-25.83-26.23-45.33-77.87-135.08-103.67-179.46-7.95-12.37-26.37-11.39-33.21,1.42-7.76,13.45-16.3,28.24-25.32,43.87-2.86,4.96-5.78,10.01-8.73,15.12-37.58,65.08-81.56,141.26-112.89,195.52-1.6,2.9-4.58,7.63-6.07,10.76-2.61,5.65-2.1,12.69,1.19,17.91,3.71,6.18,10.65,9.57,17.74,9.22,8.53,0,26.62-.01,50.01,0,5.55,0,11.4,0,17.49,0,81.33,0,205.57.05,236.06,0,14.81.03,24.1-16.21,16.72-28.97Zm-175.07-57.64l-14.97-25.93c-2.63-4.56.66-10.26,5.92-10.26h29.94c5.27,0,8.56,5.7,5.92,10.26l-14.97,25.93c-2.63,4.56-9.21,4.56-11.85,0Zm-25.08-48.29c-1.24-3.16-2.31-6.37-3.19-9.63l49.53,9.63h-46.34Zm22.62,68.22c-2.11,2.66-4.36,5.19-6.74,7.59l-16.43-47.71,23.16,40.12Zm47.78-53.7c3.35.5,6.67,1.19,9.93,2.05l-33.1,38.08,23.17-40.13Zm-76.64-40.4c-.53-4.82-.76-9.69-.74-14.57l65.41,31.91-64.68-17.33Zm-7.76,47.77l17.32,64.65c-3.91,2.87-8.01,5.51-12.25,7.93l-5.08-72.58Zm109.93.17c4.44,1.95,8.77,4.19,12.99,6.65l-60.34,40.7,47.35-47.35Zm-101.41-83.09c1.2-8.86,3.05-17.59,5.29-25.96l99.37,86.38-104.65-60.42Zm-22.02,164.51c-8.27,3.39-16.75,6.15-25.12,8.39l25.12-129.23v120.84Zm153.49-63.19c7.07,5.47,13.71,11.44,19.84,17.56l-124.49,42.86,104.65-60.42Zm-90.64-175.85c18.32,31.79,44.92,77.89,70.26,121.77l-94.61-94.61c5.56-9.62,10.83-18.75,15.69-27.18,1.93-3.33,6.73-3.33,8.66,0Zm-147.77,240.92c5.21-8.99,12.37-21.32,13.96-24.16,15.08-26.12,35.39-61.29,56.37-97.63l-34.65,129.3c-12.41,0-23.11,0-31.35,0-3.85,0-6.26-4.17-4.33-7.5Zm282.54,7.53c-28.89,0-84.98,0-140.67,0l129.31-34.65c6.78,11.74,12.21,21.14,15.69,27.16,1.93,3.33-.48,7.49-4.32,7.49Z"
|
||||
/>
|
||||
<rect fill="none" width="1459.61" height="470" />
|
||||
<path d="m594.24,116.6c-1.17-4.72-5.41-8.03-10.27-8.03h-16.41c-4.86,0-9.1,3.31-10.27,8.03l-55.64,224.39c-1.65,6.67,3.39,13.12,10.27,13.12h5.66c4.92,0,9.19-3.39,10.3-8.18l11.85-50.91c1.11-4.79,5.38-8.18,10.3-8.18h51.46c4.92,0,9.19,3.39,10.3,8.18l11.85,50.91c1.11,4.79,5.38,8.18,10.3,8.18h5.66c6.87,0,11.92-6.45,10.27-13.12l-55.64-224.39Zm-3.15,146.69h-30.65c-6.81,0-11.85-6.34-10.3-12.97l20.48-98.57c1.27-5.45,9.03-5.45,10.3,0l20.48,98.57c1.54,6.63-3.49,12.97-10.3,12.97Z" />
|
||||
<path d="m994.58,215.86h-126.97c-5.84,0-10.58-4.74-10.58-10.58v-53.08c0-11.1,9-20.1,20.1-20.1h62.83c5.84,0,10.58-4.74,10.58-10.58v-2.39c0-5.84-4.74-10.58-10.58-10.58h-70.32c-22.2,0-40.19,18-40.19,40.19v67.1h-113.04c-5.84,0-10.58-4.74-10.58-10.58v-53.08c0-11.1,9-20.1,20.1-20.1h62.83c5.84,0,10.58-4.74,10.58-10.58v-2.39c0-5.84-4.74-10.58-10.58-10.58h-70.32c-22.2,0-40.19,18-40.19,40.19v194.77c0,5.84,4.74,10.58,10.58,10.58h6.43c5.84,0,10.58-4.74,10.58-10.58v-93.54c0-5.84,4.74-10.58,10.58-10.58h113.04v104.12c0,5.84,4.74,10.58,10.58,10.58h6.43c5.84,0,10.58-4.74,10.58-10.58v-93.54c0-5.84,4.74-10.58,10.58-10.58h120.16c11.1,0,20.1,9,20.1,20.1v84.14c0,5.84,4.74,10.58,10.58,10.58h5.75c5.84,0,10.58-4.74,10.58-10.58v-87.59c0-22.2-18-40.19-40.19-40.19Z" />
|
||||
<path d="m1202.87,108.57h-5.79c-5.83,0-10.56,4.72-10.58,10.54l-.64,201.36-57.7-204.2c-1.29-4.56-5.44-7.7-10.18-7.7h-25.15c-5.84,0-10.58,4.74-10.58,10.58v224.39c0,5.84,4.74,10.58,10.58,10.58h5.79c5.83,0,10.56-4.72,10.58-10.54l.64-201.36,57.7,204.2c1.29,4.56,5.44,7.7,10.18,7.7h25.15c5.84,0,10.58-4.74,10.58-10.58V119.14c0-5.84-4.74-10.58-10.58-10.58Z" />
|
||||
<path d="m1293.92,132.11h59.81c5.84,0,10.58-4.74,10.58-10.58v-2.39c0-5.84-4.74-10.58-10.58-10.58h-66.95c-22.2,0-40.19,18-40.19,40.19v165.15c0,22.2,18,40.19,40.19,40.19h66.95c5.84,0,10.58-4.74,10.58-10.58v-2.39c0-5.84-4.74-10.58-10.58-10.58h-59.81c-11.1,0-20.1-9-20.1-20.1v-63.84c0-5.84,4.74-10.58,10.58-10.58h65.96c5.84,0,10.58-4.74,10.58-10.58v-2.39c0-5.84-4.74-10.58-10.58-10.58h-65.96c-5.84,0-10.58-4.74-10.58-10.58v-49.72c0-11.1,9-20.1,20.1-20.1Z" />
|
||||
<path d="m1018.67,138.5c-4.7-4.7-12.71-2.55-14.43,3.87l-7.77,29.01c-1.72,6.41,4.15,12.28,10.57,10.57l29.01-7.77c6.42-1.72,8.56-9.74,3.87-14.43l-21.23-21.23Z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -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<ContentParser>();
|
||||
return (
|
||||
@@ -24,6 +27,29 @@ export const Export = ({
|
||||
trigger="click"
|
||||
content={
|
||||
<>
|
||||
<MenuItem
|
||||
data-testid="export-to-pdf"
|
||||
onClick={async () => {
|
||||
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={<ExportToPdfIcon />}
|
||||
>
|
||||
{t['Export to PDF']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
data-testid="export-to-html"
|
||||
onClick={() => {
|
||||
@@ -39,6 +65,21 @@ export const Export = ({
|
||||
>
|
||||
{t['Export to HTML']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
data-testid="export-to-png"
|
||||
onClick={() => {
|
||||
if (!contentParserRef.current) {
|
||||
contentParserRef.current = new ContentParser(
|
||||
globalThis.currentEditor!.page
|
||||
);
|
||||
}
|
||||
contentParserRef.current.exportPng();
|
||||
onSelect?.({ type: 'png' });
|
||||
}}
|
||||
icon={<ExportToPngIcon />}
|
||||
>
|
||||
{t['Export to PNG']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
data-testid="export-to-markdown"
|
||||
onClick={() => {
|
||||
|
||||
@@ -34,8 +34,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",
|
||||
|
||||
@@ -126,3 +126,16 @@ export async function getMetas(page: Page): Promise<PageMeta[]> {
|
||||
() => globalThis.currentWorkspace.blockSuiteWorkspace.meta.pageMetas ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForLogMessage(
|
||||
page: Page,
|
||||
log: string
|
||||
): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'log' && msg.text() === log) {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user