Skip to content

Commit

Permalink
feat: Added "Image from URL" option and markdown image syntax support…
Browse files Browse the repository at this point in the history
… to Super. Also allows pasting images from copied web content (#2218)
  • Loading branch information
amanharwara committed Feb 25, 2023
1 parent adff597 commit a15fc1e
Show file tree
Hide file tree
Showing 14 changed files with 333 additions and 31 deletions.
1 change: 1 addition & 0 deletions packages/desktop/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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:;
"
/>

Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/javascripts/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,4 +39,5 @@ export const BlockEditorNodes = [
YouTubeNode,
FileNode,
BubbleNode,
RemoteImageNode,
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ElementTransformer,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
TextMatchTransformer,
} from '@lexical/markdown'

import {
Expand All @@ -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],
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -46,6 +48,9 @@ export default function BlockPickerMenuPlugin(): JSX.Element {
GetTableBlockOption(() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
),
GetRemoteImageBlockOption(() => {
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
}),
GetNumberedListBlockOption(editor),
GetBulletedListBlockOption(editor),
GetChecklistBlockOption(editor),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function GetRemoteImageBlock(onSelect: () => void) {
return { name: 'Image from URL', iconName: 'file-image', keywords: ['image', 'url'], onSelect }
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export const INSERT_TIME_COMMAND: LexicalCommand<string> = createCommand('INSERT
export const INSERT_DATE_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATE_COMMAND')
export const INSERT_DATETIME_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATETIME_COMMAND')
export const INSERT_PASSWORD_COMMAND: LexicalCommand<string> = createCommand('INSERT_PASSWORD_COMMAND')
export const INSERT_REMOTE_IMAGE_COMMAND: LexicalCommand<string> = createCommand('INSERT_REMOTE_IMAGE_COMMAND')
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -127,6 +129,9 @@ const MobileToolbarPlugin = () => {
GetTableBlock(() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
),
GetRemoteImageBlock(() => {
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
}),
GetNumberedListBlock(editor),
GetBulletedListBlock(editor),
GetChecklistBlock(editor),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative flex min-h-[2rem] flex-col items-center gap-2.5">
<img
alt={alt}
src={src}
onLoad={() => {
setDidImageLoad(true)
}}
/>
{didImageLoad && canShowSaveButton && (
<button
className={classNames(
'flex items-center gap-2.5 rounded border border-border bg-default px-2.5 py-1.5',
!isSaving && 'hover:bg-info hover:text-info-contrast',
)}
onClick={fetchAndUploadImage}
disabled={isSaving}
>
{isSaving ? (
<>
<Spinner className="h-4 w-4" />
Saving...
</>
) : (
<>
<Icon type="download" />
Save image to Files
</>
)}
</button>
)}
</div>
)
}

export default RemoteImageComponent
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> | 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 <RemoteImageComponent node={this} src={this.__src} alt={this.__alt} />
}
}

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)
}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<TextInput label="URL:" onChange={setURL} value={url} />
<DialogActions>
<Button onClick={onClick}>Confirm</Button>
</DialogActions>
</>
)
}

export default function RemoteImagePlugin() {
const [editor] = useLexicalComposerContext()

useEffect(() => {
return editor.registerCommand<string>(
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
}

0 comments on commit a15fc1e

Please sign in to comment.