feat: Super notes can now be exported as PDF (#2776)

This commit is contained in:
Aman Harwara
2024-01-24 13:23:38 +05:30
committed by GitHub
parent 813304c959
commit 418d1a7371
55 changed files with 1062 additions and 70 deletions

View File

@@ -142,6 +142,22 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
downloadSelectedItems().catch(console.error)
}, [downloadSelectedItems, notes])
const shareSelectedItems = useCallback(() => {
createNoteExport(application, notes)
.then((result) => {
if (!result) {
return
}
const { blob, fileName } = result
shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), blob, fileName).catch(
console.error,
)
})
.catch(console.error)
}, [application, notes])
const closeMenuAndToggleNotesList = useCallback(() => {
const isMobileScreen = matchMedia(MutuallyExclusiveMediaQueryBreakpoints.sm).matches
if (isMobileScreen) {
@@ -347,34 +363,14 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
</MenuItem>
)}
<MenuItem
onClick={() => {
if (application.isNativeMobileWeb()) {
createNoteExport(application, notes)
.then((result) => {
if (!result) {
return
}
const { blob, fileName } = result
shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), blob, fileName).catch(
console.error,
)
})
.catch(console.error)
} else {
exportSelectedItems()
}
}}
>
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
{application.platform === Platform.Android ? 'Share' : 'Export'}
<MenuItem onClick={exportSelectedItems}>
<Icon type="download" className={iconClass} />
Export
</MenuItem>
{application.platform === Platform.Android && (
<MenuItem onClick={exportSelectedItems}>
<Icon type="download" className={iconClass} />
Export
<MenuItem onClick={shareSelectedItems}>
<Icon type="share" className={iconClass} />
Share
</MenuItem>
)}
<MenuItem onClick={duplicateSelectedItems} disabled={areSomeNotesInReadonlySharedVault}>

View File

@@ -15,6 +15,7 @@ type Props = {
const SuperExportModal = ({ notes, exportNotes, close }: Props) => {
const application = useApplication()
const superNoteExportFormat = usePreference(PrefKey.SuperNoteExportFormat)
const superNoteExportEmbedBehavior = usePreference(PrefKey.SuperNoteExportEmbedBehavior)
const superNoteExportUseMDFrontmatter = usePreference(PrefKey.SuperNoteExportUseMDFrontmatter)
@@ -26,10 +27,15 @@ const SuperExportModal = ({ notes, exportNotes, close }: Props) => {
if (superNoteExportFormat === 'md' && superNoteExportEmbedBehavior === 'reference') {
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'separate')
}
if (superNoteExportFormat === 'pdf' && superNoteExportEmbedBehavior !== 'inline') {
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'inline')
}
}, [application, superNoteExportEmbedBehavior, superNoteExportFormat])
const someNotesHaveEmbeddedFiles = notes.some(noteHasEmbeddedFiles)
const canShowEmbeddedFileOptions = !['json', 'pdf'].includes(superNoteExportFormat)
return (
<Modal
title="Export notes"
@@ -61,6 +67,7 @@ const SuperExportModal = ({ notes, exportNotes, close }: Props) => {
{ label: 'Super (.json)', value: 'json' },
{ label: 'Markdown (.md)', value: 'md' },
{ label: 'HTML', value: 'html' },
{ label: 'PDF', value: 'pdf' },
]}
value={superNoteExportFormat}
onChange={(value) => {
@@ -93,7 +100,7 @@ const SuperExportModal = ({ notes, exportNotes, close }: Props) => {
</Switch>
</div>
)}
{superNoteExportFormat !== 'json' && someNotesHaveEmbeddedFiles && (
{canShowEmbeddedFileOptions && someNotesHaveEmbeddedFiles && (
<div className="mb-2 mt-4">
<div className="mb-1">How do you want embedded files to be handled?</div>
<RadioButtonGroup

View File

@@ -0,0 +1,440 @@
import { StyleSheet } from '@react-pdf/renderer'
import {
$getRoot,
$isElementNode,
$isParagraphNode,
$isLineBreakNode,
$isTextNode,
LexicalEditor,
LexicalNode,
ElementNode,
} from 'lexical'
import { $isLinkNode } from '@lexical/link'
import { $isHeadingNode, type HeadingNode, $isQuoteNode } from '@lexical/rich-text'
import { $isListNode, $isListItemNode, ListType } from '@lexical/list'
import { $isTableNode, $isTableRowNode, $isTableCellNode } from '@lexical/table'
import { $isCodeNode } from '@lexical/code'
import { $isInlineFileNode } from '../../../Plugins/InlineFilePlugin/InlineFileNode'
import { $isRemoteImageNode } from '../../../Plugins/RemoteImagePlugin/RemoteImageNode'
import { $isCollapsibleContainerNode } from '../../../Plugins/CollapsiblePlugin/CollapsibleContainerNode'
import { $isCollapsibleContentNode } from '../../../Plugins/CollapsiblePlugin/CollapsibleContentNode'
import { $isCollapsibleTitleNode } from '../../../Plugins/CollapsiblePlugin/CollapsibleTitleNode'
import { PDFDataNode, PDFWorker } from './PDFWorker'
import { wrap } from 'comlink'
import { getBase64FromBlob } from '@/Utils'
const styles = StyleSheet.create({
page: {
paddingVertical: 35,
paddingHorizontal: 35,
lineHeight: 1.5,
fontSize: 12,
gap: 14,
},
block: {
gap: 14,
},
wrap: {
flexWrap: 'wrap',
},
row: {
flexDirection: 'row',
},
column: {
flexDirection: 'column',
},
listMarker: {
flexShrink: 0,
height: '100%',
marginRight: 2,
},
collapsibleTitle: {
backgroundColor: 'rgba(0,0,0,0.05)',
paddingTop: 4,
paddingBottom: 2,
paddingHorizontal: 6,
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
},
quote: {
borderLeftWidth: 4,
color: 'rgba(46, 46, 46)',
borderLeftColor: '#72767e',
paddingLeft: 12,
paddingVertical: 4,
gap: 4,
},
})
const getListItemNode = ({
children,
value,
listType,
checked,
}: {
children: PDFDataNode[] | undefined
value: number
listType: ListType
checked?: boolean
}): PDFDataNode => {
const marker = listType === 'bullet' ? '\u2022' : `${value}.`
return {
type: 'View',
style: styles.row,
children: [
listType === 'check'
? {
type: 'View',
style: {
width: 14,
height: 14,
borderRadius: 2,
borderWidth: 1,
borderColor: checked ? '#086dd6' : '#000',
backgroundColor: checked ? '#086dd6' : 'transparent',
marginRight: 6,
},
children: checked
? [
{
type: 'Svg',
viewBox: '0 0 20 20',
fill: '#ffffff',
children: [
{
type: 'Path',
d: 'M17.5001 5.83345L7.50008 15.8334L2.91675 11.2501L4.09175 10.0751L7.50008 13.4751L16.3251 4.65845L17.5001 5.83345Z',
},
],
},
]
: undefined,
}
: {
type: 'View',
style: styles.listMarker,
children: [
{
type: 'Text',
children: marker + ' ',
},
],
},
{
type: 'Text',
style: {
flex: 1,
},
children,
},
],
}
}
const MinimumHeadingFontSize = 13
const MaxHeadingLevel = 6
const getFontSizeForHeading = (heading: HeadingNode) => {
const level = parseInt(heading.getTag().slice(1))
const multiplier = (MaxHeadingLevel - level) * 2
return MinimumHeadingFontSize + multiplier
}
const getNodeTextAlignment = (node: ElementNode) => {
const formatType = node.getFormatType()
if (!formatType) {
return 'left'
}
if (formatType === 'start') {
return 'left'
}
if (formatType === 'end') {
return 'right'
}
return formatType
}
const getPDFDataNodeFromLexicalNode = (node: LexicalNode): PDFDataNode => {
const parent = node.getParent()
if ($isLineBreakNode(node)) {
return {
type: 'Text',
children: '\n',
}
}
if ($isTextNode(node)) {
const isInlineCode = node.hasFormat('code')
const isCodeNodeText = $isCodeNode(parent)
const isBold = node.hasFormat('bold')
const isItalic = node.hasFormat('italic')
const isHighlight = node.hasFormat('highlight')
let font = isInlineCode || isCodeNodeText ? 'Courier' : 'Helvetica'
if (isBold || isItalic) {
font += '-'
if (isBold) {
font += 'Bold'
}
if (isItalic) {
font += 'Oblique'
}
}
return {
type: 'Text',
children: node.getTextContent(),
style: {
fontFamily: font,
textDecoration: node.hasFormat('underline')
? 'underline'
: node.hasFormat('strikethrough')
? 'line-through'
: undefined,
backgroundColor: isInlineCode ? '#f1f1f1' : isHighlight ? 'rgb(255,255,0)' : undefined,
fontSize: isInlineCode || isCodeNodeText ? 11 : undefined,
textAlign: $isElementNode(parent) ? getNodeTextAlignment(parent) : 'left',
},
}
}
if ($isCodeNode(node)) {
const children = node.getChildren()
const lines: LexicalNode[][] = [[]]
for (let i = 0, currentLine = 0; i < children.length; i++) {
const child = children[i]
if (!$isLineBreakNode(child)) {
lines[currentLine].push(child)
} else {
lines.push([])
currentLine++
}
}
return {
type: 'View',
style: [
styles.column,
{
backgroundColor: 'rgba(0,0,0,0.05)',
padding: 12,
borderRadius: 6,
fontFamily: 'Courier',
},
],
children: lines.map((line) => {
return {
type: 'View',
style: [styles.row, styles.wrap],
children: line.map((child) => {
return getPDFDataNodeFromLexicalNode(child)
}),
}
}),
}
}
if ($isInlineFileNode(node) || $isRemoteImageNode(node)) {
if (!node.__src.startsWith('data:')) {
return {
type: 'View',
style: styles.block,
children: [
{
type: 'Link',
src: node.__src,
children: node.__src,
},
],
}
}
return {
type: 'Image',
src: node.__src,
}
}
const children =
$isElementNode(node) || $isTableNode(node) || $isTableCellNode(node) || $isTableRowNode(node)
? node.getChildren().map((child) => {
return getPDFDataNodeFromLexicalNode(child)
})
: undefined
if ($isLinkNode(node)) {
return {
type: 'Link',
src: node.getURL(),
children,
}
}
if ($isListItemNode(node)) {
if (!$isListNode(parent)) {
return null
}
const listType = parent.getListType()
const isNestedList = node.getChildren().some((child) => $isListNode(child))
if (isNestedList) {
return {
type: 'View',
style: [
styles.column,
{
marginLeft: 10,
},
],
children,
}
}
return getListItemNode({
children,
listType,
value: node.getValue(),
checked: node.getChecked(),
})
}
if ($isListNode(node)) {
return {
type: 'View',
style: [
styles.column,
{
gap: 7,
},
],
children,
}
}
if ($isCollapsibleContentNode(node)) {
return {
type: 'View',
style: [
styles.block,
styles.column,
{
padding: 6,
},
],
children,
}
}
if ($isCollapsibleContainerNode(node)) {
return {
type: 'View',
style: [
styles.column,
{
backgroundColor: 'rgba(0,0,0,0.05)',
borderRadius: 6,
},
],
children,
}
}
if ($isParagraphNode(node) && node.getTextContent().length === 0) {
return null
}
if ($isTableCellNode(node)) {
return {
type: 'View',
style: {
backgroundColor: node.hasHeader() ? '#f4f5f7' : undefined,
borderColor: '#e3e3e3',
borderWidth: 1,
flex: 1,
padding: 2,
},
children,
}
}
if ($isTableRowNode(node)) {
return {
type: 'View',
style: styles.row,
children,
}
}
if ($isTableNode(node)) {
return {
type: 'View',
children,
}
}
if ($isElementNode(node)) {
return {
type: 'View',
style: [
styles.block,
styles.row,
styles.wrap,
{
fontSize: $isHeadingNode(node) ? getFontSizeForHeading(node) : undefined,
},
$isCollapsibleTitleNode(node) ? styles.collapsibleTitle : {},
$isQuoteNode(node) ? styles.quote : {},
],
children: [
{
type: 'Text',
style: {
lineHeight: $isHeadingNode(node) ? 1 : 1.5,
},
children,
},
],
}
}
return {
type: 'View',
style: [styles.block, styles.row, styles.wrap],
children: [{ type: 'Text', children: node.getTextContent() }],
}
}
const getPDFDataNodesFromLexicalNodes = (nodes: LexicalNode[]): PDFDataNode[] => {
return nodes.map(getPDFDataNodeFromLexicalNode)
}
const PDFWorkerComlink = wrap<PDFWorker>(new Worker(new URL('./PDFWorker.tsx', import.meta.url)))
/**
* @returns The PDF as a base64 string
*/
export function $generatePDFFromNodes(editor: LexicalEditor) {
return new Promise<string>((resolve) => {
editor.getEditorState().read(() => {
const root = $getRoot()
const nodes = root.getChildren()
const pdfDataNodes = getPDFDataNodesFromLexicalNodes(nodes)
void PDFWorkerComlink.renderPDF(pdfDataNodes).then((blob) => {
void getBase64FromBlob(blob).then((base64) => {
resolve(base64)
})
})
})
})
}

View File

@@ -0,0 +1,105 @@
import {
Document,
Page,
View,
Text,
pdf,
Link,
Image,
Svg,
Path,
ViewProps,
LinkProps,
PathProps,
TextProps,
SVGProps,
ImageWithSrcProp,
} from '@react-pdf/renderer'
import { expose } from 'comlink'
export type PDFDataNode =
| ((
| ({
type: 'View'
} & Omit<ViewProps, 'children'>)
| ({
type: 'Text'
} & Omit<TextProps, 'children'>)
| ({
type: 'Link'
} & Omit<LinkProps, 'children'>)
| ({
type: 'Image'
} & Omit<ImageWithSrcProp, 'children'>)
| ({
type: 'Svg'
} & Omit<SVGProps, 'children'>)
| ({
type: 'Path'
} & Omit<PathProps, 'children'>)
) & {
children?: PDFDataNode[] | string
})
| null
const Node = ({ node }: { node: PDFDataNode }) => {
if (!node) {
return null
}
const children =
typeof node.children === 'string'
? node.children
: node.children?.map((child, index) => {
return <Node node={child} key={index} />
})
switch (node.type) {
case 'View':
return <View {...node}>{children}</View>
case 'Text':
return <Text {...node}>{children}</Text>
case 'Link':
return <Link {...node}>{children}</Link>
case 'Image':
return <Image {...node} />
case 'Svg':
return <Svg {...node}>{children}</Svg>
case 'Path': {
const { children: _, ...props } = node
return <Path {...props} />
}
}
}
const PDFDocument = ({ nodes }: { nodes: PDFDataNode[] }) => {
return (
<Document>
<Page
style={{
paddingVertical: 35,
paddingHorizontal: 35,
lineHeight: 1.5,
fontSize: 12,
gap: 14,
}}
>
{nodes.map((node, index) => {
return <Node node={node} key={index} />
})}
</Page>
</Document>
)
}
const renderPDF = (nodes: PDFDataNode[]) => {
return pdf(<PDFDocument nodes={nodes} />).toBlob()
}
expose({
renderPDF,
})
export type PDFWorker = {
renderPDF: typeof renderPDF
}

View File

@@ -18,6 +18,7 @@ import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode'
import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode'
import { $convertFromMarkdownString } from '../Lexical/Utils/MarkdownImport'
import { $convertToMarkdownString } from '../Lexical/Utils/MarkdownExport'
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
private importEditor: LexicalEditor
private exportEditor: LexicalEditor
@@ -50,7 +51,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
async convertSuperStringToOtherFormat(
superString: string,
toFormat: 'txt' | 'md' | 'html' | 'json',
toFormat: 'txt' | 'md' | 'html' | 'json' | 'pdf',
config?: {
embedBehavior?: PrefValue[PrefKey.SuperNoteExportEmbedBehavior]
getFileItem?: (id: string) => FileItem | undefined
@@ -92,7 +93,8 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
if (!fileItem) {
return
}
if (embedBehavior === 'inline' && getFileBase64) {
const canInlineFileType = toFormat === 'pdf' ? fileItem.mimeType.startsWith('image/') : true
if (embedBehavior === 'inline' && getFileBase64 && canInlineFileType) {
const fileBase64 = await getFileBase64(fileNode.getId())
if (!fileBase64) {
return
@@ -122,31 +124,45 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
)
})
this.exportEditor.update(
() => {
switch (toFormat) {
case 'txt':
case 'md': {
const paragraphs = $nodesOfType(ParagraphNode)
for (const paragraph of paragraphs) {
if (paragraph.isEmpty()) {
paragraph.remove()
await new Promise<void>((resolve) => {
this.exportEditor.update(
() => {
switch (toFormat) {
case 'txt':
case 'md': {
const paragraphs = $nodesOfType(ParagraphNode)
for (const paragraph of paragraphs) {
if (paragraph.isEmpty()) {
paragraph.remove()
}
}
content = $convertToMarkdownString(MarkdownTransformers)
resolve()
break
}
content = $convertToMarkdownString(MarkdownTransformers)
break
case 'html':
content = $generateHtmlFromNodes(this.exportEditor)
resolve()
break
case 'pdf': {
void import('../Lexical/Utils/PDFExport/PDFExport').then(({ $generatePDFFromNodes }) => {
void $generatePDFFromNodes(this.exportEditor).then((pdf) => {
content = pdf
resolve()
})
})
break
}
case 'json':
default:
content = superString
resolve()
break
}
case 'html':
content = $generateHtmlFromNodes(this.exportEditor)
break
case 'json':
default:
content = superString
break
}
},
{ discrete: true },
)
},
{ discrete: true },
)
})
if (typeof content !== 'string') {
throw new Error('Could not export note')

View File

@@ -77,6 +77,9 @@ export const getNoteBlob = async (
case 'md':
type = 'text/markdown'
break
case 'pdf':
type = 'application/pdf'
break
default:
type = 'text/plain'
break
@@ -103,11 +106,15 @@ export const getNoteBlob = async (
PrefKey.SuperNoteExportUseMDFrontmatter,
PrefDefaults[PrefKey.SuperNoteExportUseMDFrontmatter],
)
// result is a data url string if format is pdf
const result =
format === 'html' ? superHTML(note, content) : useMDFrontmatter ? superMarkdown(note, content) : content
const blob = new Blob([result], {
type,
})
const blob =
format === 'pdf'
? await fetch(result).then((res) => res.blob())
: new Blob([result], {
type,
})
return blob
}
const blob = new Blob([note.text], {
@@ -132,7 +139,7 @@ const noteRequiresFolder = (
if (!isSuperNote(note)) {
return false
}
if (superExportFormat === 'json') {
if (superExportFormat === 'json' || superExportFormat === 'pdf') {
return false
}
if (superEmbedBehavior !== 'separate') {
@@ -178,10 +185,13 @@ export const createNoteExport = async (
PrefKey.SuperNoteExportFormat,
PrefDefaults[PrefKey.SuperNoteExportFormat],
)
const superEmbedBehaviorPref = application.getPreference(
PrefKey.SuperNoteExportEmbedBehavior,
PrefDefaults[PrefKey.SuperNoteExportEmbedBehavior],
)
const superEmbedBehaviorPref =
superExportFormatPref === 'pdf'
? 'inline'
: application.getPreference(
PrefKey.SuperNoteExportEmbedBehavior,
PrefDefaults[PrefKey.SuperNoteExportEmbedBehavior],
)
if (notes.length === 1 && !noteRequiresFolder(notes[0], superExportFormatPref, superEmbedBehaviorPref)) {
const blob = await getNoteBlob(application, notes[0], superEmbedBehaviorPref)