feat: Added "Image from URL" option and markdown image syntax support to Super. Also allows pasting images from copied web content (#2218)

This commit is contained in:
Aman Harwara
2023-02-25 11:27:03 +05:30
committed by GitHub
parent adff5979be
commit a15fc1ed1d
14 changed files with 333 additions and 31 deletions

View File

@@ -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:;
"
/>

View File

@@ -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,

View File

@@ -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,
]

View File

@@ -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,

View File

@@ -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) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
),
GetRemoteImageBlockOption(() => {
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
}),
GetNumberedListBlockOption(editor),
GetBulletedListBlockOption(editor),
GetChecklistBlockOption(editor),

View File

@@ -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,
})
}

View File

@@ -0,0 +1,3 @@
export function GetRemoteImageBlock(onSelect: () => void) {
return { name: 'Image from URL', iconName: 'file-image', keywords: ['image', 'url'], onSelect }
}

View File

@@ -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')

View File

@@ -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) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
),
GetRemoteImageBlock(() => {
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
}),
GetNumberedListBlock(editor),
GetBulletedListBlock(editor),
GetChecklistBlock(editor),

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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<Props> = ({
</SuperSearchContextProvider>
<MobileToolbarPlugin />
<CodeOptionsPlugin />
<RemoteImagePlugin />
</BlocksEditor>
</BlocksEditorComposer>
</FilesControllerProvider>

View File

@@ -348,8 +348,11 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
})
}
public async uploadNewFile(fileOrHandle: File | FileSystemFileHandle): Promise<FileItem | undefined> {
let toastId = ''
public async uploadNewFile(
fileOrHandle: File | FileSystemFileHandle,
showToast = true,
): Promise<FileItem | undefined> {
let toastId: string | undefined
try {
const minimumChunkSize = this.application.files.minimumChunkSize()
@@ -381,20 +384,24 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const initialProgress = operation.getProgress().percentComplete
toastId = addToast({
type: ToastType.Progress,
message: `Uploading file "${fileToUpload.name}" (${initialProgress}%)`,
progress: initialProgress,
})
if (showToast) {
toastId = addToast({
type: ToastType.Progress,
message: `Uploading file "${fileToUpload.name}" (${initialProgress}%)`,
progress: initialProgress,
})
}
const onChunk: OnChunkCallbackNoProgress = async ({ data, index, isLast }) => {
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<FilesControllerEvent
throw new Error('Unable to close upload session')
}
dismissToast(toastId)
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)
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({