From 506a1e83f108ecb1f9a2d2e0acb55c4f21be50ae Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Wed, 1 Feb 2023 00:47:28 +0530 Subject: [PATCH] feat: Allow exporting multiple Super notes and select what format to export them to (#2191) --- ...adless-npm-0.7.6-17a55bb1d5-9dd9cacba2.zip | Bin 0 -> 4969 bytes .../src/Domain/Syncable/UserPrefs/PrefKey.ts | 2 + .../src/Domain/Utilities/Icon/IconType.ts | 1 + packages/web/package.json | 3 + .../Components/Icon/IconNameToSvgMapping.tsx | 1 + .../NoteView/SuperEditor/SuperEditor.tsx | 3 +- .../NoteView/SuperEditor/SuperEditorNodes.ts | 4 + .../NoteView/SuperEditor/SuperNoteExporter.ts | 46 +++++++++++ .../Components/NotesOptions/NotesOptions.tsx | 30 ++++++- .../NotesOptions/SuperExportModal.tsx | 76 ++++++++++++++++++ .../src/javascripts/Utils/NoteExportUtils.ts | 18 ++++- yarn.lock | 10 +++ 12 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 .yarn/cache/@lexical-headless-npm-0.7.6-17a55bb1d5-9dd9cacba2.zip create mode 100644 packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditorNodes.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperNoteExporter.ts create mode 100644 packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx diff --git a/.yarn/cache/@lexical-headless-npm-0.7.6-17a55bb1d5-9dd9cacba2.zip b/.yarn/cache/@lexical-headless-npm-0.7.6-17a55bb1d5-9dd9cacba2.zip new file mode 100644 index 0000000000000000000000000000000000000000..847caf5a7a151cd21b354876dd6af6e557182c0f GIT binary patch literal 4969 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 literal 0 HcmV?d00001 diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index 0e4a746dd..06a31b055 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -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> + [PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html' } diff --git a/packages/models/src/Domain/Utilities/Icon/IconType.ts b/packages/models/src/Domain/Utilities/Icon/IconType.ts index 0fd96f011..aeae82224 100644 --- a/packages/models/src/Domain/Utilities/Icon/IconType.ts +++ b/packages/models/src/Domain/Utilities/Icon/IconType.ts @@ -15,6 +15,7 @@ export type IconType = | 'arrow-right' | 'arrow-up' | 'arrows-horizontal' + | 'arrows-vertical' | 'arrows-sort-down' | 'arrows-sort-up' | 'asterisk' diff --git a/packages/web/package.json b/packages/web/package.json index 498756eb9..b6e2b9425 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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" } } diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index 5c4d522f1..a1be869a0 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -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, diff --git a/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx b/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx index ccfacd59e..4c8a58b56 100644 --- a/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx @@ -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 = ({ { + 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 +} diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index 34786ca77..b6a89b094 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -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 && } )} - {notes[0].noteType !== NoteType.Super && ( + {!isOnlySuperNoteSelected && ( <> { - 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() + } }} > @@ -374,6 +392,10 @@ const NotesOptions = ({ ) : null} + + + + ) } diff --git a/packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx b/packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx new file mode 100644 index 000000000..1ab908eb2 --- /dev/null +++ b/packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx @@ -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( + () => application.getPreference(PrefKey.SuperNoteExportFormat) || 'json', + ) + useEffect(() => { + return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => { + setSuperNoteExportFormat(application.getPreference(PrefKey.SuperNoteExportFormat) || 'json') + }) + }, [application, superNoteExportFormat]) + + return ( + +
+
+ We detected your selection includes Super notes. How do you want to export them? +
+ { + void application.setPreference( + PrefKey.SuperNoteExportFormat, + value as PrefValue[PrefKey.SuperNoteExportFormat], + ) + }} + portal={false} + /> +
+
+ 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. +
+
+ ) +} + +export default SuperExportModal diff --git a/packages/web/src/javascripts/Utils/NoteExportUtils.ts b/packages/web/src/javascripts/Utils/NoteExportUtils.ts index 6e0cfce70..a56919e30 100644 --- a/packages/web/src/javascripts/Utils/NoteExportUtils.ts +++ b/packages/web/src/javascripts/Utils/NoteExportUtils.ts @@ -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 diff --git a/yarn.lock b/yarn.lock index 146deeb28..e3e62a4fe 100644 --- a/yarn.lock +++ b/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