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',
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type IconType =
|
||||
| 'arrow-right'
|
||||
| 'arrow-up'
|
||||
| 'arrows-horizontal'
|
||||
| 'arrows-vertical'
|
||||
| 'arrows-sort-down'
|
||||
| 'arrows-sort-up'
|
||||
| 'asterisk'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { 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
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user