feat: Allow exporting multiple Super notes and select what format to export them to (#2191)
This commit is contained in:
BIN
.yarn/cache/@lexical-headless-npm-0.7.6-17a55bb1d5-9dd9cacba2.zip
vendored
Normal file
BIN
.yarn/cache/@lexical-headless-npm-0.7.6-17a55bb1d5-9dd9cacba2.zip
vendored
Normal file
Binary file not shown.
@@ -43,6 +43,7 @@ export enum PrefKey {
|
|||||||
DefaultEditorIdentifier = 'defaultEditorIdentifier',
|
DefaultEditorIdentifier = 'defaultEditorIdentifier',
|
||||||
MomentsDefaultTagUuid = 'momentsDefaultTagUuid',
|
MomentsDefaultTagUuid = 'momentsDefaultTagUuid',
|
||||||
SystemViewPreferences = 'systemViewPreferences',
|
SystemViewPreferences = 'systemViewPreferences',
|
||||||
|
SuperNoteExportFormat = 'superNoteExportFormat',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NewNoteTitleFormat {
|
export enum NewNoteTitleFormat {
|
||||||
@@ -109,4 +110,5 @@ export type PrefValue = {
|
|||||||
[PrefKey.DefaultEditorIdentifier]: EditorIdentifier
|
[PrefKey.DefaultEditorIdentifier]: EditorIdentifier
|
||||||
[PrefKey.MomentsDefaultTagUuid]: string | undefined
|
[PrefKey.MomentsDefaultTagUuid]: string | undefined
|
||||||
[PrefKey.SystemViewPreferences]: Partial<Record<SystemViewId, TagPreferences>>
|
[PrefKey.SystemViewPreferences]: Partial<Record<SystemViewId, TagPreferences>>
|
||||||
|
[PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type IconType =
|
|||||||
| 'arrow-right'
|
| 'arrow-right'
|
||||||
| 'arrow-up'
|
| 'arrow-up'
|
||||||
| 'arrows-horizontal'
|
| 'arrows-horizontal'
|
||||||
|
| 'arrows-vertical'
|
||||||
| 'arrows-sort-down'
|
| 'arrows-sort-down'
|
||||||
| 'arrows-sort-up'
|
| 'arrows-sort-up'
|
||||||
| 'asterisk'
|
| 'asterisk'
|
||||||
|
|||||||
@@ -114,5 +114,8 @@
|
|||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"app/**/*.{js,ts,jsx,tsx}": "eslint --cache --fix",
|
"app/**/*.{js,ts,jsx,tsx}": "eslint --cache --fix",
|
||||||
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
|
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lexical/headless": "^0.7.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const IconNameToSvgMapping = {
|
|||||||
'arrow-up': icons.ArrowUpIcon,
|
'arrow-up': icons.ArrowUpIcon,
|
||||||
'arrows-sort-down': icons.ArrowsSortDownIcon,
|
'arrows-sort-down': icons.ArrowsSortDownIcon,
|
||||||
'arrows-sort-up': icons.ArrowsSortUpIcon,
|
'arrows-sort-up': icons.ArrowsSortUpIcon,
|
||||||
|
'arrows-vertical': icons.ArrowsVerticalIcon,
|
||||||
'attachment-file': icons.AttachmentFileIcon,
|
'attachment-file': icons.AttachmentFileIcon,
|
||||||
'check-bold': icons.CheckBoldIcon,
|
'check-bold': icons.CheckBoldIcon,
|
||||||
'check-circle': icons.CheckCircleIcon,
|
'check-circle': icons.CheckCircleIcon,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
|
|||||||
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
|
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
|
||||||
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
||||||
import ModalOverlay from '@/Components/Modal/ModalOverlay'
|
import ModalOverlay from '@/Components/Modal/ModalOverlay'
|
||||||
|
import { SuperEditorNodes } from './SuperEditorNodes'
|
||||||
|
|
||||||
const NotePreviewCharLimit = 160
|
const NotePreviewCharLimit = 160
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
|||||||
<BlocksEditorComposer
|
<BlocksEditorComposer
|
||||||
readonly={note.current.locked}
|
readonly={note.current.locked}
|
||||||
initialValue={note.current.text}
|
initialValue={note.current.text}
|
||||||
nodes={[FileNode, BubbleNode]}
|
nodes={SuperEditorNodes}
|
||||||
>
|
>
|
||||||
<BlocksEditor
|
<BlocksEditor
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
|
||||||
|
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
|
||||||
|
|
||||||
|
export const SuperEditorNodes = [FileNode, BubbleNode]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ import { iconClass } from './ClassNames'
|
|||||||
import SuperNoteOptions from './SuperNoteOptions'
|
import SuperNoteOptions from './SuperNoteOptions'
|
||||||
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
|
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
|
||||||
import MenuItem from '../Menu/MenuItem'
|
import MenuItem from '../Menu/MenuItem'
|
||||||
|
import ModalOverlay from '../Modal/ModalOverlay'
|
||||||
|
import SuperExportModal from './SuperExportModal'
|
||||||
|
|
||||||
const iconSize = MenuItemIconSize
|
const iconSize = MenuItemIconSize
|
||||||
const iconClassDanger = `text-danger mr-2 ${iconSize}`
|
const iconClassDanger = `text-danger mr-2 ${iconSize}`
|
||||||
@@ -94,6 +96,11 @@ const NotesOptions = ({
|
|||||||
}
|
}
|
||||||
}, [application])
|
}, [application])
|
||||||
|
|
||||||
|
const [showExportSuperModal, setShowExportSuperModal] = useState(false)
|
||||||
|
const closeSuperExportModal = useCallback(() => {
|
||||||
|
setShowExportSuperModal(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const downloadSelectedItems = useCallback(async () => {
|
const downloadSelectedItems = useCallback(async () => {
|
||||||
if (notes.length === 1) {
|
if (notes.length === 1) {
|
||||||
const note = notes[0]
|
const note = notes[0]
|
||||||
@@ -165,6 +172,8 @@ const NotesOptions = ({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOnlySuperNoteSelected = notes.length === 1 && notes[0].noteType === NoteType.Super
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{notes.length === 1 && (
|
{notes.length === 1 && (
|
||||||
@@ -251,13 +260,22 @@ const NotesOptions = ({
|
|||||||
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
|
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{notes[0].noteType !== NoteType.Super && (
|
{!isOnlySuperNoteSelected && (
|
||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
application.isNativeMobileWeb()
|
if (application.isNativeMobileWeb()) {
|
||||||
? void shareSelectedNotes(application, notes)
|
void shareSelectedNotes(application, notes)
|
||||||
: void downloadSelectedItems()
|
} 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} />
|
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
|
||||||
@@ -374,6 +392,10 @@ const NotesOptions = ({
|
|||||||
<NoteSizeWarning note={notes[0]} />
|
<NoteSizeWarning note={notes[0]} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ModalOverlay isOpen={showExportSuperModal} onDismiss={closeSuperExportModal}>
|
||||||
|
<SuperExportModal exportNotes={downloadSelectedItems} close={closeSuperExportModal} />
|
||||||
|
</ModalOverlay>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { WebApplication } from '@/Application/Application'
|
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) => {
|
export const getNoteFormat = (application: WebApplication, note: SNNote) => {
|
||||||
const editor = application.componentManager.editorForNote(note)
|
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 => {
|
export const getNoteFileName = (application: WebApplication, note: SNNote): string => {
|
||||||
@@ -29,7 +38,8 @@ export const getNoteBlob = (application: WebApplication, note: SNNote) => {
|
|||||||
type = 'text/plain'
|
type = 'text/plain'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const blob = new Blob([note.text], {
|
const content = note.noteType === NoteType.Super ? exportSuperNote(note, format) : note.text
|
||||||
|
const blob = new Blob([content], {
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
return blob
|
return blob
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@@ -3008,6 +3008,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@lexical/history@npm:0.7.6":
|
||||||
version: 0.7.6
|
version: 0.7.6
|
||||||
resolution: "@lexical/history@npm:0.7.6"
|
resolution: "@lexical/history@npm:0.7.6"
|
||||||
@@ -5247,6 +5256,7 @@ __metadata:
|
|||||||
"@babel/plugin-transform-react-jsx": ^7.19.0
|
"@babel/plugin-transform-react-jsx": ^7.19.0
|
||||||
"@babel/preset-env": "*"
|
"@babel/preset-env": "*"
|
||||||
"@babel/preset-typescript": ^7.18.6
|
"@babel/preset-typescript": ^7.18.6
|
||||||
|
"@lexical/headless": ^0.7.6
|
||||||
"@lexical/react": 0.7.6
|
"@lexical/react": 0.7.6
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.10
|
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.10
|
||||||
"@reach/alert": ^0.18.0
|
"@reach/alert": ^0.18.0
|
||||||
|
|||||||
Reference in New Issue
Block a user