Merge branch 'lexical' into 'main'
Lexical text editor See merge request soapbox-pub/soapbox!2177
This commit is contained in:
commit
6c318a02ef
|
@ -20,8 +20,8 @@ module.exports = {
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
'react',
|
|
||||||
'jsdoc',
|
'jsdoc',
|
||||||
|
'react',
|
||||||
'jsx-a11y',
|
'jsx-a11y',
|
||||||
'import',
|
'import',
|
||||||
'promise',
|
'promise',
|
||||||
|
|
12
package.json
12
package.json
|
@ -50,6 +50,17 @@
|
||||||
"@fontsource/inter": "^5.0.0",
|
"@fontsource/inter": "^5.0.0",
|
||||||
"@fontsource/roboto-mono": "^5.0.0",
|
"@fontsource/roboto-mono": "^5.0.0",
|
||||||
"@gamestdio/websocket": "^0.3.2",
|
"@gamestdio/websocket": "^0.3.2",
|
||||||
|
"@lexical/clipboard": "^0.11.3",
|
||||||
|
"@lexical/code": "^0.11.3",
|
||||||
|
"@lexical/hashtag": "^0.11.3",
|
||||||
|
"@lexical/html": "^0.11.3",
|
||||||
|
"@lexical/link": "^0.11.3",
|
||||||
|
"@lexical/list": "^0.11.3",
|
||||||
|
"@lexical/react": "^0.11.3",
|
||||||
|
"@lexical/rich-text": "^0.11.3",
|
||||||
|
"@lexical/selection": "^0.11.3",
|
||||||
|
"@lexical/table": "^0.11.3",
|
||||||
|
"@lexical/utils": "^0.11.3",
|
||||||
"@popperjs/core": "^2.11.5",
|
"@popperjs/core": "^2.11.5",
|
||||||
"@reach/combobox": "^0.18.0",
|
"@reach/combobox": "^0.18.0",
|
||||||
"@reach/menu-button": "^0.18.0",
|
"@reach/menu-button": "^0.18.0",
|
||||||
|
@ -112,6 +123,7 @@
|
||||||
"intl-messageformat-parser": "^6.0.0",
|
"intl-messageformat-parser": "^6.0.0",
|
||||||
"intl-pluralrules": "^2.0.0",
|
"intl-pluralrules": "^2.0.0",
|
||||||
"leaflet": "^1.8.0",
|
"leaflet": "^1.8.0",
|
||||||
|
"lexical": "^0.11.3",
|
||||||
"line-awesome": "^1.3.0",
|
"line-awesome": "^1.3.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.7.11",
|
"lodash": "^4.7.11",
|
||||||
|
|
|
@ -6,21 +6,21 @@ import { defineMessages, IntlShape } from 'react-intl';
|
||||||
import api from 'soapbox/api';
|
import api from 'soapbox/api';
|
||||||
import { isNativeEmoji } from 'soapbox/features/emoji';
|
import { isNativeEmoji } from 'soapbox/features/emoji';
|
||||||
import emojiSearch from 'soapbox/features/emoji/search';
|
import emojiSearch from 'soapbox/features/emoji/search';
|
||||||
|
import { normalizeTag } from 'soapbox/normalizers';
|
||||||
import { selectAccount, selectOwnAccount } from 'soapbox/selectors';
|
import { selectAccount, selectOwnAccount } from 'soapbox/selectors';
|
||||||
import { tagHistory } from 'soapbox/settings';
|
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';
|
||||||
|
|
||||||
|
import type { EditorState } from 'lexical';
|
||||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||||
import type { Emoji } from 'soapbox/features/emoji';
|
import type { Emoji } from 'soapbox/features/emoji';
|
||||||
import type { Account, Group } from 'soapbox/schemas';
|
import type { Account, Group } from 'soapbox/schemas';
|
||||||
|
@ -30,7 +30,7 @@ import type { History } from 'soapbox/types/history';
|
||||||
|
|
||||||
const { CancelToken, isCancel } = axios;
|
const { CancelToken, isCancel } = axios;
|
||||||
|
|
||||||
let cancelFetchComposeSuggestionsAccounts: Canceler;
|
let cancelFetchComposeSuggestions: Canceler;
|
||||||
|
|
||||||
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;
|
||||||
|
@ -87,10 +87,9 @@ const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS' as const;
|
||||||
|
|
||||||
const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const;
|
const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' 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' },
|
||||||
|
@ -304,6 +303,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
if (!isLoggedIn(getState)) return;
|
if (!isLoggedIn(getState)) return;
|
||||||
const state = getState();
|
const state = getState();
|
||||||
|
const { richText } = getFeatures(state.instance);
|
||||||
|
|
||||||
const compose = state.compose.get(composeId)!;
|
const compose = state.compose.get(composeId)!;
|
||||||
|
|
||||||
|
@ -312,6 +312,8 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
||||||
const statusId = compose.id;
|
const statusId = compose.id;
|
||||||
let to = compose.to;
|
let to = compose.to;
|
||||||
|
|
||||||
|
const contentType = richText ? 'text/markdown' : 'text/plain';
|
||||||
|
|
||||||
if (!validateSchedule(state, composeId)) {
|
if (!validateSchedule(state, composeId)) {
|
||||||
toast.error(messages.scheduleError);
|
toast.error(messages.scheduleError);
|
||||||
return;
|
return;
|
||||||
|
@ -350,7 +352,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
||||||
sensitive: compose.sensitive,
|
sensitive: compose.sensitive,
|
||||||
spoiler_text: compose.spoiler_text,
|
spoiler_text: compose.spoiler_text,
|
||||||
visibility: compose.privacy,
|
visibility: compose.privacy,
|
||||||
content_type: compose.content_type,
|
content_type: contentType,
|
||||||
poll: compose.poll,
|
poll: compose.poll,
|
||||||
scheduled_at: compose.schedule,
|
scheduled_at: compose.schedule,
|
||||||
to,
|
to,
|
||||||
|
@ -392,9 +394,6 @@ 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);
|
||||||
|
@ -412,67 +411,49 @@ 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
|
|
||||||
/* eslint-disable no-loop-func */
|
|
||||||
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)));
|
|
||||||
/* eslint-enable no-loop-func */
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadComposeRequest = (composeId: string) => ({
|
||||||
|
type: COMPOSE_UPLOAD_REQUEST,
|
||||||
|
id: composeId,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
|
||||||
|
type: COMPOSE_UPLOAD_PROGRESS,
|
||||||
|
id: composeId,
|
||||||
|
loaded: loaded,
|
||||||
|
total: total,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
|
||||||
|
type: COMPOSE_UPLOAD_SUCCESS,
|
||||||
|
id: composeId,
|
||||||
|
media: media,
|
||||||
|
file,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
|
||||||
|
type: COMPOSE_UPLOAD_FAIL,
|
||||||
|
id: composeId,
|
||||||
|
error: error,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
|
const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
if (!isLoggedIn(getState)) return;
|
if (!isLoggedIn(getState)) return;
|
||||||
|
@ -507,34 +488,6 @@ const changeUploadComposeFail = (composeId: string, id: string, error: AxiosErro
|
||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadComposeRequest = (composeId: string) => ({
|
|
||||||
type: COMPOSE_UPLOAD_REQUEST,
|
|
||||||
id: composeId,
|
|
||||||
skipLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
|
|
||||||
type: COMPOSE_UPLOAD_PROGRESS,
|
|
||||||
id: composeId,
|
|
||||||
loaded: loaded,
|
|
||||||
total: total,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
|
|
||||||
type: COMPOSE_UPLOAD_SUCCESS,
|
|
||||||
id: composeId,
|
|
||||||
media: media,
|
|
||||||
file,
|
|
||||||
skipLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
|
|
||||||
type: COMPOSE_UPLOAD_FAIL,
|
|
||||||
id: composeId,
|
|
||||||
error: error,
|
|
||||||
skipLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const undoUploadCompose = (composeId: string, media_id: string) => ({
|
const undoUploadCompose = (composeId: string, media_id: string) => ({
|
||||||
type: COMPOSE_UPLOAD_UNDO,
|
type: COMPOSE_UPLOAD_UNDO,
|
||||||
id: composeId,
|
id: composeId,
|
||||||
|
@ -554,8 +507,8 @@ const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolea
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearComposeSuggestions = (composeId: string) => {
|
const clearComposeSuggestions = (composeId: string) => {
|
||||||
if (cancelFetchComposeSuggestionsAccounts) {
|
if (cancelFetchComposeSuggestions) {
|
||||||
cancelFetchComposeSuggestionsAccounts();
|
cancelFetchComposeSuggestions();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_SUGGESTIONS_CLEAR,
|
type: COMPOSE_SUGGESTIONS_CLEAR,
|
||||||
|
@ -564,12 +517,12 @@ const clearComposeSuggestions = (composeId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
|
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
|
||||||
if (cancelFetchComposeSuggestionsAccounts) {
|
if (cancelFetchComposeSuggestions) {
|
||||||
cancelFetchComposeSuggestionsAccounts(composeId);
|
cancelFetchComposeSuggestions(composeId);
|
||||||
}
|
}
|
||||||
api(getState).get('/api/v1/accounts/search', {
|
api(getState).get('/api/v1/accounts/search', {
|
||||||
cancelToken: new CancelToken(cancel => {
|
cancelToken: new CancelToken(cancel => {
|
||||||
cancelFetchComposeSuggestionsAccounts = cancel;
|
cancelFetchComposeSuggestions = cancel;
|
||||||
}),
|
}),
|
||||||
params: {
|
params: {
|
||||||
q: token.slice(1),
|
q: token.slice(1),
|
||||||
|
@ -594,10 +547,37 @@ const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => Ro
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
|
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
|
||||||
|
if (cancelFetchComposeSuggestions) {
|
||||||
|
cancelFetchComposeSuggestions(composeId);
|
||||||
|
}
|
||||||
|
|
||||||
const state = getState();
|
const state = getState();
|
||||||
|
|
||||||
|
const instance = state.instance;
|
||||||
|
const { trends } = getFeatures(instance);
|
||||||
|
|
||||||
|
if (trends) {
|
||||||
const currentTrends = state.trends.items;
|
const currentTrends = state.trends.items;
|
||||||
|
|
||||||
dispatch(updateSuggestionTags(composeId, token, currentTrends));
|
return dispatch(updateSuggestionTags(composeId, token, currentTrends));
|
||||||
|
}
|
||||||
|
|
||||||
|
api(getState).get('/api/v2/search', {
|
||||||
|
cancelToken: new CancelToken(cancel => {
|
||||||
|
cancelFetchComposeSuggestions = cancel;
|
||||||
|
}),
|
||||||
|
params: {
|
||||||
|
q: token.slice(1),
|
||||||
|
limit: 4,
|
||||||
|
type: 'hashtags',
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
dispatch(updateSuggestionTags(composeId, token, response.data?.hashtags.map(normalizeTag)));
|
||||||
|
}).catch(error => {
|
||||||
|
if (!isCancel(error)) {
|
||||||
|
toast.showAlertForError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchComposeSuggestions = (composeId: string, token: string) =>
|
const fetchComposeSuggestions = (composeId: string, token: string) =>
|
||||||
|
@ -675,11 +655,11 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
|
||||||
dispatch(action);
|
dispatch(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList<Tag>) => ({
|
const updateSuggestionTags = (composeId: string, token: string, tags: ImmutableList<Tag>) => ({
|
||||||
type: COMPOSE_SUGGESTION_TAGS_UPDATE,
|
type: COMPOSE_SUGGESTION_TAGS_UPDATE,
|
||||||
id: composeId,
|
id: composeId,
|
||||||
token,
|
token,
|
||||||
currentTrends,
|
tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateTagHistory = (composeId: string, tags: string[]) => ({
|
const updateTagHistory = (composeId: string, tags: string[]) => ({
|
||||||
|
@ -861,6 +841,12 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setEditorState = (composeId: string, editorState: EditorState | string | null) => ({
|
||||||
|
type: COMPOSE_EDITOR_STATE_SET,
|
||||||
|
id: composeId,
|
||||||
|
editorState: editorState,
|
||||||
|
});
|
||||||
|
|
||||||
type ComposeAction =
|
type ComposeAction =
|
||||||
ComposeSetStatusAction
|
ComposeSetStatusAction
|
||||||
| ReturnType<typeof changeCompose>
|
| ReturnType<typeof changeCompose>
|
||||||
|
@ -906,6 +892,7 @@ type ComposeAction =
|
||||||
| ComposeAddToMentionsAction
|
| ComposeAddToMentionsAction
|
||||||
| ComposeRemoveFromMentionsAction
|
| ComposeRemoveFromMentionsAction
|
||||||
| ComposeEventReplyAction
|
| ComposeEventReplyAction
|
||||||
|
| ReturnType<typeof setEditorState>
|
||||||
|
|
||||||
export {
|
export {
|
||||||
COMPOSE_CHANGE,
|
COMPOSE_CHANGE,
|
||||||
|
@ -952,6 +939,7 @@ export {
|
||||||
COMPOSE_ADD_TO_MENTIONS,
|
COMPOSE_ADD_TO_MENTIONS,
|
||||||
COMPOSE_REMOVE_FROM_MENTIONS,
|
COMPOSE_REMOVE_FROM_MENTIONS,
|
||||||
COMPOSE_SET_STATUS,
|
COMPOSE_SET_STATUS,
|
||||||
|
COMPOSE_EDITOR_STATE_SET,
|
||||||
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
|
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
|
||||||
setComposeToStatus,
|
setComposeToStatus,
|
||||||
changeCompose,
|
changeCompose,
|
||||||
|
@ -968,6 +956,7 @@ export {
|
||||||
submitComposeRequest,
|
submitComposeRequest,
|
||||||
submitComposeSuccess,
|
submitComposeSuccess,
|
||||||
submitComposeFail,
|
submitComposeFail,
|
||||||
|
uploadFile,
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
changeUploadCompose,
|
changeUploadCompose,
|
||||||
changeUploadComposeRequest,
|
changeUploadComposeRequest,
|
||||||
|
@ -1006,5 +995,6 @@ export {
|
||||||
addToMentions,
|
addToMentions,
|
||||||
removeFromMentions,
|
removeFromMentions,
|
||||||
eventDiscussionCompose,
|
eventDiscussionCompose,
|
||||||
|
setEditorState,
|
||||||
type ComposeAction,
|
type ComposeAction,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
@ -15,73 +13,74 @@ import {
|
||||||
} from './statuses';
|
} from './statuses';
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
|
import type { ReducerStatus } from 'soapbox/reducers/statuses';
|
||||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||||
import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST';
|
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST' as const;
|
||||||
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS';
|
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS' as const;
|
||||||
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL';
|
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL' as const;
|
||||||
|
|
||||||
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE';
|
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE' as const;
|
||||||
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE';
|
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE' as const;
|
||||||
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE';
|
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE' as const;
|
||||||
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE';
|
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE' as const;
|
||||||
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE';
|
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE' as const;
|
||||||
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE';
|
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE' as const;
|
||||||
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE';
|
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE' as const;
|
||||||
|
|
||||||
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST';
|
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST' as const;
|
||||||
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS';
|
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS' as const;
|
||||||
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS';
|
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS' as const;
|
||||||
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL';
|
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL' as const;
|
||||||
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO';
|
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO' as const;
|
||||||
|
|
||||||
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST';
|
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST' as const;
|
||||||
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS';
|
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS' as const;
|
||||||
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL';
|
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST';
|
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST' as const;
|
||||||
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS';
|
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS' as const;
|
||||||
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL';
|
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST';
|
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST' as const;
|
||||||
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS';
|
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS' as const;
|
||||||
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL';
|
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST';
|
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST' as const;
|
||||||
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS';
|
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS' as const;
|
||||||
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL';
|
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST';
|
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST' as const;
|
||||||
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS';
|
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS' as const;
|
||||||
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL';
|
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST';
|
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST' as const;
|
||||||
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS';
|
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS' as const;
|
||||||
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL';
|
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST';
|
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST' as const;
|
||||||
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS';
|
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS' as const;
|
||||||
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL';
|
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST';
|
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST' as const;
|
||||||
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS';
|
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS' as const;
|
||||||
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL';
|
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST';
|
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST' as const;
|
||||||
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS';
|
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS' as const;
|
||||||
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL';
|
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL' as const;
|
||||||
|
|
||||||
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL';
|
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL' as const;
|
||||||
|
|
||||||
const EVENT_FORM_SET = 'EVENT_FORM_SET';
|
const EVENT_FORM_SET = 'EVENT_FORM_SET' as const;
|
||||||
|
|
||||||
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST';
|
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST' as const;
|
||||||
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS';
|
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS' as const;
|
||||||
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL';
|
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL' as const;
|
||||||
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST';
|
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST' as const;
|
||||||
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS';
|
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS' as const;
|
||||||
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL';
|
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL' as const;
|
||||||
|
|
||||||
const noOp = () => new Promise(f => f(undefined));
|
const noOp = () => new Promise(f => f(undefined));
|
||||||
|
|
||||||
|
@ -153,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 = () => ({
|
||||||
|
@ -576,6 +544,13 @@ const cancelEventCompose = () => ({
|
||||||
type: EVENT_COMPOSE_CANCEL,
|
type: EVENT_COMPOSE_CANCEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface EventFormSetAction {
|
||||||
|
type: typeof EVENT_FORM_SET
|
||||||
|
status: ReducerStatus
|
||||||
|
text: string
|
||||||
|
location: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const status = getState().statuses.get(id)!;
|
const status = getState().statuses.get(id)!;
|
||||||
|
|
||||||
|
@ -637,6 +612,10 @@ const fetchJoinedEvents = () =>
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EventsAction =
|
||||||
|
| ReturnType<typeof cancelEventCompose>
|
||||||
|
| EventFormSetAction;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
LOCATION_SEARCH_REQUEST,
|
LOCATION_SEARCH_REQUEST,
|
||||||
LOCATION_SEARCH_SUCCESS,
|
LOCATION_SEARCH_SUCCESS,
|
||||||
|
@ -743,4 +722,5 @@ export {
|
||||||
editEvent,
|
editEvent,
|
||||||
fetchRecentEvents,
|
fetchRecentEvents,
|
||||||
fetchJoinedEvents,
|
fetchJoinedEvents,
|
||||||
|
type EventsAction,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -35,7 +35,6 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
if (onClick) onClick();
|
if (onClick) onClick();
|
||||||
|
|
||||||
|
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
history.push(item.to);
|
history.push(item.to);
|
||||||
|
|
|
@ -92,9 +92,9 @@
|
||||||
@apply w-5 h-5 m-0;
|
@apply w-5 h-5 m-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide Markdown images (Pleroma) */
|
/* Markdown inline images (Pleroma) */
|
||||||
[data-markup] img:not(.emojione) {
|
[data-markup] img:not(.emojione) {
|
||||||
@apply hidden;
|
@apply max-h-[500px] mx-auto rounded-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User setting to underline links */
|
/* User setting to underline links */
|
||||||
|
|
|
@ -20,7 +20,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
|
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
|
||||||
return !!compose && [
|
return !!compose && [
|
||||||
compose.text.length > 0,
|
compose.editorState && compose.editorState.length > 0,
|
||||||
compose.spoiler_text.length > 0,
|
compose.spoiler_text.length > 0,
|
||||||
compose.media_attachments.size > 0,
|
compose.media_attachments.size > 0,
|
||||||
compose.poll !== null,
|
compose.poll !== null,
|
||||||
|
|
|
@ -5,16 +5,21 @@ import { joinPublicPath } from 'soapbox/utils/static';
|
||||||
|
|
||||||
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
/** Unicode emoji character. */
|
/** Unicode emoji character. */
|
||||||
emoji: string
|
emoji?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single emoji image. */
|
/** A single emoji image. */
|
||||||
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
||||||
const { emoji, alt, src, ...rest } = props;
|
const { emoji, alt, src, ...rest } = props;
|
||||||
const codepoints = toCodePoints(removeVS16s(emoji));
|
|
||||||
const filename = codepoints.join('-');
|
|
||||||
|
|
||||||
if (!filename) return null;
|
let filename;
|
||||||
|
|
||||||
|
if (emoji) {
|
||||||
|
const codepoints = toCodePoints(removeVS16s(emoji));
|
||||||
|
filename = codepoints.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filename && !src) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
|
import InlineSVG, { Props as InlineSVGProps } from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
|
||||||
|
|
||||||
interface ISvgIcon {
|
interface ISvgIcon extends InlineSVGProps {
|
||||||
/** Class name for the <svg> */
|
/** Class name for the <svg> */
|
||||||
className?: string
|
className?: string
|
||||||
/** Tooltip text for the icon. */
|
/** Tooltip text for the icon. */
|
||||||
|
|
|
@ -84,7 +84,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
||||||
type={revealed ? 'text' : type}
|
type={revealed ? 'text' : type}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={clsx('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', {
|
className={clsx('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', {
|
||||||
'block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
|
'block w-full sm:text-sm ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
|
||||||
['normal', 'search'].includes(theme),
|
['normal', 'search'].includes(theme),
|
||||||
'text-gray-900 dark:text-gray-100': !props.disabled,
|
'text-gray-900 dark:text-gray-100': !props.disabled,
|
||||||
'text-gray-600': props.disabled,
|
'text-gray-600': props.disabled,
|
||||||
|
|
|
@ -15,7 +15,6 @@ const Portal: React.FC<IPortal> = ({ children }) => {
|
||||||
setIsRendered(true);
|
setIsRendered(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
if (!isRendered) {
|
if (!isRendered) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,7 +99,6 @@ const findElementPosition = (el: HTMLElement) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
|
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
|
||||||
const box = findElementPosition(el);
|
const box = findElementPosition(el);
|
||||||
const boxW = el.offsetWidth;
|
const boxW = el.offsetWidth;
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { spring } from 'react-motion';
|
import { spring } from 'react-motion';
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import IconButton from 'soapbox/components/icon-button';
|
import { HStack, IconButton } from 'soapbox/components/ui';
|
||||||
import Motion from 'soapbox/features/ui/util/optional-motion';
|
import Motion from 'soapbox/features/ui/util/optional-motion';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
import { Attachment } from 'soapbox/types/entities';
|
import { Attachment } from 'soapbox/types/entities';
|
||||||
|
@ -57,6 +57,7 @@ export const MIMETYPE_ICONS: Record<string, string> = {
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
||||||
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
|
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
|
||||||
|
preview: { id: 'upload_form.preview', defaultMessage: 'Preview' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IUpload {
|
interface IUpload {
|
||||||
|
@ -158,24 +159,28 @@ const Upload: React.FC<IUpload> = ({
|
||||||
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
|
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
|
||||||
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
|
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
|
||||||
>
|
>
|
||||||
<div className={clsx('compose-form__upload__actions', { active })}>
|
<HStack className='absolute right-2 top-2 z-10' space={2}>
|
||||||
{onDelete && (
|
|
||||||
<IconButton
|
|
||||||
onClick={handleUndoClick}
|
|
||||||
src={require('@tabler/icons/x.svg')}
|
|
||||||
text={<FormattedMessage id='upload_form.undo' defaultMessage='Delete' />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Only display the "Preview" button for a valid attachment with a URL */}
|
|
||||||
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
|
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleOpenModal}
|
onClick={handleOpenModal}
|
||||||
src={require('@tabler/icons/zoom-in.svg')}
|
src={require('@tabler/icons/zoom-in.svg')}
|
||||||
text={<FormattedMessage id='upload_form.preview' defaultMessage='Preview' />}
|
theme='dark'
|
||||||
|
className='hover:scale-105 hover:bg-gray-900'
|
||||||
|
iconClassName='h-5 w-5'
|
||||||
|
title={intl.formatMessage(messages.preview)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
{onDelete && (
|
||||||
|
<IconButton
|
||||||
|
onClick={handleUndoClick}
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
theme='dark'
|
||||||
|
className='hover:scale-105 hover:bg-gray-900'
|
||||||
|
iconClassName='h-5 w-5'
|
||||||
|
title={intl.formatMessage(messages.delete)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
{onDescriptionChange && (
|
{onDescriptionChange && (
|
||||||
<div className={clsx('compose-form__upload-description', { active })}>
|
<div className={clsx('compose-form__upload-description', { active })}>
|
||||||
|
|
|
@ -12,5 +12,4 @@ function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export { parseEntitiesPath };
|
export { parseEntitiesPath };
|
|
@ -37,7 +37,6 @@ const UserIndex: React.FC = () => {
|
||||||
updateQuery();
|
updateQuery();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const hasMore = items.count() < total && !!next;
|
const hasMore = items.count() < total && !!next;
|
||||||
|
|
||||||
const showLoading = isLoading && items.isEmpty();
|
const showLoading = isLoading && items.isEmpty();
|
||||||
|
|
|
@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
|
||||||
|
|
||||||
import { ChatContext } from 'soapbox/contexts/chat-context';
|
import { ChatContext } from 'soapbox/contexts/chat-context';
|
||||||
import { buildAccount } from 'soapbox/jest/factory';
|
import { buildAccount } from 'soapbox/jest/factory';
|
||||||
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
|
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { CLEAR_EDITOR_COMMAND, type LexicalEditor } from 'lexical';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { defineMessages, FormattedMessage, MessageDescriptor, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
|
|
||||||
|
@ -17,6 +18,8 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest
|
||||||
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
||||||
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
||||||
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
||||||
|
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||||
|
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
|
||||||
import { isMobile } from 'soapbox/is-mobile';
|
import { isMobile } from 'soapbox/is-mobile';
|
||||||
|
|
||||||
|
@ -49,7 +52,6 @@ const messages = defineMessages({
|
||||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
||||||
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
|
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
|
||||||
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
|
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
|
||||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
|
|
||||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||||
message: { id: 'compose_form.message', defaultMessage: 'Message' },
|
message: { id: 'compose_form.message', defaultMessage: 'Message' },
|
||||||
|
@ -75,12 +77,24 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
const compose = useCompose(id);
|
const compose = useCompose(id);
|
||||||
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
|
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
|
||||||
const isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE'));
|
|
||||||
const maxTootChars = configuration.getIn(['statuses', 'max_characters']) as number;
|
const maxTootChars = configuration.getIn(['statuses', 'max_characters']) as number;
|
||||||
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
|
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
|
||||||
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt, group_id: groupId } = compose;
|
const {
|
||||||
|
spoiler,
|
||||||
|
spoiler_text: spoilerText,
|
||||||
|
privacy,
|
||||||
|
focusDate,
|
||||||
|
caretPosition,
|
||||||
|
is_submitting: isSubmitting,
|
||||||
|
is_changing_upload:
|
||||||
|
isChangingUpload,
|
||||||
|
is_uploading: isUploading,
|
||||||
|
schedule: scheduledAt,
|
||||||
|
group_id: groupId,
|
||||||
|
} = compose;
|
||||||
|
|
||||||
const prevSpoiler = usePrevious(spoiler);
|
const prevSpoiler = usePrevious(spoiler);
|
||||||
|
|
||||||
const hasPoll = !!compose.poll;
|
const hasPoll = !!compose.poll;
|
||||||
|
@ -88,25 +102,16 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
const anyMedia = compose.media_attachments.size > 0;
|
const anyMedia = compose.media_attachments.size > 0;
|
||||||
|
|
||||||
const [composeFocused, setComposeFocused] = useState(false);
|
const [composeFocused, setComposeFocused] = useState(false);
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
const firstRender = useRef(true);
|
const firstRender = useRef(true);
|
||||||
const formRef = useRef<HTMLDivElement>(null);
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
const spoilerTextRef = useRef<AutosuggestInput>(null);
|
const spoilerTextRef = useRef<AutosuggestInput>(null);
|
||||||
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
|
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
|
||||||
|
const editorRef = useRef<LexicalEditor>(null);
|
||||||
|
|
||||||
const { isDraggedOver } = useDraggedFiles(formRef);
|
const { isDraggedOver } = useDraggedFiles(formRef);
|
||||||
|
|
||||||
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
|
|
||||||
dispatch(changeCompose(id, e.target.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown: React.KeyboardEventHandler = (e) => {
|
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
|
||||||
handleSubmit();
|
|
||||||
e.preventDefault(); // Prevent bubbling to other ComposeForm instances
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getClickableArea = () => {
|
const getClickableArea = () => {
|
||||||
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
|
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
|
||||||
};
|
};
|
||||||
|
@ -141,11 +146,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e?: React.FormEvent<Element>) => {
|
const handleSubmit = (e?: React.FormEvent<Element>) => {
|
||||||
if (text !== autosuggestTextareaRef.current?.textarea?.value) {
|
dispatch(changeCompose(id, text));
|
||||||
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
|
||||||
// Update the state to match the current text
|
|
||||||
dispatch(changeCompose(id, autosuggestTextareaRef.current!.textarea!.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit disabled:
|
// Submit disabled:
|
||||||
const fulltext = [spoilerText, countableText(text)].join('');
|
const fulltext = [spoilerText, countableText(text)].join('');
|
||||||
|
@ -159,6 +160,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(submitCompose(id, history));
|
dispatch(submitCompose(id, history));
|
||||||
|
editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSuggestionsClearRequested = () => {
|
const onSuggestionsClearRequested = () => {
|
||||||
|
@ -169,10 +171,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
dispatch(fetchComposeSuggestions(id, token as string));
|
dispatch(fetchComposeSuggestions(id, token as string));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSuggestionSelected = (tokenStart: number, token: string | null, value: string | undefined) => {
|
|
||||||
if (value) dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['text']));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
|
const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
|
||||||
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
|
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
|
||||||
};
|
};
|
||||||
|
@ -249,18 +247,34 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
|
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
|
||||||
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
||||||
|
|
||||||
let publishText: string = '';
|
const composeModifiers = !condensed && (
|
||||||
let publishIcon: string | undefined;
|
<Stack space={4} className='compose-form__modifiers'>
|
||||||
let textareaPlaceholder: MessageDescriptor;
|
<UploadForm composeId={id} />
|
||||||
|
<PollForm composeId={id} />
|
||||||
|
|
||||||
|
<SpoilerInput
|
||||||
|
composeId={id}
|
||||||
|
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||||
|
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||||
|
onSuggestionSelected={onSpoilerSuggestionSelected}
|
||||||
|
ref={spoilerTextRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScheduleFormContainer composeId={id} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
let publishText: string | JSX.Element = '';
|
||||||
|
let publishIcon: string | undefined = undefined;
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
publishText = intl.formatMessage(messages.saveChanges);
|
publishText = intl.formatMessage(messages.saveChanges);
|
||||||
} else if (privacy === 'direct') {
|
} else if (privacy === 'direct') {
|
||||||
publishText = intl.formatMessage(messages.message);
|
|
||||||
publishIcon = require('@tabler/icons/mail.svg');
|
publishIcon = require('@tabler/icons/mail.svg');
|
||||||
|
publishText = intl.formatMessage(messages.message);
|
||||||
} else if (privacy === 'private') {
|
} else if (privacy === 'private') {
|
||||||
publishText = intl.formatMessage(messages.publish);
|
|
||||||
publishIcon = require('@tabler/icons/lock.svg');
|
publishIcon = require('@tabler/icons/lock.svg');
|
||||||
|
publishText = intl.formatMessage(messages.publish);
|
||||||
} else {
|
} else {
|
||||||
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
||||||
}
|
}
|
||||||
|
@ -269,14 +283,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
publishText = intl.formatMessage(messages.schedule);
|
publishText = intl.formatMessage(messages.schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event) {
|
|
||||||
textareaPlaceholder = messages.eventPlaceholder;
|
|
||||||
} else if (hasPoll) {
|
|
||||||
textareaPlaceholder = messages.pollPlaceholder;
|
|
||||||
} else {
|
|
||||||
textareaPlaceholder = messages.placeholder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||||
{scheduledStatusCount > 0 && !event && !group && (
|
{scheduledStatusCount > 0 && !event && !group && (
|
||||||
|
@ -306,42 +312,26 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
||||||
|
|
||||||
<AutosuggestTextarea
|
<div>
|
||||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
<Bundle fetchComponent={ComposeEditor}>
|
||||||
placeholder={intl.formatMessage(textareaPlaceholder)}
|
{(Component: any) => (
|
||||||
disabled={disabled}
|
<Component
|
||||||
value={text}
|
ref={editorRef}
|
||||||
onChange={handleChange}
|
className='mt-2'
|
||||||
suggestions={suggestions}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={handleComposeFocus}
|
|
||||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
|
||||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
|
||||||
onSuggestionSelected={onSuggestionSelected}
|
|
||||||
onPaste={onPaste}
|
|
||||||
autoFocus={shouldAutoFocus}
|
|
||||||
condensed={condensed}
|
|
||||||
id='compose-textarea'
|
|
||||||
>
|
|
||||||
{
|
|
||||||
!condensed && (
|
|
||||||
<Stack space={4} className='compose-form__modifiers'>
|
|
||||||
<UploadForm composeId={id} />
|
|
||||||
<PollForm composeId={id} />
|
|
||||||
|
|
||||||
<SpoilerInput
|
|
||||||
composeId={id}
|
composeId={id}
|
||||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
condensed={condensed}
|
||||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
eventDiscussion={!!event}
|
||||||
onSuggestionSelected={onSpoilerSuggestionSelected}
|
autoFocus={shouldAutoFocus}
|
||||||
ref={spoilerTextRef}
|
hasPoll={hasPoll}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
onChange={setText}
|
||||||
|
onFocus={handleComposeFocus}
|
||||||
|
onPaste={onPaste}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<ScheduleFormContainer composeId={id} />
|
</Bundle>
|
||||||
</Stack>
|
{composeModifiers}
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
</AutosuggestTextarea>
|
|
||||||
|
|
||||||
<QuotedStatusContainer composeId={id} />
|
<QuotedStatusContainer composeId={id} />
|
||||||
|
|
||||||
|
@ -362,7 +352,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} />
|
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={disabledButton} />
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -10,7 +10,7 @@ const messages = defineMessages({
|
||||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
|
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const onlyImages = (types: ImmutableList<string>) => {
|
export const onlyImages = (types: ImmutableList<string>) => {
|
||||||
return Boolean(types && types.every(type => type.startsWith('image/')));
|
return Boolean(types && types.every(type => type.startsWith('image/')));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { HStack } from 'soapbox/components/ui';
|
||||||
import { useCompose } from 'soapbox/hooks';
|
import { useCompose } from 'soapbox/hooks';
|
||||||
|
|
||||||
import Upload from './upload';
|
import Upload from './upload';
|
||||||
|
@ -14,19 +15,16 @@ interface IUploadForm {
|
||||||
|
|
||||||
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
|
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
|
||||||
const mediaIds = useCompose(composeId).media_attachments.map((item: AttachmentEntity) => item.id);
|
const mediaIds = useCompose(composeId).media_attachments.map((item: AttachmentEntity) => item.id);
|
||||||
const classes = clsx('compose-form__uploads-wrapper', {
|
|
||||||
'contains-media': mediaIds.size !== 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose-form__upload-wrapper'>
|
<div className='overflow-hidden'>
|
||||||
<UploadProgress composeId={composeId} />
|
<UploadProgress composeId={composeId} />
|
||||||
|
|
||||||
<div className={classes}>
|
<HStack wrap className={clsx('overflow-hidden', mediaIds.size !== 0 && 'p-1')}>
|
||||||
{mediaIds.map((id: string) => (
|
{mediaIds.map((id: string) => (
|
||||||
<Upload id={id} key={id} composeId={composeId} />
|
<Upload id={id} key={id} composeId={composeId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the `/src/features/compose/editor` directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AutoLinkPlugin, createLinkMatcherWithRegExp } from '@lexical/react/LexicalAutoLinkPlugin';
|
||||||
|
import { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin';
|
||||||
|
import { LexicalComposer, InitialConfigType } from '@lexical/react/LexicalComposer';
|
||||||
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||||
|
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||||
|
import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin';
|
||||||
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||||
|
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||||
|
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { $createParagraphNode, $createTextNode, $getRoot, type LexicalEditor } from 'lexical';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import { useNodes } from './nodes';
|
||||||
|
import AutosuggestPlugin from './plugins/autosuggest-plugin';
|
||||||
|
import FocusPlugin from './plugins/focus-plugin';
|
||||||
|
import MentionPlugin from './plugins/mention-plugin';
|
||||||
|
import RefPlugin from './plugins/ref-plugin';
|
||||||
|
import StatePlugin from './plugins/state-plugin';
|
||||||
|
|
||||||
|
const LINK_MATCHERS = [
|
||||||
|
createLinkMatcherWithRegExp(
|
||||||
|
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/,
|
||||||
|
(text) => text.startsWith('http') ? text : `https://${text}`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
interface IComposeEditor {
|
||||||
|
className?: string
|
||||||
|
placeholderClassName?: string
|
||||||
|
composeId: string
|
||||||
|
condensed?: boolean
|
||||||
|
eventDiscussion?: boolean
|
||||||
|
hasPoll?: boolean
|
||||||
|
autoFocus?: boolean
|
||||||
|
handleSubmit?(): void
|
||||||
|
onPaste?(files: FileList): void
|
||||||
|
onChange?(text: string): void
|
||||||
|
onFocus?: React.FocusEventHandler<HTMLDivElement>
|
||||||
|
placeholder?: JSX.Element | string
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme: InitialConfigType['theme'] = {
|
||||||
|
hashtag: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
|
||||||
|
mention: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
|
||||||
|
link: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
|
||||||
|
text: {
|
||||||
|
bold: 'font-bold',
|
||||||
|
code: 'font-mono',
|
||||||
|
italic: 'italic',
|
||||||
|
strikethrough: 'line-through',
|
||||||
|
underline: 'underline',
|
||||||
|
underlineStrikethrough: 'underline-line-through',
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
h1: 'text-2xl font-bold',
|
||||||
|
h2: 'text-xl font-bold',
|
||||||
|
h3: 'text-lg font-semibold',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
|
||||||
|
className,
|
||||||
|
placeholderClassName,
|
||||||
|
composeId,
|
||||||
|
condensed,
|
||||||
|
eventDiscussion,
|
||||||
|
hasPoll,
|
||||||
|
autoFocus,
|
||||||
|
handleSubmit,
|
||||||
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
onPaste,
|
||||||
|
placeholder,
|
||||||
|
}, ref) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const nodes = useNodes();
|
||||||
|
|
||||||
|
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
|
||||||
|
|
||||||
|
const initialConfig = useMemo<InitialConfigType>(() => ({
|
||||||
|
namespace: 'ComposeForm',
|
||||||
|
onError: console.error,
|
||||||
|
nodes,
|
||||||
|
theme,
|
||||||
|
editorState: dispatch((_, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const compose = state.compose.get(composeId);
|
||||||
|
|
||||||
|
if (!compose) return;
|
||||||
|
|
||||||
|
if (compose.editorState) {
|
||||||
|
return compose.editorState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
const textNode = $createTextNode(compose.text);
|
||||||
|
|
||||||
|
paragraph.append(textNode);
|
||||||
|
|
||||||
|
$getRoot()
|
||||||
|
.clear()
|
||||||
|
.append(paragraph);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const handlePaste: React.ClipboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
if (onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
|
||||||
|
onPaste(e.clipboardData.files);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let textareaPlaceholder = placeholder || <FormattedMessage id='compose_form.placeholder' defaultMessage="What's on your mind?" />;
|
||||||
|
|
||||||
|
if (eventDiscussion) {
|
||||||
|
textareaPlaceholder = <FormattedMessage id='compose_form.event_placeholder' defaultMessage='Post to this event' />;
|
||||||
|
} else if (hasPoll) {
|
||||||
|
textareaPlaceholder = <FormattedMessage id='compose_form.poll_placeholder' defaultMessage='Add a poll topic…' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LexicalComposer initialConfig={initialConfig}>
|
||||||
|
<div className={clsx('relative', className)}>
|
||||||
|
<PlainTextPlugin
|
||||||
|
contentEditable={
|
||||||
|
<div onFocus={onFocus} onPaste={handlePaste}>
|
||||||
|
<ContentEditable
|
||||||
|
className={clsx('relative z-10 text-[1rem] outline-none transition-[min-height] motion-reduce:transition-none', {
|
||||||
|
'min-h-[39px]': condensed,
|
||||||
|
'min-h-[99px]': !condensed,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placeholder={(
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'pointer-events-none absolute top-0 select-none text-[1rem] text-gray-600 dark:placeholder:text-gray-600',
|
||||||
|
placeholderClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{textareaPlaceholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
|
/>
|
||||||
|
<OnChangePlugin onChange={(_, editor) => {
|
||||||
|
onChange?.(editor.getEditorState().read(() => $getRoot().getTextContent()));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HistoryPlugin />
|
||||||
|
<HashtagPlugin />
|
||||||
|
<MentionPlugin />
|
||||||
|
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
|
||||||
|
<AutoLinkPlugin matchers={LINK_MATCHERS} />
|
||||||
|
<StatePlugin composeId={composeId} handleSubmit={handleSubmit} />
|
||||||
|
<FocusPlugin autoFocus={autoFocus} />
|
||||||
|
<ClearEditorPlugin />
|
||||||
|
<RefPlugin ref={ref} />
|
||||||
|
</div>
|
||||||
|
</LexicalComposer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ComposeEditor;
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Emoji } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DOMExportOutput,
|
||||||
|
EditorConfig,
|
||||||
|
LexicalNode,
|
||||||
|
NodeKey,
|
||||||
|
SerializedLexicalNode,
|
||||||
|
Spread,
|
||||||
|
} from 'lexical';
|
||||||
|
|
||||||
|
type SerializedEmojiNode = Spread<{
|
||||||
|
name: string
|
||||||
|
src: string
|
||||||
|
type: 'emoji'
|
||||||
|
version: 1
|
||||||
|
}, SerializedLexicalNode>;
|
||||||
|
|
||||||
|
class EmojiNode extends DecoratorNode<JSX.Element> {
|
||||||
|
|
||||||
|
__name: string;
|
||||||
|
__src: string;
|
||||||
|
|
||||||
|
static getType(): 'emoji' {
|
||||||
|
return 'emoji';
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node: EmojiNode): EmojiNode {
|
||||||
|
return new EmojiNode(node.__name, node.__src);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(name: string, src: string, key?: NodeKey) {
|
||||||
|
super(key);
|
||||||
|
this.__name = name;
|
||||||
|
this.__src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM(config: EditorConfig): HTMLElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
const theme = config.theme;
|
||||||
|
const className = theme.emoji;
|
||||||
|
if (className !== undefined) {
|
||||||
|
span.className = className;
|
||||||
|
}
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDOM(): false {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDOM(): DOMExportOutput {
|
||||||
|
const element = document.createElement('img');
|
||||||
|
element.setAttribute('src', this.__src);
|
||||||
|
element.setAttribute('alt', this.__name);
|
||||||
|
element.classList.add('h-4', 'w-4');
|
||||||
|
return { element };
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
|
||||||
|
const { name, src } =
|
||||||
|
serializedNode;
|
||||||
|
const node = $createEmojiNode(name, src);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON(): SerializedEmojiNode {
|
||||||
|
return {
|
||||||
|
name: this.__name,
|
||||||
|
src: this.__src,
|
||||||
|
type: 'emoji',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
canInsertTextBefore(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTextEntity(): true {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
decorate(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Emoji src={this.__src} alt={this.__name} className='emojione h-4 w-4' />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src));
|
||||||
|
|
||||||
|
const $isEmojiNode = (
|
||||||
|
node: LexicalNode | null | undefined,
|
||||||
|
): node is EmojiNode => node instanceof EmojiNode;
|
||||||
|
|
||||||
|
export { EmojiNode, $createEmojiNode, $isEmojiNode };
|
|
@ -0,0 +1,359 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
||||||
|
import { mergeRegister } from '@lexical/utils';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
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 { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { HStack, IconButton } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import { $isImageNode } from './image-node';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GridSelection,
|
||||||
|
LexicalEditor,
|
||||||
|
NodeKey,
|
||||||
|
NodeSelection,
|
||||||
|
RangeSelection,
|
||||||
|
} from 'lexical';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageCache = new Set();
|
||||||
|
|
||||||
|
const 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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImageComponent = ({
|
||||||
|
src,
|
||||||
|
altText,
|
||||||
|
nodeKey,
|
||||||
|
}: {
|
||||||
|
altText: string
|
||||||
|
nodeKey: NodeKey
|
||||||
|
src: string
|
||||||
|
}): JSX.Element => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
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 [hovered, setHovered] = useState(false);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const deleteNode = useCallback(
|
||||||
|
() => {
|
||||||
|
editor.update(() => {
|
||||||
|
const node = $getNodeByKey(nodeKey);
|
||||||
|
if ($isImageNode(node)) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nodeKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewImage = () => {
|
||||||
|
const image = normalizeAttachment({
|
||||||
|
type: 'image',
|
||||||
|
url: src,
|
||||||
|
altText,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(image), index: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = useCallback(
|
||||||
|
(payload: KeyboardEvent) => {
|
||||||
|
if (isSelected && $isNodeSelection($getSelection())) {
|
||||||
|
const event: KeyboardEvent = payload;
|
||||||
|
event.preventDefault();
|
||||||
|
deleteNode();
|
||||||
|
}
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler = (e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
handleInputBlur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
setFocused(false);
|
||||||
|
|
||||||
|
if (dirtyDescription !== null) {
|
||||||
|
editor.update(() => {
|
||||||
|
const node = $getNodeByKey(nodeKey);
|
||||||
|
if ($isImageNode(node)) {
|
||||||
|
node.setAltText(dirtyDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirtyDescription(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
|
||||||
|
setDirtyDescription(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setHovered(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setHovered(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
setFocused(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setFocused(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 active = hovered || focused;
|
||||||
|
const description = dirtyDescription || (dirtyDescription !== '' && altText) || '';
|
||||||
|
const draggable = isSelected && $isNodeSelection(selection);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<>
|
||||||
|
<div className='relative' draggable={draggable} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
|
||||||
|
<HStack className='absolute right-2 top-2 z-10' space={2}>
|
||||||
|
<IconButton
|
||||||
|
onClick={previewImage}
|
||||||
|
src={require('@tabler/icons/zoom-in.svg')}
|
||||||
|
theme='dark'
|
||||||
|
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
|
||||||
|
iconClassName='h-5 w-5'
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={deleteNode}
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
theme='dark'
|
||||||
|
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
|
||||||
|
iconClassName='h-5 w-5'
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<div className={clsx('compose-form__upload-description', { active })}>
|
||||||
|
<label>
|
||||||
|
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
placeholder={intl.formatMessage(messages.description)}
|
||||||
|
value={description}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LazyImage
|
||||||
|
className={
|
||||||
|
clsx('cursor-default', {
|
||||||
|
'select-none': isSelected,
|
||||||
|
'cursor-grab active:cursor-grabbing': isSelected && $isNodeSelection(selection),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
src={src}
|
||||||
|
altText={altText}
|
||||||
|
imageRef={imageRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageComponent;
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAltText(altText: string): void {
|
||||||
|
const writable = this.getWritable();
|
||||||
|
|
||||||
|
if (altText !== undefined) {
|
||||||
|
writable.__altText = altText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decorate(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ImageComponent
|
||||||
|
src={this.__src}
|
||||||
|
altText={this.__altText}
|
||||||
|
nodeKey={this.getKey()}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const $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,
|
||||||
|
};
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CodeHighlightNode, CodeNode } from '@lexical/code';
|
||||||
|
import { HashtagNode } from '@lexical/hashtag';
|
||||||
|
import { AutoLinkNode, LinkNode } from '@lexical/link';
|
||||||
|
import { ListItemNode, ListNode } from '@lexical/list';
|
||||||
|
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
|
||||||
|
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||||
|
|
||||||
|
import { useFeatures, useInstance } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import { EmojiNode } from './emoji-node';
|
||||||
|
import { ImageNode } from './image-node';
|
||||||
|
import { MentionNode } from './mention-node';
|
||||||
|
|
||||||
|
import type { Klass, LexicalNode } from 'lexical';
|
||||||
|
|
||||||
|
const useNodes = () => {
|
||||||
|
const features = useFeatures();
|
||||||
|
const instance = useInstance();
|
||||||
|
|
||||||
|
const nodes: Array<Klass<LexicalNode>> = [
|
||||||
|
AutoLinkNode,
|
||||||
|
HashtagNode,
|
||||||
|
EmojiNode,
|
||||||
|
MentionNode,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (features.richText) {
|
||||||
|
nodes.push(
|
||||||
|
CodeHighlightNode,
|
||||||
|
CodeNode,
|
||||||
|
HorizontalRuleNode,
|
||||||
|
LinkNode,
|
||||||
|
ListItemNode,
|
||||||
|
ListNode,
|
||||||
|
QuoteNode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_headings'])) nodes.push(HeadingNode);
|
||||||
|
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images'])) nodes.push(ImageNode);
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useNodes };
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addClassNamesToElement } from '@lexical/utils';
|
||||||
|
import { $applyNodeReplacement, TextNode } from 'lexical';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EditorConfig,
|
||||||
|
LexicalNode,
|
||||||
|
NodeKey,
|
||||||
|
SerializedTextNode,
|
||||||
|
} from 'lexical';
|
||||||
|
|
||||||
|
class MentionNode extends TextNode {
|
||||||
|
|
||||||
|
static getType(): string {
|
||||||
|
return 'mention';
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node: MentionNode): MentionNode {
|
||||||
|
return new MentionNode(node.__text, node.__key);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(text: string, key?: NodeKey) {
|
||||||
|
super(text, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM(config: EditorConfig): HTMLElement {
|
||||||
|
const element = super.createDOM(config);
|
||||||
|
addClassNamesToElement(element, config.theme.mention);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode: SerializedTextNode): MentionNode {
|
||||||
|
const node = $createMentionNode(serializedNode.text);
|
||||||
|
node.setFormat(serializedNode.format);
|
||||||
|
node.setDetail(serializedNode.detail);
|
||||||
|
node.setMode(serializedNode.mode);
|
||||||
|
node.setStyle(serializedNode.style);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON(): SerializedTextNode {
|
||||||
|
return {
|
||||||
|
...super.exportJSON(),
|
||||||
|
type: 'mention',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
canInsertTextBefore(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTextEntity(): true {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const $createMentionNode = (text = ''): MentionNode => $applyNodeReplacement(new MentionNode(text));
|
||||||
|
|
||||||
|
const $isMentionNode = (
|
||||||
|
node: LexicalNode | null | undefined,
|
||||||
|
): node is MentionNode => node instanceof MentionNode;
|
||||||
|
|
||||||
|
export { MentionNode, $createMentionNode, $isMentionNode };
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addClassNamesToElement } from '@lexical/utils';
|
||||||
|
import { $applyNodeReplacement, TextNode } from 'lexical';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EditorConfig,
|
||||||
|
LexicalNode,
|
||||||
|
NodeKey,
|
||||||
|
SerializedTextNode,
|
||||||
|
} from 'lexical';
|
||||||
|
|
||||||
|
class MentionNode extends TextNode {
|
||||||
|
|
||||||
|
static getType(): string {
|
||||||
|
return 'mention';
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node: MentionNode): MentionNode {
|
||||||
|
return new MentionNode(node.__text, node.__key);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(text: string, key?: NodeKey) {
|
||||||
|
super(text, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM(config: EditorConfig): HTMLElement {
|
||||||
|
const element = super.createDOM(config);
|
||||||
|
addClassNamesToElement(element, config.theme.mention);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode: SerializedTextNode): MentionNode {
|
||||||
|
const node = $createMentionNode(serializedNode.text);
|
||||||
|
node.setFormat(serializedNode.format);
|
||||||
|
node.setDetail(serializedNode.detail);
|
||||||
|
node.setMode(serializedNode.mode);
|
||||||
|
node.setStyle(serializedNode.style);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON(): SerializedTextNode {
|
||||||
|
return {
|
||||||
|
...super.exportJSON(),
|
||||||
|
type: 'mention',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
canInsertTextBefore(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTextEntity(): true {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const $createMentionNode = (text = ''): MentionNode => $applyNodeReplacement(new MentionNode(text));
|
||||||
|
|
||||||
|
const $isMentionNode = (
|
||||||
|
node: LexicalNode | null | undefined,
|
||||||
|
): node is MentionNode => node instanceof MentionNode;
|
||||||
|
|
||||||
|
export { MentionNode, $createMentionNode, $isMentionNode };
|
|
@ -0,0 +1,561 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { mergeRegister } from '@lexical/utils';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
$getSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
$isTextNode,
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
KEY_ARROW_DOWN_COMMAND,
|
||||||
|
KEY_ARROW_UP_COMMAND,
|
||||||
|
KEY_ENTER_COMMAND,
|
||||||
|
KEY_ESCAPE_COMMAND,
|
||||||
|
KEY_TAB_COMMAND,
|
||||||
|
LexicalEditor,
|
||||||
|
RangeSelection,
|
||||||
|
} from 'lexical';
|
||||||
|
import React, {
|
||||||
|
MutableRefObject,
|
||||||
|
ReactPortal,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
import { clearComposeSuggestions, fetchComposeSuggestions } from 'soapbox/actions/compose';
|
||||||
|
import { useEmoji } from 'soapbox/actions/emojis';
|
||||||
|
import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
|
||||||
|
import { isNativeEmoji } from 'soapbox/features/emoji';
|
||||||
|
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||||
|
import { selectAccount } from 'soapbox/selectors';
|
||||||
|
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||||
|
|
||||||
|
import AutosuggestAccount from '../../components/autosuggest-account';
|
||||||
|
import { $createEmojiNode } from '../nodes/emoji-node';
|
||||||
|
|
||||||
|
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||||
|
|
||||||
|
type QueryMatch = {
|
||||||
|
leadOffset: number
|
||||||
|
matchingString: string
|
||||||
|
};
|
||||||
|
|
||||||
|
type Resolution = {
|
||||||
|
match: QueryMatch
|
||||||
|
getRect: () => DOMRect
|
||||||
|
};
|
||||||
|
|
||||||
|
type MenuRenderFn = (
|
||||||
|
anchorElementRef: MutableRefObject<HTMLElement | null>,
|
||||||
|
) => ReactPortal | JSX.Element | null;
|
||||||
|
|
||||||
|
const tryToPositionRange = (leadOffset: number, range: Range): boolean => {
|
||||||
|
const domSelection = window.getSelection();
|
||||||
|
if (domSelection === null || !domSelection.isCollapsed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const anchorNode = domSelection.anchorNode;
|
||||||
|
const startOffset = leadOffset;
|
||||||
|
const endOffset = domSelection.anchorOffset;
|
||||||
|
|
||||||
|
if (!anchorNode || !endOffset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
range.setStart(anchorNode, startOffset);
|
||||||
|
range.setEnd(anchorNode, endOffset);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelectionOnEntityBoundary = (
|
||||||
|
editor: LexicalEditor,
|
||||||
|
offset: number,
|
||||||
|
): boolean => {
|
||||||
|
if (offset !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return editor.getEditorState().read(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
const anchor = selection.anchor;
|
||||||
|
const anchorNode = anchor.getNode();
|
||||||
|
const prevSibling = anchorNode.getPreviousSibling();
|
||||||
|
return $isTextNode(prevSibling) && prevSibling.isTextEntity();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTransition = (callback: () => void) => {
|
||||||
|
if (React.startTransition) {
|
||||||
|
React.startTransition(callback);
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Got from https://stackoverflow.com/a/42543908/2013580
|
||||||
|
const getScrollParent = (
|
||||||
|
element: HTMLElement,
|
||||||
|
includeHidden: boolean,
|
||||||
|
): HTMLElement | HTMLBodyElement => {
|
||||||
|
let style = getComputedStyle(element);
|
||||||
|
const excludeStaticParent = style.position === 'absolute';
|
||||||
|
const overflowRegex = includeHidden
|
||||||
|
? /(auto|scroll|hidden)/
|
||||||
|
: /(auto|scroll)/;
|
||||||
|
if (style.position === 'fixed') {
|
||||||
|
return document.body;
|
||||||
|
}
|
||||||
|
for (let parent: HTMLElement | null = element; (parent = parent.parentElement);) {
|
||||||
|
style = getComputedStyle(parent);
|
||||||
|
if (excludeStaticParent && style.position === 'static') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) {
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTriggerVisibleInNearestScrollContainer = (
|
||||||
|
targetElement: HTMLElement,
|
||||||
|
containerElement: HTMLElement,
|
||||||
|
): boolean => {
|
||||||
|
const tRect = targetElement.getBoundingClientRect();
|
||||||
|
const cRect = containerElement.getBoundingClientRect();
|
||||||
|
return tRect.top > cRect.top && tRect.top < cRect.bottom;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reposition the menu on scroll, window resize, and element resize.
|
||||||
|
const useDynamicPositioning = (
|
||||||
|
resolution: Resolution | null,
|
||||||
|
targetElement: HTMLElement | null,
|
||||||
|
onReposition: () => void,
|
||||||
|
onVisibilityChange?: (isInView: boolean) => void,
|
||||||
|
) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetElement && resolution) {
|
||||||
|
const rootElement = editor.getRootElement();
|
||||||
|
const rootScrollParent =
|
||||||
|
rootElement
|
||||||
|
? getScrollParent(rootElement, false)
|
||||||
|
: document.body;
|
||||||
|
let ticking = false;
|
||||||
|
let previousIsInView = isTriggerVisibleInNearestScrollContainer(
|
||||||
|
targetElement,
|
||||||
|
rootScrollParent,
|
||||||
|
);
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
onReposition();
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
const isInView = isTriggerVisibleInNearestScrollContainer(
|
||||||
|
targetElement,
|
||||||
|
rootScrollParent,
|
||||||
|
);
|
||||||
|
if (isInView !== previousIsInView) {
|
||||||
|
previousIsInView = isInView;
|
||||||
|
if (onVisibilityChange) {
|
||||||
|
onVisibilityChange(isInView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resizeObserver = new ResizeObserver(onReposition);
|
||||||
|
window.addEventListener('resize', onReposition);
|
||||||
|
document.addEventListener('scroll', handleScroll, {
|
||||||
|
capture: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
resizeObserver.observe(targetElement);
|
||||||
|
return () => {
|
||||||
|
resizeObserver.unobserve(targetElement);
|
||||||
|
window.removeEventListener('resize', onReposition);
|
||||||
|
document.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LexicalPopoverMenu = ({ anchorElementRef, menuRenderFn }: {
|
||||||
|
anchorElementRef: MutableRefObject<HTMLElement>
|
||||||
|
menuRenderFn: MenuRenderFn
|
||||||
|
}): JSX.Element | null => menuRenderFn(anchorElementRef);
|
||||||
|
|
||||||
|
const useMenuAnchorRef = (
|
||||||
|
resolution: Resolution | null,
|
||||||
|
setResolution: (r: Resolution | null) => void,
|
||||||
|
): MutableRefObject<HTMLElement> => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
|
||||||
|
const positionMenu = useCallback(() => {
|
||||||
|
const rootElement = editor.getRootElement();
|
||||||
|
const containerDiv = anchorElementRef.current;
|
||||||
|
|
||||||
|
if (rootElement !== null && resolution !== null) {
|
||||||
|
const { left, top, width, height } = resolution.getRect();
|
||||||
|
containerDiv.style.top = `${top + window.scrollY}px`;
|
||||||
|
containerDiv.style.left = `${left + window.scrollX}px`;
|
||||||
|
containerDiv.style.height = `${height}px`;
|
||||||
|
containerDiv.style.width = `${width}px`;
|
||||||
|
|
||||||
|
if (!containerDiv.isConnected) {
|
||||||
|
containerDiv.setAttribute('aria-label', 'Typeahead menu');
|
||||||
|
containerDiv.setAttribute('id', 'typeahead-menu');
|
||||||
|
containerDiv.setAttribute('role', 'listbox');
|
||||||
|
containerDiv.style.display = 'block';
|
||||||
|
containerDiv.style.position = 'absolute';
|
||||||
|
document.body.append(containerDiv);
|
||||||
|
}
|
||||||
|
anchorElementRef.current = containerDiv;
|
||||||
|
rootElement.setAttribute('aria-controls', 'typeahead-menu');
|
||||||
|
}
|
||||||
|
}, [editor, resolution]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rootElement = editor.getRootElement();
|
||||||
|
if (resolution !== null) {
|
||||||
|
positionMenu();
|
||||||
|
return () => {
|
||||||
|
if (rootElement !== null) {
|
||||||
|
rootElement.removeAttribute('aria-controls');
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerDiv = anchorElementRef.current;
|
||||||
|
if (containerDiv !== null && containerDiv.isConnected) {
|
||||||
|
containerDiv.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [editor, positionMenu, resolution]);
|
||||||
|
|
||||||
|
const onVisibilityChange = useCallback(
|
||||||
|
(isInView: boolean) => {
|
||||||
|
if (resolution !== null) {
|
||||||
|
if (!isInView) {
|
||||||
|
setResolution(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resolution, setResolution],
|
||||||
|
);
|
||||||
|
|
||||||
|
useDynamicPositioning(
|
||||||
|
resolution,
|
||||||
|
anchorElementRef.current,
|
||||||
|
positionMenu,
|
||||||
|
onVisibilityChange,
|
||||||
|
);
|
||||||
|
|
||||||
|
return anchorElementRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutosuggestPluginProps = {
|
||||||
|
composeId: string
|
||||||
|
suggestionsHidden: boolean
|
||||||
|
setSuggestionsHidden: (value: boolean) => void
|
||||||
|
};
|
||||||
|
|
||||||
|
const AutosuggestPlugin = ({
|
||||||
|
composeId,
|
||||||
|
suggestionsHidden,
|
||||||
|
setSuggestionsHidden,
|
||||||
|
}: AutosuggestPluginProps): JSX.Element | null => {
|
||||||
|
const { suggestions } = useCompose(composeId);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [resolution, setResolution] = useState<Resolution | null>(null);
|
||||||
|
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
||||||
|
const anchorElementRef = useMenuAnchorRef(
|
||||||
|
resolution,
|
||||||
|
setResolution,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
|
return onSelectSuggestion(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectSuggestion = (index: number) => {
|
||||||
|
const suggestion = suggestions.get(index) as AutoSuggestion;
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
dispatch((dispatch, getState) => {
|
||||||
|
const state = editor.getEditorState();
|
||||||
|
const node = (state._selection as RangeSelection)?.anchor?.getNode();
|
||||||
|
|
||||||
|
if (typeof suggestion === 'object') {
|
||||||
|
if (!suggestion.id) return;
|
||||||
|
|
||||||
|
dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks
|
||||||
|
|
||||||
|
const { leadOffset, matchingString } = resolution!.match;
|
||||||
|
|
||||||
|
if (isNativeEmoji(suggestion)) {
|
||||||
|
node.spliceText(leadOffset - 1, matchingString.length, `${suggestion.native} `, true);
|
||||||
|
} else {
|
||||||
|
const completion = suggestion.colons;
|
||||||
|
|
||||||
|
let emojiText;
|
||||||
|
|
||||||
|
if (leadOffset === 1) emojiText = node;
|
||||||
|
else [, emojiText] = node.splitText(leadOffset - 1);
|
||||||
|
|
||||||
|
[emojiText] = emojiText.splitText(matchingString.length);
|
||||||
|
|
||||||
|
emojiText?.replace($createEmojiNode(completion, suggestion.imageUrl));
|
||||||
|
}
|
||||||
|
} else if (suggestion[0] === '#') {
|
||||||
|
node.setTextContent(`${suggestion} `);
|
||||||
|
node.select();
|
||||||
|
} else {
|
||||||
|
const content = selectAccount(getState(), suggestion)!.acct;
|
||||||
|
|
||||||
|
node.setTextContent(`@${content} `);
|
||||||
|
node.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(clearComposeSuggestions(composeId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQueryTextForSearch = (editor: LexicalEditor) => {
|
||||||
|
const state = editor.getEditorState();
|
||||||
|
const node = (state._selection as RangeSelection)?.anchor?.getNode();
|
||||||
|
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
if (['mention', 'hashtag'].includes(node.getType())) {
|
||||||
|
const matchingString = node.getTextContent();
|
||||||
|
return { leadOffset: 0, matchingString };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.getType() === 'text') {
|
||||||
|
const [leadOffset, matchingString] = textAtCursorMatchesToken(node.getTextContent(), (state._selection as RangeSelection)?.anchor?.offset, [':']);
|
||||||
|
if (!leadOffset || !matchingString) return null;
|
||||||
|
return { leadOffset, matchingString };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSuggestion = (suggestion: AutoSuggestion, i: number) => {
|
||||||
|
let inner: string | JSX.Element;
|
||||||
|
let key: React.Key;
|
||||||
|
|
||||||
|
if (typeof suggestion === 'object') {
|
||||||
|
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||||
|
key = suggestion.id;
|
||||||
|
} else if (suggestion[0] === '#') {
|
||||||
|
inner = suggestion;
|
||||||
|
key = suggestion;
|
||||||
|
} else {
|
||||||
|
inner = <AutosuggestAccount id={suggestion} />;
|
||||||
|
key = suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role='button'
|
||||||
|
tabIndex={0}
|
||||||
|
key={key}
|
||||||
|
data-index={i}
|
||||||
|
className={clsx({
|
||||||
|
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
|
||||||
|
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
|
||||||
|
})}
|
||||||
|
onMouseDown={handleSelectSuggestion}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTypeahead = useCallback(() => {
|
||||||
|
setResolution(null);
|
||||||
|
}, [resolution]);
|
||||||
|
|
||||||
|
const openTypeahead = useCallback(
|
||||||
|
(res: Resolution) => {
|
||||||
|
setResolution(res);
|
||||||
|
},
|
||||||
|
[resolution],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateListener = () => {
|
||||||
|
editor.getEditorState().read(() => {
|
||||||
|
const range = document.createRange();
|
||||||
|
const match = getQueryTextForSearch(editor);
|
||||||
|
const nativeSelection = window.getSelection();
|
||||||
|
|
||||||
|
if (!match || nativeSelection?.anchorOffset !== nativeSelection?.focusOffset) {
|
||||||
|
closeTypeahead();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(fetchComposeSuggestions(composeId, match.matchingString.trim()));
|
||||||
|
|
||||||
|
if (!isSelectionOnEntityBoundary(editor, match.leadOffset)) {
|
||||||
|
const isRangePositioned = tryToPositionRange(match.leadOffset, range);
|
||||||
|
if (isRangePositioned !== null) {
|
||||||
|
startTransition(() =>
|
||||||
|
openTypeahead({
|
||||||
|
getRect: () => range.getBoundingClientRect(),
|
||||||
|
match,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeTypeahead();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUpdateListener = editor.registerUpdateListener(updateListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeUpdateListener();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
resolution,
|
||||||
|
closeTypeahead,
|
||||||
|
openTypeahead,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestions && suggestions.size > 0) setSuggestionsHidden(false);
|
||||||
|
}, [suggestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolution !== null && !suggestionsHidden && !suggestions.isEmpty()) {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
if (!editor._rootElement?.contains(target) && !anchorElementRef.current.contains(target)) {
|
||||||
|
setResolution(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('click', handleClick);
|
||||||
|
}
|
||||||
|
}, [resolution, suggestionsHidden, suggestions.isEmpty()]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolution === null) return;
|
||||||
|
|
||||||
|
return mergeRegister(
|
||||||
|
editor.registerCommand<KeyboardEvent>(
|
||||||
|
KEY_ARROW_UP_COMMAND,
|
||||||
|
(payload) => {
|
||||||
|
const event = payload;
|
||||||
|
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
|
||||||
|
const newSelectedSuggestion = selectedSuggestion !== 0 ? selectedSuggestion - 1 : suggestions.size - 1;
|
||||||
|
setSelectedSuggestion(newSelectedSuggestion);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
),
|
||||||
|
editor.registerCommand<KeyboardEvent>(
|
||||||
|
KEY_ARROW_DOWN_COMMAND,
|
||||||
|
(payload) => {
|
||||||
|
const event = payload;
|
||||||
|
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
|
||||||
|
const newSelectedSuggestion = selectedSuggestion !== suggestions.size - 1 ? selectedSuggestion + 1 : 0;
|
||||||
|
setSelectedSuggestion(newSelectedSuggestion);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
),
|
||||||
|
editor.registerCommand<KeyboardEvent>(
|
||||||
|
KEY_TAB_COMMAND,
|
||||||
|
(payload) => {
|
||||||
|
const event = payload;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
onSelectSuggestion(selectedSuggestion);
|
||||||
|
setResolution(null);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
),
|
||||||
|
editor.registerCommand<KeyboardEvent>(
|
||||||
|
KEY_ENTER_COMMAND,
|
||||||
|
(payload) => {
|
||||||
|
const event = payload;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
onSelectSuggestion(selectedSuggestion);
|
||||||
|
setResolution(null);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
),
|
||||||
|
editor.registerCommand<KeyboardEvent>(
|
||||||
|
KEY_ESCAPE_COMMAND,
|
||||||
|
(payload) => {
|
||||||
|
const event = payload;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
setResolution(null);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_LOW,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [editor, suggestions, selectedSuggestion, resolution]);
|
||||||
|
|
||||||
|
return resolution === null || editor === null ? null : (
|
||||||
|
<LexicalPopoverMenu
|
||||||
|
anchorElementRef={anchorElementRef}
|
||||||
|
menuRenderFn={(anchorElementRef) =>
|
||||||
|
anchorElementRef.current
|
||||||
|
? ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
className={clsx({
|
||||||
|
'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
|
||||||
|
hidden: suggestionsHidden || suggestions.isEmpty(),
|
||||||
|
block: !suggestionsHidden && !suggestions.isEmpty(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{suggestions.map(renderSuggestion)}
|
||||||
|
</div>,
|
||||||
|
anchorElementRef.current,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutosuggestPlugin;
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { COMMAND_PRIORITY_NORMAL, createCommand, type LexicalCommand } from 'lexical';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface IFocusPlugin {
|
||||||
|
autoFocus?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand();
|
||||||
|
|
||||||
|
const FocusPlugin: React.FC<IFocusPlugin> = ({ autoFocus }) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
editor.dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => editor.registerCommand(FOCUS_EDITOR_COMMAND, () => {
|
||||||
|
editor.focus(
|
||||||
|
() => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const rootElement = editor.getRootElement();
|
||||||
|
if (rootElement !== null && (activeElement === null || !rootElement.contains(activeElement))) {
|
||||||
|
rootElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, { defaultSelection: 'rootEnd' },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}, COMMAND_PRIORITY_NORMAL));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FocusPlugin;
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LinkPlugin as LexicalLinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { validateUrl } from '../utils/url';
|
||||||
|
|
||||||
|
const LinkPlugin = (): JSX.Element => {
|
||||||
|
return <LexicalLinkPlugin validateUrl={validateUrl} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkPlugin;
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
|
||||||
|
|
||||||
|
import type { TextNode } from 'lexical';
|
||||||
|
|
||||||
|
const MENTION_REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
|
||||||
|
|
||||||
|
const getMentionMatch = (text: string) => {
|
||||||
|
const matchArr = MENTION_REGEX.exec(text);
|
||||||
|
|
||||||
|
if (!matchArr) return null;
|
||||||
|
return matchArr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MentionPlugin = (): JSX.Element | null => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor.hasNodes([MentionNode])) {
|
||||||
|
throw new Error('MentionPlugin: MentionNode not registered on editor');
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const createMentionNode = useCallback((textNode: TextNode): MentionNode => {
|
||||||
|
return $createMentionNode(textNode.getTextContent());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getEntityMatch = useCallback((text: string) => {
|
||||||
|
const matchArr = getMentionMatch(text);
|
||||||
|
|
||||||
|
if (!matchArr) return null;
|
||||||
|
|
||||||
|
const mentionLength = matchArr[3].length + 1;
|
||||||
|
const startOffset = matchArr.index + matchArr[1].length;
|
||||||
|
const endOffset = startOffset + mentionLength;
|
||||||
|
return {
|
||||||
|
end: endOffset,
|
||||||
|
start: startOffset,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLexicalTextEntity<MentionNode>(
|
||||||
|
getEntityMatch,
|
||||||
|
MentionNode,
|
||||||
|
createMentionNode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MentionPlugin;
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { LexicalEditor } from 'lexical';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
/** Set the ref to the current Lexical editor instance. */
|
||||||
|
const RefPlugin = React.forwardRef<LexicalEditor>((_props, ref) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref && typeof ref !== 'function') {
|
||||||
|
ref.current = editor;
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RefPlugin;
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { $getRoot, KEY_ENTER_COMMAND } from 'lexical';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { setEditorState } from 'soapbox/actions/compose';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
interface IStatePlugin {
|
||||||
|
composeId: string
|
||||||
|
handleSubmit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatePlugin = ({ composeId, handleSubmit }: IStatePlugin) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (handleSubmit) editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
|
||||||
|
if (event?.ctrlKey) {
|
||||||
|
handleSubmit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, 1);
|
||||||
|
editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
const isEmpty = editorState.read(() => $getRoot().getTextContent()) === '';
|
||||||
|
const data = isEmpty ? null : JSON.stringify(editorState.toJSON());
|
||||||
|
dispatch(setEditorState(composeId, data));
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatePlugin;
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable eqeqeq */
|
||||||
|
|
||||||
|
export const getDOMRangeRect = (
|
||||||
|
nativeSelection: Selection,
|
||||||
|
rootElement: HTMLElement,
|
||||||
|
): DOMRect => {
|
||||||
|
const domRange = nativeSelection.getRangeAt(0);
|
||||||
|
|
||||||
|
let rect;
|
||||||
|
|
||||||
|
if (nativeSelection.anchorNode === rootElement) {
|
||||||
|
let inner = rootElement;
|
||||||
|
while (inner.firstElementChild != null) {
|
||||||
|
inner = inner.firstElementChild as HTMLElement;
|
||||||
|
}
|
||||||
|
rect = inner.getBoundingClientRect();
|
||||||
|
} else {
|
||||||
|
rect = domRange.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rect;
|
||||||
|
};
|
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $isAtNodeEnd } from '@lexical/selection';
|
||||||
|
import { ElementNode, RangeSelection, TextNode } from 'lexical';
|
||||||
|
|
||||||
|
export const getSelectedNode = (
|
||||||
|
selection: RangeSelection,
|
||||||
|
): TextNode | ElementNode => {
|
||||||
|
const anchor = selection.anchor;
|
||||||
|
const focus = selection.focus;
|
||||||
|
const anchorNode = selection.anchor.getNode();
|
||||||
|
const focusNode = selection.focus.getNode();
|
||||||
|
if (anchorNode === focusNode) {
|
||||||
|
return anchorNode;
|
||||||
|
}
|
||||||
|
const isBackward = selection.isBackward();
|
||||||
|
if (isBackward) {
|
||||||
|
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
|
||||||
|
} else {
|
||||||
|
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,4 @@
|
||||||
|
const isHTMLElement = (x: unknown): x is HTMLElement => x instanceof HTMLElement;
|
||||||
|
|
||||||
|
export default isHTMLElement;
|
||||||
|
export { isHTMLElement };
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Point {
|
||||||
|
|
||||||
|
private readonly _x: number;
|
||||||
|
private readonly _y: number;
|
||||||
|
|
||||||
|
constructor(x: number, y: number) {
|
||||||
|
this._x = x;
|
||||||
|
this._y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
get x(): number {
|
||||||
|
return this._x;
|
||||||
|
}
|
||||||
|
|
||||||
|
get y(): number {
|
||||||
|
return this._y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals({ x, y }: Point): boolean {
|
||||||
|
return this.x === x && this.y === y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public calcDeltaXTo({ x }: Point): number {
|
||||||
|
return this.x - x;
|
||||||
|
}
|
||||||
|
|
||||||
|
public calcDeltaYTo({ y }: Point): number {
|
||||||
|
return this.y - y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public calcHorizontalDistanceTo(point: Point): number {
|
||||||
|
return Math.abs(this.calcDeltaXTo(point));
|
||||||
|
}
|
||||||
|
|
||||||
|
public calcVerticalDistance(point: Point): number {
|
||||||
|
return Math.abs(this.calcDeltaYTo(point));
|
||||||
|
}
|
||||||
|
|
||||||
|
public calcDistanceTo(point: Point): number {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.pow(this.calcDeltaXTo(point), 2) +
|
||||||
|
Math.pow(this.calcDeltaYTo(point), 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPoint = (x: unknown): x is Point => x instanceof Point;
|
||||||
|
|
||||||
|
export default Point;
|
||||||
|
export { Point, isPoint };
|
|
@ -0,0 +1,163 @@
|
||||||
|
/* eslint-disable no-dupe-class-members */
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isPoint, Point } from './point';
|
||||||
|
|
||||||
|
type ContainsPointReturn = {
|
||||||
|
result: boolean
|
||||||
|
reason: {
|
||||||
|
isOnTopSide: boolean
|
||||||
|
isOnBottomSide: boolean
|
||||||
|
isOnLeftSide: boolean
|
||||||
|
isOnRightSide: boolean
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class Rect {
|
||||||
|
|
||||||
|
private readonly _left: number;
|
||||||
|
private readonly _top: number;
|
||||||
|
private readonly _right: number;
|
||||||
|
private readonly _bottom: number;
|
||||||
|
|
||||||
|
constructor(left: number, top: number, right: number, bottom: number) {
|
||||||
|
const [physicTop, physicBottom] =
|
||||||
|
top <= bottom ? [top, bottom] : [bottom, top];
|
||||||
|
|
||||||
|
const [physicLeft, physicRight] =
|
||||||
|
left <= right ? [left, right] : [right, left];
|
||||||
|
|
||||||
|
this._top = physicTop;
|
||||||
|
this._right = physicRight;
|
||||||
|
this._left = physicLeft;
|
||||||
|
this._bottom = physicBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
get top(): number {
|
||||||
|
return this._top;
|
||||||
|
}
|
||||||
|
|
||||||
|
get right(): number {
|
||||||
|
return this._right;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bottom(): number {
|
||||||
|
return this._bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
get left(): number {
|
||||||
|
return this._left;
|
||||||
|
}
|
||||||
|
|
||||||
|
get width(): number {
|
||||||
|
return Math.abs(this._left - this._right);
|
||||||
|
}
|
||||||
|
|
||||||
|
get height(): number {
|
||||||
|
return Math.abs(this._bottom - this._top);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals({ top, left, bottom, right }: Rect): boolean {
|
||||||
|
return (
|
||||||
|
top === this._top &&
|
||||||
|
bottom === this._bottom &&
|
||||||
|
left === this._left &&
|
||||||
|
right === this._right
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains({ x, y }: Point): ContainsPointReturn;
|
||||||
|
public contains({ top, left, bottom, right }: Rect): boolean;
|
||||||
|
public contains(target: Point | Rect): boolean | ContainsPointReturn {
|
||||||
|
if (isPoint(target)) {
|
||||||
|
const { x, y } = target;
|
||||||
|
|
||||||
|
const isOnTopSide = y < this._top;
|
||||||
|
const isOnBottomSide = y > this._bottom;
|
||||||
|
const isOnLeftSide = x < this._left;
|
||||||
|
const isOnRightSide = x > this._right;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
!isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reason: {
|
||||||
|
isOnBottomSide,
|
||||||
|
isOnLeftSide,
|
||||||
|
isOnRightSide,
|
||||||
|
isOnTopSide,
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const { top, left, bottom, right } = target;
|
||||||
|
|
||||||
|
return (
|
||||||
|
top >= this._top &&
|
||||||
|
top <= this._bottom &&
|
||||||
|
bottom >= this._top &&
|
||||||
|
bottom <= this._bottom &&
|
||||||
|
left >= this._left &&
|
||||||
|
left <= this._right &&
|
||||||
|
right >= this._left &&
|
||||||
|
right <= this._right
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public intersectsWith(rect: Rect): boolean {
|
||||||
|
const { left: x1, top: y1, width: w1, height: h1 } = rect;
|
||||||
|
const { left: x2, top: y2, width: w2, height: h2 } = this;
|
||||||
|
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
|
||||||
|
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
|
||||||
|
const minX = x1 <= x2 ? x1 : x2;
|
||||||
|
const minY = y1 <= y2 ? y1 : y2;
|
||||||
|
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateNewRect({
|
||||||
|
left = this.left,
|
||||||
|
top = this.top,
|
||||||
|
right = this.right,
|
||||||
|
bottom = this.bottom,
|
||||||
|
}): Rect {
|
||||||
|
return new Rect(left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromLTRB(
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
right: number,
|
||||||
|
bottom: number,
|
||||||
|
): Rect {
|
||||||
|
return new Rect(left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromLWTH(
|
||||||
|
left: number,
|
||||||
|
width: number,
|
||||||
|
top: number,
|
||||||
|
height: number,
|
||||||
|
): Rect {
|
||||||
|
return new Rect(left, top, left + width, top + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPoints(startPoint: Point, endPoint: Point): Rect {
|
||||||
|
const { y: top, x: left } = startPoint;
|
||||||
|
const { y: bottom, x: right } = endPoint;
|
||||||
|
return Rect.fromLTRB(left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromDOM(dom: HTMLElement): Rect {
|
||||||
|
const { top, width, left, height } = dom.getBoundingClientRect();
|
||||||
|
return Rect.fromLWTH(left, width, top, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Rect;
|
||||||
|
export { Rect };
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERTICAL_GAP = 10;
|
||||||
|
const HORIZONTAL_OFFSET = 5;
|
||||||
|
|
||||||
|
export const setFloatingElemPosition = (
|
||||||
|
targetRect: ClientRect | null,
|
||||||
|
floatingElem: HTMLElement,
|
||||||
|
anchorElem: HTMLElement,
|
||||||
|
verticalGap: number = VERTICAL_GAP,
|
||||||
|
horizontalOffset: number = HORIZONTAL_OFFSET,
|
||||||
|
): void => {
|
||||||
|
const scrollerElem = anchorElem.parentElement;
|
||||||
|
|
||||||
|
if (targetRect === null || !scrollerElem) {
|
||||||
|
floatingElem.style.opacity = '0';
|
||||||
|
floatingElem.style.transform = 'translate(-10000px, -10000px)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingElemRect = floatingElem.getBoundingClientRect();
|
||||||
|
const anchorElementRect = anchorElem.getBoundingClientRect();
|
||||||
|
const editorScrollerRect = scrollerElem.getBoundingClientRect();
|
||||||
|
|
||||||
|
let top = targetRect.top - floatingElemRect.height - verticalGap;
|
||||||
|
let left = targetRect.left - horizontalOffset;
|
||||||
|
|
||||||
|
if (top < editorScrollerRect.top) {
|
||||||
|
top += floatingElemRect.height + targetRect.height + verticalGap * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left + floatingElemRect.width > editorScrollerRect.right) {
|
||||||
|
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
top -= anchorElementRect.top;
|
||||||
|
left -= anchorElementRect.left;
|
||||||
|
|
||||||
|
floatingElem.style.opacity = '1';
|
||||||
|
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
|
||||||
|
};
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* This source code is derived from code from Meta Platforms, Inc.
|
||||||
|
* and affiliates, licensed under the MIT license located in the
|
||||||
|
* LICENSE file in the /app/soapbox/features/compose/editor directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const sanitizeUrl = (url: string): string => {
|
||||||
|
/** A pattern that matches safe URLs. */
|
||||||
|
const SAFE_URL_PATTERN =
|
||||||
|
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
|
||||||
|
|
||||||
|
/** A pattern that matches safe data URLs. */
|
||||||
|
const DATA_URL_PATTERN =
|
||||||
|
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;
|
||||||
|
|
||||||
|
url = String(url).trim();
|
||||||
|
|
||||||
|
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
|
||||||
|
|
||||||
|
return 'https://';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source: https://stackoverflow.com/a/8234912/2013580
|
||||||
|
const urlRegExp = new RegExp(
|
||||||
|
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const validateUrl = (url: string): boolean => {
|
||||||
|
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
|
||||||
|
// Maybe show a dialog where they user can type the URL before inserting it.
|
||||||
|
return url === 'https://' || urlRegExp.test(url);
|
||||||
|
};
|
|
@ -98,7 +98,7 @@ const SettingsStore: React.FC = () => {
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader className='mb-4'>
|
||||||
<CardTitle title={intl.formatMessage(messages.advanced)} />
|
<CardTitle title={intl.formatMessage(messages.advanced)} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
|
@ -75,5 +75,4 @@ const EmojiPickerDropdownContainer = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default EmojiPickerDropdownContainer;
|
export default EmojiPickerDropdownContainer;
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
import data, { EmojiData } from './data';
|
import data, { EmojiData } from './data';
|
||||||
|
|
||||||
const stripLeadingZeros = /^0+/;
|
const stripLeadingZeros = /^0+/;
|
||||||
|
|
||||||
function replaceAll(str: string, find: string, replace: string) {
|
|
||||||
return str.replace(new RegExp(find, 'g'), replace);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UnicodeMap {
|
interface UnicodeMap {
|
||||||
[s: string]: {
|
[s: string]: {
|
||||||
unified: string
|
unified: string
|
||||||
|
@ -80,7 +75,7 @@ const stripcodes = (unified: string, native: string) => {
|
||||||
if (unified.includes('200d') && !(unified in blacklist)) {
|
if (unified.includes('200d') && !(unified in blacklist)) {
|
||||||
return stripped;
|
return stripped;
|
||||||
} else {
|
} else {
|
||||||
return replaceAll(stripped, '-fe0f', '');
|
return stripped.replaceAll('-fe0f', '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||||
|
|
||||||
|
|
||||||
interface IIconPickerMenu {
|
interface IIconPickerMenu {
|
||||||
icons: Record<string, Array<string>>
|
icons: Record<string, Array<string>>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -21,11 +21,11 @@ import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||||
import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location';
|
import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location';
|
||||||
import LocationSearch from 'soapbox/components/location-search';
|
import LocationSearch from 'soapbox/components/location-search';
|
||||||
import { checkEventComposeContent } from 'soapbox/components/modal-root';
|
import { checkEventComposeContent } from 'soapbox/components/modal-root';
|
||||||
import { Button, Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Spinner, Stack, Tabs, Text, Textarea, Toggle } from 'soapbox/components/ui';
|
import { Button, Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Spinner, Stack, Tabs, Text, Toggle } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
import AccountContainer from 'soapbox/containers/account-container';
|
||||||
import { isCurrentOrFutureDate } from 'soapbox/features/compose/components/schedule-form';
|
import { isCurrentOrFutureDate } from 'soapbox/features/compose/components/schedule-form';
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||||
import { DatePicker } from 'soapbox/features/ui/util/async-components';
|
import { ComposeEditor, DatePicker } from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
import UploadButton from './upload-button';
|
import UploadButton from './upload-button';
|
||||||
|
@ -94,13 +94,14 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const editorStateRef = useRef<string>(null);
|
||||||
|
|
||||||
const [tab, setTab] = useState<'edit' | 'pending'>('edit');
|
const [tab, setTab] = useState<'edit' | 'pending'>('edit');
|
||||||
|
|
||||||
const banner = useAppSelector((state) => state.compose_event.banner);
|
const banner = useAppSelector((state) => state.compose_event.banner);
|
||||||
const isUploading = useAppSelector((state) => state.compose_event.is_uploading);
|
const isUploading = useAppSelector((state) => state.compose_event.is_uploading);
|
||||||
|
|
||||||
const name = useAppSelector((state) => state.compose_event.name);
|
const name = useAppSelector((state) => state.compose_event.name);
|
||||||
const description = useAppSelector((state) => state.compose_event.status);
|
|
||||||
const startTime = useAppSelector((state) => state.compose_event.start_time);
|
const startTime = useAppSelector((state) => state.compose_event.start_time);
|
||||||
const endTime = useAppSelector((state) => state.compose_event.end_time);
|
const endTime = useAppSelector((state) => state.compose_event.end_time);
|
||||||
const approvalRequired = useAppSelector((state) => state.compose_event.approval_required);
|
const approvalRequired = useAppSelector((state) => state.compose_event.approval_required);
|
||||||
|
@ -114,10 +115,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
dispatch(changeEditEventName(target.value));
|
dispatch(changeEditEventName(target.value));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
|
||||||
dispatch(changeEditEventDescription(target.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChangeStartTime = (date: Date) => {
|
const onChangeStartTime = (date: Date) => {
|
||||||
dispatch(changeEditEventStartTime(date));
|
dispatch(changeEditEventStartTime(date));
|
||||||
};
|
};
|
||||||
|
@ -170,6 +167,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
dispatch(changeEditEventDescription(editorStateRef.current!));
|
||||||
dispatch(submitEvent());
|
dispatch(submitEvent());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -236,14 +234,19 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup
|
<FormGroup
|
||||||
labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />}
|
labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />}
|
||||||
hintText={<FormattedMessage id='compose_event.fields.description_hint' defaultMessage='Markdown syntax is supported' />}
|
|
||||||
>
|
>
|
||||||
<Textarea
|
<BundleContainer fetchComponent={ComposeEditor}>
|
||||||
autoComplete='off'
|
{(Component: any) => (
|
||||||
|
<Component
|
||||||
|
ref={editorStateRef}
|
||||||
|
className='block w-full rounded-md border border-gray-400 bg-white px-3 py-2 text-base text-gray-900 ring-1 placeholder:text-gray-600 focus-within:border-primary-500 focus-within:ring-primary-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500 sm:text-sm'
|
||||||
|
placeholderClassName='pt-2'
|
||||||
|
composeId='compose-event-modal'
|
||||||
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}
|
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}
|
||||||
value={description}
|
handleSubmit={handleSubmit}
|
||||||
onChange={onChangeDescription}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</BundleContainer>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup
|
<FormGroup
|
||||||
labelText={<FormattedMessage id='compose_event.fields.location_label' defaultMessage='Event location' />}
|
labelText={<FormattedMessage id='compose_event.fields.location_label' defaultMessage='Event location' />}
|
||||||
|
|
|
@ -86,6 +86,7 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose, composeId = 'compose-m
|
||||||
<ComposeForm
|
<ComposeForm
|
||||||
id={composeId}
|
id={composeId}
|
||||||
extra={<ComposeFormGroupToggle composeId={composeId} groupId={groupId} />}
|
extra={<ComposeFormGroupToggle composeId={composeId} groupId={groupId} />}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
@ -641,3 +641,7 @@ export function FollowedTags() {
|
||||||
export function AccountNotePanel() {
|
export function AccountNotePanel() {
|
||||||
return import('../components/panels/account-note-panel');
|
return import('../components/panels/account-note-panel');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ComposeEditor() {
|
||||||
|
return import('../../compose/editor');
|
||||||
|
}
|
||||||
|
|
|
@ -3,10 +3,13 @@ import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { resetCompose } from 'soapbox/actions/compose';
|
import { resetCompose } from 'soapbox/actions/compose';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { FOCUS_EDITOR_COMMAND } from 'soapbox/features/compose/editor/plugins/focus-plugin';
|
||||||
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { HotKeys } from '../components/hotkeys';
|
import { HotKeys } from '../components/hotkeys';
|
||||||
|
|
||||||
|
import type { LexicalEditor } from 'lexical';
|
||||||
|
|
||||||
const keyMap = {
|
const keyMap = {
|
||||||
help: '?',
|
help: '?',
|
||||||
new: 'n',
|
new: 'n',
|
||||||
|
@ -51,10 +54,10 @@ const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
|
||||||
const handleHotkeyNew = (e?: KeyboardEvent) => {
|
const handleHotkeyNew = (e?: KeyboardEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
const element = node.current?.querySelector('textarea#compose-textarea') as HTMLTextAreaElement;
|
const element = node.current?.querySelector('div[data-lexical-editor="true"]') as HTMLTextAreaElement;
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.focus();
|
((element as any).__lexicalEditor as LexicalEditor).dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
|
||||||
} else {
|
} else {
|
||||||
dispatch(openModal('COMPOSE'));
|
dispatch(openModal('COMPOSE'));
|
||||||
}
|
}
|
||||||
|
@ -91,7 +94,7 @@ const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
hotkeys.current.__mousetrap__.stopCallback = (_e, element) => {
|
hotkeys.current.__mousetrap__.stopCallback = (_e, element) => {
|
||||||
return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName);
|
return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName) || !!element.closest('[contenteditable]');
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -394,7 +394,6 @@
|
||||||
"compose_event.edit_success": "Your event was edited",
|
"compose_event.edit_success": "Your event was edited",
|
||||||
"compose_event.fields.approval_required": "I want to approve participation requests manually",
|
"compose_event.fields.approval_required": "I want to approve participation requests manually",
|
||||||
"compose_event.fields.banner_label": "Event banner",
|
"compose_event.fields.banner_label": "Event banner",
|
||||||
"compose_event.fields.description_hint": "Markdown syntax is supported",
|
|
||||||
"compose_event.fields.description_label": "Event description",
|
"compose_event.fields.description_label": "Event description",
|
||||||
"compose_event.fields.description_placeholder": "Description",
|
"compose_event.fields.description_placeholder": "Description",
|
||||||
"compose_event.fields.end_time_label": "Event end date",
|
"compose_event.fields.end_time_label": "Event end date",
|
||||||
|
@ -418,6 +417,14 @@
|
||||||
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
|
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
|
||||||
"compose_form.event_placeholder": "Post to this event",
|
"compose_form.event_placeholder": "Post to this event",
|
||||||
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
|
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
|
||||||
|
"compose_form.lexical.create_horizontal_line": "Create horizontal line",
|
||||||
|
"compose_form.lexical.format_bold": "Format bold",
|
||||||
|
"compose_form.lexical.format_italic": "Format italic",
|
||||||
|
"compose_form.lexical.format_strikethrough": "Format strikethrough",
|
||||||
|
"compose_form.lexical.format_underline": "Format underline",
|
||||||
|
"compose_form.lexical.insert_code_block": "Insert code block",
|
||||||
|
"compose_form.lexical.insert_link": "Insert link",
|
||||||
|
"compose_form.lexical.upload_media": "Upload media",
|
||||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||||
"compose_form.lock_disclaimer.lock": "locked",
|
"compose_form.lock_disclaimer.lock": "locked",
|
||||||
"compose_form.markdown.marked": "Post markdown enabled",
|
"compose_form.markdown.marked": "Post markdown enabled",
|
||||||
|
|
|
@ -140,7 +140,6 @@ const normalizeLocked = (group: ImmutableMap<string, any>) => {
|
||||||
return group.set('locked', locked);
|
return group.set('locked', locked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Rewrite `<p></p>` to empty string. */
|
/** Rewrite `<p></p>` to empty string. */
|
||||||
const fixNote = (group: ImmutableMap<string, any>) => {
|
const fixNote = (group: ImmutableMap<string, any>) => {
|
||||||
if (group.get('note') === '<p></p>') {
|
if (group.get('note') === '<p></p>') {
|
||||||
|
|
|
@ -374,7 +374,7 @@ describe('compose reducer', () => {
|
||||||
type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE,
|
type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE,
|
||||||
id: 'home',
|
id: 'home',
|
||||||
token: 'aaadken3',
|
token: 'aaadken3',
|
||||||
currentTrends: ImmutableList([
|
tags: ImmutableList([
|
||||||
TagRecord({ name: 'hashtag' }),
|
TagRecord({ name: 'hashtag' }),
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
|
@ -51,9 +51,11 @@ import {
|
||||||
COMPOSE_REMOVE_FROM_MENTIONS,
|
COMPOSE_REMOVE_FROM_MENTIONS,
|
||||||
COMPOSE_SET_STATUS,
|
COMPOSE_SET_STATUS,
|
||||||
COMPOSE_EVENT_REPLY,
|
COMPOSE_EVENT_REPLY,
|
||||||
|
COMPOSE_EDITOR_STATE_SET,
|
||||||
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
|
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
|
||||||
ComposeAction,
|
ComposeAction,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
|
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
|
||||||
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me';
|
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me';
|
||||||
import { SETTING_CHANGE, FE_NAME, SettingsAction } from '../actions/settings';
|
import { SETTING_CHANGE, FE_NAME, SettingsAction } from '../actions/settings';
|
||||||
import { TIMELINE_DELETE, TimelineAction } from '../actions/timelines';
|
import { TIMELINE_DELETE, TimelineAction } from '../actions/timelines';
|
||||||
|
@ -80,6 +82,7 @@ const PollRecord = ImmutableRecord({
|
||||||
export const ReducerCompose = ImmutableRecord({
|
export const ReducerCompose = ImmutableRecord({
|
||||||
caretPosition: null as number | null,
|
caretPosition: null as number | null,
|
||||||
content_type: 'text/plain',
|
content_type: 'text/plain',
|
||||||
|
editorState: null as string | null,
|
||||||
focusDate: null as Date | null,
|
focusDate: null as Date | null,
|
||||||
group_id: null as string | null,
|
group_id: null as string | null,
|
||||||
idempotencyKey: '',
|
idempotencyKey: '',
|
||||||
|
@ -180,11 +183,11 @@ const insertSuggestion = (compose: Compose, position: number, token: string | nu
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSuggestionTags = (compose: Compose, token: string, currentTrends: ImmutableList<Tag>) => {
|
const updateSuggestionTags = (compose: Compose, token: string, tags: ImmutableList<Tag>) => {
|
||||||
const prefix = token.slice(1);
|
const prefix = token.slice(1);
|
||||||
|
|
||||||
return compose.merge({
|
return compose.merge({
|
||||||
suggestions: ImmutableList(currentTrends
|
suggestions: ImmutableList(tags
|
||||||
.filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase()))
|
.filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase()))
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((tag) => '#' + tag.name)),
|
.map((tag) => '#' + tag.name)),
|
||||||
|
@ -273,7 +276,7 @@ export const initialState: State = ImmutableMap({
|
||||||
default: ReducerCompose({ idempotencyKey: uuid(), resetFileKey: getResetFileKey() }),
|
default: ReducerCompose({ idempotencyKey: uuid(), resetFileKey: getResetFileKey() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function compose(state = initialState, action: ComposeAction | MeAction | SettingsAction | TimelineAction) {
|
export default function compose(state = initialState, action: ComposeAction | EventsAction | MeAction | SettingsAction | TimelineAction) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case COMPOSE_TYPE_CHANGE:
|
case COMPOSE_TYPE_CHANGE:
|
||||||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||||
|
@ -409,7 +412,7 @@ export default function compose(state = initialState, action: ComposeAction | Me
|
||||||
case COMPOSE_SUGGESTION_SELECT:
|
case COMPOSE_SUGGESTION_SELECT:
|
||||||
return updateCompose(state, action.id, compose => insertSuggestion(compose, action.position, action.token, action.completion, action.path));
|
return updateCompose(state, action.id, compose => insertSuggestion(compose, action.position, action.token, action.completion, action.path));
|
||||||
case COMPOSE_SUGGESTION_TAGS_UPDATE:
|
case COMPOSE_SUGGESTION_TAGS_UPDATE:
|
||||||
return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.currentTrends));
|
return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.tags));
|
||||||
case COMPOSE_TAG_HISTORY_UPDATE:
|
case COMPOSE_TAG_HISTORY_UPDATE:
|
||||||
return updateCompose(state, action.id, compose => compose.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>));
|
return updateCompose(state, action.id, compose => compose.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>));
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
|
@ -511,6 +514,12 @@ export default function compose(state = initialState, action: ComposeAction | Me
|
||||||
return updateCompose(state, 'default', compose => importAccount(compose, action.me));
|
return updateCompose(state, 'default', compose => importAccount(compose, action.me));
|
||||||
case SETTING_CHANGE:
|
case SETTING_CHANGE:
|
||||||
return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value));
|
return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value));
|
||||||
|
case COMPOSE_EDITOR_STATE_SET:
|
||||||
|
return updateCompose(state, action.id, compose => compose.set('editorState', action.editorState as string));
|
||||||
|
case EVENT_COMPOSE_CANCEL:
|
||||||
|
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', ''));
|
||||||
|
case EVENT_FORM_SET:
|
||||||
|
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', action.text));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
@apply bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start justify-between opacity-0 transition-opacity duration-100 ease-linear;
|
@apply p-2 bg-gradient-to-b from-gray-900/80 via-gray-900/50 to-transparent flex items-start gap-2 justify-end opacity-0 transition-opacity duration-100 ease-linear;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@apply opacity-100;
|
@apply opacity-100;
|
||||||
|
|
|
@ -120,3 +120,7 @@
|
||||||
30% { opacity: 0.75; }
|
30% { opacity: 0.75; }
|
||||||
100% { opacity: 1; }
|
100% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.underline-line-through {
|
||||||
|
text-decoration: underline line-through;
|
||||||
|
}
|
||||||
|
|
|
@ -24,7 +24,6 @@ const getScopes = (state: RootState) => {
|
||||||
return getInstanceScopes(state.instance);
|
return getInstanceScopes(state.instance);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getInstanceScopes,
|
getInstanceScopes,
|
||||||
getScopes,
|
getScopes,
|
||||||
|
|
166
yarn.lock
166
yarn.lock
|
@ -1699,6 +1699,160 @@
|
||||||
"@jridgewell/resolve-uri" "^3.1.0"
|
"@jridgewell/resolve-uri" "^3.1.0"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||||
|
|
||||||
|
"@lexical/clipboard@0.11.3", "@lexical/clipboard@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/clipboard/-/clipboard-0.11.3.tgz#f4bfac8fad1f41d43a45c4e1ffc79542757314f9"
|
||||||
|
integrity sha512-6xggT8b0hd4OQy25mBH+yiJsr3Bm8APHjDOd3yINCGeiiHXIC+2qKQn3MG70euxQQuyzq++tYHcSsFq42g8Jyw==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/html" "0.11.3"
|
||||||
|
"@lexical/list" "0.11.3"
|
||||||
|
"@lexical/selection" "0.11.3"
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/code@0.11.3", "@lexical/code@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/code/-/code-0.11.3.tgz#4a5ef193655557859c63dd4a54012c78580585fa"
|
||||||
|
integrity sha512-BIMPd2op65iP4N9SkKIUVodZoWeSsnk6skNJ8UHBO/Rg0ZxyAqxLpnBhEgHq2QOoTBbEW6OEFtkc7/+f9LINZg==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
prismjs "^1.27.0"
|
||||||
|
|
||||||
|
"@lexical/dragon@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/dragon/-/dragon-0.11.3.tgz#b4254953b09a68c20277ba0fb125548dd6630921"
|
||||||
|
integrity sha512-S18uwqOOpV2yIAFVWqSvBdhZ5BGadPQO4ejZF15wP8LUuqkxCs+0I/MjLovQ7tx0Cx34KdDaOXtM6XeG74ixYw==
|
||||||
|
|
||||||
|
"@lexical/hashtag@0.11.3", "@lexical/hashtag@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/hashtag/-/hashtag-0.11.3.tgz#9ffb2f7b2bb9a62fa9641355fc06feb9d209273a"
|
||||||
|
integrity sha512-7auoaWp2QhsX9/Bq0SxLXatUaSwqoT9HlWNTH2vKsw8tdeUBYacTHLuBNncTGrznXLG0/B5+FWoLuM6Pzqq4Ig==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/history@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/history/-/history-0.11.3.tgz#bcf7b2e708a5b3b5f90d1a39e07e0ae51bac41df"
|
||||||
|
integrity sha512-QLJQRH2rbadRwXd4c/U4TqjLWDQna6Q43nCocIZF+SdVG9TlASp7m6dS7hiHfPtV1pkxJUxPhZY6EsB/Ok5WGA==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/html@0.11.3", "@lexical/html@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.11.3.tgz#c02b38f512eb808726922c8215dd2374df6b77cc"
|
||||||
|
integrity sha512-+8AYnxxml9PneZLkGfdTenqDjE2yD1ZfCmQLrD/L1TEn22OjZh4uvKVHb13wEhgUZTuLKF0PNdnuecko9ON/aQ==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/selection" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/link@0.11.3", "@lexical/link@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.11.3.tgz#7efab94e3b061a84639314eaec0b915d3457329c"
|
||||||
|
integrity sha512-stAjIrDrF18dPKK25ExPwMCcMe0KKD0FWVzo3F7ejh9DvrQcLFeBPcs8ze71chS3D5fQDB/CzdwvMjEViKmq2A==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/list@0.11.3", "@lexical/list@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.11.3.tgz#d158ac4b4b42d772b30a1cd2a42ca0462a5a154e"
|
||||||
|
integrity sha512-Cs9071wDfqi4j1VgodceiR1jTHj13eCoEJDhr3e/FW0x5we7vfbTMtWlOWbveIoryAh+rQNgiD5e8SrAm6Zs3g==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/mark@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.11.3.tgz#7f87a264d44762e275ba7b3d85e18307bbabd9e3"
|
||||||
|
integrity sha512-0wAtufmaA0rMVFXoiJ0sY/tiJsQbHuDpgywb1Qa8qnZZcg7ZTrQMz9Go0fEWYcbSp8OH2o0cjbDTz3ACS1qCUA==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/markdown@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/markdown/-/markdown-0.11.3.tgz#0656a33bdf8b506899c010bb44960f86276e346b"
|
||||||
|
integrity sha512-sF8ow32BDme3UvxaKpf+j+vMc4T/XvDEzteZHmvvP7NX/iUtK3yUkTyT7rKuGwiKLYfMBwQaKMGjU3/nlIOzUg==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/code" "0.11.3"
|
||||||
|
"@lexical/link" "0.11.3"
|
||||||
|
"@lexical/list" "0.11.3"
|
||||||
|
"@lexical/rich-text" "0.11.3"
|
||||||
|
"@lexical/text" "0.11.3"
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/offset@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/offset/-/offset-0.11.3.tgz#55c2ef5f036235d70f2aa762ed295e32dde9593a"
|
||||||
|
integrity sha512-3H9X8iqDSk0LrMOHZuqYuqX4EYGb78TIhtjrFbLJi/OgKmHaSeLx59xcMZdgd5kBdRitzQYMmvbRDvbLfMgWrA==
|
||||||
|
|
||||||
|
"@lexical/overflow@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/overflow/-/overflow-0.11.3.tgz#a77d72a4fdf8dcc4f558e68a5d0ac210f020472b"
|
||||||
|
integrity sha512-ShjCG8lICShOBKwrpP+9PjRFKEBCSUUMjbIGZfLnoL//3hyRtGv5aRgRyfJlRgDhCve0ROt5znLJV88EXzGRyA==
|
||||||
|
|
||||||
|
"@lexical/plain-text@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/plain-text/-/plain-text-0.11.3.tgz#ee56c0f0bc10a6333dc9790fb6f27b8d41887da8"
|
||||||
|
integrity sha512-cQ5Us+GNzShyjjgRqWTnYv0rC+jHJ96LvBA1aSieM77H8/Im5BeoLl6TgBK2NqPkp8fGpj8JnDEdT8h9Qh1jtA==
|
||||||
|
|
||||||
|
"@lexical/react@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/react/-/react-0.11.3.tgz#e889170cf29bf71e3c4799a104d4f4c99499fa2e"
|
||||||
|
integrity sha512-Rn0Agnrz3uLIWbNyS9PRlkxOxcIDl2kxaVfgBacqQtYKR0ZVB2Hnoi89Cq6VmWPovauPyryx4Q3FC8Y11X7Otg==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/clipboard" "0.11.3"
|
||||||
|
"@lexical/code" "0.11.3"
|
||||||
|
"@lexical/dragon" "0.11.3"
|
||||||
|
"@lexical/hashtag" "0.11.3"
|
||||||
|
"@lexical/history" "0.11.3"
|
||||||
|
"@lexical/link" "0.11.3"
|
||||||
|
"@lexical/list" "0.11.3"
|
||||||
|
"@lexical/mark" "0.11.3"
|
||||||
|
"@lexical/markdown" "0.11.3"
|
||||||
|
"@lexical/overflow" "0.11.3"
|
||||||
|
"@lexical/plain-text" "0.11.3"
|
||||||
|
"@lexical/rich-text" "0.11.3"
|
||||||
|
"@lexical/selection" "0.11.3"
|
||||||
|
"@lexical/table" "0.11.3"
|
||||||
|
"@lexical/text" "0.11.3"
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
"@lexical/yjs" "0.11.3"
|
||||||
|
react-error-boundary "^3.1.4"
|
||||||
|
|
||||||
|
"@lexical/rich-text@0.11.3", "@lexical/rich-text@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.11.3.tgz#9501789bfe9671c220da7e95bf8589fa92cecf8c"
|
||||||
|
integrity sha512-fBFs6wMS7GFLbk+mzIWtwpP+EmnTZZ5bHpveuQ5wXONBuUuLcsYF5KO7UhLxXNLmiViV6lxatZPavEzgZdW7oQ==
|
||||||
|
|
||||||
|
"@lexical/selection@0.11.3", "@lexical/selection@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.11.3.tgz#f7250fae305a84c6e264a413f5feab056aeabfa3"
|
||||||
|
integrity sha512-15lQpcKT/vd7XZ5pnF1nb+kpKb72e9Yi1dVqieSxTeXkzt1cAZFKP3NB4RlhOKCv1N+glSBnjSxRwgsFfbD+NQ==
|
||||||
|
|
||||||
|
"@lexical/table@0.11.3", "@lexical/table@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.11.3.tgz#9b1980d828d7a588aaffa4cb8c6bb61401729034"
|
||||||
|
integrity sha512-EyRnN39CSPsMceADBR7Kf+sBHNpNQlPEkn/52epeDSnakR6s80woyrA3kIzKo6mLB4afvoqdYc7RfR96M9JLIA==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/utils" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/text@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.11.3.tgz#81ce2b5cd0caa9d89372e52c3548173d9395ad3d"
|
||||||
|
integrity sha512-gCEN8lJyR6b+yaOwKWGj79pbOfCQPWU/PHWyoNFUkEJXn3KydCzr2EYb6ta2cvQWRQU4G2BClKCR56jL4NS+qg==
|
||||||
|
|
||||||
|
"@lexical/utils@0.11.3", "@lexical/utils@^0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.11.3.tgz#c4eb953289d29943974008c75f80c8d4e294e009"
|
||||||
|
integrity sha512-vC4saCrlcmyIJnvrYKw1uYxZojlD1DCIBsFlgmO8kXyRYXjj+o/8PBdn2dsgSQ3rADrC2mUloOm/maekDcYe9Q==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/list" "0.11.3"
|
||||||
|
"@lexical/selection" "0.11.3"
|
||||||
|
"@lexical/table" "0.11.3"
|
||||||
|
|
||||||
|
"@lexical/yjs@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.11.3.tgz#b7049c0be85945fe0766b614256df8121bb05499"
|
||||||
|
integrity sha512-TLDQG2FSEw/aOfppEBb0wRlIuzJ57W//8ImfzyZvckSC12tvU0YKQQX8nQz/rybXdyfRy5eN+8gX5K2EyZx+pQ==
|
||||||
|
dependencies:
|
||||||
|
"@lexical/offset" "0.11.3"
|
||||||
|
|
||||||
"@mdn/browser-compat-data@^5.2.34", "@mdn/browser-compat-data@^5.3.13":
|
"@mdn/browser-compat-data@^5.2.34", "@mdn/browser-compat-data@^5.3.13":
|
||||||
version "5.3.16"
|
version "5.3.16"
|
||||||
resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.3.16.tgz#c3b6585c256461fe5e2eac85182b11b36ea2678b"
|
resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.3.16.tgz#c3b6585c256461fe5e2eac85182b11b36ea2678b"
|
||||||
|
@ -5830,6 +5984,11 @@ levn@^0.4.1:
|
||||||
prelude-ls "^1.2.1"
|
prelude-ls "^1.2.1"
|
||||||
type-check "~0.4.0"
|
type-check "~0.4.0"
|
||||||
|
|
||||||
|
lexical@^0.11.3:
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/lexical/-/lexical-0.11.3.tgz#1ad1a56a657eb55d1b9644733f271bf75a65cbe9"
|
||||||
|
integrity sha512-xsMKgx/Fa+QHg/nweemU04lCy7TnEr8LyeDtsKUC7fIDN9wH3GqbnQ0+e3Hbg4FmxlhDCiPPt0GcZAROq3R8uw==
|
||||||
|
|
||||||
li@^1.3.0:
|
li@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b"
|
resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b"
|
||||||
|
@ -6993,6 +7152,11 @@ pretty-format@^29.5.0:
|
||||||
ansi-styles "^5.0.0"
|
ansi-styles "^5.0.0"
|
||||||
react-is "^18.0.0"
|
react-is "^18.0.0"
|
||||||
|
|
||||||
|
prismjs@^1.27.0:
|
||||||
|
version "1.29.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12"
|
||||||
|
integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==
|
||||||
|
|
||||||
process@^0.11.10:
|
process@^0.11.10:
|
||||||
version "0.11.10"
|
version "0.11.10"
|
||||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||||
|
@ -7127,7 +7291,7 @@ react-dom@^18.0.0:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
scheduler "^0.23.0"
|
scheduler "^0.23.0"
|
||||||
|
|
||||||
react-error-boundary@^3.1.0:
|
react-error-boundary@^3.1.0, react-error-boundary@^3.1.4:
|
||||||
version "3.1.4"
|
version "3.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
|
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
|
||||||
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
|
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
|
||||||
|
|
Loading…
Reference in New Issue