kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Add uploadFile function, allow uploading images in Lexical
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>environments/review-lexical-ujdd17/deployments/3671
rodzic
f55a76886f
commit
79200cae0f
|
@ -11,12 +11,10 @@ import { tagHistory } from 'soapbox/settings';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||||
import { getFeatures, parseVersion } from 'soapbox/utils/features';
|
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 { useEmoji } from './emojis';
|
||||||
import { importFetchedAccounts } from './importer';
|
import { importFetchedAccounts } from './importer';
|
||||||
import { uploadMedia, fetchMedia, updateMedia } from './media';
|
import { uploadFile, updateMedia } from './media';
|
||||||
import { openModal, closeModal } from './modals';
|
import { openModal, closeModal } from './modals';
|
||||||
import { getSettings } from './settings';
|
import { getSettings } from './settings';
|
||||||
import { createStatus } from './statuses';
|
import { createStatus } from './statuses';
|
||||||
|
@ -33,11 +31,6 @@ const { CancelToken, isCancel } = axios;
|
||||||
|
|
||||||
let cancelFetchComposeSuggestions: Canceler;
|
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_CHANGE = 'COMPOSE_CHANGE' as const;
|
||||||
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
|
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
|
||||||
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' 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 COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const;
|
||||||
|
|
||||||
const messages = defineMessages({
|
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.' },
|
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' },
|
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
|
||||||
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
|
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
|
||||||
|
@ -393,109 +383,10 @@ const submitComposeFail = (composeId: string, error: AxiosError) => ({
|
||||||
error: error,
|
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) =>
|
const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
if (!isLoggedIn(getState)) return;
|
if (!isLoggedIn(getState)) return;
|
||||||
const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number;
|
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 media = getState().compose.get(composeId)?.media_attachments;
|
||||||
const progress = new Array(files.length).fill(0);
|
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) => {
|
Array.from(files).forEach(async(f, i) => {
|
||||||
if (mediaCount + i > attachmentLimit - 1) return;
|
if (mediaCount + i > attachmentLimit - 1) return;
|
||||||
|
|
||||||
const isImage = f.type.match(/image.*/);
|
dispatch(uploadFile(
|
||||||
const isVideo = f.type.match(/video.*/);
|
f,
|
||||||
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(f) : 0;
|
intl,
|
||||||
|
(data) => dispatch(uploadComposeSuccess(composeId, data, f)),
|
||||||
if (isImage && maxImageSize && (f.size > maxImageSize)) {
|
(error) => dispatch(uploadComposeFail(composeId, error)),
|
||||||
const limit = formatBytes(maxImageSize);
|
({ loaded }: any) => {
|
||||||
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) => {
|
|
||||||
progress[i] = loaded;
|
progress[i] = loaded;
|
||||||
dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total));
|
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,
|
submitComposeRequest,
|
||||||
submitComposeSuccess,
|
submitComposeSuccess,
|
||||||
submitComposeFail,
|
submitComposeFail,
|
||||||
uploadFiles,
|
uploadFile,
|
||||||
uploadFilesRequest,
|
|
||||||
uploadFilesSuccess,
|
|
||||||
uploadFilesProgress,
|
|
||||||
uploadFilesFail,
|
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
changeUploadCompose,
|
changeUploadCompose,
|
||||||
changeUploadComposeRequest,
|
changeUploadComposeRequest,
|
||||||
|
|
|
@ -2,11 +2,9 @@ import { defineMessages, IntlShape } from 'react-intl';
|
||||||
|
|
||||||
import api, { getLinks } from 'soapbox/api';
|
import api, { getLinks } from 'soapbox/api';
|
||||||
import toast from 'soapbox/toast';
|
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 { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
import { fetchMedia, uploadMedia } from './media';
|
import { uploadFile } from './media';
|
||||||
import { closeModal, openModal } from './modals';
|
import { closeModal, openModal } from './modals';
|
||||||
import {
|
import {
|
||||||
STATUS_FETCH_SOURCE_FAIL,
|
STATUS_FETCH_SOURCE_FAIL,
|
||||||
|
@ -154,52 +152,21 @@ const changeEditEventLocation = (value: string | null) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadEventBanner = (file: File, intl: IntlShape) =>
|
const uploadEventBanner = (file: File, intl: IntlShape) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch) => {
|
||||||
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
|
|
||||||
|
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
|
|
||||||
dispatch(uploadEventBannerRequest());
|
dispatch(uploadEventBannerRequest());
|
||||||
|
|
||||||
if (maxImageSize && (file.size > maxImageSize)) {
|
dispatch(uploadFile(
|
||||||
const limit = formatBytes(maxImageSize);
|
file,
|
||||||
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
|
intl,
|
||||||
toast.error(message);
|
(data) => dispatch(uploadEventBannerSuccess(data, file)),
|
||||||
dispatch(uploadEventBannerFail(true));
|
(error) => dispatch(uploadEventBannerFail(error)),
|
||||||
return;
|
({ loaded }: any) => {
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
progress = loaded;
|
progress = loaded;
|
||||||
dispatch(uploadEventBannerProgress(progress));
|
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 = () => ({
|
const uploadEventBannerRequest = () => ({
|
||||||
|
|
|
@ -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 { getFeatures } from 'soapbox/utils/features';
|
||||||
|
import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
|
||||||
|
import resizeImage from 'soapbox/utils/resize-image';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
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) => {};
|
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 {
|
export {
|
||||||
fetchMedia,
|
fetchMedia,
|
||||||
updateMedia,
|
updateMedia,
|
||||||
uploadMediaV1,
|
uploadMediaV1,
|
||||||
uploadMediaV2,
|
uploadMediaV2,
|
||||||
uploadMedia,
|
uploadMedia,
|
||||||
|
uploadFile,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 (
|
||||||
|
<img
|
||||||
|
className={className || undefined}
|
||||||
|
src={src}
|
||||||
|
alt={altText}
|
||||||
|
ref={imageRef}
|
||||||
|
draggable='false'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageComponent({
|
||||||
|
src,
|
||||||
|
altText,
|
||||||
|
nodeKey,
|
||||||
|
}: {
|
||||||
|
altText: string
|
||||||
|
nodeKey: NodeKey
|
||||||
|
src: string
|
||||||
|
}): JSX.Element {
|
||||||
|
const imageRef = useRef<null | HTMLImageElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [isSelected, setSelected, clearSelection] =
|
||||||
|
useLexicalNodeSelection(nodeKey);
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [selection, setSelection] = useState<
|
||||||
|
RangeSelection | NodeSelection | GridSelection | null
|
||||||
|
>(null);
|
||||||
|
const activeEditorRef = useRef<LexicalEditor | null>(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<MouseEvent>(
|
||||||
|
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 (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<>
|
||||||
|
<div draggable={draggable}>
|
||||||
|
<LazyImage
|
||||||
|
className={
|
||||||
|
isFocused
|
||||||
|
? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
src={src}
|
||||||
|
altText={altText}
|
||||||
|
imageRef={imageRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<JSX.Element> {
|
||||||
|
|
||||||
|
__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 (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ImageComponent
|
||||||
|
src={this.__src}
|
||||||
|
altText={this.__altText}
|
||||||
|
nodeKey={this.getKey()}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
|
@ -23,7 +23,7 @@ import * as React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { uploadFiles } from 'soapbox/actions/compose';
|
import { uploadFile } from 'soapbox/actions/compose';
|
||||||
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { onlyImages } from '../../components/upload-button';
|
import { onlyImages } from '../../components/upload-button';
|
||||||
|
@ -49,7 +49,11 @@ const UploadButton: React.FC<IUploadButton> = ({ onSelectFile }) => {
|
||||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
if (e.target.files?.length) {
|
if (e.target.files?.length) {
|
||||||
// @ts-ignore
|
// @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),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Ładowanie…
Reference in New Issue