feat: Allow exporting multiple Super notes and select what format to export them to (#2191)

This commit is contained in:
Aman Harwara
2023-02-01 00:47:28 +05:30
committed by GitHub
parent 5c17ae3c4e
commit 506a1e83f1
12 changed files with 185 additions and 9 deletions

Binary file not shown.

View File

@@ -43,6 +43,7 @@ export enum PrefKey {
DefaultEditorIdentifier = 'defaultEditorIdentifier',
MomentsDefaultTagUuid = 'momentsDefaultTagUuid',
SystemViewPreferences = 'systemViewPreferences',
SuperNoteExportFormat = 'superNoteExportFormat',
}
export enum NewNoteTitleFormat {
@@ -109,4 +110,5 @@ export type PrefValue = {
[PrefKey.DefaultEditorIdentifier]: EditorIdentifier
[PrefKey.MomentsDefaultTagUuid]: string | undefined
[PrefKey.SystemViewPreferences]: Partial<Record<SystemViewId, TagPreferences>>
[PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html'
}

View File

@@ -15,6 +15,7 @@ export type IconType =
| 'arrow-right'
| 'arrow-up'
| 'arrows-horizontal'
| 'arrows-vertical'
| 'arrows-sort-down'
| 'arrows-sort-up'
| 'asterisk'

View File

@@ -114,5 +114,8 @@
"lint-staged": {
"app/**/*.{js,ts,jsx,tsx}": "eslint --cache --fix",
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
},
"dependencies": {
"@lexical/headless": "^0.7.6"
}
}

View File

@@ -8,6 +8,7 @@ export const IconNameToSvgMapping = {
'arrow-up': icons.ArrowUpIcon,
'arrows-sort-down': icons.ArrowsSortDownIcon,
'arrows-sort-up': icons.ArrowsSortUpIcon,
'arrows-vertical': icons.ArrowsVerticalIcon,
'attachment-file': icons.AttachmentFileIcon,
'check-bold': icons.CheckBoldIcon,
'check-circle': icons.CheckCircleIcon,

View File

@@ -41,6 +41,7 @@ import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
import ModalOverlay from '@/Components/Modal/ModalOverlay'
import { SuperEditorNodes } from './SuperEditorNodes'
const NotePreviewCharLimit = 160
@@ -170,7 +171,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
<BlocksEditorComposer
readonly={note.current.locked}
initialValue={note.current.text}
nodes={[FileNode, BubbleNode]}
nodes={SuperEditorNodes}
>
<BlocksEditor
onChange={handleChange}

View File

@@ -0,0 +1,4 @@
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
export const SuperEditorNodes = [FileNode, BubbleNode]

View File

@@ -0,0 +1,46 @@
import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString } from '@lexical/markdown'
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
import { $generateHtmlFromNodes } from '@lexical/html'
import { BlockEditorNodes } from '@standardnotes/blocks-editor/src/Lexical/Nodes/AllNodes'
import BlocksEditorTheme from '@standardnotes/blocks-editor/src/Lexical/Theme/Theme'
import { SNNote } from '@standardnotes/models'
import { SuperEditorNodes } from './SuperEditorNodes'
export const exportSuperNote = (note: SNNote, format: 'txt' | 'md' | 'html' | 'json') => {
const headlessEditor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: [...SuperEditorNodes, ...BlockEditorNodes],
})
headlessEditor.setEditorState(headlessEditor.parseEditorState(note.text))
let content: string | undefined
headlessEditor.update(() => {
switch (format) {
case 'md':
content = $convertToMarkdownString(MarkdownTransformers)
break
case 'html':
content = $generateHtmlFromNodes(headlessEditor)
break
case 'json':
content = JSON.stringify(headlessEditor.toJSON())
break
case 'txt':
default:
content = note.text
break
}
})
if (!content) {
throw new Error('Could not export note')
}
return content
}

View File

@@ -32,6 +32,8 @@ import { iconClass } from './ClassNames'
import SuperNoteOptions from './SuperNoteOptions'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import MenuItem from '../Menu/MenuItem'
import ModalOverlay from '../Modal/ModalOverlay'
import SuperExportModal from './SuperExportModal'
const iconSize = MenuItemIconSize
const iconClassDanger = `text-danger mr-2 ${iconSize}`
@@ -94,6 +96,11 @@ const NotesOptions = ({
}
}, [application])
const [showExportSuperModal, setShowExportSuperModal] = useState(false)
const closeSuperExportModal = useCallback(() => {
setShowExportSuperModal(false)
}, [])
const downloadSelectedItems = useCallback(async () => {
if (notes.length === 1) {
const note = notes[0]
@@ -165,6 +172,8 @@ const NotesOptions = ({
return null
}
const isOnlySuperNoteSelected = notes.length === 1 && notes[0].noteType === NoteType.Super
return (
<>
{notes.length === 1 && (
@@ -251,13 +260,22 @@ const NotesOptions = ({
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
</MenuItem>
)}
{notes[0].noteType !== NoteType.Super && (
{!isOnlySuperNoteSelected && (
<>
<MenuItem
onClick={() => {
application.isNativeMobileWeb()
? void shareSelectedNotes(application, notes)
: void downloadSelectedItems()
if (application.isNativeMobileWeb()) {
void shareSelectedNotes(application, notes)
} else {
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
if (hasSuperNote) {
setShowExportSuperModal(true)
return
}
void downloadSelectedItems()
}
}}
>
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
@@ -374,6 +392,10 @@ const NotesOptions = ({
<NoteSizeWarning note={notes[0]} />
</>
) : null}
<ModalOverlay isOpen={showExportSuperModal} onDismiss={closeSuperExportModal}>
<SuperExportModal exportNotes={downloadSelectedItems} close={closeSuperExportModal} />
</ModalOverlay>
</>
)
}

View File

@@ -0,0 +1,76 @@
import { ApplicationEvent, PrefKey, PrefValue } from '@standardnotes/snjs'
import { useEffect, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import Dropdown from '../Dropdown/Dropdown'
import Modal from '../Modal/Modal'
type Props = {
exportNotes: () => void
close: () => void
}
const SuperExportModal = ({ exportNotes, close }: Props) => {
const application = useApplication()
const [superNoteExportFormat, setSuperNoteExportFormat] = useState<PrefValue[PrefKey.SuperNoteExportFormat]>(
() => application.getPreference(PrefKey.SuperNoteExportFormat) || 'json',
)
useEffect(() => {
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
setSuperNoteExportFormat(application.getPreference(PrefKey.SuperNoteExportFormat) || 'json')
})
}, [application, superNoteExportFormat])
return (
<Modal
title="Export notes"
className={{
description: 'p-4',
}}
close={close}
actions={[
{
label: 'Cancel',
type: 'cancel',
onClick: close,
mobileSlot: 'left',
},
{
label: 'Export',
type: 'primary',
onClick: exportNotes,
mobileSlot: 'right',
},
]}
>
<div className="mb-4">
<div className="mb-1 text-base">
We detected your selection includes Super notes. How do you want to export them?
</div>
<Dropdown
id="export-format-dropdown"
label="Super notes export format"
items={[
{ label: 'Keep as Super', value: 'json' },
{ label: 'Markdown', value: 'md' },
{ label: 'HTML', value: 'html' },
]}
value={superNoteExportFormat}
onChange={(value) => {
void application.setPreference(
PrefKey.SuperNoteExportFormat,
value as PrefValue[PrefKey.SuperNoteExportFormat],
)
}}
portal={false}
/>
</div>
<div className="text-passive-0">
Note that if you convert Super notes to Markdown then import them back into Standard Notes in the future, you
will lose some formatting that the Markdown format is incapable of expressing, such as collapsible blocks and
embeds.
</div>
</Modal>
)
}
export default SuperExportModal

View File

@@ -1,10 +1,19 @@
import { WebApplication } from '@/Application/Application'
import { SNNote } from '@standardnotes/snjs'
import { exportSuperNote } from '@/Components/NoteView/SuperEditor/SuperNoteExporter'
import { NoteType, PrefKey, SNNote } from '@standardnotes/snjs'
export const getNoteFormat = (application: WebApplication, note: SNNote) => {
const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'
return format
const isSuperNote = note.noteType === NoteType.Super
if (isSuperNote) {
const superNoteExportFormatPref = application.getPreference(PrefKey.SuperNoteExportFormat) || 'json'
return superNoteExportFormatPref
}
return editor?.package_info?.file_type || 'txt'
}
export const getNoteFileName = (application: WebApplication, note: SNNote): string => {
@@ -29,7 +38,8 @@ export const getNoteBlob = (application: WebApplication, note: SNNote) => {
type = 'text/plain'
break
}
const blob = new Blob([note.text], {
const content = note.noteType === NoteType.Super ? exportSuperNote(note, format) : note.text
const blob = new Blob([content], {
type,
})
return blob

View File

@@ -3008,6 +3008,15 @@ __metadata:
languageName: node
linkType: hard
"@lexical/headless@npm:^0.7.6":
version: 0.7.6
resolution: "@lexical/headless@npm:0.7.6"
peerDependencies:
lexical: 0.7.6
checksum: 9dd9cacba2a45a2e9b0fce5e8ccda1642f7c7c1f04ecf96b2393c1534004f55c04dcce819d88fd61c47204f78b3864fa67ffb4611c94806548b307c622498352
languageName: node
linkType: hard
"@lexical/history@npm:0.7.6":
version: 0.7.6
resolution: "@lexical/history@npm:0.7.6"
@@ -5247,6 +5256,7 @@ __metadata:
"@babel/plugin-transform-react-jsx": ^7.19.0
"@babel/preset-env": "*"
"@babel/preset-typescript": ^7.18.6
"@lexical/headless": ^0.7.6
"@lexical/react": 0.7.6
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.10
"@reach/alert": ^0.18.0