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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user