feat: Notes from Evernote ENEX files are now correctly imported as Super notes with attachments (#2467)
This commit is contained in:
@@ -15,6 +15,7 @@ import { CollapsibleTitleNode } from '../../Plugins/CollapsiblePlugin/Collapsibl
|
||||
import { FileNode } from '../../Plugins/EncryptedFilePlugin/Nodes/FileNode'
|
||||
import { BubbleNode } from '../../Plugins/ItemBubblePlugin/Nodes/BubbleNode'
|
||||
import { RemoteImageNode } from '../../Plugins/RemoteImagePlugin/RemoteImageNode'
|
||||
import { InlineFileNode } from '../../Plugins/InlineFilePlugin/InlineFileNode'
|
||||
|
||||
export const BlockEditorNodes = [
|
||||
AutoLinkNode,
|
||||
@@ -40,4 +41,5 @@ export const BlockEditorNodes = [
|
||||
FileNode,
|
||||
BubbleNode,
|
||||
RemoteImageNode,
|
||||
InlineFileNode,
|
||||
]
|
||||
|
||||
@@ -67,7 +67,11 @@ export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel,
|
||||
)
|
||||
|
||||
if (!file) {
|
||||
return <div>Unable to find file {fileUuid}</div>
|
||||
return (
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
<div>Unable to find file {fileUuid}</div>
|
||||
</BlockWithAlignableContents>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
|
||||
import { Platform, classNames } from '@standardnotes/snjs'
|
||||
import { ElementFormatType, NodeKey } from 'lexical'
|
||||
import { InlineFileNode } from './InlineFileNode'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { $createFileNode } from '../EncryptedFilePlugin/Nodes/FileUtils'
|
||||
import { isIOS } from '@standardnotes/ui-services'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import Spinner from '@/Components/Spinner/Spinner'
|
||||
|
||||
type Props = {
|
||||
fileName: string | undefined
|
||||
mimeType: string
|
||||
src: string
|
||||
className: Readonly<{
|
||||
base: string
|
||||
focus: string
|
||||
}>
|
||||
format: ElementFormatType | null
|
||||
node: InlineFileNode
|
||||
nodeKey: NodeKey
|
||||
}
|
||||
|
||||
const InlineFileComponent = ({ className, src, mimeType, fileName, format, node, nodeKey }: Props) => {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const saveToFilesAndReplaceNode = useCallback(async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const blob = await fetch(src).then((response) => response.blob())
|
||||
const file = new File([blob], fileName || application.generateUUID(), { type: mimeType })
|
||||
|
||||
const { filesController, linkingController } = application
|
||||
|
||||
const uploadedFile = await filesController.uploadNewFile(file, { showToast: false })
|
||||
|
||||
if (!uploadedFile) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
const fileNode = $createFileNode(uploadedFile.uuid)
|
||||
node.replace(fileNode)
|
||||
})
|
||||
|
||||
void linkingController.linkItemToSelectedItem(uploadedFile)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [application, editor, fileName, mimeType, node, src])
|
||||
|
||||
const isPDF = mimeType === 'application/pdf'
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
{mimeType.startsWith('image') ? (
|
||||
<div className="relative flex min-h-[2rem] flex-col items-center gap-2.5">
|
||||
<img alt={fileName} src={src} />
|
||||
</div>
|
||||
) : mimeType.startsWith('video') ? (
|
||||
<video className="h-full w-full" controls autoPlay>
|
||||
<source src={src} type={mimeType} />
|
||||
</video>
|
||||
) : mimeType.startsWith('audio') ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<audio controls>
|
||||
<source src={src} type={mimeType} />
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<object
|
||||
className={classNames('h-full w-full', isPDF && 'min-h-[65vh]')}
|
||||
data={isPDF ? src + '#view=FitV' : src}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={classNames(
|
||||
'mx-auto mt-2 flex items-center gap-2.5 rounded border border-border bg-default px-2.5 py-1.5',
|
||||
!isSaving && 'hover:bg-info hover:text-info-contrast',
|
||||
)}
|
||||
onClick={() => {
|
||||
const isIOSPlatform = application.platform === Platform.Ios || isIOS()
|
||||
if (isIOSPlatform && document.activeElement) {
|
||||
;(document.activeElement as HTMLElement).blur()
|
||||
}
|
||||
saveToFilesAndReplaceNode().catch(console.error)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon type="download" />
|
||||
Save to Files
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</BlockWithAlignableContents>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlineFileComponent
|
||||
@@ -0,0 +1,178 @@
|
||||
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
import { DOMConversionMap, DOMExportOutput, EditorConfig, LexicalEditor, LexicalNode, Spread } from 'lexical'
|
||||
import InlineFileComponent from './InlineFileComponent'
|
||||
|
||||
type SerializedInlineFileNode = Spread<
|
||||
{
|
||||
version: 1
|
||||
type: 'inline-file'
|
||||
fileName: string | undefined
|
||||
mimeType: string
|
||||
src: string
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>
|
||||
|
||||
export class InlineFileNode extends DecoratorBlockNode {
|
||||
__fileName: string | undefined
|
||||
__mimeType: string
|
||||
__src: string
|
||||
|
||||
static getType(): string {
|
||||
return 'inline-file'
|
||||
}
|
||||
|
||||
constructor(src: string, mimeType: string, fileName: string | undefined) {
|
||||
super()
|
||||
this.__src = src
|
||||
this.__mimeType = mimeType
|
||||
this.__fileName = fileName
|
||||
}
|
||||
|
||||
static clone(node: InlineFileNode): InlineFileNode {
|
||||
return new InlineFileNode(node.__src, node.__mimeType, node.__fileName)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedInlineFileNode): InlineFileNode {
|
||||
const node = $createInlineFileNode(serializedNode.src, serializedNode.mimeType, serializedNode.fileName)
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedInlineFileNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
src: this.__src,
|
||||
mimeType: this.__mimeType,
|
||||
fileName: this.__fileName,
|
||||
version: 1,
|
||||
type: 'inline-file',
|
||||
}
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
|
||||
return {
|
||||
object: (domNode: HTMLDivElement) => {
|
||||
if (domNode.tagName !== 'OBJECT') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: () => {
|
||||
if (!(domNode instanceof HTMLObjectElement)) {
|
||||
return null
|
||||
}
|
||||
const mimeType = domNode.type || 'application/octet-stream'
|
||||
const fileName = domNode.getAttribute('data-file-name') || undefined
|
||||
const src = domNode.data
|
||||
return {
|
||||
node: $createInlineFileNode(src, mimeType, fileName),
|
||||
}
|
||||
},
|
||||
priority: 2,
|
||||
}
|
||||
},
|
||||
img: (domNode: HTMLDivElement) => {
|
||||
if (domNode.tagName !== 'IMG') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: () => {
|
||||
if (!(domNode instanceof HTMLImageElement)) {
|
||||
return null
|
||||
}
|
||||
const mimeType = domNode.getAttribute('data-mime-type') || 'image/png'
|
||||
const fileName = domNode.getAttribute('data-file-name') || domNode.alt
|
||||
return {
|
||||
node: $createInlineFileNode(domNode.currentSrc || domNode.src, mimeType, fileName),
|
||||
}
|
||||
},
|
||||
priority: 2,
|
||||
}
|
||||
},
|
||||
source: (domNode: HTMLDivElement) => {
|
||||
if (domNode.tagName !== 'SOURCE') {
|
||||
return null
|
||||
}
|
||||
const parent = domNode.parentElement
|
||||
const isParentVideoOrAudio = !!parent && (parent.tagName === 'VIDEO' || parent.tagName === 'AUDIO')
|
||||
if (!isParentVideoOrAudio) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: () => {
|
||||
if (!(domNode instanceof HTMLSourceElement)) {
|
||||
return null
|
||||
}
|
||||
const mimeType = domNode.type || parent.tagName === 'VIDEO' ? 'video/mp4' : 'audio/mp3'
|
||||
const src = domNode.src
|
||||
const fileName = domNode.getAttribute('data-file-name') || undefined
|
||||
return {
|
||||
node: $createInlineFileNode(src, mimeType, fileName),
|
||||
}
|
||||
},
|
||||
priority: 2,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
if (this.__mimeType.startsWith('image/')) {
|
||||
const img = document.createElement('img')
|
||||
img.setAttribute('src', this.__src)
|
||||
img.setAttribute('data-mime-type', this.__mimeType)
|
||||
img.setAttribute('data-file-name', this.__fileName || '')
|
||||
return { element: img }
|
||||
} else if (this.__mimeType.startsWith('audio')) {
|
||||
const audio = document.createElement('audio')
|
||||
audio.setAttribute('controls', '')
|
||||
audio.setAttribute('data-file-name', this.__fileName || '')
|
||||
const source = document.createElement('source')
|
||||
source.setAttribute('src', this.__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', '')
|
||||
video.setAttribute('data-file-name', this.__fileName || '')
|
||||
const source = document.createElement('source')
|
||||
source.setAttribute('src', this.__src)
|
||||
source.setAttribute('type', this.__mimeType)
|
||||
video.appendChild(source)
|
||||
return { element: video }
|
||||
}
|
||||
const object = document.createElement('object')
|
||||
object.setAttribute('data', this.__src)
|
||||
object.setAttribute('type', this.__mimeType)
|
||||
object.setAttribute('data-file-name', this.__fileName || '')
|
||||
return { element: object }
|
||||
}
|
||||
|
||||
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {}
|
||||
const className = {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineFileComponent
|
||||
className={className}
|
||||
format={this.__format}
|
||||
node={this}
|
||||
nodeKey={this.getKey()}
|
||||
src={this.__src}
|
||||
mimeType={this.__mimeType}
|
||||
fileName={this.__fileName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function $isInlineFileNode(node: InlineFileNode | LexicalNode | null | undefined): node is InlineFileNode {
|
||||
return node instanceof InlineFileNode
|
||||
}
|
||||
|
||||
export function $createInlineFileNode(src: string, mimeType: string, fileName: string | undefined): InlineFileNode {
|
||||
return new InlineFileNode(src, mimeType, fileName)
|
||||
}
|
||||
@@ -63,7 +63,8 @@ const RemoteImageComponent = ({ className, src, alt, node, format, nodeKey }: Pr
|
||||
}
|
||||
}, [application, editor, node, src])
|
||||
|
||||
const canShowSaveButton = application.isNativeMobileWeb() || isDesktopApplication()
|
||||
const isBase64OrDataUrl = src.startsWith('data:')
|
||||
const canShowSaveButton = application.isNativeMobileWeb() || isDesktopApplication() || isBase64OrDataUrl
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
|
||||
@@ -88,41 +88,58 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||
return otherFormatString
|
||||
}
|
||||
|
||||
this.editor.update(
|
||||
() => {
|
||||
$getRoot().clear()
|
||||
},
|
||||
{
|
||||
discrete: true,
|
||||
},
|
||||
)
|
||||
|
||||
let didThrow = false
|
||||
if (fromFormat === 'html') {
|
||||
this.editor.update(
|
||||
() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const dom = parser.parseFromString(otherFormatString, 'text/html')
|
||||
const generatedNodes = $generateNodesFromDOM(this.editor, dom)
|
||||
const nodesToInsert: LexicalNode[] = []
|
||||
generatedNodes.forEach((node) => {
|
||||
const type = node.getType()
|
||||
|
||||
const parser = new DOMParser()
|
||||
const dom = parser.parseFromString(otherFormatString, 'text/html')
|
||||
const generatedNodes = $generateNodesFromDOM(this.editor, dom)
|
||||
const nodesToInsert: LexicalNode[] = []
|
||||
generatedNodes.forEach((node) => {
|
||||
const type = node.getType()
|
||||
// Wrap text & link nodes with paragraph since they can't
|
||||
// be top-level nodes in Super
|
||||
if (type === 'text' || type === 'link' || type === 'unencrypted-image' || type === 'inline-file') {
|
||||
const paragraphNode = $createParagraphNode()
|
||||
paragraphNode.append(node)
|
||||
nodesToInsert.push(paragraphNode)
|
||||
return
|
||||
} else {
|
||||
nodesToInsert.push(node)
|
||||
}
|
||||
|
||||
// Wrap text & link nodes with paragraph since they can't
|
||||
// be top-level nodes in Super
|
||||
if (type === 'text' || type === 'link') {
|
||||
const paragraphNode = $createParagraphNode()
|
||||
paragraphNode.append(node)
|
||||
nodesToInsert.push(paragraphNode)
|
||||
return
|
||||
} else {
|
||||
nodesToInsert.push(node)
|
||||
}
|
||||
|
||||
nodesToInsert.push($createParagraphNode())
|
||||
})
|
||||
$getRoot().selectEnd()
|
||||
$insertNodes(nodesToInsert.concat($createParagraphNode()))
|
||||
nodesToInsert.push($createParagraphNode())
|
||||
})
|
||||
$getRoot().selectEnd()
|
||||
$insertNodes(nodesToInsert.concat($createParagraphNode()))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
didThrow = true
|
||||
}
|
||||
},
|
||||
{ discrete: true },
|
||||
)
|
||||
} else {
|
||||
this.editor.update(
|
||||
() => {
|
||||
$convertFromMarkdownString(otherFormatString, MarkdownTransformers)
|
||||
try {
|
||||
$convertFromMarkdownString(otherFormatString, MarkdownTransformers)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
didThrow = true
|
||||
}
|
||||
},
|
||||
{
|
||||
discrete: true,
|
||||
@@ -130,6 +147,10 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||
)
|
||||
}
|
||||
|
||||
if (didThrow) {
|
||||
throw new Error('Could not import note')
|
||||
}
|
||||
|
||||
return JSON.stringify(this.editor.getEditorState())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user