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:
@ -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": {},
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
};
|
||||
@ -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`);
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user