feat: Markdown, HTML & JSON export options for super notes (#2054)

This commit is contained in:
Aman Harwara
2022-11-25 13:58:44 +05:30
committed by GitHub
parent ff1d71c2f8
commit dcc8cfbe45
9 changed files with 268 additions and 61 deletions

View File

@@ -0,0 +1,113 @@
import { useApplication } from '@/Components/ApplicationView/ApplicationProvider'
import { downloadBlobOnAndroid } from '@/NativeMobileWeb/DownloadBlobOnAndroid'
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { Platform } from '@standardnotes/snjs'
import {
sanitizeFileName,
SUPER_EXPORT_HTML,
SUPER_EXPORT_JSON,
SUPER_EXPORT_MARKDOWN,
} from '@standardnotes/ui-services'
import { useCallback, useEffect } from 'react'
import { $convertToMarkdownString } from '@lexical/markdown'
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
import { $generateHtmlFromNodes } from '@lexical/html'
import { useCommandService } from '@/Components/ApplicationView/CommandProvider'
export const ExportPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const commandService = useCommandService()
const downloadData = useCallback(
(data: Blob, fileName: string) => {
if (!application.isNativeMobileWeb()) {
application.getArchiveService().downloadData(data, fileName)
return
}
if (application.platform === Platform.Android) {
downloadBlobOnAndroid(application, data, fileName).catch(console.error)
} else {
shareBlobOnMobile(application, data, fileName).catch(console.error)
}
},
[application],
)
const exportJson = useCallback(
(title: string) => {
const content = JSON.stringify(editor.toJSON())
const blob = new Blob([content], { type: 'application/json' })
downloadData(blob, `${sanitizeFileName(title)}.json`)
},
[downloadData, editor],
)
const exportMarkdown = useCallback(
(title: string) => {
editor.getEditorState().read(() => {
const content = $convertToMarkdownString(MarkdownTransformers)
const blob = new Blob([content], { type: 'text/markdown' })
downloadData(blob, `${sanitizeFileName(title)}.md`)
})
},
[downloadData, editor],
)
const exportHtml = useCallback(
(title: string) => {
editor.getEditorState().read(() => {
const content = $generateHtmlFromNodes(editor)
const blob = new Blob([content], { type: 'text/html' })
downloadData(blob, `${sanitizeFileName(title)}.html`)
})
},
[downloadData, editor],
)
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_JSON,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportJson(title)
},
})
}, [commandService, exportJson])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_MARKDOWN,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportMarkdown(title)
},
})
}, [commandService, exportMarkdown])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_HTML,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportHtml(title)
},
})
}, [commandService, exportHtml])
return null
}

View File

@@ -26,6 +26,7 @@ import { PrefDefaults } from '@/Constants/PrefDefaults'
import { useCommandService } from '@/Components/ApplicationView/CommandProvider'
import { SUPER_SHOW_MARKDOWN_PREVIEW } from '@standardnotes/ui-services'
import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview'
import { ExportPlugin } from './Plugins/ExportPlugin/ExportPlugin'
const NotePreviewCharLimit = 160
@@ -155,6 +156,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
/>
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
<ExportPlugin />
</BlocksEditor>
</BlocksEditorComposer>
</FilesControllerProvider>

View File

@@ -0,0 +1,13 @@
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
import { classNames } from '@standardnotes/utils'
export const menuItemTextClassNames = 'text-mobile-menu-item md:text-tablet-menu-item lg:text-menu-item'
export const menuItemClassNames = classNames(
menuItemTextClassNames,
'flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none',
)
export const menuItemSwitchClassNames = classNames(menuItemTextClassNames, menuItemClassNames, 'justify-between')
export const iconClass = `text-neutral mr-2 ${MenuItemIconSize}`

View File

@@ -30,9 +30,10 @@ import { SpellcheckOptions } from './SpellcheckOptions'
import { NoteSizeWarning } from './NoteSizeWarning'
import { DeletePermanentlyButton } from './DeletePermanentlyButton'
import { useCommandService } from '../ApplicationView/CommandProvider'
import { menuItemClassNames, menuItemSwitchClassNames, iconClass } from './ClassNames'
import SuperNoteOptions from './SuperNoteOptions'
const iconSize = MenuItemIconSize
export const iconClass = `text-neutral mr-2 ${iconSize}`
const iconClassDanger = `text-danger mr-2 ${iconSize}`
const iconClassWarning = `text-warning mr-2 ${iconSize}`
const iconClassSuccess = `text-success mr-2 ${iconSize}`
@@ -159,22 +160,13 @@ const NotesOptions = ({
return <ProtectedUnauthorizedLabel />
}
const textClassNames = 'text-mobile-menu-item md:text-tablet-menu-item lg:text-menu-item'
const defaultClassNames = classNames(
textClassNames,
'flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none',
)
const switchClassNames = classNames(textClassNames, defaultClassNames, 'justify-between')
const firstItemClass = 'pt-4'
return (
<>
{notes.length === 1 && (
<>
<button className={classNames(defaultClassNames, firstItemClass)} onClick={openRevisionHistoryModal}>
<button className={classNames(menuItemClassNames, firstItemClass)} onClick={openRevisionHistoryModal}>
<div className="flex w-full items-center justify-between">
<span className="flex">
<Icon type="history" className={iconClass} />
@@ -187,7 +179,7 @@ const NotesOptions = ({
</>
)}
<button
className={switchClassNames}
className={menuItemSwitchClassNames}
onClick={() => {
notesController.setLockSelectedNotes(!locked)
}}
@@ -199,7 +191,7 @@ const NotesOptions = ({
<Switch className="px-0" checked={locked} />
</button>
<button
className={switchClassNames}
className={menuItemSwitchClassNames}
onClick={() => {
notesController.setHideSelectedNotePreviews(!hidePreviews)
}}
@@ -211,7 +203,7 @@ const NotesOptions = ({
<Switch className="px-0" checked={!hidePreviews} />
</button>
<button
className={switchClassNames}
className={menuItemSwitchClassNames}
onClick={() => {
notesController.setProtectSelectedNotes(!protect).catch(console.error)
}}
@@ -227,7 +219,7 @@ const NotesOptions = ({
<HorizontalSeparator classes="my-2" />
<ChangeEditorOption
iconClassName={iconClass}
className={switchClassNames}
className={menuItemSwitchClassNames}
application={application}
note={notes[0]}
/>
@@ -237,14 +229,14 @@ const NotesOptions = ({
{navigationController.tagsCount > 0 && (
<AddTagOption
iconClassName={iconClass}
className={switchClassNames}
className={menuItemSwitchClassNames}
navigationController={navigationController}
notesController={notesController}
/>
)}
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={() => {
notesController.setStarSelectedNotes(!starred)
}}
@@ -260,7 +252,7 @@ const NotesOptions = ({
{unpinned && (
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={() => {
notesController.setPinSelectedNotes(true)
}}
@@ -276,7 +268,7 @@ const NotesOptions = ({
)}
{pinned && (
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={() => {
notesController.setPinSelectedNotes(false)
}}
@@ -290,28 +282,34 @@ const NotesOptions = ({
</div>
</button>
)}
<button
className={defaultClassNames}
onClick={() => {
application.isNativeMobileWeb() ? void shareSelectedNotes(application, notes) : void downloadSelectedItems()
}}
>
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
{application.platform === Platform.Android ? 'Share' : 'Export'}
</button>
{application.platform === Platform.Android && (
<button className={defaultClassNames} onClick={() => downloadSelectedNotesOnAndroid(application, notes)}>
<Icon type="download" className={iconClass} />
Export
</button>
{notes[0].noteType !== NoteType.Super && (
<>
<button
className={menuItemClassNames}
onClick={() => {
application.isNativeMobileWeb()
? void shareSelectedNotes(application, notes)
: void downloadSelectedItems()
}}
>
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
{application.platform === Platform.Android ? 'Share' : 'Export'}
</button>
{application.platform === Platform.Android && (
<button className={menuItemClassNames} onClick={() => downloadSelectedNotesOnAndroid(application, notes)}>
<Icon type="download" className={iconClass} />
Export
</button>
)}
</>
)}
<button className={defaultClassNames} onClick={duplicateSelectedItems}>
<button className={menuItemClassNames} onClick={duplicateSelectedItems}>
<Icon type="copy" className={iconClass} />
Duplicate
</button>
{unarchived && (
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={async () => {
await notesController.setArchiveSelectedNotes(true).catch(console.error)
closeMenuAndToggleNotesList()
@@ -323,7 +321,7 @@ const NotesOptions = ({
)}
{archived && (
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={async () => {
await notesController.setArchiveSelectedNotes(false).catch(console.error)
closeMenuAndToggleNotesList()
@@ -343,7 +341,7 @@ const NotesOptions = ({
/>
) : (
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={async () => {
await notesController.setTrashSelectedNotes(true)
closeMenuAndToggleNotesList()
@@ -356,7 +354,7 @@ const NotesOptions = ({
{trashed && (
<>
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={async () => {
await notesController.setTrashSelectedNotes(false)
closeMenuAndToggleNotesList()
@@ -372,7 +370,7 @@ const NotesOptions = ({
}}
/>
<button
className={defaultClassNames}
className={menuItemClassNames}
onClick={async () => {
await notesController.emptyTrash()
closeMenuAndToggleNotesList()
@@ -392,27 +390,17 @@ const NotesOptions = ({
{notes.length === 1 ? (
<>
{notes[0].noteType === NoteType.Super && (
<>
<HorizontalSeparator classes="my-2" />
<div className="my-1 px-3 text-base font-semibold uppercase text-text lg:text-xs">Super</div>
<button className={defaultClassNames} onClick={enableSuperMarkdownPreview}>
<div className="flex w-full items-center justify-between">
<span className="flex">
<Icon type="markdown" className={iconClass} />
Show Markdown
</span>
{markdownShortcut && <KeyboardShortcutIndicator className={''} shortcut={markdownShortcut} />}
</div>
</button>
</>
<SuperNoteOptions
note={notes[0]}
markdownShortcut={markdownShortcut}
enableSuperMarkdownPreview={enableSuperMarkdownPreview}
/>
)}
<HorizontalSeparator classes="my-2" />
<ListedActionsOption
iconClassName={iconClass}
className={switchClassNames}
className={menuItemSwitchClassNames}
application={application}
note={notes[0]}
/>
@@ -420,7 +408,7 @@ const NotesOptions = ({
<HorizontalSeparator classes="my-2" />
<SpellcheckOptions
className={switchClassNames}
className={menuItemSwitchClassNames}
editorForNote={editorForNote}
notesController={notesController}
note={notes[0]}

View File

@@ -3,7 +3,7 @@ import Switch from '@/Components/Switch/Switch'
import { FunctionComponent } from 'react'
import { SNComponent, SNNote } from '@standardnotes/snjs'
import { NotesController } from '@/Controllers/NotesController/NotesController'
import { iconClass } from './NotesOptions'
import { iconClass } from './ClassNames'
export const SpellcheckOptions: FunctionComponent<{
editorForNote: SNComponent | undefined

View File

@@ -0,0 +1,88 @@
import { SNNote } from '@standardnotes/snjs'
import {
PlatformedKeyboardShortcut,
SUPER_EXPORT_HTML,
SUPER_EXPORT_JSON,
SUPER_EXPORT_MARKDOWN,
} from '@standardnotes/ui-services'
import { useRef, useState } from 'react'
import { useCommandService } from '../ApplicationView/CommandProvider'
import Icon from '../Icon/Icon'
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
import Menu from '../Menu/Menu'
import MenuItem from '../Menu/MenuItem'
import Popover from '../Popover/Popover'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { iconClass, menuItemClassNames, menuItemSwitchClassNames } from './ClassNames'
type Props = {
note: SNNote
markdownShortcut?: PlatformedKeyboardShortcut
enableSuperMarkdownPreview: () => void
}
const SuperNoteOptions = ({ note, markdownShortcut, enableSuperMarkdownPreview }: Props) => {
const commandService = useCommandService()
const exportButtonRef = useRef<HTMLButtonElement>(null)
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false)
return (
<>
<HorizontalSeparator classes="my-2" />
<div className="my-1 px-3 text-base font-semibold uppercase text-text lg:text-xs">Super</div>
<button className={menuItemClassNames} onClick={enableSuperMarkdownPreview}>
<div className="flex w-full items-center justify-between">
<span className="flex">
<Icon type="markdown" className={iconClass} />
Show Markdown
</span>
{markdownShortcut && <KeyboardShortcutIndicator shortcut={markdownShortcut} />}
</div>
</button>
<button
ref={exportButtonRef}
className={menuItemSwitchClassNames}
onClick={() => {
setIsExportMenuOpen((open) => !open)
}}
>
<div className="flex items-center">
<Icon type="download" className={iconClass} />
Export
</div>
<Icon type="chevron-right" className="text-neutral" />
</button>
<Popover
side="left"
align="start"
open={isExportMenuOpen}
anchorElement={exportButtonRef.current}
togglePopover={() => {
setIsExportMenuOpen(!isExportMenuOpen)
}}
className="py-1"
>
<Menu a11yLabel={'Super note export menu'} isOpen={isExportMenuOpen}>
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_JSON, note.title)}>
<Icon type="code" className={iconClass} />
Export as JSON
</MenuItem>
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_MARKDOWN, note.title)}>
<Icon type="markdown" className={iconClass} />
Export as Markdown
</MenuItem>
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_HTML, note.title)}>
<Icon type="rich-text" className={iconClass} />
Export as HTML
</MenuItem>
</Menu>
</Popover>
</>
)
}
export default SuperNoteOptions