feat: When exporting a Super note, embedded files can be inlined in the note or exported along the note in a zip file. You can now also choose to include frontmatter when exporting to Markdown format.

(#2610)
This commit is contained in:
Aman Harwara
2023-10-31 01:19:04 +05:30
committed by GitHub
parent 044776d937
commit 991de1ddf5
23 changed files with 605 additions and 416 deletions

View File

@@ -18,6 +18,7 @@ import { RemoteImageNode } from '../../Plugins/RemoteImagePlugin/RemoteImageNode
import { InlineFileNode } from '../../Plugins/InlineFilePlugin/InlineFileNode'
import { CreateEditorArgs } from 'lexical'
import { ListHTMLExportNode } from '../../Plugins/List/ListHTMLExportNode'
import { FileExportNode } from './FileExportNode'
const CommonNodes = [
AutoLinkNode,
@@ -46,8 +47,10 @@ const CommonNodes = [
]
export const BlockEditorNodes = [...CommonNodes, ListNode]
export const HTMLExportNodes: CreateEditorArgs['nodes'] = [
export const SuperExportNodes: CreateEditorArgs['nodes'] = [
...CommonNodes,
FileExportNode,
ListHTMLExportNode,
{
replace: ListNode,

View File

@@ -0,0 +1,93 @@
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { parseAndCreateZippableFileName } from '@standardnotes/ui-services'
import { DOMExportOutput, Spread } from 'lexical'
type SerializedFileExportNode = Spread<
{
version: 1
type: 'file-export'
name: string
mimeType: string
},
SerializedDecoratorBlockNode
>
export class FileExportNode extends DecoratorBlockNode {
__name: string
__mimeType: string
static getType(): string {
return 'file-export'
}
constructor(name: string, mimeType: string) {
super()
this.__name = name
this.__mimeType = mimeType
}
static clone(node: FileExportNode): FileExportNode {
return new FileExportNode(node.__name, node.__mimeType)
}
static importJSON(serializedNode: SerializedFileExportNode): FileExportNode {
const node = new FileExportNode(serializedNode.name, serializedNode.mimeType)
return node
}
exportJSON(): SerializedFileExportNode {
return {
...super.exportJSON(),
name: this.__name,
mimeType: this.__mimeType,
version: 1,
type: 'file-export',
}
}
getZippableFileName(): string {
return parseAndCreateZippableFileName(this.__name)
}
getTextContent(): string {
return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__name}](./${this.getZippableFileName()})`
}
exportDOM(): DOMExportOutput {
const src = `./${this.getZippableFileName()}`
if (this.__mimeType.startsWith('image/')) {
const img = document.createElement('img')
img.setAttribute('src', src)
return { element: img }
} else if (this.__mimeType.startsWith('audio')) {
const audio = document.createElement('audio')
audio.setAttribute('controls', '')
const source = document.createElement('source')
source.setAttribute('src', src)
source.setAttribute('type', this.__mimeType)
audio.appendChild(source)
return { element: audio }
} else if (this.__mimeType.startsWith('video')) {
const video = document.createElement('video')
video.setAttribute('controls', '')
const source = document.createElement('source')
source.setAttribute('src', src)
source.setAttribute('type', this.__mimeType)
video.appendChild(source)
return { element: video }
}
const object = document.createElement('object')
object.setAttribute('data', src)
object.setAttribute('type', this.__mimeType)
return { element: object }
}
decorate(): JSX.Element {
// Doesn't need to actually render anything since this is only used for export
return <></>
}
}
export function $createFileExportNode(name: string, mimeType: string): FileExportNode {
return new FileExportNode(name, mimeType)
}

View File

@@ -1,139 +0,0 @@
import { useApplication } from '@/Components/ApplicationProvider'
import { downloadBlobOnAndroid } from '@/NativeMobileWeb/DownloadBlobOnAndroid'
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { Platform } from '@standardnotes/snjs'
import {
sanitizeFileName,
SUPER_EXPORT_HTML,
SUPER_EXPORT_JSON,
SUPER_EXPORT_MARKDOWN,
} from '@standardnotes/ui-services'
import { useCallback, useEffect, useRef } from 'react'
import { useCommandService } from '@/Components/CommandProvider'
import { HeadlessSuperConverter } from '../../Tools/HeadlessSuperConverter'
// @ts-expect-error Using inline loaders to load CSS as string
import superEditorCSS from '!css-loader!sass-loader!../../Lexical/Theme/editor.scss'
// @ts-expect-error Using inline loaders to load CSS as string
import snColorsCSS from '!css-loader!sass-loader!@standardnotes/styles/src/Styles/_colors.scss'
// @ts-expect-error Using inline loaders to load CSS as string
import exportOverridesCSS from '!css-loader!sass-loader!../../Lexical/Theme/export-overrides.scss'
const html = (title: string, content: string) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
${snColorsCSS.toString()}
${superEditorCSS.toString()}
${exportOverridesCSS.toString()}
</style>
</head>
<body>
${content}
</body>
</html>
`
export const ExportPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const commandService = useCommandService()
const converter = useRef(new HeadlessSuperConverter())
const downloadData = useCallback(
(data: Blob, fileName: string) => {
if (!application.isNativeMobileWeb()) {
application.archiveService.downloadData(data, fileName)
return
}
if (application.platform === Platform.Android) {
downloadBlobOnAndroid(application.mobileDevice, data, fileName).catch(console.error)
} else {
shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), data, fileName).catch(
console.error,
)
}
},
[application],
)
const exportJson = useCallback(
(title: string) => {
const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'json')
const blob = new Blob([content], { type: 'application/json' })
downloadData(blob, `${sanitizeFileName(title)}.json`)
},
[downloadData, editor],
)
const exportMarkdown = useCallback(
(title: string) => {
const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'md')
const blob = new Blob([content], { type: 'text/markdown' })
downloadData(blob, `${sanitizeFileName(title)}.md`)
},
[downloadData, editor],
)
const exportHtml = useCallback(
(title: string) => {
const content = html(
title,
converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'html'),
)
const blob = new Blob([content], { type: 'text/html' })
downloadData(blob, `${sanitizeFileName(title)}.html`)
},
[downloadData, editor],
)
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_JSON,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportJson(title)
},
})
}, [commandService, exportJson])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_MARKDOWN,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportMarkdown(title)
},
})
}, [commandService, exportMarkdown])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_HTML,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportHtml(title)
},
})
}, [commandService, exportHtml])
return null
}

View File

@@ -148,6 +148,10 @@ export class InlineFileNode extends DecoratorBlockNode {
return { element: object }
}
getTextContent(): string {
return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__fileName}](${this.__src})`
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}
const className = {

View File

@@ -37,7 +37,6 @@ import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
import { useCommandService } from '@/Components/CommandProvider'
import { SUPER_SHOW_MARKDOWN_PREVIEW } from '@standardnotes/ui-services'
import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview'
import { ExportPlugin } from './Plugins/ExportPlugin/ExportPlugin'
import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMarkdownPlugin/GetMarkdownPlugin'
import { useResponsiveEditorFontSize } from '@/Utils/getPlaintextFontSize'
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
@@ -243,7 +242,6 @@ export const SuperEditor: FunctionComponent<Props> = ({
/>
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
<ExportPlugin />
{readonly === undefined && <ReadonlyPlugin note={note.current} />}
<AutoFocusPlugin isEnabled={controller.isTemplateNote} />
<SuperSearchContextProvider>

View File

@@ -6,7 +6,7 @@ import {
isUIFeatureAnIframeFeature,
spaceSeparatedStrings,
} from '@standardnotes/snjs'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import IframeFeatureView from '../ComponentView/IframeFeatureView'
import Icon from '../Icon/Icon'
@@ -52,18 +52,23 @@ const SuperNoteConverter = ({
return 'json'
}, [uiFeature])
const convertedContent = useMemo(() => {
if (note.text.length === 0) {
const [convertedContent, setConvertedContent] = useState<string>('')
useEffect(() => {
const convertContent = async () => {
if (note.text.length === 0) {
return note.text
}
try {
return new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format)
} catch (error) {
console.error(error)
}
return note.text
}
try {
return new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format)
} catch (error) {
console.error(error)
}
return note.text
convertContent().then(setConvertedContent).catch(console.error)
}, [format, note])
const componentViewer = useMemo(() => {

View File

@@ -1,6 +1,6 @@
import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown'
import { SuperConverterServiceInterface } from '@standardnotes/snjs'
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
import {
$createParagraphNode,
$getRoot,
@@ -11,13 +11,14 @@ import {
ParagraphNode,
} from 'lexical'
import BlocksEditorTheme from '../Lexical/Theme/Theme'
import { BlockEditorNodes, HTMLExportNodes } from '../Lexical/Nodes/AllNodes'
import { SuperExportNodes } from '../Lexical/Nodes/AllNodes'
import { MarkdownTransformers } from '../MarkdownTransformers'
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
import { FileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode'
import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode'
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
private editor: LexicalEditor
private htmlExportEditor: LexicalEditor
constructor() {
this.editor = createHeadlessEditor({
@@ -25,14 +26,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: [...BlockEditorNodes],
})
this.htmlExportEditor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: HTMLExportNodes,
nodes: SuperExportNodes,
})
}
@@ -45,34 +39,80 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
}
}
convertSuperStringToOtherFormat(superString: string, toFormat: 'txt' | 'md' | 'html' | 'json'): string {
async convertSuperStringToOtherFormat(
superString: string,
toFormat: 'txt' | 'md' | 'html' | 'json',
config?: {
embedBehavior?: PrefValue[PrefKey.SuperNoteExportEmbedBehavior]
getFileItem?: (id: string) => FileItem | undefined
getFileBase64?: (id: string) => Promise<string | undefined>
},
): Promise<string> {
if (superString.length === 0) {
return superString
}
if (toFormat === 'html') {
this.htmlExportEditor.setEditorState(this.htmlExportEditor.parseEditorState(superString))
const { embedBehavior, getFileItem, getFileBase64 } = config ?? { embedBehavior: 'reference' }
let content: string | undefined
this.htmlExportEditor.update(
() => {
content = $generateHtmlFromNodes(this.htmlExportEditor)
},
{ discrete: true },
)
if (typeof content !== 'string') {
throw new Error('Could not export note')
}
return content
if (embedBehavior === 'separate' && !getFileItem) {
throw new Error('getFileItem must be provided when embedBehavior is "separate"')
}
if (embedBehavior === 'inline' && !getFileItem && !getFileBase64) {
throw new Error('getFileItem and getFileBase64 must be provided when embedBehavior is "inline"')
}
this.editor.setEditorState(this.editor.parseEditorState(superString))
let content: string | undefined
await new Promise<void>((resolve) => {
this.editor.update(
() => {
if (embedBehavior === 'reference') {
resolve()
return
}
if (!getFileItem) {
resolve()
return
}
const fileNodes = $nodesOfType(FileNode)
Promise.all(
fileNodes.map(async (fileNode) => {
const fileItem = getFileItem(fileNode.getId())
if (!fileItem) {
return
}
if (embedBehavior === 'inline' && getFileBase64) {
const fileBase64 = await getFileBase64(fileNode.getId())
if (!fileBase64) {
return
}
this.editor.update(
() => {
const inlineFileNode = $createInlineFileNode(fileBase64, fileItem.mimeType, fileItem.name)
fileNode.replace(inlineFileNode)
},
{ discrete: true },
)
} else {
this.editor.update(
() => {
const fileExportNode = $createFileExportNode(fileItem.name, fileItem.mimeType)
fileNode.replace(fileExportNode)
},
{ discrete: true },
)
}
}),
)
.then(() => resolve())
.catch(console.error)
},
{ discrete: true },
)
})
this.editor.update(
() => {
switch (toFormat) {
@@ -87,6 +127,9 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
content = $convertToMarkdownString(MarkdownTransformers)
break
}
case 'html':
content = $generateHtmlFromNodes(this.editor)
break
case 'json':
default:
content = superString
@@ -183,4 +226,23 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
return JSON.stringify(this.editor.getEditorState())
}
getEmbeddedFileIDsFromSuperString(superString: string): string[] {
if (superString.length === 0) {
return []
}
this.editor.setEditorState(this.editor.parseEditorState(superString))
const ids: string[] = []
this.editor.getEditorState().read(() => {
const fileNodes = $nodesOfType(FileNode)
fileNodes.forEach((fileNode) => {
ids.push(fileNode.getId())
})
})
return ids
}
}