From a15fc1ed1ddc1f273cc1cd267bd6cf20cc7e8219 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Sat, 25 Feb 2023 11:27:03 +0530 Subject: [PATCH] feat: Added "Image from URL" option and markdown image syntax support to Super. Also allows pasting images from copied web content (#2218) --- packages/desktop/app/index.html | 1 + packages/web/src/javascripts/App.tsx | 4 +- .../SuperEditor/Lexical/Nodes/AllNodes.ts | 2 + .../SuperEditor/MarkdownTransformers.ts | 27 ++++++ .../BlockPickerPlugin/BlockPickerPlugin.tsx | 5 ++ .../BlockPickerPlugin/Options/RemoteImage.tsx | 12 +++ .../Plugins/Blocks/RemoteImage.tsx | 3 + .../SuperEditor/Plugins/Commands.ts | 1 + .../MobileToolbarPlugin.tsx | 5 ++ .../RemoteImageComponent.tsx | 88 ++++++++++++++++++ .../RemoteImagePlugin/RemoteImageNode.tsx | 89 +++++++++++++++++++ .../RemoteImagePlugin/RemoteImagePlugin.tsx | 56 ++++++++++++ .../Components/SuperEditor/SuperEditor.tsx | 2 + .../Controllers/FilesController.ts | 69 ++++++++------ 14 files changed, 333 insertions(+), 31 deletions(-) create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/Options/RemoteImage.tsx create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/RemoteImage.tsx create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageComponent.tsx create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageNode.tsx create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImagePlugin.tsx diff --git a/packages/desktop/app/index.html b/packages/desktop/app/index.html index 1d67af23f..791958ec8 100644 --- a/packages/desktop/app/index.html +++ b/packages/desktop/app/index.html @@ -15,6 +15,7 @@ connect-src * data: blob:; style-src 'unsafe-inline' 'self' http://localhost:* http://127.0.0.1:45653; frame-src * blob:; + img-src * data: blob:; " /> diff --git a/packages/web/src/javascripts/App.tsx b/packages/web/src/javascripts/App.tsx index 85a4019d9..99bd8cd10 100644 --- a/packages/web/src/javascripts/App.tsx +++ b/packages/web/src/javascripts/App.tsx @@ -24,7 +24,7 @@ declare global { } } -import { disableIosTextFieldZoom } from '@/Utils' +import { disableIosTextFieldZoom, getPlatform } from '@/Utils' import { IsWebPlatform, WebAppVersion } from '@/Constants/Version' import { DesktopManagerInterface, Platform, SNLog } from '@standardnotes/snjs' import ApplicationGroupView from './Components/ApplicationGroupView/ApplicationGroupView' @@ -101,7 +101,7 @@ if (IsWebPlatform) { setTimeout(() => { const device = window.reactNativeDevice || new WebDevice(WebAppVersion) - window.platform = device.platform + window.platform = getPlatform(device) startApplication(window.defaultSyncServer, device, window.enabledUnfinishedFeatures, window.websocketUrl).catch( console.error, 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 0724e4dfa..f5c42a44b 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/AllNodes.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Nodes/AllNodes.ts @@ -14,6 +14,7 @@ import { CollapsibleContentNode } from '../../Plugins/CollapsiblePlugin/Collapsi import { CollapsibleTitleNode } from '../../Plugins/CollapsiblePlugin/CollapsibleTitleNode' import { FileNode } from '../../Plugins/EncryptedFilePlugin/Nodes/FileNode' import { BubbleNode } from '../../Plugins/ItemBubblePlugin/Nodes/BubbleNode' +import { RemoteImageNode } from '../../Plugins/RemoteImagePlugin/RemoteImageNode' export const BlockEditorNodes = [ AutoLinkNode, @@ -38,4 +39,5 @@ export const BlockEditorNodes = [ YouTubeNode, FileNode, BubbleNode, + RemoteImageNode, ] diff --git a/packages/web/src/javascripts/Components/SuperEditor/MarkdownTransformers.ts b/packages/web/src/javascripts/Components/SuperEditor/MarkdownTransformers.ts index be1bc5d6c..66d10e698 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/MarkdownTransformers.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/MarkdownTransformers.ts @@ -4,6 +4,7 @@ import { ElementTransformer, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS, + TextMatchTransformer, } from '@lexical/markdown' import { @@ -12,6 +13,11 @@ import { $isHorizontalRuleNode, } from '@lexical/react/LexicalHorizontalRuleNode' import { LexicalNode } from 'lexical' +import { + $createRemoteImageNode, + $isRemoteImageNode, + RemoteImageNode, +} from './Plugins/RemoteImagePlugin/RemoteImageNode' const HorizontalRule: ElementTransformer = { dependencies: [HorizontalRuleNode], @@ -33,8 +39,29 @@ const HorizontalRule: ElementTransformer = { type: 'element', } +const IMAGE: TextMatchTransformer = { + dependencies: [RemoteImageNode], + export: (node) => { + if (!$isRemoteImageNode(node)) { + return null + } + + return `![${node.__alt ? node.__alt : 'image'}](${node.__src})` + }, + importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/, + regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/, + replace: (textNode, match) => { + const [, alt, src] = match + const imageNode = $createRemoteImageNode(src, alt) + textNode.replace(imageNode) + }, + trigger: ')', + type: 'text-match', +} + export const MarkdownTransformers = [ CHECK_LIST, + IMAGE, ...ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS, diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx index c473f6b01..2e921506f 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx @@ -25,6 +25,8 @@ import { GetDatetimeBlockOptions } from './Options/DateTime' import { isMobileScreen } from '@/Utils' import { useApplication } from '@/Components/ApplicationProvider' import { GetIndentOutdentBlockOptions } from './Options/IndentOutdent' +import { GetRemoteImageBlockOption } from './Options/RemoteImage' +import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin' export default function BlockPickerMenuPlugin(): JSX.Element { const [editor] = useLexicalComposerContext() @@ -46,6 +48,9 @@ export default function BlockPickerMenuPlugin(): JSX.Element { GetTableBlockOption(() => showModal('Insert Table', (onClose) => ), ), + GetRemoteImageBlockOption(() => { + showModal('Insert image from URL', (onClose) => ) + }), GetNumberedListBlockOption(editor), GetBulletedListBlockOption(editor), GetChecklistBlockOption(editor), diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/Options/RemoteImage.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/Options/RemoteImage.tsx new file mode 100644 index 000000000..d0a7fc4f3 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/BlockPickerPlugin/Options/RemoteImage.tsx @@ -0,0 +1,12 @@ +import { LexicalIconName } from '@/Components/Icon/LexicalIcons' +import { GetRemoteImageBlock } from '../../Blocks/RemoteImage' +import { BlockPickerOption } from '../BlockPickerOption' + +export function GetRemoteImageBlockOption(onSelect: () => void) { + const block = GetRemoteImageBlock(onSelect) + return new BlockPickerOption(block.name, { + iconName: block.iconName as LexicalIconName, + keywords: block.keywords, + onSelect: block.onSelect, + }) +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/RemoteImage.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/RemoteImage.tsx new file mode 100644 index 000000000..f04c18d13 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Blocks/RemoteImage.tsx @@ -0,0 +1,3 @@ +export function GetRemoteImageBlock(onSelect: () => void) { + return { name: 'Image from URL', iconName: 'file-image', keywords: ['image', 'url'], onSelect } +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts index 167303fd5..215d9199b 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/Commands.ts @@ -6,3 +6,4 @@ export const INSERT_TIME_COMMAND: LexicalCommand = createCommand('INSERT export const INSERT_DATE_COMMAND: LexicalCommand = createCommand('INSERT_DATE_COMMAND') export const INSERT_DATETIME_COMMAND: LexicalCommand = createCommand('INSERT_DATETIME_COMMAND') export const INSERT_PASSWORD_COMMAND: LexicalCommand = createCommand('INSERT_PASSWORD_COMMAND') +export const INSERT_REMOTE_IMAGE_COMMAND: LexicalCommand = createCommand('INSERT_REMOTE_IMAGE_COMMAND') diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/MobileToolbarPlugin/MobileToolbarPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/MobileToolbarPlugin/MobileToolbarPlugin.tsx index e13ad9d99..129f71b4c 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/MobileToolbarPlugin/MobileToolbarPlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/MobileToolbarPlugin/MobileToolbarPlugin.tsx @@ -26,6 +26,8 @@ import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/u import { classNames } from '@standardnotes/snjs' import { SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services' import { useApplication } from '@/Components/ApplicationProvider' +import { GetRemoteImageBlock } from '../Blocks/RemoteImage' +import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin' const MobileToolbarPlugin = () => { const application = useApplication() @@ -127,6 +129,9 @@ const MobileToolbarPlugin = () => { GetTableBlock(() => showModal('Insert Table', (onClose) => ), ), + GetRemoteImageBlock(() => { + showModal('Insert image from URL', (onClose) => ) + }), GetNumberedListBlock(editor), GetBulletedListBlock(editor), GetChecklistBlock(editor), diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageComponent.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageComponent.tsx new file mode 100644 index 000000000..f27beec69 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageComponent.tsx @@ -0,0 +1,88 @@ +import { useApplication } from '@/Components/ApplicationProvider' +import Icon from '@/Components/Icon/Icon' +import Spinner from '@/Components/Spinner/Spinner' +import { isDesktopApplication } from '@/Utils' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { classNames } from '@standardnotes/snjs' +import { useCallback, useState } from 'react' +import { $createFileNode } from '../EncryptedFilePlugin/Nodes/FileUtils' +import { RemoteImageNode } from './RemoteImageNode' + +const RemoteImageComponent = ({ src, alt, node }: { src: string; alt?: string; node: RemoteImageNode }) => { + const application = useApplication() + const [editor] = useLexicalComposerContext() + + const [didImageLoad, setDidImageLoad] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + const fetchAndUploadImage = useCallback(async () => { + setIsSaving(true) + try { + const response = await fetch(src) + + if (!response.ok) { + return + } + + const blob = await response.blob() + const file = new File([blob], src, { type: blob.type }) + + const { filesController, linkingController } = application.getViewControllerManager() + + const uploadedFile = await filesController.uploadNewFile(file, false) + + if (!uploadedFile) { + return + } + + editor.update(() => { + const fileNode = $createFileNode(uploadedFile.uuid) + node.replace(fileNode) + }) + + void linkingController.linkItemToSelectedItem(uploadedFile) + } catch (error) { + console.error(error) + } finally { + setIsSaving(false) + } + }, [application, editor, node, src]) + + const canShowSaveButton = application.isNativeMobileWeb() || isDesktopApplication() + + return ( +
+ {alt} { + setDidImageLoad(true) + }} + /> + {didImageLoad && canShowSaveButton && ( + + )} +
+ ) +} + +export default RemoteImageComponent diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageNode.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageNode.tsx new file mode 100644 index 000000000..7e32e9309 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImageNode.tsx @@ -0,0 +1,89 @@ +import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode' +import { DOMConversionMap, DOMExportOutput, LexicalNode, Spread } from 'lexical' +import RemoteImageComponent from './RemoteImageComponent' + +type SerializedRemoteImageNode = Spread< + { + version: 1 + type: 'unencrypted-image' + alt: string | undefined + src: string + }, + SerializedDecoratorBlockNode +> + +export class RemoteImageNode extends DecoratorBlockNode { + __alt: string | undefined + __src: string + + static getType(): string { + return 'unencrypted-image' + } + + constructor(src: string, alt?: string) { + super() + this.__src = src + this.__alt = alt + } + + static clone(node: RemoteImageNode): RemoteImageNode { + return new RemoteImageNode(node.__src, node.__alt) + } + + static importJSON(serializedNode: SerializedRemoteImageNode): RemoteImageNode { + const node = $createRemoteImageNode(serializedNode.src, serializedNode.alt) + return node + } + + exportJSON(): SerializedRemoteImageNode { + return { + ...super.exportJSON(), + src: this.__src, + alt: this.__alt, + version: 1, + type: 'unencrypted-image', + } + } + + static importDOM(): DOMConversionMap | null { + return { + img: (domNode: HTMLDivElement) => { + if (domNode.tagName !== 'IMG') { + return null + } + return { + conversion: () => { + if (!(domNode instanceof HTMLImageElement)) { + return null + } + return { + node: $createRemoteImageNode(domNode.currentSrc || domNode.src, domNode.alt), + } + }, + priority: 2, + } + }, + } + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('img') + if (this.__alt) { + element.setAttribute('alt', this.__alt) + } + element.setAttribute('src', this.__src) + return { element } + } + + decorate(): JSX.Element { + return + } +} + +export function $isRemoteImageNode(node: RemoteImageNode | LexicalNode | null | undefined): node is RemoteImageNode { + return node instanceof RemoteImageNode +} + +export function $createRemoteImageNode(src: string, alt?: string): RemoteImageNode { + return new RemoteImageNode(src, alt) +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImagePlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImagePlugin.tsx new file mode 100644 index 000000000..ddf2cabd1 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/RemoteImagePlugin/RemoteImagePlugin.tsx @@ -0,0 +1,56 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_NORMAL } from 'lexical' +import { useEffect, useState } from 'react' +import Button from '../../Lexical/UI/Button' +import { DialogActions } from '../../Lexical/UI/Dialog' +import TextInput from '../../Lexical/UI/TextInput' +import { INSERT_REMOTE_IMAGE_COMMAND } from '../Commands' +import { $createRemoteImageNode } from './RemoteImageNode' +import { $wrapNodeInElement } from '@lexical/utils' + +export function InsertRemoteImageDialog({ onClose }: { onClose: () => void }) { + const [url, setURL] = useState('') + const [editor] = useLexicalComposerContext() + + const onClick = () => { + if (url.length < 1) { + return + } + + editor.dispatchCommand(INSERT_REMOTE_IMAGE_COMMAND, url) + onClose() + } + + return ( + <> + + + + + + ) +} + +export default function RemoteImagePlugin() { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + return editor.registerCommand( + INSERT_REMOTE_IMAGE_COMMAND, + (payload) => { + const imageNode = $createRemoteImageNode(payload) + $insertNodes([imageNode]) + if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) { + $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd() + } + const newLineNode = $createParagraphNode() + $insertNodes([newLineNode]) + + return true + }, + COMMAND_PRIORITY_NORMAL, + ) + }, [editor]) + + return null +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx index 01190bfb3..8b2e97a77 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx @@ -44,6 +44,7 @@ import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin' import ModalOverlay from '@/Components/Modal/ModalOverlay' import MobileToolbarPlugin from './Plugins/MobileToolbarPlugin/MobileToolbarPlugin' import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions' +import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin' export const SuperNotePreviewCharLimit = 160 @@ -202,6 +203,7 @@ export const SuperEditor: FunctionComponent = ({ + diff --git a/packages/web/src/javascripts/Controllers/FilesController.ts b/packages/web/src/javascripts/Controllers/FilesController.ts index 402e69c90..2496e07fa 100644 --- a/packages/web/src/javascripts/Controllers/FilesController.ts +++ b/packages/web/src/javascripts/Controllers/FilesController.ts @@ -348,8 +348,11 @@ export class FilesController extends AbstractViewController { - let toastId = '' + public async uploadNewFile( + fileOrHandle: File | FileSystemFileHandle, + showToast = true, + ): Promise { + let toastId: string | undefined try { const minimumChunkSize = this.application.files.minimumChunkSize() @@ -381,20 +384,24 @@ export class FilesController extends AbstractViewController { await this.application.files.pushBytesForUpload(operation, data, index, isLast) const percentComplete = Math.round(operation.getProgress().percentComplete) - updateToast(toastId, { - message: `Uploading file "${fileToUpload.name}" (${percentComplete}%)`, - progress: percentComplete, - }) + if (toastId) { + updateToast(toastId, { + message: `Uploading file "${fileToUpload.name}" (${percentComplete}%)`, + progress: percentComplete, + }) + } } const fileResult = await this.reader.readFile(fileToUpload, minimumChunkSize, onChunk) @@ -414,30 +421,34 @@ export class FilesController extends AbstractViewController { - void this.handleFileAction({ - type: FileItemActionType.PreviewFile, - payload: { file: uploadedFile }, - }) - dismissToast(toastId) + if (toastId) { + dismissToast(toastId) + } + if (showToast) { + addToast({ + type: ToastType.Success, + message: `Uploaded file "${uploadedFile.name}"`, + actions: [ + { + label: 'Open', + handler: (toastId) => { + void this.handleFileAction({ + type: FileItemActionType.PreviewFile, + payload: { file: uploadedFile }, + }) + dismissToast(toastId) + }, }, - }, - ], - autoClose: true, - }) + ], + autoClose: true, + }) + } return uploadedFile } catch (error) { console.error(error) - if (toastId.length > 0) { + if (toastId) { dismissToast(toastId) } addToast({