diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 6de614d27..aa9b0fc48 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -11,12 +11,10 @@ import { tagHistory } from 'soapbox/settings'; import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion } from 'soapbox/utils/features'; -import { formatBytes, getVideoDuration } from 'soapbox/utils/media'; -import resizeImage from 'soapbox/utils/resize-image'; import { useEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; -import { uploadMedia, fetchMedia, updateMedia } from './media'; +import { uploadFile, updateMedia } from './media'; import { openModal, closeModal } from './modals'; import { getSettings } from './settings'; import { createStatus } from './statuses'; @@ -33,11 +31,6 @@ const { CancelToken, isCancel } = axios; let cancelFetchComposeSuggestions: Canceler; -const FILES_UPLOAD_REQUEST = 'FILES_UPLOAD_REQUEST' as const; -const FILES_UPLOAD_SUCCESS = 'FILES_UPLOAD_SUCCESS' as const; -const FILES_UPLOAD_FAIL = 'FILES_UPLOAD_FAIL' as const; -const FILES_UPLOAD_PROGRESS = 'FILES_UPLOAD_PROGRESS' as const; - const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const; const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const; const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const; @@ -96,9 +89,6 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const; const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const; const messages = defineMessages({ - exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, - exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, - exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' }, scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' }, editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, @@ -393,109 +383,10 @@ const submitComposeFail = (composeId: string, error: AxiosError) => ({ error: error, }); -const uploadFiles = (files: FileList, intl: IntlShape) => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; - const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; - const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined; - - const progress = new Array(files.length).fill(0); - let total = Array.from(files).reduce((a, v) => a + v.size, 0); - - dispatch(uploadFilesRequest()); - - return Array.from(files).forEach(async(f, i) => { - const isImage = f.type.match(/image.*/); - const isVideo = f.type.match(/video.*/); - const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(f) : 0; - - if (isImage && maxImageSize && (f.size > maxImageSize)) { - const limit = formatBytes(maxImageSize); - const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - toast.error(message); - dispatch(uploadFilesFail(true)); - return; - } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { - const limit = formatBytes(maxVideoSize); - const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); - toast.error(message); - dispatch(uploadFilesFail(true)); - return; - } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { - const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); - toast.error(message); - dispatch(uploadFilesFail(true)); - return; - } - - // FIXME: Don't define const in loop - resizeImage(f).then(file => { - const data = new FormData(); - data.append('file', file); - // Account for disparity in size of original image and resized data - total += file.size - f.size; - - const onUploadProgress = ({ loaded }: any) => { - progress[i] = loaded; - dispatch(uploadFilesProgress(progress.reduce((a, v) => a + v, 0), total)); - }; - - return dispatch(uploadMedia(data, onUploadProgress)) - .then(({ status, data }) => { - // If server-side processing of the media attachment has not completed yet, - // poll the server until it is, before showing the media attachment as uploaded - if (status === 200) { - dispatch(uploadFilesSuccess(data, f)); - } else if (status === 202) { - const poll = () => - dispatch(fetchMedia(data.id)).then(({ status, data }) => { - if (status === 200) { - dispatch(uploadFilesSuccess(data, f)); - return data; - } else if (status === 206) { - setTimeout(() => poll(), 1000); - } - }).catch(error => dispatch(uploadFilesFail(error))); - - poll(); - } - }); - }).catch(error => dispatch(uploadFilesFail(error))); - }); - }; - -const uploadFilesRequest = () => ({ - type: FILES_UPLOAD_REQUEST, - skipLoading: true, -}); - -const uploadFilesProgress = (loaded: number, total: number) => ({ - type: FILES_UPLOAD_PROGRESS, - loaded: loaded, - total: total, -}); - -const uploadFilesSuccess = (media: APIEntity, file: File) => ({ - type: FILES_UPLOAD_SUCCESS, - media: media, - file, - skipLoading: true, -}); - -const uploadFilesFail = (error: AxiosError | true) => ({ - type: FILES_UPLOAD_FAIL, - error: error, - skipLoading: true, -}); - const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number; - const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; - const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; - const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined; const media = getState().compose.get(composeId)?.media_attachments; const progress = new Array(files.length).fill(0); @@ -513,62 +404,18 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => Array.from(files).forEach(async(f, i) => { if (mediaCount + i > attachmentLimit - 1) return; - const isImage = f.type.match(/image.*/); - const isVideo = f.type.match(/video.*/); - const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(f) : 0; - - if (isImage && maxImageSize && (f.size > maxImageSize)) { - const limit = formatBytes(maxImageSize); - const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - toast.error(message); - dispatch(uploadComposeFail(composeId, true)); - return; - } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { - const limit = formatBytes(maxVideoSize); - const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); - toast.error(message); - dispatch(uploadComposeFail(composeId, true)); - return; - } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { - const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); - toast.error(message); - dispatch(uploadComposeFail(composeId, true)); - return; - } - - // FIXME: Don't define const in loop - resizeImage(f).then(file => { - const data = new FormData(); - data.append('file', file); - // Account for disparity in size of original image and resized data - total += file.size - f.size; - - const onUploadProgress = ({ loaded }: any) => { + dispatch(uploadFile( + f, + intl, + (data) => dispatch(uploadComposeSuccess(composeId, data, f)), + (error) => dispatch(uploadComposeFail(composeId, error)), + ({ loaded }: any) => { progress[i] = loaded; dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total)); - }; + }, + (value) => total += value, + )); - return dispatch(uploadMedia(data, onUploadProgress)) - .then(({ status, data }) => { - // If server-side processing of the media attachment has not completed yet, - // poll the server until it is, before showing the media attachment as uploaded - if (status === 200) { - dispatch(uploadComposeSuccess(composeId, data, f)); - } else if (status === 202) { - const poll = () => { - dispatch(fetchMedia(data.id)).then(({ status, data }) => { - if (status === 200) { - dispatch(uploadComposeSuccess(composeId, data, f)); - } else if (status === 206) { - setTimeout(() => poll(), 1000); - } - }).catch(error => dispatch(uploadComposeFail(composeId, error))); - }; - - poll(); - } - }); - }).catch(error => dispatch(uploadComposeFail(composeId, error))); }); }; @@ -1100,11 +947,7 @@ export { submitComposeRequest, submitComposeSuccess, submitComposeFail, - uploadFiles, - uploadFilesRequest, - uploadFilesSuccess, - uploadFilesProgress, - uploadFilesFail, + uploadFile, uploadCompose, changeUploadCompose, changeUploadComposeRequest, diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts index 6cf6fee50..6d62d4585 100644 --- a/app/soapbox/actions/events.ts +++ b/app/soapbox/actions/events.ts @@ -2,11 +2,9 @@ import { defineMessages, IntlShape } from 'react-intl'; import api, { getLinks } from 'soapbox/api'; import toast from 'soapbox/toast'; -import { formatBytes } from 'soapbox/utils/media'; -import resizeImage from 'soapbox/utils/resize-image'; import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer'; -import { fetchMedia, uploadMedia } from './media'; +import { uploadFile } from './media'; import { closeModal, openModal } from './modals'; import { STATUS_FETCH_SOURCE_FAIL, @@ -154,52 +152,21 @@ const changeEditEventLocation = (value: string | null) => }; const uploadEventBanner = (file: File, intl: IntlShape) => - (dispatch: AppDispatch, getState: () => RootState) => { - const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; - + (dispatch: AppDispatch) => { let progress = 0; dispatch(uploadEventBannerRequest()); - if (maxImageSize && (file.size > maxImageSize)) { - const limit = formatBytes(maxImageSize); - const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); - toast.error(message); - dispatch(uploadEventBannerFail(true)); - return; - } - - resizeImage(file).then(file => { - const data = new FormData(); - data.append('file', file); - // Account for disparity in size of original image and resized data - - const onUploadProgress = ({ loaded }: any) => { + dispatch(uploadFile( + file, + intl, + (data) => dispatch(uploadEventBannerSuccess(data, file)), + (error) => dispatch(uploadEventBannerFail(error)), + ({ loaded }: any) => { progress = loaded; dispatch(uploadEventBannerProgress(progress)); - }; - - return dispatch(uploadMedia(data, onUploadProgress)) - .then(({ status, data }) => { - // If server-side processing of the media attachment has not completed yet, - // poll the server until it is, before showing the media attachment as uploaded - if (status === 200) { - dispatch(uploadEventBannerSuccess(data, file)); - } else if (status === 202) { - const poll = () => { - dispatch(fetchMedia(data.id)).then(({ status, data }) => { - if (status === 200) { - dispatch(uploadEventBannerSuccess(data, file)); - } else if (status === 206) { - setTimeout(() => poll(), 1000); - } - }).catch(error => dispatch(uploadEventBannerFail(error))); - }; - - poll(); - } - }); - }).catch(error => dispatch(uploadEventBannerFail(error))); + }, + )); }; const uploadEventBannerRequest = () => ({ diff --git a/app/soapbox/actions/media.ts b/app/soapbox/actions/media.ts index 15c637c35..112d0e02b 100644 --- a/app/soapbox/actions/media.ts +++ b/app/soapbox/actions/media.ts @@ -1,8 +1,22 @@ +import { defineMessages, type IntlShape } from 'react-intl'; + +import toast from 'soapbox/toast'; +import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; +import { formatBytes, getVideoDuration } from 'soapbox/utils/media'; +import resizeImage from 'soapbox/utils/resize-image'; import api from '../api'; +import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const messages = defineMessages({ + exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, + exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, + exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' }, +}); const noOp = (e: any) => {}; @@ -41,10 +55,78 @@ const uploadMedia = (data: FormData, onUploadProgress = noOp) => } }; +const uploadFile = ( + file: File, + intl: IntlShape, + onSuccess: (data: APIEntity) => void = () => {}, + onFail: (error: AxiosError | true) => void = () => {}, + onProgress: (loaded: number) => void = () => {}, + changeTotal: (value: number) => void = () => {}, +) => + async (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; + const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; + const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined; + + const isImage = file.type.match(/image.*/); + const isVideo = file.type.match(/video.*/); + const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(file) : 0; + + if (isImage && maxImageSize && (file.size > maxImageSize)) { + const limit = formatBytes(maxImageSize); + const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); + toast.error(message); + onFail(true); + return; + } else if (isVideo && maxVideoSize && (file.size > maxVideoSize)) { + const limit = formatBytes(maxVideoSize); + const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); + toast.error(message); + onFail(true); + return; + } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { + const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); + toast.error(message); + onFail(true); + return; + } + + // FIXME: Don't define const in loop + resizeImage(file).then(resized => { + const data = new FormData(); + data.append('file', resized); + // Account for disparity in size of original image and resized data + changeTotal(resized.size - file.size); + + return dispatch(uploadMedia(data, onProgress)) + .then(({ status, data }) => { + // If server-side processing of the media attachment has not completed yet, + // poll the server until it is, before showing the media attachment as uploaded + if (status === 200) { + onSuccess(data); + } else if (status === 202) { + const poll = () => { + dispatch(fetchMedia(data.id)).then(({ status, data }) => { + if (status === 200) { + onSuccess(data); + } else if (status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => onFail(error)); + }; + + poll(); + } + }); + }).catch(error => onFail(error)); + }; + export { fetchMedia, updateMedia, uploadMediaV1, uploadMediaV2, uploadMedia, + uploadFile, }; diff --git a/app/soapbox/features/compose/editor/nodes/image-component.tsx b/app/soapbox/features/compose/editor/nodes/image-component.tsx new file mode 100644 index 000000000..634095bcd --- /dev/null +++ b/app/soapbox/features/compose/editor/nodes/image-component.tsx @@ -0,0 +1,253 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; +import { mergeRegister } from '@lexical/utils'; +import { + $getNodeByKey, + $getSelection, + $isNodeSelection, + $setSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + DRAGSTART_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import * as React from 'react'; +import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; + +import { $isImageNode } from './image-node'; + +import type { + GridSelection, + LexicalEditor, + NodeKey, + NodeSelection, + RangeSelection, +} from 'lexical'; + +const imageCache = new Set(); + +function useSuspenseImage(src: string) { + if (!imageCache.has(src)) { + throw new Promise((resolve) => { + const img = new Image(); + img.src = src; + img.onload = () => { + imageCache.add(src); + resolve(null); + }; + }); + } +} + +function LazyImage({ + altText, + className, + imageRef, + src, +}: { + altText: string + className: string | null + imageRef: {current: null | HTMLImageElement} + src: string +}): JSX.Element { + useSuspenseImage(src); + return ( + {altText} + ); +} + +export default function ImageComponent({ + src, + altText, + nodeKey, +}: { + altText: string + nodeKey: NodeKey + src: string +}): JSX.Element { + const imageRef = useRef(null); + const buttonRef = useRef(null); + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey); + const [editor] = useLexicalComposerContext(); + const [selection, setSelection] = useState< + RangeSelection | NodeSelection | GridSelection | null + >(null); + const activeEditorRef = useRef(null); + + const onDelete = useCallback( + (payload: KeyboardEvent) => { + if (isSelected && $isNodeSelection($getSelection())) { + const event: KeyboardEvent = payload; + event.preventDefault(); + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + node.remove(); + } + } + return false; + }, + [isSelected, nodeKey], + ); + + const onEnter = useCallback( + (event: KeyboardEvent) => { + const latestSelection = $getSelection(); + const buttonElem = buttonRef.current; + if ( + isSelected && + $isNodeSelection(latestSelection) && + latestSelection.getNodes().length === 1 + ) { + if ( + buttonElem !== null && + buttonElem !== document.activeElement + ) { + event.preventDefault(); + buttonElem.focus(); + return true; + } + } + return false; + }, + [isSelected], + ); + + const onEscape = useCallback( + (event: KeyboardEvent) => { + if (buttonRef.current === event.target) { + $setSelection(null); + editor.update(() => { + setSelected(true); + const parentRootElement = editor.getRootElement(); + if (parentRootElement !== null) { + parentRootElement.focus(); + } + }); + return true; + } + return false; + }, + [editor, setSelected], + ); + + useEffect(() => { + let isMounted = true; + const unregister = mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + if (isMounted) { + setSelection(editorState.read(() => $getSelection())); + } + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_, activeEditor) => { + activeEditorRef.current = activeEditor; + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CLICK_COMMAND, + (payload) => { + const event = payload; + + if (event.target === imageRef.current) { + if (event.shiftKey) { + setSelected(!isSelected); + } else { + clearSelection(); + setSelected(true); + } + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + if (event.target === imageRef.current) { + // TODO This is just a temporary workaround for FF to behave like other browsers. + // Ideally, this handles drag & drop too (and all browsers). + event.preventDefault(); + return true; + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_DELETE_COMMAND, + onDelete, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + onDelete, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + onEscape, + COMMAND_PRIORITY_LOW, + ), + ); + return () => { + isMounted = false; + unregister(); + }; + }, [ + clearSelection, + editor, + isSelected, + nodeKey, + onDelete, + onEnter, + onEscape, + setSelected, + ]); + + const draggable = isSelected && $isNodeSelection(selection); + const isFocused = isSelected; + return ( + + <> +
+ +
+ +
+ ); +} diff --git a/app/soapbox/features/compose/editor/nodes/image-node.tsx b/app/soapbox/features/compose/editor/nodes/image-node.tsx new file mode 100644 index 000000000..85bb7dad9 --- /dev/null +++ b/app/soapbox/features/compose/editor/nodes/image-node.tsx @@ -0,0 +1,173 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { $applyNodeReplacement, DecoratorNode } from 'lexical'; +import * as React from 'react'; +import { Suspense } from 'react'; + +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; + +const ImageComponent = React.lazy(() => import('./image-component')); + +interface ImagePayload { + altText?: string + key?: NodeKey + src: string +} + +function convertImageElement(domNode: Node): null | DOMConversionOutput { + if (domNode instanceof HTMLImageElement) { + const { alt: altText, src } = domNode; + const node = $createImageNode({ altText, src }); + return { node }; + } + return null; +} + +type SerializedImageNode = Spread< + { + altText: string + src: string + }, + SerializedLexicalNode +>; + +class ImageNode extends DecoratorNode { + + __src: string; + __altText: string; + + static getType(): string { + return 'image'; + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode( + node.__src, + node.__altText, + node.__key, + ); + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const { altText, src } = + serializedNode; + const node = $createImageNode({ + altText, + src, + }); + return node; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('img'); + element.setAttribute('src', this.__src); + element.setAttribute('alt', this.__altText); + return { element }; + } + + static importDOM(): DOMConversionMap | null { + return { + img: (node: Node) => ({ + conversion: convertImageElement, + priority: 0, + }), + }; + } + + constructor( + src: string, + altText: string, + key?: NodeKey, + ) { + super(key); + this.__src = src; + this.__altText = altText; + } + + exportJSON(): SerializedImageNode { + return { + altText: this.getAltText(), + src: this.getSrc(), + type: 'image', + version: 1, + }; + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const span = document.createElement('span'); + const theme = config.theme; + const className = theme.image; + if (className !== undefined) { + span.className = className; + } + return span; + } + + updateDOM(): false { + return false; + } + + getSrc(): string { + return this.__src; + } + + getAltText(): string { + return this.__altText; + } + + decorate(): JSX.Element { + return ( + + + + ); + } + +} + +function $createImageNode({ + altText = '', + src, + key, +}: ImagePayload): ImageNode { + return $applyNodeReplacement( + new ImageNode( + src, + altText, + key, + ), + ); +} + +const $isImageNode = ( + node: LexicalNode | null | undefined, +): node is ImageNode => node instanceof ImageNode; + +export { + type ImagePayload, + type SerializedImageNode, + ImageNode, + $createImageNode, + $isImageNode, +}; diff --git a/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx b/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx index 0b443ec91..95d3fe6d7 100644 --- a/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx @@ -23,7 +23,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; import { useIntl } from 'react-intl'; -import { uploadFiles } from 'soapbox/actions/compose'; +import { uploadFile } from 'soapbox/actions/compose'; import { useAppDispatch, useInstance } from 'soapbox/hooks'; import { onlyImages } from '../../components/upload-button'; @@ -49,7 +49,11 @@ const UploadButton: React.FC = ({ onSelectFile }) => { const handleChange: React.ChangeEventHandler = (e) => { if (e.target.files?.length) { // @ts-ignore - dispatch(uploadFiles([e.target.files.item(0)] as any, intl)); + dispatch(uploadFile( + e.target.files.item(0) as File, + intl, + ({ url }) => onSelectFile(url), + )); } };