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:
@@ -1,6 +1,6 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { NoteType, Platform, SNNote } from '@standardnotes/snjs'
|
||||
import {
|
||||
CHANGE_EDITOR_WIDTH_COMMAND,
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
PIN_NOTE_COMMAND,
|
||||
SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
|
||||
STAR_NOTE_COMMAND,
|
||||
SUPER_EXPORT_HTML,
|
||||
SUPER_EXPORT_JSON,
|
||||
SUPER_EXPORT_MARKDOWN,
|
||||
SUPER_SHOW_MARKDOWN_PREVIEW,
|
||||
} from '@standardnotes/ui-services'
|
||||
import ChangeEditorOption from './ChangeEditorOption'
|
||||
@@ -20,9 +17,7 @@ import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
|
||||
import { NotesOptionsProps } from './NotesOptionsProps'
|
||||
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
|
||||
import { AppPaneId } from '../Panes/AppPaneMetadata'
|
||||
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
||||
import { shareSelectedNotes } from '@/NativeMobileWeb/ShareSelectedNotes'
|
||||
import { downloadSelectedNotesOnAndroid } from '@/NativeMobileWeb/DownloadSelectedNotesOnAndroid'
|
||||
import { createNoteExport } from '@/Utils/NoteExportUtils'
|
||||
import ProtectedUnauthorizedLabel from '../ProtectedItemOverlay/ProtectedUnauthorizedLabel'
|
||||
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
||||
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
|
||||
@@ -39,9 +34,9 @@ import SuperExportModal from './SuperExportModal'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import { MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
|
||||
import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
|
||||
import Menu from '../Menu/Menu'
|
||||
import Popover from '../Popover/Popover'
|
||||
import MenuSection from '../Menu/MenuSection'
|
||||
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
|
||||
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
|
||||
|
||||
const iconSize = MenuItemIconSize
|
||||
const iconClassDanger = `text-danger mr-2 ${iconSize}`
|
||||
@@ -104,34 +99,40 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
}, [])
|
||||
|
||||
const downloadSelectedItems = useCallback(async () => {
|
||||
if (notes.length === 1) {
|
||||
const note = notes[0]
|
||||
const blob = getNoteBlob(application, note)
|
||||
application.archiveService.downloadData(blob, getNoteFileName(application, note))
|
||||
return
|
||||
}
|
||||
|
||||
if (notes.length > 1) {
|
||||
const loadingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Exporting ${notes.length} notes...`,
|
||||
try {
|
||||
const result = await createNoteExport(application, notes)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
const { blob, fileName } = result
|
||||
void downloadOrShareBlobBasedOnPlatform({
|
||||
archiveService: application.archiveService,
|
||||
platform: application.platform,
|
||||
mobileDevice: application.mobileDevice,
|
||||
blob: blob,
|
||||
filename: fileName,
|
||||
isNativeMobileWeb: application.isNativeMobileWeb(),
|
||||
})
|
||||
await application.archiveService.downloadDataAsZip(
|
||||
notes.map((note) => {
|
||||
return {
|
||||
name: getNoteFileName(application, note),
|
||||
content: getNoteBlob(application, note),
|
||||
}
|
||||
}),
|
||||
)
|
||||
dismissToast(loadingToastId)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Exported ${notes.length} notes`,
|
||||
type: ToastType.Error,
|
||||
message: 'Could not export notes',
|
||||
})
|
||||
}
|
||||
}, [application, notes])
|
||||
|
||||
const exportSelectedItems = useCallback(() => {
|
||||
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
|
||||
|
||||
if (hasSuperNote) {
|
||||
setShowExportSuperModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
downloadSelectedItems().catch(console.error)
|
||||
}, [downloadSelectedItems, notes])
|
||||
|
||||
const closeMenuAndToggleNotesList = useCallback(() => {
|
||||
const isMobileScreen = matchMedia(MutuallyExclusiveMediaQueryBreakpoints.sm).matches
|
||||
if (isMobileScreen) {
|
||||
@@ -199,9 +200,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
[application],
|
||||
)
|
||||
|
||||
const superExportButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [isSuperExportMenuOpen, setIsSuperExportMenuOpen] = useState(false)
|
||||
|
||||
const unauthorized = notes.some((note) => !application.isAuthorizedToRenderItem(note))
|
||||
if (unauthorized) {
|
||||
return <ProtectedUnauthorizedLabel />
|
||||
@@ -224,8 +222,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const isOnlySuperNoteSelected = notes.length === 1 && notes[0].noteType === NoteType.Super
|
||||
|
||||
return (
|
||||
<>
|
||||
{notes.length === 1 && (
|
||||
@@ -342,77 +338,35 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isOnlySuperNoteSelected ? (
|
||||
<>
|
||||
<MenuItem
|
||||
ref={superExportButtonRef}
|
||||
onClick={() => {
|
||||
setIsSuperExportMenuOpen((open) => !open)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</div>
|
||||
<Icon type="chevron-right" className="ml-auto text-neutral" />
|
||||
</MenuItem>
|
||||
<Popover
|
||||
title="Export note"
|
||||
side="left"
|
||||
align="start"
|
||||
open={isSuperExportMenuOpen}
|
||||
anchorElement={superExportButtonRef.current}
|
||||
togglePopover={() => {
|
||||
setIsSuperExportMenuOpen(!isSuperExportMenuOpen)
|
||||
}}
|
||||
className="md:py-1"
|
||||
>
|
||||
<Menu a11yLabel={'Super note export menu'}>
|
||||
<MenuSection>
|
||||
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_JSON, notes[0].title)}>
|
||||
<Icon type="code" className={iconClass} />
|
||||
Export as JSON
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_MARKDOWN, notes[0].title)}>
|
||||
<Icon type="markdown" className={iconClass} />
|
||||
Export as Markdown
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_HTML, notes[0].title)}>
|
||||
<Icon type="rich-text" className={iconClass} />
|
||||
Export as HTML
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
</Menu>
|
||||
</Popover>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (application.isNativeMobileWeb()) {
|
||||
void shareSelectedNotes(application, notes)
|
||||
} else {
|
||||
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
|
||||
|
||||
if (hasSuperNote) {
|
||||
setShowExportSuperModal(true)
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (application.isNativeMobileWeb()) {
|
||||
createNoteExport(application, notes)
|
||||
.then((result) => {
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
void downloadSelectedItems()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
|
||||
{application.platform === Platform.Android ? 'Share' : 'Export'}
|
||||
</MenuItem>
|
||||
{application.platform === Platform.Android && (
|
||||
<MenuItem onClick={() => downloadSelectedNotesOnAndroid(application, notes)}>
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
const { blob, fileName } = result
|
||||
|
||||
shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), blob, fileName).catch(
|
||||
console.error,
|
||||
)
|
||||
})
|
||||
.catch(console.error)
|
||||
} else {
|
||||
exportSelectedItems()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
|
||||
{application.platform === Platform.Android ? 'Share' : 'Export'}
|
||||
</MenuItem>
|
||||
{application.platform === Platform.Android && (
|
||||
<MenuItem onClick={exportSelectedItems}>
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={duplicateSelectedItems} disabled={areSomeNotesInReadonlySharedVault}>
|
||||
<Icon type="copy" className={iconClass} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { PrefKey, PrefValue } from '@standardnotes/snjs'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import Dropdown from '../Dropdown/Dropdown'
|
||||
import Modal from '../Modal/Modal'
|
||||
import usePreference from '@/Hooks/usePreference'
|
||||
import RadioButtonGroup from '../RadioButtonGroup/RadioButtonGroup'
|
||||
import { useEffect } from 'react'
|
||||
import Switch from '../Switch/Switch'
|
||||
|
||||
type Props = {
|
||||
exportNotes: () => void
|
||||
@@ -12,6 +14,17 @@ type Props = {
|
||||
const SuperExportModal = ({ exportNotes, close }: Props) => {
|
||||
const application = useApplication()
|
||||
const superNoteExportFormat = usePreference(PrefKey.SuperNoteExportFormat)
|
||||
const superNoteExportEmbedBehavior = usePreference(PrefKey.SuperNoteExportEmbedBehavior)
|
||||
const superNoteExportUseMDFrontmatter = usePreference(PrefKey.SuperNoteExportUseMDFrontmatter)
|
||||
|
||||
useEffect(() => {
|
||||
if (superNoteExportFormat === 'json' && superNoteExportEmbedBehavior === 'separate') {
|
||||
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'reference')
|
||||
}
|
||||
if (superNoteExportFormat === 'md' && superNoteExportEmbedBehavior === 'reference') {
|
||||
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'separate')
|
||||
}
|
||||
}, [application, superNoteExportEmbedBehavior, superNoteExportFormat])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -28,20 +41,21 @@ const SuperExportModal = ({ exportNotes, close }: Props) => {
|
||||
{
|
||||
label: 'Export',
|
||||
type: 'primary',
|
||||
onClick: exportNotes,
|
||||
onClick: () => {
|
||||
close()
|
||||
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
|
||||
label="Super notes export format"
|
||||
<div className="mb-2">
|
||||
<div className="mb-3 text-base">We detected your selection includes Super notes.</div>
|
||||
<div className="mb-1">What format do you want to export them in?</div>
|
||||
<RadioButtonGroup
|
||||
items={[
|
||||
{ label: 'Keep as Super', value: 'json' },
|
||||
{ label: 'Markdown', value: 'md' },
|
||||
{ label: 'Super (.json)', value: 'json' },
|
||||
{ label: 'Markdown (.md)', value: 'md' },
|
||||
{ label: 'HTML', value: 'html' },
|
||||
]}
|
||||
value={superNoteExportFormat}
|
||||
@@ -52,12 +66,47 @@ const SuperExportModal = ({ exportNotes, close }: Props) => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{superNoteExportFormat === 'md' && (
|
||||
<div className="mt-2 text-xs text-passive-0">
|
||||
Note that conversion to Markdown is not lossless. Some features like collapsible blocks and formatting like
|
||||
superscript/subscript may not be correctly converted.
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{superNoteExportFormat === 'md' && (
|
||||
<div className="mt-4">
|
||||
<Switch
|
||||
checked={superNoteExportUseMDFrontmatter}
|
||||
onChange={(checked) => {
|
||||
void application.setPreference(
|
||||
PrefKey.SuperNoteExportUseMDFrontmatter,
|
||||
checked as PrefValue[PrefKey.SuperNoteExportUseMDFrontmatter],
|
||||
)
|
||||
}}
|
||||
className="!flex items-center"
|
||||
>
|
||||
<span className="ml-2">Export with frontmatter</span>
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
{superNoteExportFormat !== 'json' && (
|
||||
<div className="mb-2 mt-4">
|
||||
<div className="mb-1">How do you want embedded files to be handled?</div>
|
||||
<RadioButtonGroup
|
||||
items={[
|
||||
{ label: 'Inline', value: 'inline' },
|
||||
{ label: 'Export separately', value: 'separate' },
|
||||
].concat(superNoteExportFormat !== 'md' ? [{ label: 'Keep as reference', value: 'reference' }] : [])}
|
||||
value={superNoteExportEmbedBehavior}
|
||||
onChange={(value) => {
|
||||
void application.setPreference(
|
||||
PrefKey.SuperNoteExportEmbedBehavior,
|
||||
value as PrefValue[PrefKey.SuperNoteExportEmbedBehavior],
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user