From 991de1ddf5dbb1016348d603d244699c676d9b5f Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Tue, 31 Oct 2023 01:19:04 +0530 Subject: [PATCH] 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) --- .../Service/SuperConverterServiceInterface.ts | 3 +- .../Domain/Syncable/UserPrefs/PrefDefaults.ts | 2 + .../src/Domain/Syncable/UserPrefs/PrefKey.ts | 4 + .../src/Domain/Backups/FilesBackupService.ts | 2 +- .../ui-services/src/Archive/ArchiveManager.ts | 5 + .../EvernoteConverter.spec.ts | 3 +- .../GoogleKeepConverter.spec.ts | 3 +- .../src/Keyboard/KeyboardCommands.ts | 1 - .../NoteConflictResolutionModal/DiffView.tsx | 36 +-- .../Components/NotesOptions/NotesOptions.tsx | 162 +++++--------- .../NotesOptions/SuperExportModal.tsx | 79 +++++-- .../SuperEditor/Lexical/Nodes/AllNodes.ts | 5 +- .../Lexical/Nodes/FileExportNode.tsx | 93 ++++++++ .../Plugins/ExportPlugin/ExportPlugin.ts | 139 ------------ .../InlineFilePlugin/InlineFileNode.tsx | 4 + .../Components/SuperEditor/SuperEditor.tsx | 2 - .../SuperEditor/SuperNoteConverter.tsx | 27 ++- .../Tools/HeadlessSuperConverter.tsx | 120 +++++++--- .../javascripts/Components/Switch/Switch.tsx | 39 ++-- .../Controllers/FilesController.ts | 14 ++ .../DownloadSelectedNotesOnAndroid.tsx | 32 --- .../NativeMobileWeb/ShareSelectedNotes.tsx | 36 --- .../src/javascripts/Utils/NoteExportUtils.ts | 210 +++++++++++++++++- 23 files changed, 605 insertions(+), 416 deletions(-) create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/FileExportNode.tsx delete mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts delete mode 100644 packages/web/src/javascripts/NativeMobileWeb/DownloadSelectedNotesOnAndroid.tsx delete mode 100644 packages/web/src/javascripts/NativeMobileWeb/ShareSelectedNotes.tsx diff --git a/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts b/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts index 9e826f67b..8c9203505 100644 --- a/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts +++ b/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts @@ -1,5 +1,6 @@ export interface SuperConverterServiceInterface { isValidSuperString(superString: string): boolean - convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => string + convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => Promise convertOtherFormatToSuperString: (otherFormatString: string, fromFormat: 'txt' | 'md' | 'html' | 'json') => string + getEmbeddedFileIDsFromSuperString(superString: string): string[] } diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index 54055705a..3d91d2a88 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -40,6 +40,8 @@ export const PrefDefaults = { [PrefKey.ClipperDefaultTagUuid]: undefined, [PrefKey.DefaultEditorIdentifier]: NativeFeatureIdentifier.TYPES.PlainEditor, [PrefKey.SuperNoteExportFormat]: 'json', + [PrefKey.SuperNoteExportEmbedBehavior]: 'reference', + [PrefKey.SuperNoteExportUseMDFrontmatter]: true, [PrefKey.SystemViewPreferences]: {}, [PrefKey.AuthenticatorNames]: '', [PrefKey.ComponentPreferences]: {}, diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index a29c26c0e..6fc899a57 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -41,6 +41,8 @@ export enum PrefKey { ClipperDefaultTagUuid = 'clipperDefaultTagUuid', SystemViewPreferences = 'systemViewPreferences', SuperNoteExportFormat = 'superNoteExportFormat', + SuperNoteExportEmbedBehavior = 'superNoteExportEmbedBehavior', + SuperNoteExportUseMDFrontmatter = 'superNoteExportUseMDFrontmatter', AuthenticatorNames = 'authenticatorNames', PaneGesturesEnabled = 'paneGesturesEnabled', ComponentPreferences = 'componentPreferences', @@ -83,6 +85,8 @@ export type PrefValue = { [PrefKey.ClipperDefaultTagUuid]: string | undefined [PrefKey.SystemViewPreferences]: Partial> [PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html' + [PrefKey.SuperNoteExportEmbedBehavior]: 'reference' | 'inline' | 'separate' + [PrefKey.SuperNoteExportUseMDFrontmatter]: boolean [PrefKey.AuthenticatorNames]: string [PrefKey.PaneGesturesEnabled]: boolean [PrefKey.ComponentPreferences]: AllComponentPreferences diff --git a/packages/services/src/Domain/Backups/FilesBackupService.ts b/packages/services/src/Domain/Backups/FilesBackupService.ts index 428ea7e58..07d8117fa 100644 --- a/packages/services/src/Domain/Backups/FilesBackupService.ts +++ b/packages/services/src/Domain/Backups/FilesBackupService.ts @@ -462,7 +462,7 @@ export class FilesBackupService const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag)) const text = note.noteType === NoteType.Super - ? this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md') + ? await this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md') : note.text await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, text) } diff --git a/packages/ui-services/src/Archive/ArchiveManager.ts b/packages/ui-services/src/Archive/ArchiveManager.ts index 62848a46b..e24cc89dd 100644 --- a/packages/ui-services/src/Archive/ArchiveManager.ts +++ b/packages/ui-services/src/Archive/ArchiveManager.ts @@ -19,6 +19,11 @@ function zippableFileName(name: string, suffix = '', format = 'txt'): string { return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd } +export function parseAndCreateZippableFileName(name: string) { + const { name: parsedName, ext } = parseFileName(name) + return zippableFileName(parsedName, '', ext) +} + type ZippableData = { name: string content: Blob diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts index 732f0602f..83151f43c 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts @@ -31,7 +31,8 @@ describe('EvernoteConverter', () => { const superConverterService: SuperConverterServiceInterface = { isValidSuperString: () => true, convertOtherFormatToSuperString: (data: string) => data, - convertSuperStringToOtherFormat: (data: string) => data, + convertSuperStringToOtherFormat: async (data: string) => data, + getEmbeddedFileIDsFromSuperString: () => [], } const generateUuid = new GenerateUuid(crypto) diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts index 4cc57aa1e..c0ca6324b 100644 --- a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts @@ -16,7 +16,8 @@ describe('GoogleKeepConverter', () => { const superConverterService: SuperConverterServiceInterface = { isValidSuperString: () => true, convertOtherFormatToSuperString: (data: string) => data, - convertSuperStringToOtherFormat: (data: string) => data, + convertSuperStringToOtherFormat: async (data: string) => data, + getEmbeddedFileIDsFromSuperString: () => [], } const generateUuid = new GenerateUuid(crypto) diff --git a/packages/ui-services/src/Keyboard/KeyboardCommands.ts b/packages/ui-services/src/Keyboard/KeyboardCommands.ts index b796f8108..356782c1e 100644 --- a/packages/ui-services/src/Keyboard/KeyboardCommands.ts +++ b/packages/ui-services/src/Keyboard/KeyboardCommands.ts @@ -36,7 +36,6 @@ export const SUPER_SHOW_MARKDOWN_PREVIEW = createKeyboardCommand('SUPER_SHOW_MAR export const SUPER_EXPORT_JSON = createKeyboardCommand('SUPER_EXPORT_JSON') export const SUPER_EXPORT_MARKDOWN = createKeyboardCommand('SUPER_EXPORT_MARKDOWN') -export const SUPER_EXPORT_HTML = createKeyboardCommand('SUPER_EXPORT_HTML') export const OPEN_PREFERENCES_COMMAND = createKeyboardCommand('OPEN_PREFERENCES_COMMAND') export const CHANGE_EDITOR_WIDTH_COMMAND = createKeyboardCommand('CHANGE_EDITOR_WIDTH_COMMAND') diff --git a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx index 6580eed5f..88d461b4b 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx @@ -27,25 +27,29 @@ export const DiffView = ({ const [textDiff, setTextDiff] = useState([]) useEffect(() => { - const firstNote = selectedNotes[0] - const firstTitle = firstNote.title - const firstText = - firstNote.noteType === NoteType.Super && convertSuperToMarkdown - ? new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md') - : firstNote.text + const setDiffs = async () => { + const firstNote = selectedNotes[0] + const firstTitle = firstNote.title + const firstText = + firstNote.noteType === NoteType.Super && convertSuperToMarkdown + ? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md') + : firstNote.text - const secondNote = selectedNotes[1] - const secondTitle = secondNote.title - const secondText = - secondNote.noteType === NoteType.Super && convertSuperToMarkdown - ? new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md') - : secondNote.text + const secondNote = selectedNotes[1] + const secondTitle = secondNote.title + const secondText = + secondNote.noteType === NoteType.Super && convertSuperToMarkdown + ? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md') + : secondNote.text - const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true) - const textDiff = fastdiff(firstText, secondText, undefined, true) + const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true) + const textDiff = fastdiff(firstText, secondText, undefined, true) - setTitleDiff(titleDiff) - setTextDiff(textDiff) + setTitleDiff(titleDiff) + setTextDiff(textDiff) + } + + setDiffs().catch(console.error) }, [convertSuperToMarkdown, selectedNotes]) const [preElement, setPreElement] = useState(null) diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index d833574bf..8d96ca118 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -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(null) - const [isSuperExportMenuOpen, setIsSuperExportMenuOpen] = useState(false) - const unauthorized = notes.some((note) => !application.isAuthorizedToRenderItem(note)) if (unauthorized) { return @@ -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 && } )} - {isOnlySuperNoteSelected ? ( - <> - { - setIsSuperExportMenuOpen((open) => !open) - }} - > -
- - Export -
- -
- { - setIsSuperExportMenuOpen(!isSuperExportMenuOpen) - }} - className="md:py-1" - > - - - commandService.triggerCommand(SUPER_EXPORT_JSON, notes[0].title)}> - - Export as JSON - - commandService.triggerCommand(SUPER_EXPORT_MARKDOWN, notes[0].title)}> - - Export as Markdown - - commandService.triggerCommand(SUPER_EXPORT_HTML, notes[0].title)}> - - Export as HTML - - - - - - ) : ( - <> - { - if (application.isNativeMobileWeb()) { - void shareSelectedNotes(application, notes) - } else { - const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super) - - if (hasSuperNote) { - setShowExportSuperModal(true) + { + if (application.isNativeMobileWeb()) { + createNoteExport(application, notes) + .then((result) => { + if (!result) { return } - void downloadSelectedItems() - } - }} - > - - {application.platform === Platform.Android ? 'Share' : 'Export'} - - {application.platform === Platform.Android && ( - downloadSelectedNotesOnAndroid(application, notes)}> - - Export - - )} - + const { blob, fileName } = result + + shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), blob, fileName).catch( + console.error, + ) + }) + .catch(console.error) + } else { + exportSelectedItems() + } + }} + > + + {application.platform === Platform.Android ? 'Share' : 'Export'} + + {application.platform === Platform.Android && ( + + + Export + )} diff --git a/packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx b/packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx index cabb6b813..186b5e8c6 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/SuperExportModal.tsx @@ -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 ( { { label: 'Export', type: 'primary', - onClick: exportNotes, + onClick: () => { + close() + exportNotes() + }, mobileSlot: 'right', }, ]} > -
-
- We detected your selection includes Super notes. How do you want to export them? -
- +
We detected your selection includes Super notes.
+
What format do you want to export them in?
+ { ) }} /> + {superNoteExportFormat === 'md' && ( +
+ Note that conversion to Markdown is not lossless. Some features like collapsible blocks and formatting like + superscript/subscript may not be correctly converted. +
+ )}
-
- 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. -
+ {superNoteExportFormat === 'md' && ( +
+ { + void application.setPreference( + PrefKey.SuperNoteExportUseMDFrontmatter, + checked as PrefValue[PrefKey.SuperNoteExportUseMDFrontmatter], + ) + }} + className="!flex items-center" + > + Export with frontmatter + +
+ )} + {superNoteExportFormat !== 'json' && ( +
+
How do you want embedded files to be handled?
+ { + void application.setPreference( + PrefKey.SuperNoteExportEmbedBehavior, + value as PrefValue[PrefKey.SuperNoteExportEmbedBehavior], + ) + }} + /> +
+ )}
) } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/AllNodes.ts b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/AllNodes.ts index d6ed373d2..7d9c36d17 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/AllNodes.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/AllNodes.ts @@ -18,6 +18,7 @@ import { RemoteImageNode } from '../../Plugins/RemoteImagePlugin/RemoteImageNode import { InlineFileNode } from '../../Plugins/InlineFilePlugin/InlineFileNode' import { CreateEditorArgs } from 'lexical' import { ListHTMLExportNode } from '../../Plugins/List/ListHTMLExportNode' +import { FileExportNode } from './FileExportNode' const CommonNodes = [ AutoLinkNode, @@ -46,8 +47,10 @@ const CommonNodes = [ ] export const BlockEditorNodes = [...CommonNodes, ListNode] -export const HTMLExportNodes: CreateEditorArgs['nodes'] = [ + +export const SuperExportNodes: CreateEditorArgs['nodes'] = [ ...CommonNodes, + FileExportNode, ListHTMLExportNode, { replace: ListNode, diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/FileExportNode.tsx b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/FileExportNode.tsx new file mode 100644 index 000000000..4206e836f --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/FileExportNode.tsx @@ -0,0 +1,93 @@ +import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' +import { parseAndCreateZippableFileName } from '@standardnotes/ui-services' +import { DOMExportOutput, Spread } from 'lexical' + +type SerializedFileExportNode = Spread< + { + version: 1 + type: 'file-export' + name: string + mimeType: string + }, + SerializedDecoratorBlockNode +> + +export class FileExportNode extends DecoratorBlockNode { + __name: string + __mimeType: string + + static getType(): string { + return 'file-export' + } + + constructor(name: string, mimeType: string) { + super() + this.__name = name + this.__mimeType = mimeType + } + + static clone(node: FileExportNode): FileExportNode { + return new FileExportNode(node.__name, node.__mimeType) + } + + static importJSON(serializedNode: SerializedFileExportNode): FileExportNode { + const node = new FileExportNode(serializedNode.name, serializedNode.mimeType) + return node + } + + exportJSON(): SerializedFileExportNode { + return { + ...super.exportJSON(), + name: this.__name, + mimeType: this.__mimeType, + version: 1, + type: 'file-export', + } + } + + getZippableFileName(): string { + return parseAndCreateZippableFileName(this.__name) + } + + getTextContent(): string { + return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__name}](./${this.getZippableFileName()})` + } + + exportDOM(): DOMExportOutput { + const src = `./${this.getZippableFileName()}` + if (this.__mimeType.startsWith('image/')) { + const img = document.createElement('img') + img.setAttribute('src', src) + return { element: img } + } else if (this.__mimeType.startsWith('audio')) { + const audio = document.createElement('audio') + audio.setAttribute('controls', '') + const source = document.createElement('source') + source.setAttribute('src', src) + source.setAttribute('type', this.__mimeType) + audio.appendChild(source) + return { element: audio } + } else if (this.__mimeType.startsWith('video')) { + const video = document.createElement('video') + video.setAttribute('controls', '') + const source = document.createElement('source') + source.setAttribute('src', src) + source.setAttribute('type', this.__mimeType) + video.appendChild(source) + return { element: video } + } + const object = document.createElement('object') + object.setAttribute('data', src) + object.setAttribute('type', this.__mimeType) + return { element: object } + } + + decorate(): JSX.Element { + // Doesn't need to actually render anything since this is only used for export + return <> + } +} + +export function $createFileExportNode(name: string, mimeType: string): FileExportNode { + return new FileExportNode(name, mimeType) +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts deleted file mode 100644 index 8038c57bf..000000000 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { useApplication } from '@/Components/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, useRef } from 'react' -import { useCommandService } from '@/Components/CommandProvider' -import { HeadlessSuperConverter } from '../../Tools/HeadlessSuperConverter' - -// @ts-expect-error Using inline loaders to load CSS as string -import superEditorCSS from '!css-loader!sass-loader!../../Lexical/Theme/editor.scss' -// @ts-expect-error Using inline loaders to load CSS as string -import snColorsCSS from '!css-loader!sass-loader!@standardnotes/styles/src/Styles/_colors.scss' -// @ts-expect-error Using inline loaders to load CSS as string -import exportOverridesCSS from '!css-loader!sass-loader!../../Lexical/Theme/export-overrides.scss' - -const html = (title: string, content: string) => ` - - - - - ${title} - - - - ${content} - - -` - -export const ExportPlugin = () => { - const application = useApplication() - const [editor] = useLexicalComposerContext() - const commandService = useCommandService() - - const converter = useRef(new HeadlessSuperConverter()) - - const downloadData = useCallback( - (data: Blob, fileName: string) => { - if (!application.isNativeMobileWeb()) { - application.archiveService.downloadData(data, fileName) - return - } - - if (application.platform === Platform.Android) { - downloadBlobOnAndroid(application.mobileDevice, data, fileName).catch(console.error) - } else { - shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), data, fileName).catch( - console.error, - ) - } - }, - [application], - ) - - const exportJson = useCallback( - (title: string) => { - const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'json') - const blob = new Blob([content], { type: 'application/json' }) - downloadData(blob, `${sanitizeFileName(title)}.json`) - }, - [downloadData, editor], - ) - - const exportMarkdown = useCallback( - (title: string) => { - const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'md') - const blob = new Blob([content], { type: 'text/markdown' }) - downloadData(blob, `${sanitizeFileName(title)}.md`) - }, - [downloadData, editor], - ) - - const exportHtml = useCallback( - (title: string) => { - const content = html( - title, - converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'html'), - ) - 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 -} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/InlineFilePlugin/InlineFileNode.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/InlineFilePlugin/InlineFileNode.tsx index c7cc00f34..2c5fea3cf 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/InlineFilePlugin/InlineFileNode.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/InlineFilePlugin/InlineFileNode.tsx @@ -148,6 +148,10 @@ export class InlineFileNode extends DecoratorBlockNode { return { element: object } } + getTextContent(): string { + return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__fileName}](${this.__src})` + } + decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element { const embedBlockTheme = config.theme.embedBlock || {} const className = { diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx index b6db168c2..498a716de 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx @@ -37,7 +37,6 @@ import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin' import { useCommandService } from '@/Components/CommandProvider' import { SUPER_SHOW_MARKDOWN_PREVIEW } from '@standardnotes/ui-services' import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview' -import { ExportPlugin } from './Plugins/ExportPlugin/ExportPlugin' import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMarkdownPlugin/GetMarkdownPlugin' import { useResponsiveEditorFontSize } from '@/Utils/getPlaintextFontSize' import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin' @@ -243,7 +242,6 @@ export const SuperEditor: FunctionComponent = ({ /> - {readonly === undefined && } diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx index 305d4d951..4247f88c5 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx @@ -6,7 +6,7 @@ import { isUIFeatureAnIframeFeature, spaceSeparatedStrings, } from '@standardnotes/snjs' -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useApplication } from '../ApplicationProvider' import IframeFeatureView from '../ComponentView/IframeFeatureView' import Icon from '../Icon/Icon' @@ -52,18 +52,23 @@ const SuperNoteConverter = ({ return 'json' }, [uiFeature]) - const convertedContent = useMemo(() => { - if (note.text.length === 0) { + const [convertedContent, setConvertedContent] = useState('') + + useEffect(() => { + const convertContent = async () => { + if (note.text.length === 0) { + return note.text + } + + try { + return new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format) + } catch (error) { + console.error(error) + } + return note.text } - - try { - return new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format) - } catch (error) { - console.error(error) - } - - return note.text + convertContent().then(setConvertedContent).catch(console.error) }, [format, note]) const componentViewer = useMemo(() => { diff --git a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx index 330db074f..37a01729e 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx @@ -1,6 +1,6 @@ import { createHeadlessEditor } from '@lexical/headless' import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown' -import { SuperConverterServiceInterface } from '@standardnotes/snjs' +import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs' import { $createParagraphNode, $getRoot, @@ -11,13 +11,14 @@ import { ParagraphNode, } from 'lexical' import BlocksEditorTheme from '../Lexical/Theme/Theme' -import { BlockEditorNodes, HTMLExportNodes } from '../Lexical/Nodes/AllNodes' +import { SuperExportNodes } from '../Lexical/Nodes/AllNodes' import { MarkdownTransformers } from '../MarkdownTransformers' import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html' - +import { FileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileNode' +import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode' +import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode' export class HeadlessSuperConverter implements SuperConverterServiceInterface { private editor: LexicalEditor - private htmlExportEditor: LexicalEditor constructor() { this.editor = createHeadlessEditor({ @@ -25,14 +26,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { theme: BlocksEditorTheme, editable: false, onError: (error: Error) => console.error(error), - nodes: [...BlockEditorNodes], - }) - this.htmlExportEditor = createHeadlessEditor({ - namespace: 'BlocksEditor', - theme: BlocksEditorTheme, - editable: false, - onError: (error: Error) => console.error(error), - nodes: HTMLExportNodes, + nodes: SuperExportNodes, }) } @@ -45,34 +39,80 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { } } - convertSuperStringToOtherFormat(superString: string, toFormat: 'txt' | 'md' | 'html' | 'json'): string { + async convertSuperStringToOtherFormat( + superString: string, + toFormat: 'txt' | 'md' | 'html' | 'json', + config?: { + embedBehavior?: PrefValue[PrefKey.SuperNoteExportEmbedBehavior] + getFileItem?: (id: string) => FileItem | undefined + getFileBase64?: (id: string) => Promise + }, + ): Promise { if (superString.length === 0) { return superString } - if (toFormat === 'html') { - this.htmlExportEditor.setEditorState(this.htmlExportEditor.parseEditorState(superString)) + const { embedBehavior, getFileItem, getFileBase64 } = config ?? { embedBehavior: 'reference' } - let content: string | undefined - - this.htmlExportEditor.update( - () => { - content = $generateHtmlFromNodes(this.htmlExportEditor) - }, - { discrete: true }, - ) - - if (typeof content !== 'string') { - throw new Error('Could not export note') - } - - return content + if (embedBehavior === 'separate' && !getFileItem) { + throw new Error('getFileItem must be provided when embedBehavior is "separate"') + } + if (embedBehavior === 'inline' && !getFileItem && !getFileBase64) { + throw new Error('getFileItem and getFileBase64 must be provided when embedBehavior is "inline"') } this.editor.setEditorState(this.editor.parseEditorState(superString)) let content: string | undefined + await new Promise((resolve) => { + this.editor.update( + () => { + if (embedBehavior === 'reference') { + resolve() + return + } + if (!getFileItem) { + resolve() + return + } + const fileNodes = $nodesOfType(FileNode) + Promise.all( + fileNodes.map(async (fileNode) => { + const fileItem = getFileItem(fileNode.getId()) + if (!fileItem) { + return + } + if (embedBehavior === 'inline' && getFileBase64) { + const fileBase64 = await getFileBase64(fileNode.getId()) + if (!fileBase64) { + return + } + this.editor.update( + () => { + const inlineFileNode = $createInlineFileNode(fileBase64, fileItem.mimeType, fileItem.name) + fileNode.replace(inlineFileNode) + }, + { discrete: true }, + ) + } else { + this.editor.update( + () => { + const fileExportNode = $createFileExportNode(fileItem.name, fileItem.mimeType) + fileNode.replace(fileExportNode) + }, + { discrete: true }, + ) + } + }), + ) + .then(() => resolve()) + .catch(console.error) + }, + { discrete: true }, + ) + }) + this.editor.update( () => { switch (toFormat) { @@ -87,6 +127,9 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { content = $convertToMarkdownString(MarkdownTransformers) break } + case 'html': + content = $generateHtmlFromNodes(this.editor) + break case 'json': default: content = superString @@ -183,4 +226,23 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { return JSON.stringify(this.editor.getEditorState()) } + + getEmbeddedFileIDsFromSuperString(superString: string): string[] { + if (superString.length === 0) { + return [] + } + + this.editor.setEditorState(this.editor.parseEditorState(superString)) + + const ids: string[] = [] + + this.editor.getEditorState().read(() => { + const fileNodes = $nodesOfType(FileNode) + fileNodes.forEach((fileNode) => { + ids.push(fileNode.getId()) + }) + }) + + return ids + } } diff --git a/packages/web/src/javascripts/Components/Switch/Switch.tsx b/packages/web/src/javascripts/Components/Switch/Switch.tsx index 3c23c1b6a..5a1a4b177 100644 --- a/packages/web/src/javascripts/Components/Switch/Switch.tsx +++ b/packages/web/src/javascripts/Components/Switch/Switch.tsx @@ -8,6 +8,7 @@ const Switch = ({ disabled = false, tabIndex, forceDesktopStyle, + children, }: { checked: boolean onChange: (checked: boolean) => void @@ -15,20 +16,12 @@ const Switch = ({ disabled?: boolean tabIndex?: number forceDesktopStyle?: boolean + children?: React.ReactNode }) => { const isActive = checked && !disabled return ( -