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:
@@ -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:;
|
||||
"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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 ``
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function GetRemoteImageBlock(onSelect: () => void) {
|
||||
return { name: 'Image from URL', iconName: 'file-image', keywords: ['image', 'url'], onSelect }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user