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 000000000..847caf5a7 Binary files /dev/null and b/.yarn/cache/@lexical-headless-npm-0.7.6-17a55bb1d5-9dd9cacba2.zip differ 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