Export Notes to PDF/Word Feature Implementation (#8439) (#9269)

Closes #8439

## Overview
This PR implements functionality to export notes/tasks to PDF and Word
formats.


https://github.com/user-attachments/assets/67eaf4eb-cabc-45ba-8727-13f22ba31067

## Testing
- [x] Verified that the export functionality works for both notes and
tasks, whether exporting immediately after opening the editor or after
editing.
- [x] Ensured the export button appears in the action menu only when the
object is a note/task.
- [x] Ensured the export button appears in the
RightDrawerActionMenuDropdown for a note/task.

## Notes
- The code already supports exporting to Word, but only PDF export is
currently available. To enable Word export, we just need a UI option
allowing users to choose between PDF and Word.
- After upgrading the Blocknote packages to the latest version,
dependency conflicts arose with tiptap and prosemirror-model. To address
this, all tiptap dependencies were consolidated in the root
package.json, and a resolution was added for prosemirror-model. Also,
some methods in CustomAddBlockItem.tsx were missing in the newer
version, so I updated the code to accommodate these changes.
- Exporting a note with an image works only if the image is embedded, as
Blocknote doesn’t support actual image uploads. Uploaded images are
omitted in the PDF export, while the text is retained.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Samyak Piya
2024-12-30 03:16:44 -05:00
committed by GitHub
parent 4e496a9025
commit ba2f55a627
9 changed files with 1520 additions and 360 deletions

View File

@ -6,8 +6,8 @@
"@aws-sdk/client-lambda": "^3.614.0",
"@aws-sdk/client-s3": "^3.363.0",
"@aws-sdk/credential-providers": "^3.363.0",
"@blocknote/mantine": "^0.15.3",
"@blocknote/react": "^0.15.3",
"@blocknote/mantine": "^0.22.0",
"@blocknote/react": "^0.22.0",
"@codesandbox/sandpack-react": "^2.13.5",
"@dagrejs/dagre": "^1.1.2",
"@emotion/react": "^11.11.1",
@ -50,7 +50,6 @@
"@stoplight/elements": "^8.0.5",
"@swc/jest": "^0.2.29",
"@tabler/icons-react": "^2.44.0",
"@tiptap/extension-hard-break": "^2.9.1",
"@types/dompurify": "^3.0.5",
"@types/facepaint": "^1.2.5",
"@types/lodash.camelcase": "^4.3.7",
@ -247,6 +246,7 @@
"@types/chrome": "^0.0.267",
"@types/deep-equal": "^1.0.1",
"@types/express": "^4.17.13",
"@types/file-saver": "^2.0.7",
"@types/graphql-fields": "^1.3.6",
"@types/graphql-upload": "^8.0.12",
"@types/js-cookie": "^3.0.3",
@ -346,7 +346,8 @@
"resolutions": {
"graphql": "16.8.0",
"type-fest": "4.10.1",
"typescript": "5.3.3"
"typescript": "5.3.3",
"prosemirror-model": "1.23.0"
},
"version": "0.2.1",
"nx": {},

View File

@ -30,17 +30,27 @@
"workerDirectory": "public"
},
"dependencies": {
"@blocknote/xl-docx-exporter": "^0.22.0",
"@blocknote/xl-pdf-exporter": "^0.22.0",
"@nivo/calendar": "^0.87.0",
"@nivo/core": "^0.87.0",
"@nivo/line": "^0.87.0",
"@tiptap/extension-document": "^2.9.0",
"@tiptap/extension-paragraph": "^2.9.0",
"@tiptap/extension-placeholder": "^2.9.0",
"@tiptap/extension-text": "^2.9.0",
"@tiptap/extension-text-style": "^2.8.0",
"@tiptap/react": "^2.8.0",
"@react-pdf/renderer": "^4.1.6",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-document": "^2.10.4",
"@tiptap/extension-hard-break": "^2.10.4",
"@tiptap/extension-paragraph": "^2.10.4",
"@tiptap/extension-placeholder": "^2.10.4",
"@tiptap/extension-text": "^2.10.4",
"@tiptap/extension-text-style": "^2.10.4",
"@tiptap/react": "^2.10.4",
"@xyflow/react": "^12.0.4",
"buffer": "^6.0.3",
"docx": "^9.1.0",
"file-saver": "^2.0.5",
"transliteration": "^2.3.5"
},
"devDependencies": {
"@types/file-saver": "^2"
}
}

View File

@ -1,6 +1,7 @@
import { useAddToFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction';
import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction';
import { useDestroySingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction';
import { useExportNoteAction } from '@/action-menu/actions/record-actions/single-record/hooks/useExportNoteAction';
import { useNavigateToNextRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction';
import { useNavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction';
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
@ -15,6 +16,7 @@ import {
import {
IconChevronDown,
IconChevronUp,
IconFileExport,
IconHeart,
IconHeartOff,
IconTrash,
@ -27,13 +29,25 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
actionHook: SingleRecordActionHook;
}
> = {
exportNoteToPdf: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.EXPORT_NOTE_TO_PDF,
label: 'Export to PDF',
shortLabel: 'Export',
position: 0,
isPinned: false,
Icon: IconFileExport,
availableOn: [ActionAvailableOn.SHOW_PAGE],
actionHook: useExportNoteAction,
},
addToFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.ADD_TO_FAVORITES,
label: 'Add to favorites',
shortLabel: 'Add to favorites',
position: 0,
position: 1,
isPinned: true,
Icon: IconHeart,
availableOn: [
@ -49,7 +63,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
label: 'Remove from favorites',
shortLabel: 'Remove from favorites',
isPinned: true,
position: 1,
position: 2,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
@ -63,7 +77,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
key: SingleRecordActionKeys.DELETE,
label: 'Delete record',
shortLabel: 'Delete',
position: 2,
position: 3,
Icon: IconTrash,
accent: 'danger',
isPinned: true,
@ -79,7 +93,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
key: SingleRecordActionKeys.DESTROY,
label: 'Permanently destroy record',
shortLabel: 'Destroy',
position: 3,
position: 4,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
@ -95,7 +109,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
key: SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
label: 'Navigate to previous record',
shortLabel: '',
position: 4,
position: 5,
isPinned: true,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
@ -107,7 +121,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
key: SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
label: 'Navigate to next record',
shortLabel: '',
position: 5,
position: 6,
isPinned: true,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],

View File

@ -0,0 +1,49 @@
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { BlockNoteEditor } from '@blocknote/core';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useExportNoteAction: SingleRecordActionHookWithObjectMetadataItem =
({ recordId, objectMetadataItem }) => {
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
const filename = `${(selectedRecord?.title || 'Untitled Note').replace(/[<>:"/\\|?*]/g, '-')}`;
const isNoteOrTask =
objectMetadataItem?.nameSingular === CoreObjectNameSingular.Note ||
objectMetadataItem?.nameSingular === CoreObjectNameSingular.Task;
const shouldBeRegistered =
isDefined(objectMetadataItem) &&
isDefined(selectedRecord) &&
isNoteOrTask;
const onClick = async () => {
if (!shouldBeRegistered || !selectedRecord?.body) {
return;
}
const editor = await BlockNoteEditor.create({
initialContent: JSON.parse(selectedRecord.body),
});
const { exportBlockNoteEditorToPdf } = await import(
'@/action-menu/actions/record-actions/single-record/utils/exportBlockNoteEditorToPdf'
);
await exportBlockNoteEditorToPdf(editor, filename);
// TODO later: implement DOCX export
// const { exportBlockNoteEditorToDocx } = await import(
// '@/action-menu/actions/record-actions/single-record/utils/exportBlockNoteEditorToDocx'
// );
// await exportBlockNoteEditorToDocx(editor, filename);
};
return {
shouldBeRegistered,
onClick,
};
};

View File

@ -5,4 +5,5 @@ export enum SingleRecordActionKeys {
REMOVE_FROM_FAVORITES = 'remove-from-favorites-single-record',
NAVIGATE_TO_NEXT_RECORD = 'navigate-to-next-record-single-record',
NAVIGATE_TO_PREVIOUS_RECORD = 'navigate-to-previous-record-single-record',
EXPORT_NOTE_TO_PDF = 'export-note-to-pdf',
}

View File

@ -0,0 +1,26 @@
import { BlockNoteEditor } from '@blocknote/core';
import {
docxDefaultSchemaMappings,
DOCXExporter,
} from '@blocknote/xl-docx-exporter';
import { Buffer } from 'buffer';
import { Packer } from 'docx';
import { saveAs } from 'file-saver';
export const exportBlockNoteEditorToDocx = async (
editor: BlockNoteEditor,
filename: string,
) => {
// Polyfill needed for exportBlockNoteEditorToDocX
if (typeof window !== 'undefined') {
window.Buffer = Buffer;
}
const exporter = new DOCXExporter(editor.schema, docxDefaultSchemaMappings);
const docxDocument = await exporter.toDocxJsDocument(editor.document);
const blob = await Packer.toBlob(docxDocument);
saveAs(blob, `${filename}.docx`);
};

View File

@ -0,0 +1,19 @@
import { BlockNoteEditor } from '@blocknote/core';
import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import * as ReactPDF from '@react-pdf/renderer';
import { saveAs } from 'file-saver';
export const exportBlockNoteEditorToPdf = async (
editor: BlockNoteEditor,
filename: string,
) => {
const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings);
const pdfDocument = await exporter.toReactPDFDocument(editor.document);
const blob = await ReactPDF.pdf(pdfDocument).toBlob();
saveAs(blob, `${filename}.pdf`);
};

View File

@ -31,10 +31,9 @@ export const CustomAddBlockItem = ({
const [firstElement] = currentBlockContent || [];
if (firstElement === undefined) {
editor.openSelectionMenu('/');
editor.openSuggestionMenu('/');
} else {
editor.sideMenu.addBlock();
editor.openSelectionMenu('/');
editor.openSuggestionMenu('/');
editor.sideMenu.unfreezeMenu();
}
};

1723
yarn.lock

File diff suppressed because it is too large Load Diff