Merge remote-tracking branch 'soapbox/main' into lexical
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
32
src/features/compose/components/__tests__/search.test.tsx
Normal file
32
src/features/compose/components/__tests__/search.test.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
test.skip('skip', () => {});
|
||||
|
||||
// import userEvent from '@testing-library/user-event';
|
||||
// import React from 'react';
|
||||
|
||||
// import { __stub } from 'soapbox/api';
|
||||
|
||||
// import { render, screen, waitFor } from '../../../../jest/test-helpers';
|
||||
// import Search from '../search';
|
||||
|
||||
// describe('<Search />', () => {
|
||||
// it('successfully renders', async() => {
|
||||
// render(<Search autosuggest />);
|
||||
// expect(screen.getByLabelText('Search')).toBeInTheDocument();
|
||||
// });
|
||||
|
||||
// it('handles onChange', async() => {
|
||||
// __stub(mock => {
|
||||
// mock.onGet('/api/v1/accounts/search').reply(200, [{ id: 1 }]);
|
||||
// });
|
||||
// const user = userEvent.setup();
|
||||
|
||||
// render(<Search autosuggest />);
|
||||
|
||||
// await user.type(screen.getByLabelText('Search'), '@jus');
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(screen.getByLabelText('Search')).toHaveValue('@jus');
|
||||
// expect(screen.getByTestId('account')).toBeInTheDocument();
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
18
src/features/compose/components/autosuggest-account.tsx
Normal file
18
src/features/compose/components/autosuggest-account.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useAccount } from 'soapbox/api/hooks';
|
||||
import Account from 'soapbox/components/account';
|
||||
|
||||
interface IAutosuggestAccount {
|
||||
id: string
|
||||
}
|
||||
|
||||
const AutosuggestAccount: React.FC<IAutosuggestAccount> = ({ id }) => {
|
||||
const { account } = useAccount(id);
|
||||
if (!account) return null;
|
||||
|
||||
return <Account account={account} hideActions showProfileHoverCard={false} />;
|
||||
|
||||
};
|
||||
|
||||
export default AutosuggestAccount;
|
||||
39
src/features/compose/components/compose-form-button.tsx
Normal file
39
src/features/compose/components/compose-form-button.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
|
||||
interface IComposeFormButton {
|
||||
icon: string
|
||||
title?: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const ComposeFormButton: React.FC<IComposeFormButton> = ({
|
||||
icon,
|
||||
title,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<IconButton
|
||||
className={
|
||||
clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': !active,
|
||||
'text-primary-500 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-400': active,
|
||||
})
|
||||
}
|
||||
src={icon}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComposeFormButton;
|
||||
405
src/features/compose/components/compose-form.tsx
Normal file
405
src/features/compose/components/compose-form.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl, type MessageDescriptor } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import {
|
||||
changeCompose,
|
||||
submitCompose,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
selectComposeSuggestion,
|
||||
insertEmojiCompose,
|
||||
uploadCompose,
|
||||
} from 'soapbox/actions/compose';
|
||||
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
||||
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
||||
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, useSettings } from 'soapbox/hooks';
|
||||
import { isMobile } from 'soapbox/is-mobile';
|
||||
|
||||
import QuotedStatusContainer from '../containers/quoted-status-container';
|
||||
import ReplyIndicatorContainer from '../containers/reply-indicator-container';
|
||||
import ScheduleFormContainer from '../containers/schedule-form-container';
|
||||
import UploadButtonContainer from '../containers/upload-button-container';
|
||||
import WarningContainer from '../containers/warning-container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
import MarkdownButton from './markdown-button';
|
||||
import PollButton from './poll-button';
|
||||
import PollForm from './polls/poll-form';
|
||||
import PrivacyDropdown from './privacy-dropdown';
|
||||
import ReplyGroupIndicator from './reply-group-indicator';
|
||||
import ReplyMentions from './reply-mentions';
|
||||
import ScheduleButton from './schedule-button';
|
||||
import SpoilerButton from './spoiler-button';
|
||||
import SpoilerInput from './spoiler-input';
|
||||
import TextCharacterCounter from './text-character-counter';
|
||||
import UploadForm from './upload-form';
|
||||
import VisualCharacterCounter from './visual-character-counter';
|
||||
import Warning from './warning';
|
||||
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
||||
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
|
||||
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||
message: { id: 'compose_form.message', defaultMessage: 'Message' },
|
||||
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
|
||||
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
|
||||
});
|
||||
|
||||
interface IComposeForm<ID extends string> {
|
||||
id: ID extends 'default' ? never : ID
|
||||
shouldCondense?: boolean
|
||||
autoFocus?: boolean
|
||||
clickableAreaRef?: React.RefObject<HTMLDivElement>
|
||||
event?: string
|
||||
group?: string
|
||||
extra?: React.ReactNode
|
||||
}
|
||||
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event, group, extra }: IComposeForm<ID>) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { configuration } = useInstance();
|
||||
|
||||
const compose = useCompose(id);
|
||||
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 scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
|
||||
const features = useFeatures();
|
||||
|
||||
const wysiwygEditor = useSettings().get('wysiwyg');
|
||||
|
||||
const { text: composeText, 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 prevSpoiler = usePrevious(spoiler);
|
||||
|
||||
const hasPoll = !!compose.poll;
|
||||
const isEditing = compose.id !== null;
|
||||
const anyMedia = compose.media_attachments.size > 0;
|
||||
|
||||
const [composeFocused, setComposeFocused] = useState(false);
|
||||
|
||||
const firstRender = useRef(true);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const spoilerTextRef = useRef<AutosuggestInput>(null);
|
||||
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
|
||||
const editorStateRef = useRef<string>(null);
|
||||
const text = wysiwygEditor ? editorStateRef.current || '' : composeText;
|
||||
|
||||
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 = () => {
|
||||
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
|
||||
};
|
||||
|
||||
const isEmpty = () => {
|
||||
return !(text || spoilerText || anyMedia);
|
||||
};
|
||||
|
||||
const isClickOutside = (e: MouseEvent | React.MouseEvent) => {
|
||||
return ![
|
||||
// List of elements that shouldn't collapse the composer when clicked
|
||||
// FIXME: Make this less brittle
|
||||
getClickableArea(),
|
||||
document.querySelector('.privacy-dropdown__dropdown'),
|
||||
document.querySelector('em-emoji-picker'),
|
||||
document.getElementById('modal-overlay'),
|
||||
].some(element => element?.contains(e.target as any));
|
||||
};
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent | React.MouseEvent) => {
|
||||
if (isEmpty() && isClickOutside(e)) {
|
||||
handleClickOutside();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
setComposeFocused(false);
|
||||
};
|
||||
|
||||
const handleComposeFocus = () => {
|
||||
setComposeFocused(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent<Element>) => {
|
||||
if (wysiwygEditor) {
|
||||
dispatch(changeCompose(id, editorStateRef.current!));
|
||||
} else {
|
||||
if (text !== autosuggestTextareaRef.current?.textarea?.value) {
|
||||
// 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:
|
||||
const fulltext = [spoilerText, countableText(text)].join('');
|
||||
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(submitCompose(id, history));
|
||||
};
|
||||
|
||||
const onSuggestionsClearRequested = () => {
|
||||
dispatch(clearComposeSuggestions(id));
|
||||
};
|
||||
|
||||
const onSuggestionsFetchRequested = (token: string | number) => {
|
||||
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) => {
|
||||
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
|
||||
};
|
||||
|
||||
const setCursor = (start: number, end: number = start) => {
|
||||
if (!autosuggestTextareaRef.current?.textarea) return;
|
||||
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
|
||||
};
|
||||
|
||||
const handleEmojiPick = (data: Emoji) => {
|
||||
const position = autosuggestTextareaRef.current!.textarea!.selectionStart;
|
||||
const needsSpace = !!data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
||||
|
||||
dispatch(insertEmojiCompose(id, position, data, needsSpace));
|
||||
};
|
||||
|
||||
const onPaste = (files: FileList) => {
|
||||
dispatch(uploadCompose(id, files, intl));
|
||||
};
|
||||
|
||||
const focusSpoilerInput = () => {
|
||||
spoilerTextRef.current?.input?.focus();
|
||||
};
|
||||
|
||||
const focusTextarea = () => {
|
||||
autosuggestTextareaRef.current?.textarea?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const length = text.length;
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
if (length > 0) {
|
||||
setCursor(length); // Set cursor at end
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (spoiler && firstRender.current) {
|
||||
focusTextarea();
|
||||
firstRender.current = false;
|
||||
} else if (!spoiler && prevSpoiler) {
|
||||
focusTextarea();
|
||||
} else if (spoiler && !prevSpoiler) {
|
||||
focusSpoilerInput();
|
||||
}
|
||||
}, [spoiler]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof caretPosition === 'number') {
|
||||
setCursor(caretPosition);
|
||||
}
|
||||
}, [focusDate]);
|
||||
|
||||
const renderButtons = useCallback(() => (
|
||||
<HStack alignItems='center' space={2}>
|
||||
{features.media && <UploadButtonContainer composeId={id} />}
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} condensed={shouldCondense} />
|
||||
{features.polls && <PollButton composeId={id} />}
|
||||
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
|
||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||
{features.spoilers && <SpoilerButton composeId={id} />}
|
||||
{!wysiwygEditor && features.richText && <MarkdownButton composeId={id} />}
|
||||
</HStack>
|
||||
), [features, id]);
|
||||
|
||||
const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty() && !isUploading;
|
||||
const disabled = isSubmitting;
|
||||
const countedText = [spoilerText, countableText(text)].join('');
|
||||
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
|
||||
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
||||
|
||||
const composeModifiers = !condensed && (
|
||||
<Stack space={4} className='compose-form__modifiers'>
|
||||
<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;
|
||||
let textareaPlaceholder: MessageDescriptor;
|
||||
|
||||
|
||||
if (isEditing) {
|
||||
publishText = intl.formatMessage(messages.saveChanges);
|
||||
} else if (privacy === 'direct') {
|
||||
publishIcon = require('@tabler/icons/mail.svg');
|
||||
publishText = intl.formatMessage(messages.message);
|
||||
} else if (privacy === 'private') {
|
||||
publishIcon = require('@tabler/icons/lock.svg');
|
||||
publishText = intl.formatMessage(messages.publish);
|
||||
} else {
|
||||
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
||||
}
|
||||
|
||||
if (scheduledAt) {
|
||||
publishText = intl.formatMessage(messages.schedule);
|
||||
}
|
||||
|
||||
if (event) {
|
||||
textareaPlaceholder = messages.eventPlaceholder;
|
||||
} else if (hasPoll) {
|
||||
textareaPlaceholder = messages.pollPlaceholder;
|
||||
} else {
|
||||
textareaPlaceholder = messages.placeholder;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||
{scheduledStatusCount > 0 && !event && !group && (
|
||||
<Warning
|
||||
message={(
|
||||
<FormattedMessage
|
||||
id='compose_form.scheduled_statuses.message'
|
||||
defaultMessage='You have scheduled posts. {click_here} to see them.'
|
||||
values={{ click_here: (
|
||||
<Link to='/scheduled_statuses'>
|
||||
<FormattedMessage
|
||||
id='compose_form.scheduled_statuses.click_here'
|
||||
defaultMessage='Click here'
|
||||
/>
|
||||
</Link>
|
||||
) }}
|
||||
/>)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WarningContainer composeId={id} />
|
||||
|
||||
{!shouldCondense && !event && !group && groupId && <ReplyGroupIndicator composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && !group && <ReplyIndicatorContainer composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
||||
|
||||
{wysiwygEditor ? (
|
||||
<div>
|
||||
<Bundle fetchComponent={ComposeEditor}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
ref={editorStateRef}
|
||||
className='my-2'
|
||||
composeId={id}
|
||||
condensed={condensed}
|
||||
eventDiscussion={!!event}
|
||||
autoFocus={shouldAutoFocus}
|
||||
hasPoll={hasPoll}
|
||||
handleSubmit={handleSubmit}
|
||||
onFocus={handleComposeFocus}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
{composeModifiers}
|
||||
</div>
|
||||
) : (
|
||||
<AutosuggestTextarea
|
||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
||||
placeholder={intl.formatMessage(textareaPlaceholder)}
|
||||
disabled={disabled}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
suggestions={suggestions}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleComposeFocus}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onPaste={onPaste}
|
||||
autoFocus={shouldAutoFocus}
|
||||
condensed={condensed}
|
||||
id='compose-textarea'
|
||||
>
|
||||
{composeModifiers}
|
||||
</AutosuggestTextarea>
|
||||
)}
|
||||
|
||||
<QuotedStatusContainer composeId={id} />
|
||||
|
||||
{extra && <div className={clsx({ 'hidden': condensed })}>{extra}</div>}
|
||||
|
||||
<div
|
||||
className={clsx('flex flex-wrap items-center justify-between', {
|
||||
'hidden': condensed,
|
||||
})}
|
||||
>
|
||||
{renderButtons()}
|
||||
|
||||
<HStack space={4} alignItems='center' className='ml-auto rtl:ml-0 rtl:mr-auto'>
|
||||
{maxTootChars && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<TextCharacterCounter max={maxTootChars} text={text} />
|
||||
<VisualCharacterCounter max={maxTootChars} text={text} />
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={disabledButton} />
|
||||
</HStack>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComposeForm;
|
||||
37
src/features/compose/components/markdown-button.tsx
Normal file
37
src/features/compose/components/markdown-button.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeComposeContentType } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import ComposeFormButton from './compose-form-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
marked: { id: 'compose_form.markdown.marked', defaultMessage: 'Post markdown enabled' },
|
||||
unmarked: { id: 'compose_form.markdown.unmarked', defaultMessage: 'Post markdown disabled' },
|
||||
});
|
||||
|
||||
interface IMarkdownButton {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const MarkdownButton: React.FC<IMarkdownButton> = ({ composeId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const active = useCompose(composeId).content_type === 'text/markdown';
|
||||
|
||||
const onClick = () => dispatch(changeComposeContentType(composeId, active ? 'text/plain' : 'text/markdown'));
|
||||
|
||||
return (
|
||||
<ComposeFormButton
|
||||
icon={require('@tabler/icons/markdown.svg')}
|
||||
title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default MarkdownButton;
|
||||
51
src/features/compose/components/poll-button.tsx
Normal file
51
src/features/compose/components/poll-button.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { addPoll, removePoll } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import ComposeFormButton from './compose-form-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
|
||||
remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
|
||||
});
|
||||
|
||||
interface IPollButton {
|
||||
composeId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const PollButton: React.FC<IPollButton> = ({ composeId, disabled }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const unavailable = compose.is_uploading;
|
||||
const active = compose.poll !== null;
|
||||
|
||||
const onClick = () => {
|
||||
if (active) {
|
||||
dispatch(removePoll(composeId));
|
||||
} else {
|
||||
dispatch(addPoll(composeId));
|
||||
}
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposeFormButton
|
||||
icon={require('@tabler/icons/chart-bar.svg')}
|
||||
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollButton;
|
||||
@ -0,0 +1,77 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../../../../jest/test-helpers';
|
||||
import DurationSelector from '../duration-selector';
|
||||
|
||||
describe('<DurationSelector />', () => {
|
||||
it('defaults to 2 days', () => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-days')).toHaveValue('2');
|
||||
expect(screen.getByTestId('duration-selector-hours')).toHaveValue('0');
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toHaveValue('0');
|
||||
});
|
||||
|
||||
describe('when changing the day', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '1 day' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(86400); // 1 day
|
||||
});
|
||||
|
||||
it('should disable the hour/minute select if 7 days selected', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).not.toBeDisabled();
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '7 days' }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the hour', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-hours'),
|
||||
screen.getByRole('option', { name: '1 hour' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(176400); // 2 days, 1 hour
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the minute', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-minutes'),
|
||||
screen.getByRole('option', { name: '15 minutes' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(173700); // 2 days, 1 minute
|
||||
});
|
||||
});
|
||||
});
|
||||
85
src/features/compose/components/polls/duration-selector.tsx
Normal file
85
src/features/compose/components/polls/duration-selector.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Select } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
});
|
||||
|
||||
interface IDurationSelector {
|
||||
onDurationChange(expiresIn: number): void
|
||||
}
|
||||
|
||||
const DurationSelector = ({ onDurationChange }: IDurationSelector) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [days, setDays] = useState<number>(2);
|
||||
const [hours, setHours] = useState<number>(0);
|
||||
const [minutes, setMinutes] = useState<number>(0);
|
||||
|
||||
const value = (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60);
|
||||
|
||||
useEffect(() => {
|
||||
if (days === 7) {
|
||||
setHours(0);
|
||||
setMinutes(0);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => {
|
||||
onDurationChange(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={days}
|
||||
onChange={(event) => setDays(Number(event.target.value))}
|
||||
data-testid='duration-selector-days'
|
||||
>
|
||||
{[...Array(8).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.days, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={hours}
|
||||
onChange={(event) => setHours(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-hours'
|
||||
>
|
||||
{[...Array(24).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.hours, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={minutes}
|
||||
onChange={(event) => setMinutes(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-minutes'
|
||||
>
|
||||
{[0, 15, 30, 45].map((number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.minutes, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DurationSelector;
|
||||
211
src/features/compose/components/polls/poll-form.tsx
Normal file
211
src/features/compose/components/polls/poll-form.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { addPollOption, changePollOption, changePollSettings, clearComposeSuggestions, fetchComposeSuggestions, removePoll, removePollOption, selectComposeSuggestion } from 'soapbox/actions/compose';
|
||||
import AutosuggestInput from 'soapbox/components/autosuggest-input';
|
||||
import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
|
||||
|
||||
import DurationSelector from './duration-selector';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
|
||||
const messages = defineMessages({
|
||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Answer #{number}' },
|
||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add an answer' },
|
||||
pollDuration: { id: 'compose_form.poll.duration', defaultMessage: 'Duration' },
|
||||
removePoll: { id: 'compose_form.poll.remove', defaultMessage: 'Remove poll' },
|
||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple answers' },
|
||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single answer' },
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
multiSelect: { id: 'compose_form.poll.multiselect', defaultMessage: 'Multi-Select' },
|
||||
multiSelectDetail: { id: 'compose_form.poll.multiselect_detail', defaultMessage: 'Allow users to select multiple answers' },
|
||||
});
|
||||
|
||||
interface IOption {
|
||||
composeId: string
|
||||
index: number
|
||||
maxChars: number
|
||||
numOptions: number
|
||||
onChange(index: number, value: string): void
|
||||
onRemove(index: number): void
|
||||
onRemovePoll(): void
|
||||
title: string
|
||||
}
|
||||
|
||||
const Option: React.FC<IOption> = ({
|
||||
composeId,
|
||||
index,
|
||||
maxChars,
|
||||
numOptions,
|
||||
onChange,
|
||||
onRemove,
|
||||
onRemovePoll,
|
||||
title,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const suggestions = useCompose(composeId).suggestions;
|
||||
|
||||
const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value);
|
||||
|
||||
const handleOptionRemove = () => {
|
||||
if (numOptions > 2) {
|
||||
onRemove(index);
|
||||
} else {
|
||||
onRemovePoll();
|
||||
}
|
||||
};
|
||||
|
||||
const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions(composeId));
|
||||
|
||||
const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(composeId, token));
|
||||
|
||||
const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
|
||||
if (token && typeof token === 'string') {
|
||||
dispatch(selectComposeSuggestion(composeId, tokenStart, token, value, ['poll', 'options', index]));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' justifyContent='between' space={4}>
|
||||
<HStack alignItems='center' space={2} grow>
|
||||
<div className='w-6'>
|
||||
<Text weight='bold'>{index + 1}.</Text>
|
||||
</div>
|
||||
|
||||
<AutosuggestInput
|
||||
className='rounded-md !bg-transparent dark:!bg-transparent'
|
||||
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
|
||||
maxLength={maxChars}
|
||||
value={title}
|
||||
onChange={handleOptionTitleChange}
|
||||
suggestions={suggestions}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
searchTokens={[':']}
|
||||
autoFocus={index === 0 || index >= 2}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{index > 1 && (
|
||||
<div>
|
||||
<Button theme='danger' size='sm' onClick={handleOptionRemove}>
|
||||
<FormattedMessage id='compose_form.poll.remove_option' defaultMessage='Delete' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPollForm {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const PollForm: React.FC<IPollForm> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const { configuration } = useInstance();
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const pollLimits = configuration.get('polls') as ImmutableMap<string, number>;
|
||||
const options = compose.poll?.options;
|
||||
const expiresIn = compose.poll?.expires_in;
|
||||
const isMultiple = compose.poll?.multiple;
|
||||
|
||||
const maxOptions = pollLimits.get('max_options') as number;
|
||||
const maxOptionChars = pollLimits.get('max_characters_per_option') as number;
|
||||
|
||||
const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index));
|
||||
const onChangeOption = (index: number, title: string) => dispatch(changePollOption(composeId, index, title));
|
||||
const handleAddOption = () => dispatch(addPollOption(composeId, ''));
|
||||
const onChangeSettings = (expiresIn: number, isMultiple?: boolean) =>
|
||||
dispatch(changePollSettings(composeId, expiresIn, isMultiple));
|
||||
const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple);
|
||||
const handleToggleMultiple = () => onChangeSettings(Number(expiresIn), !isMultiple);
|
||||
const onRemovePoll = () => dispatch(removePoll(composeId));
|
||||
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Stack space={2}>
|
||||
{options.map((title: string, i: number) => (
|
||||
<Option
|
||||
composeId={composeId}
|
||||
title={title}
|
||||
key={i}
|
||||
index={i}
|
||||
onChange={onChangeOption}
|
||||
onRemove={onRemoveOption}
|
||||
maxChars={maxOptionChars}
|
||||
numOptions={options.size}
|
||||
onRemovePoll={onRemovePoll}
|
||||
/>
|
||||
))}
|
||||
|
||||
<HStack space={2}>
|
||||
<div className='w-6' />
|
||||
|
||||
{options.size < maxOptions && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={handleAddOption}
|
||||
size='sm'
|
||||
block
|
||||
>
|
||||
<FormattedMessage {...messages.add_option} />
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<button type='button' onClick={handleToggleMultiple} className='text-start'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Stack>
|
||||
<Text weight='medium'>
|
||||
{intl.formatMessage(messages.multiSelect)}
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' size='sm'>
|
||||
{intl.formatMessage(messages.multiSelectDetail)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Toggle checked={isMultiple} onChange={handleToggleMultiple} />
|
||||
</HStack>
|
||||
</button>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Duration */}
|
||||
<Stack space={2}>
|
||||
<Text weight='medium'>
|
||||
{intl.formatMessage(messages.pollDuration)}
|
||||
</Text>
|
||||
|
||||
<DurationSelector onDurationChange={handleSelectDuration} />
|
||||
</Stack>
|
||||
|
||||
{/* Remove Poll */}
|
||||
<div className='text-center'>
|
||||
<button type='button' className='text-danger-500' onClick={onRemovePoll}>
|
||||
{intl.formatMessage(messages.removePoll)}
|
||||
</button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollForm;
|
||||
270
src/features/compose/components/privacy-dropdown.tsx
Normal file
270
src/features/compose/components/privacy-dropdown.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import { spring } from 'react-motion';
|
||||
// @ts-ignore
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
|
||||
import { changeComposeVisibility } from 'soapbox/actions/compose';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is-mobile';
|
||||
|
||||
import Motion from '../../ui/util/optional-motion';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
|
||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' },
|
||||
});
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
interface IPrivacyDropdownMenu {
|
||||
style?: React.CSSProperties
|
||||
items: any[]
|
||||
value: string
|
||||
placement: string
|
||||
onClose: () => void
|
||||
onChange: (value: string | null) => void
|
||||
unavailable?: boolean
|
||||
}
|
||||
|
||||
const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, placement, value, onClose, onChange }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const focusedItem = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
|
||||
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
|
||||
if (node.current && !node.current.contains(e.target as HTMLElement)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex(item => item.value === value);
|
||||
let element: ChildNode | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element = node.current?.childNodes[index + 1] || node.current?.firstChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = node.current?.childNodes[index - 1] || node.current?.lastChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = node.current?.childNodes[index - 1] || node.current?.lastChild;
|
||||
} else {
|
||||
element = node.current?.childNodes[index + 1] || node.current?.firstChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = node.current?.firstChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = node.current?.lastChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
(element as HTMLElement).focus();
|
||||
onChange((element as HTMLElement).getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
|
||||
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
focusedItem.current?.focus({ preventScroll: true });
|
||||
setMounted(true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', handleDocumentClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div className={clsx('privacy-dropdown__dropdown', placement)} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }} role='listbox' ref={node}>
|
||||
{items.map(item => (
|
||||
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={handleKeyDown} onClick={handleClick} className={clsx('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? focusedItem : null}>
|
||||
<div className='privacy-dropdown__option__icon'>
|
||||
<Icon src={item.icon} />
|
||||
</div>
|
||||
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPrivacyDropdown {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
|
||||
composeId,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const value = compose.privacy;
|
||||
const unavailable = compose.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [placement, setPlacement] = useState('bottom');
|
||||
|
||||
const options = [
|
||||
{ icon: require('@tabler/icons/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) },
|
||||
{ icon: require('@tabler/icons/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
|
||||
{ icon: require('@tabler/icons/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) },
|
||||
{ icon: require('@tabler/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
|
||||
];
|
||||
|
||||
const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value));
|
||||
|
||||
const onModalOpen = (props: Record<string, any>) => dispatch(openModal('ACTIONS', props));
|
||||
|
||||
const onModalClose = () => dispatch(closeModal('ACTIONS'));
|
||||
|
||||
const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (isUserTouching()) {
|
||||
if (open) {
|
||||
onModalClose();
|
||||
} else {
|
||||
onModalOpen({
|
||||
actions: options.map(option => ({ ...option, active: option.value === value })),
|
||||
onClick: handleModalActionClick,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const { top } = e.currentTarget.getBoundingClientRect();
|
||||
if (open) {
|
||||
activeElement.current?.focus();
|
||||
}
|
||||
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
|
||||
setOpen(!open);
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleModalActionClick: React.MouseEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { value } = options[e.currentTarget.getAttribute('data-index') as any];
|
||||
|
||||
onModalClose();
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
if (!open) {
|
||||
activeElement.current = document.activeElement as HTMLElement | null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonKeyDown: React.KeyboardEventHandler = (e) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (open) {
|
||||
activeElement.current?.focus();
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueOption = options.find(item => item.value === value);
|
||||
|
||||
return (
|
||||
<div className={clsx('privacy-dropdown', placement, { active: open })} onKeyDown={handleKeyDown} ref={node}>
|
||||
<div className={clsx('privacy-dropdown__value', { active: valueOption && options.indexOf(valueOption) === 0 })}>
|
||||
<IconButton
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': !open,
|
||||
'text-primary-500 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-400': open,
|
||||
})}
|
||||
src={valueOption?.icon}
|
||||
title={intl.formatMessage(messages.change_privacy)}
|
||||
onClick={handleToggle}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Overlay show={open} placement={placement} target={node.current}>
|
||||
<PrivacyDropdownMenu
|
||||
items={options}
|
||||
value={value}
|
||||
onClose={handleClose}
|
||||
onChange={onChange}
|
||||
placement={placement}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyDropdown;
|
||||
42
src/features/compose/components/reply-group-indicator.tsx
Normal file
42
src/features/compose/components/reply-group-indicator.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Link from 'soapbox/components/link';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { Group } from 'soapbox/schemas';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
interface IReplyGroupIndicator {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const ReplyGroupIndicator = (props: IReplyGroupIndicator) => {
|
||||
const { composeId } = props;
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector((state) => getStatus(state, { id: state.compose.get(composeId)?.in_reply_to! }));
|
||||
const group = status?.group as Group;
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage
|
||||
id='compose.reply_group_indicator.message'
|
||||
defaultMessage='Posting to {groupLink}'
|
||||
values={{
|
||||
groupLink: <Link
|
||||
to={`/group/${group.slug}`}
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyGroupIndicator;
|
||||
66
src/features/compose/components/reply-indicator.tsx
Normal file
66
src/features/compose/components/reply-indicator.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||
import Markup from 'soapbox/components/markup';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { isRtl } from 'soapbox/rtl';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
interface IReplyIndicator {
|
||||
className?: string
|
||||
status?: Status
|
||||
onCancel?: () => void
|
||||
hideActions: boolean
|
||||
}
|
||||
|
||||
const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActions, onCancel }) => {
|
||||
const handleClick = () => {
|
||||
onCancel!();
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let actions = {};
|
||||
if (!hideActions && onCancel) {
|
||||
actions = {
|
||||
onActionClick: handleClick,
|
||||
actionIcon: require('@tabler/icons/x.svg'),
|
||||
actionAlignment: 'top',
|
||||
actionTitle: 'Dismiss',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={2} className={clsx('rounded-lg bg-gray-100 p-4 dark:bg-gray-800', className)}>
|
||||
<AccountContainer
|
||||
{...actions}
|
||||
id={status.getIn(['account', 'id']) as string}
|
||||
timestamp={status.created_at}
|
||||
showProfileHoverCard={false}
|
||||
withLinkToProfile={false}
|
||||
hideActions={hideActions}
|
||||
/>
|
||||
|
||||
<Markup
|
||||
className='break-words'
|
||||
size='sm'
|
||||
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
||||
direction={isRtl(status.search_index) ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
|
||||
{status.media_attachments.size > 0 && (
|
||||
<AttachmentThumbs
|
||||
media={status.media_attachments}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyIndicator;
|
||||
83
src/features/compose/components/reply-mentions.tsx
Normal file
83
src/features/compose/components/reply-mentions.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { FormattedList, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { isPubkey } from 'soapbox/utils/nostr';
|
||||
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IReplyMentions {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector<StatusEntity | null>(state => getStatus(state, { id: compose.in_reply_to! }));
|
||||
const to = compose.to;
|
||||
const { account } = useOwnAccount();
|
||||
|
||||
if (!features.explicitAddressing || !status || !to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentTo = status && statusToMentionsAccountIdsArray(status, account!);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(openModal('REPLY_MENTIONS', {
|
||||
composeId,
|
||||
}));
|
||||
};
|
||||
|
||||
if (!parentTo || (parentTo.size === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (to.size === 0) {
|
||||
return (
|
||||
<a href='#' className='reply-mentions' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply_empty'
|
||||
defaultMessage='Replying to post'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const accounts = to.slice(0, 2).map((acct: string) => {
|
||||
const username = acct.split('@')[0];
|
||||
return (
|
||||
<span className='reply-mentions__account'>
|
||||
@{isPubkey(username) ? username.slice(0, 8) : username}
|
||||
</span>
|
||||
);
|
||||
}).toArray();
|
||||
|
||||
if (to.size > 2) {
|
||||
accounts.push(
|
||||
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href='#' className='reply-mentions' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply'
|
||||
defaultMessage='Replying to {accounts}'
|
||||
values={{
|
||||
accounts: <FormattedList type='conjunction' value={accounts} />,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyMentions;
|
||||
51
src/features/compose/components/schedule-button.tsx
Normal file
51
src/features/compose/components/schedule-button.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { addSchedule, removeSchedule } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import ComposeFormButton from './compose-form-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
add_schedule: { id: 'schedule_button.add_schedule', defaultMessage: 'Schedule post for later' },
|
||||
remove_schedule: { id: 'schedule_button.remove_schedule', defaultMessage: 'Post immediately' },
|
||||
});
|
||||
|
||||
interface IScheduleButton {
|
||||
composeId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ScheduleButton: React.FC<IScheduleButton> = ({ composeId, disabled }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const active = !!compose.schedule;
|
||||
const unavailable = !!compose.id;
|
||||
|
||||
const handleClick = () => {
|
||||
if (active) {
|
||||
dispatch(removeSchedule(composeId));
|
||||
} else {
|
||||
dispatch(addSchedule(composeId));
|
||||
}
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposeFormButton
|
||||
icon={require('@tabler/icons/calendar-stats.svg')}
|
||||
title={intl.formatMessage(active ? messages.remove_schedule : messages.add_schedule)}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleButton;
|
||||
86
src/features/compose/components/schedule-form.tsx
Normal file
86
src/features/compose/components/schedule-form.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { setSchedule, removeSchedule } from 'soapbox/actions/compose';
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import { DatePicker } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
export const isCurrentOrFutureDate = (date: Date) => {
|
||||
return date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0);
|
||||
};
|
||||
|
||||
const isFiveMinutesFromNow = (time: Date) => {
|
||||
const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); // now, plus five minutes (Pleroma won't schedule posts )
|
||||
const selectedDate = new Date(time);
|
||||
|
||||
return fiveMinutesFromNow.getTime() < selectedDate.getTime();
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
schedule: { id: 'schedule.post_time', defaultMessage: 'Post Date/Time' },
|
||||
remove: { id: 'schedule.remove', defaultMessage: 'Remove schedule' },
|
||||
});
|
||||
|
||||
export interface IScheduleForm {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const ScheduleForm: React.FC<IScheduleForm> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const scheduledAt = useCompose(composeId).schedule;
|
||||
const active = !!scheduledAt;
|
||||
|
||||
const onSchedule = (date: Date) => {
|
||||
dispatch(setSchedule(composeId, date));
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
dispatch(removeSchedule(composeId));
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='datepicker.hint' defaultMessage='Scheduled to post at…' />
|
||||
</Text>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<BundleContainer fetchComponent={DatePicker}>
|
||||
{Component => (<Component
|
||||
selected={scheduledAt}
|
||||
showTimeSelect
|
||||
dateFormat='MMMM d, yyyy h:mm aa'
|
||||
timeIntervals={15}
|
||||
wrapperClassName='react-datepicker-wrapper'
|
||||
onChange={onSchedule}
|
||||
placeholderText={intl.formatMessage(messages.schedule)}
|
||||
filterDate={isCurrentOrFutureDate}
|
||||
filterTime={isFiveMinutesFromNow}
|
||||
className={clsx({
|
||||
'has-error': !isFiveMinutesFromNow(scheduledAt),
|
||||
})}
|
||||
/>)}
|
||||
</BundleContainer>
|
||||
<IconButton
|
||||
iconClassName='h-4 w-4'
|
||||
className='bg-transparent text-gray-400 hover:text-gray-600'
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={handleRemove}
|
||||
title={intl.formatMessage(messages.remove)}
|
||||
/>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleForm;
|
||||
242
src/features/compose/components/search-results.tsx
Normal file
242
src/features/compose/components/search-results.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search';
|
||||
import { fetchTrendingStatuses } from 'soapbox/actions/trending-statuses';
|
||||
import { useAccount } from 'soapbox/api/hooks';
|
||||
import Hashtag from 'soapbox/components/hashtag';
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { HStack, Tabs, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import StatusContainer from 'soapbox/containers/status-container';
|
||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||
|
||||
const messages = defineMessages({
|
||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||
});
|
||||
|
||||
const SearchResults = () => {
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const value = useAppSelector((state) => state.search.submittedValue);
|
||||
const results = useAppSelector((state) => state.search.results);
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const trendingStatuses = useAppSelector((state) => state.trending_statuses.items);
|
||||
const trends = useAppSelector((state) => state.trends.items);
|
||||
const submitted = useAppSelector((state) => state.search.submitted);
|
||||
const selectedFilter = useAppSelector((state) => state.search.filter);
|
||||
const filterByAccount = useAppSelector((state) => state.search.accountId || undefined);
|
||||
const { account } = useAccount(filterByAccount);
|
||||
|
||||
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
|
||||
|
||||
const handleUnsetAccount = () => dispatch(setSearchAccount(null));
|
||||
|
||||
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||
|
||||
const renderFilterBar = () => {
|
||||
const items = [];
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.accounts),
|
||||
action: () => selectFilter('accounts'),
|
||||
name: 'accounts',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.statuses),
|
||||
action: () => selectFilter('statuses'),
|
||||
name: 'statuses',
|
||||
},
|
||||
);
|
||||
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.hashtags),
|
||||
action: () => selectFilter('hashtags'),
|
||||
name: 'hashtags',
|
||||
},
|
||||
);
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
||||
const getCurrentIndex = (id: string): number => {
|
||||
return resultsIds?.keySeq().findIndex(key => key === id);
|
||||
};
|
||||
|
||||
const handleMoveUp = (id: string) => {
|
||||
if (!resultsIds) return;
|
||||
|
||||
const elementIndex = getCurrentIndex(id) - 1;
|
||||
selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string) => {
|
||||
if (!resultsIds) return;
|
||||
|
||||
const elementIndex = getCurrentIndex(id) + 1;
|
||||
selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const selectChild = (index: number) => {
|
||||
node.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const element = document.querySelector<HTMLDivElement>(`#search-results [data-index="${index}"] .focusable`);
|
||||
element?.focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTrendingStatuses());
|
||||
}, []);
|
||||
|
||||
let searchResults;
|
||||
let hasMore = false;
|
||||
let loaded;
|
||||
let noResultsMessage;
|
||||
let placeholderComponent = PlaceholderStatus as React.ComponentType;
|
||||
let resultsIds: ImmutableOrderedSet<string>;
|
||||
|
||||
if (selectedFilter === 'accounts') {
|
||||
hasMore = results.accountsHasMore;
|
||||
loaded = results.accountsLoaded;
|
||||
placeholderComponent = PlaceholderAccount;
|
||||
|
||||
if (results.accounts && results.accounts.size > 0) {
|
||||
searchResults = results.accounts.map(accountId => <AccountContainer key={accountId} id={accountId} />);
|
||||
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
|
||||
searchResults = suggestions.map(suggestion => <AccountContainer key={suggestion.account} id={suggestion.account} />);
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.accounts'
|
||||
defaultMessage='There are no people results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'statuses') {
|
||||
hasMore = results.statusesHasMore;
|
||||
loaded = results.statusesLoaded;
|
||||
|
||||
if (results.statuses && results.statuses.size > 0) {
|
||||
searchResults = results.statuses.map((statusId: string) => (
|
||||
// @ts-ignore
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
));
|
||||
resultsIds = results.statuses;
|
||||
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
||||
searchResults = trendingStatuses.map((statusId: string) => (
|
||||
// @ts-ignore
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
));
|
||||
resultsIds = trendingStatuses;
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.statuses'
|
||||
defaultMessage='There are no posts results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'hashtags') {
|
||||
hasMore = results.hashtagsHasMore;
|
||||
loaded = results.hashtagsLoaded;
|
||||
placeholderComponent = PlaceholderHashtag;
|
||||
|
||||
if (results.hashtags && results.hashtags.size > 0) {
|
||||
searchResults = results.hashtags.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
|
||||
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
|
||||
searchResults = trends.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.hashtags'
|
||||
defaultMessage='There are no hashtags results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{filterByAccount ? (
|
||||
<HStack className='mb-4 border-b border-solid border-gray-200 px-2 pb-4 dark:border-gray-800' space={2}>
|
||||
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleUnsetAccount} />
|
||||
<Text truncate>
|
||||
<FormattedMessage
|
||||
id='search_results.filter_message'
|
||||
defaultMessage='You are searching for posts from @{acct}.'
|
||||
values={{ acct: <strong className='break-words'>{account?.acct}</strong> }}
|
||||
/>
|
||||
</Text>
|
||||
</HStack>
|
||||
) : renderFilterBar()}
|
||||
|
||||
{noResultsMessage || (
|
||||
<ScrollableList
|
||||
id='search-results'
|
||||
ref={node}
|
||||
key={selectedFilter}
|
||||
scrollKey={`${selectedFilter}:${value}`}
|
||||
isLoading={submitted && !loaded}
|
||||
showLoading={submitted && !loaded && searchResults?.isEmpty()}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
placeholderComponent={placeholderComponent}
|
||||
placeholderCount={20}
|
||||
className={clsx({
|
||||
'divide-gray-200 dark:divide-gray-800 divide-solid divide-y': selectedFilter === 'statuses',
|
||||
})}
|
||||
itemClassName={clsx({
|
||||
'pb-4': selectedFilter === 'accounts',
|
||||
'pb-3': selectedFilter === 'hashtags',
|
||||
})}
|
||||
>
|
||||
{searchResults || []}
|
||||
</ScrollableList>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
184
src/features/compose/components/search.tsx
Normal file
184
src/features/compose/components/search.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import clsx from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
changeSearch,
|
||||
clearSearch,
|
||||
clearSearchResults,
|
||||
setSearchAccount,
|
||||
showSearch,
|
||||
submitSearch,
|
||||
} from 'soapbox/actions/search';
|
||||
import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input';
|
||||
import { Input } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { selectAccount } from 'soapbox/selectors';
|
||||
import { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
action: { id: 'search.action', defaultMessage: 'Search for “{query}”' },
|
||||
});
|
||||
|
||||
function redirectToAccount(accountId: string, routerHistory: any) {
|
||||
return (_dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const acct = selectAccount(getState(), accountId)!.acct;
|
||||
|
||||
if (acct && routerHistory) {
|
||||
routerHistory.push(`/@${acct}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface ISearch {
|
||||
autoFocus?: boolean
|
||||
autoSubmit?: boolean
|
||||
autosuggest?: boolean
|
||||
openInRoute?: boolean
|
||||
}
|
||||
|
||||
const Search = (props: ISearch) => {
|
||||
const {
|
||||
autoFocus = false,
|
||||
autoSubmit = false,
|
||||
autosuggest = false,
|
||||
openInRoute = false,
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
|
||||
const value = useAppSelector((state) => state.search.value);
|
||||
const submitted = useAppSelector((state) => state.search.submitted);
|
||||
|
||||
const debouncedSubmit = useCallback(debounce(() => {
|
||||
dispatch(submitSearch());
|
||||
}, 900), []);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
|
||||
dispatch(changeSearch(value));
|
||||
|
||||
if (autoSubmit) {
|
||||
debouncedSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (value.length > 0 || submitted) {
|
||||
dispatch(clearSearchResults());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (openInRoute) {
|
||||
dispatch(setSearchAccount(null));
|
||||
dispatch(submitSearch());
|
||||
|
||||
history.push('/search');
|
||||
} else {
|
||||
dispatch(submitSearch());
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
dispatch(showSearch());
|
||||
};
|
||||
|
||||
const handleSelected = (accountId: string) => {
|
||||
dispatch(clearSearch());
|
||||
dispatch(redirectToAccount(accountId, history));
|
||||
};
|
||||
|
||||
const makeMenu = () => [
|
||||
{
|
||||
text: intl.formatMessage(messages.action, { query: value }),
|
||||
icon: require('@tabler/icons/search.svg'),
|
||||
action: handleSubmit,
|
||||
},
|
||||
];
|
||||
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
const componentProps: any = {
|
||||
type: 'text',
|
||||
id: 'search',
|
||||
placeholder: intl.formatMessage(messages.placeholder),
|
||||
value,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
onFocus: handleFocus,
|
||||
autoFocus: autoFocus,
|
||||
theme: 'search',
|
||||
className: 'pr-10 rtl:pl-10 rtl:pr-3',
|
||||
};
|
||||
|
||||
if (autosuggest) {
|
||||
componentProps.onSelected = handleSelected;
|
||||
componentProps.menu = makeMenu();
|
||||
componentProps.autoSelect = false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const newPath = history.location.pathname;
|
||||
const shouldPersistSearch = !!newPath.match(/@.+\/posts\/[a-zA-Z0-9]+/g)
|
||||
|| !!newPath.match(/\/tags\/.+/g);
|
||||
|
||||
if (!shouldPersistSearch) {
|
||||
dispatch(changeSearch(''));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<label htmlFor='search' className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
|
||||
|
||||
<div className='relative'>
|
||||
{autosuggest ? (
|
||||
<AutosuggestAccountInput {...componentProps} />
|
||||
) : (
|
||||
<Input {...componentProps} />
|
||||
)}
|
||||
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-3 rtl:left-0 rtl:right-auto'
|
||||
onClick={handleClear}
|
||||
>
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/search.svg')}
|
||||
className={clsx('h-4 w-4 text-gray-600', { hidden: hasValue })}
|
||||
/>
|
||||
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
className={clsx('h-4 w-4 text-gray-600', { hidden: !hasValue })}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
37
src/features/compose/components/spoiler-button.tsx
Normal file
37
src/features/compose/components/spoiler-button.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeComposeSpoilerness } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import ComposeFormButton from './compose-form-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
|
||||
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' },
|
||||
});
|
||||
|
||||
interface ISpoilerButton {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const SpoilerButton: React.FC<ISpoilerButton> = ({ composeId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const active = useCompose(composeId).spoiler;
|
||||
|
||||
const onClick = () =>
|
||||
dispatch(changeComposeSpoilerness(composeId));
|
||||
|
||||
return (
|
||||
<ComposeFormButton
|
||||
icon={require('@tabler/icons/alert-triangle.svg')}
|
||||
title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoilerButton;
|
||||
80
src/features/compose/components/spoiler-input.tsx
Normal file
80
src/features/compose/components/spoiler-input.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeComposeSpoilerness, changeComposeSpoilerText } from 'soapbox/actions/compose';
|
||||
import AutosuggestInput, { IAutosuggestInput } from 'soapbox/components/autosuggest-input';
|
||||
import { Divider, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'compose_form.spoiler_title', defaultMessage: 'Sensitive content' },
|
||||
placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
|
||||
remove: { id: 'compose_form.spoiler_remove', defaultMessage: 'Remove sensitive' },
|
||||
});
|
||||
|
||||
interface ISpoilerInput extends Pick<IAutosuggestInput, 'onSuggestionsFetchRequested' | 'onSuggestionsClearRequested' | 'onSuggestionSelected'> {
|
||||
composeId: string extends 'default' ? never : string
|
||||
}
|
||||
|
||||
/** Text input for content warning in composer. */
|
||||
const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
|
||||
composeId,
|
||||
onSuggestionsFetchRequested,
|
||||
onSuggestionsClearRequested,
|
||||
onSuggestionSelected,
|
||||
}, ref) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
dispatch(changeComposeSpoilerText(composeId, e.target.value));
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
dispatch(changeComposeSpoilerness(composeId));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
space={4}
|
||||
className={clsx({
|
||||
'relative transition-height': true,
|
||||
'hidden': !compose.spoiler,
|
||||
})}
|
||||
>
|
||||
<Divider />
|
||||
|
||||
<Stack space={2}>
|
||||
<Text weight='medium'>
|
||||
{intl.formatMessage(messages.title)}
|
||||
</Text>
|
||||
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={compose.spoiler_text}
|
||||
onChange={handleChangeSpoilerText}
|
||||
disabled={!compose.spoiler}
|
||||
suggestions={compose.suggestions}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
searchTokens={[':']}
|
||||
id='cw-spoiler-input'
|
||||
className='rounded-md !bg-transparent dark:!bg-transparent'
|
||||
ref={ref}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className='text-center'>
|
||||
<button type='button' className='text-danger-500' onClick={handleRemove}>
|
||||
{intl.formatMessage(messages.remove)}
|
||||
</button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default SpoilerInput;
|
||||
28
src/features/compose/components/text-character-counter.tsx
Normal file
28
src/features/compose/components/text-character-counter.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { length } from 'stringz';
|
||||
|
||||
interface ITextCharacterCounter {
|
||||
max: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const TextCharacterCounter: React.FC<ITextCharacterCounter> = ({ text, max }) => {
|
||||
const checkRemainingText = (diff: number) => {
|
||||
return (
|
||||
<span
|
||||
className={clsx('text-sm font-medium', {
|
||||
'text-gray-700': diff >= 0,
|
||||
'text-secondary-600': diff < 0,
|
||||
})}
|
||||
>
|
||||
{diff}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const diff = max - length(text);
|
||||
return checkRemainingText(diff);
|
||||
};
|
||||
|
||||
export default TextCharacterCounter;
|
||||
91
src/features/compose/components/upload-button.tsx
Normal file
91
src/features/compose/components/upload-button.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { defineMessages, IntlShape, useIntl } from 'react-intl';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { useInstance } from 'soapbox/hooks';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
|
||||
});
|
||||
|
||||
export const onlyImages = (types: ImmutableList<string>) => {
|
||||
return Boolean(types && types.every(type => type.startsWith('image/')));
|
||||
};
|
||||
|
||||
export interface IUploadButton {
|
||||
disabled?: boolean
|
||||
unavailable?: boolean
|
||||
onSelectFile: (files: FileList, intl: IntlShape) => void
|
||||
style?: React.CSSProperties
|
||||
resetFileKey: number | null
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const UploadButton: React.FC<IUploadButton> = ({
|
||||
disabled = false,
|
||||
unavailable = false,
|
||||
onSelectFile,
|
||||
resetFileKey,
|
||||
className = 'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500',
|
||||
iconClassName,
|
||||
icon,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { configuration } = useInstance();
|
||||
|
||||
const fileElement = useRef<HTMLInputElement>(null);
|
||||
const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>;
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.target.files?.length) {
|
||||
onSelectFile(e.target.files, intl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileElement.current?.click();
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = icon || (
|
||||
onlyImages(attachmentTypes)
|
||||
? require('@tabler/icons/photo.svg')
|
||||
: require('@tabler/icons/paperclip.svg')
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton
|
||||
src={src}
|
||||
className={className}
|
||||
iconClassName={iconClassName}
|
||||
title={intl.formatMessage(messages.upload)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
<label>
|
||||
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
|
||||
<input
|
||||
key={resetFileKey}
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
multiple
|
||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadButton;
|
||||
32
src/features/compose/components/upload-form.tsx
Normal file
32
src/features/compose/components/upload-form.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { useCompose } from 'soapbox/hooks';
|
||||
|
||||
import Upload from './upload';
|
||||
import UploadProgress from './upload-progress';
|
||||
|
||||
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IUploadForm {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
|
||||
const mediaIds = useCompose(composeId).media_attachments.map((item: AttachmentEntity) => item.id);
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<UploadProgress composeId={composeId} />
|
||||
|
||||
<HStack wrap className={clsx('overflow-hidden', mediaIds.size !== 0 && 'p-1')}>
|
||||
{mediaIds.map((id: string) => (
|
||||
<Upload id={id} key={id} composeId={composeId} />
|
||||
))}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadForm;
|
||||
26
src/features/compose/components/upload-progress.tsx
Normal file
26
src/features/compose/components/upload-progress.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import UploadProgress from 'soapbox/components/upload-progress';
|
||||
import { useCompose } from 'soapbox/hooks';
|
||||
|
||||
interface IComposeUploadProgress {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
/** File upload progress bar for post composer. */
|
||||
const ComposeUploadProgress: React.FC<IComposeUploadProgress> = ({ composeId }) => {
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const active = compose.is_uploading;
|
||||
const progress = compose.progress;
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UploadProgress progress={progress} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ComposeUploadProgress;
|
||||
44
src/features/compose/components/upload.tsx
Normal file
44
src/features/compose/components/upload.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { undoUploadCompose, changeUploadCompose, submitCompose } from 'soapbox/actions/compose';
|
||||
import Upload from 'soapbox/components/upload';
|
||||
import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
|
||||
|
||||
interface IUploadCompose {
|
||||
id: string
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id }) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const { description_limit: descriptionLimit } = useInstance();
|
||||
|
||||
const media = useCompose(composeId).media_attachments.find(item => item.id === id)!;
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitCompose(composeId, history));
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
dispatch(changeUploadCompose(composeId, media.id, { description }));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
dispatch(undoUploadCompose(composeId, media.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Upload
|
||||
media={media}
|
||||
onDelete={handleDelete}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
onSubmit={handleSubmit}
|
||||
descriptionLimit={descriptionLimit}
|
||||
withPreview
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadCompose;
|
||||
35
src/features/compose/components/visual-character-counter.tsx
Normal file
35
src/features/compose/components/visual-character-counter.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import ProgressCircle from 'soapbox/components/progress-circle';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'compose.character_counter.title', defaultMessage: 'Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}' },
|
||||
});
|
||||
|
||||
interface IVisualCharacterCounter {
|
||||
/** max text allowed */
|
||||
max: number
|
||||
/** text to use to measure */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Renders a character counter */
|
||||
const VisualCharacterCounter: React.FC<IVisualCharacterCounter> = ({ text, max }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const textLength = length(text);
|
||||
const progress = textLength / max;
|
||||
|
||||
return (
|
||||
<ProgressCircle
|
||||
title={intl.formatMessage(messages.title, { chars: textLength, maxChars: max })}
|
||||
progress={progress}
|
||||
radius={10}
|
||||
stroke={3}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualCharacterCounter;
|
||||
21
src/features/compose/components/warning.tsx
Normal file
21
src/features/compose/components/warning.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { spring } from 'react-motion';
|
||||
|
||||
import Motion from '../../ui/util/optional-motion';
|
||||
|
||||
interface IWarning {
|
||||
message: React.ReactNode
|
||||
}
|
||||
|
||||
/** Warning message displayed in ComposeForm. */
|
||||
const Warning: React.FC<IWarning> = ({ message }) => (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
|
||||
export default Warning;
|
||||
38
src/features/compose/containers/quoted-status-container.tsx
Normal file
38
src/features/compose/containers/quoted-status-container.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { cancelQuoteCompose } from 'soapbox/actions/compose';
|
||||
import QuotedStatus from 'soapbox/components/quoted-status';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
interface IQuotedStatusContainer {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
/** QuotedStatus shown in post composer. */
|
||||
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
|
||||
|
||||
const onCancel = () => {
|
||||
dispatch(cancelQuoteCompose());
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-2'>
|
||||
<QuotedStatus
|
||||
status={status}
|
||||
onCancel={onCancel}
|
||||
compose
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotedStatusContainer;
|
||||
35
src/features/compose/containers/reply-indicator-container.ts
Normal file
35
src/features/compose/containers/reply-indicator-container.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { cancelReplyCompose } from 'soapbox/actions/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import ReplyIndicator from '../components/reply-indicator';
|
||||
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => {
|
||||
const statusId = state.compose.get(composeId)?.in_reply_to!;
|
||||
const editing = !!state.compose.get(composeId)?.id;
|
||||
|
||||
return {
|
||||
status: getStatus(state, { id: statusId }) as Status,
|
||||
hideActions: editing,
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch: AppDispatch) => ({
|
||||
|
||||
onCancel() {
|
||||
dispatch(cancelReplyCompose());
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
|
||||
14
src/features/compose/containers/schedule-form-container.tsx
Normal file
14
src/features/compose/containers/schedule-form-container.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
import type { IScheduleForm } from '../components/schedule-form';
|
||||
|
||||
const ScheduleFormContainer: React.FC<IScheduleForm> = (props) => (
|
||||
<BundleContainer fetchComponent={ScheduleForm}>
|
||||
{Component => <Component {...props} />}
|
||||
</BundleContainer>
|
||||
);
|
||||
|
||||
export default ScheduleFormContainer;
|
||||
23
src/features/compose/containers/upload-button-container.ts
Normal file
23
src/features/compose/containers/upload-button-container.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { uploadCompose } from 'soapbox/actions/compose';
|
||||
|
||||
import UploadButton from '../components/upload-button';
|
||||
|
||||
import type { IntlShape } from 'react-intl';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => ({
|
||||
disabled: state.compose.get(composeId)?.is_uploading,
|
||||
resetFileKey: state.compose.get(composeId)?.resetFileKey!,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: AppDispatch, { composeId }: { composeId: string }) => ({
|
||||
|
||||
onSelectFile(files: FileList, intl: IntlShape) {
|
||||
dispatch(uploadCompose(composeId, files, intl));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
|
||||
72
src/features/compose/containers/warning-container.tsx
Normal file
72
src/features/compose/containers/warning-container.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useAppSelector, useCompose } from 'soapbox/hooks';
|
||||
import { selectOwnAccount } from 'soapbox/selectors';
|
||||
|
||||
import Warning from '../components/warning';
|
||||
|
||||
const APPROX_HASHTAG_RE = /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
|
||||
|
||||
interface IWarningWrapper {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const WarningWrapper: React.FC<IWarningWrapper> = ({ composeId }) => {
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const needsLockWarning = useAppSelector((state) => compose.privacy === 'private' && !selectOwnAccount(state)!.locked);
|
||||
const hashtagWarning = (compose.privacy !== 'public' && compose.privacy !== 'group') && APPROX_HASHTAG_RE.test(compose.text);
|
||||
const directMessageWarning = compose.privacy === 'direct';
|
||||
|
||||
if (needsLockWarning) {
|
||||
return (
|
||||
<Warning
|
||||
message={(
|
||||
<FormattedMessage
|
||||
id='compose_form.lock_disclaimer'
|
||||
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
|
||||
values={{
|
||||
locked: (
|
||||
<Link to='/settings/profile'>
|
||||
<FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hashtagWarning) {
|
||||
return (
|
||||
<Warning
|
||||
message={(
|
||||
<FormattedMessage
|
||||
id='compose_form.hashtag_warning'
|
||||
defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (directMessageWarning) {
|
||||
const message = (
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='compose_form.direct_message_warning'
|
||||
defaultMessage='This post will only be sent to the mentioned users.'
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
return <Warning message={message} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default WarningWrapper;
|
||||
17
src/features/compose/editor/LICENSE
Normal file
17
src/features/compose/editor/LICENSE
Normal file
@ -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.
|
||||
11
src/features/compose/editor/handlers/image.ts
Normal file
11
src/features/compose/editor/handlers/image.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { $createImageNode } from '../nodes/image-node';
|
||||
|
||||
import type { ImportHandler } from '@mkljczk/lexical-remark';
|
||||
import type { LexicalNode } from 'lexical';
|
||||
|
||||
const importImage: ImportHandler<any> /* TODO */ = (node: LexicalNode, parser) => {
|
||||
const lexicalNode = $createImageNode({ altText: node.alt ?? '', src: node.url });
|
||||
parser.append(lexicalNode);
|
||||
};
|
||||
|
||||
export { importImage };
|
||||
204
src/features/compose/editor/index.tsx
Normal file
204
src/features/compose/editor/index.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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 { AutoLinkPlugin, createLinkMatcherWithRegExp } from '@lexical/react/LexicalAutoLinkPlugin';
|
||||
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 { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { $createRemarkExport, $createRemarkImport } from '@mkljczk/lexical-remark';
|
||||
import clsx from 'clsx';
|
||||
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import { importImage } from './handlers/image';
|
||||
import { useNodes } from './nodes';
|
||||
import AutosuggestPlugin from './plugins/autosuggest-plugin';
|
||||
import FloatingBlockTypeToolbarPlugin from './plugins/floating-block-type-toolbar-plugin';
|
||||
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
|
||||
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
|
||||
import FocusPlugin from './plugins/focus-plugin';
|
||||
import MentionPlugin from './plugins/mention-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
|
||||
onFocus?: React.FocusEventHandler<HTMLDivElement>
|
||||
onPaste?: (files: FileList) => void
|
||||
placeholder?: JSX.Element | string
|
||||
}
|
||||
|
||||
const 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',
|
||||
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<string, IComposeEditor>(({
|
||||
className,
|
||||
placeholderClassName,
|
||||
composeId,
|
||||
condensed,
|
||||
eventDiscussion,
|
||||
hasPoll,
|
||||
autoFocus,
|
||||
handleSubmit,
|
||||
onFocus,
|
||||
onPaste,
|
||||
placeholder,
|
||||
}, editorStateRef) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const nodes = useNodes();
|
||||
|
||||
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
|
||||
|
||||
const initialConfig: InitialConfigType = useMemo(() => ({
|
||||
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 () => {
|
||||
if (compose.content_type === 'text/markdown') {
|
||||
$createRemarkImport({
|
||||
handlers: {
|
||||
image: importImage,
|
||||
},
|
||||
})(compose.text);
|
||||
} else {
|
||||
const paragraph = $createParagraphNode();
|
||||
const textNode = $createTextNode(compose.text);
|
||||
|
||||
paragraph.append(textNode);
|
||||
|
||||
$getRoot()
|
||||
.clear()
|
||||
.append(paragraph);
|
||||
}
|
||||
};
|
||||
}),
|
||||
}), []);
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem);
|
||||
}
|
||||
};
|
||||
|
||||
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('lexical relative', className)} data-markup>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div className='editor' ref={onRef} onFocus={onFocus} onPaste={handlePaste}>
|
||||
<ContentEditable
|
||||
className={clsx('outline-none transition-[min-height] motion-reduce:transition-none', {
|
||||
'min-h-[40px]': condensed,
|
||||
'min-h-[100px]': !condensed,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placeholder={(
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute top-0 select-none text-gray-600 dark:placeholder:text-gray-600',
|
||||
placeholderClassName,
|
||||
)}
|
||||
>
|
||||
{textareaPlaceholder}
|
||||
</div>
|
||||
)}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<OnChangePlugin onChange={(_, editor) => {
|
||||
if (editorStateRef) (editorStateRef as any).current = editor.getEditorState().read($createRemarkExport());
|
||||
}}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<HashtagPlugin />
|
||||
<MentionPlugin />
|
||||
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
|
||||
<AutoLinkPlugin matchers={LINK_MATCHERS} />
|
||||
{features.richText && <LinkPlugin />}
|
||||
{features.richText && <ListPlugin />}
|
||||
{features.richText && floatingAnchorElem && (
|
||||
<>
|
||||
<FloatingBlockTypeToolbarPlugin anchorElem={floatingAnchorElem} />
|
||||
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
||||
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
|
||||
</>
|
||||
)}
|
||||
<StatePlugin composeId={composeId} handleSubmit={handleSubmit} />
|
||||
<FocusPlugin autoFocus={autoFocus} />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
});
|
||||
|
||||
export default ComposeEditor;
|
||||
101
src/features/compose/editor/nodes/emoji-node.tsx
Normal file
101
src/features/compose/editor/nodes/emoji-node.tsx
Normal file
@ -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 };
|
||||
359
src/features/compose/editor/nodes/image-component.tsx
Normal file
359
src/features/compose/editor/nodes/image-component.tsx
Normal file
@ -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;
|
||||
179
src/features/compose/editor/nodes/image-node.tsx
Normal file
179
src/features/compose/editor/nodes/image-node.tsx
Normal file
@ -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,
|
||||
};
|
||||
51
src/features/compose/editor/nodes/index.ts
Normal file
51
src/features/compose/editor/nodes/index.ts
Normal file
@ -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 };
|
||||
69
src/features/compose/editor/nodes/mention-node.ts
Normal file
69
src/features/compose/editor/nodes/mention-node.ts
Normal file
@ -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 };
|
||||
69
src/features/compose/editor/nodes/mention-node.tsx
Normal file
69
src/features/compose/editor/nodes/mention-node.tsx
Normal file
@ -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 };
|
||||
568
src/features/compose/editor/plugins/autosuggest-plugin.tsx
Normal file
568
src/features/compose/editor/plugins/autosuggest-plugin.tsx
Normal file
@ -0,0 +1,568 @@
|
||||
/**
|
||||
* 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 { 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.pageYOffset}px`;
|
||||
containerDiv.style.left = `${left + window.pageXOffset}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 = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
return onSelectSuggestion(index);
|
||||
};
|
||||
|
||||
const onSelectSuggestion = (index: any) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
|
||||
const newSelectedSuggestion = event.shiftKey
|
||||
? (selectedSuggestion !== 0 ? selectedSuggestion - 1 : suggestions.size - 1)
|
||||
: (selectedSuggestion !== suggestions.size - 1 ? selectedSuggestion + 1 : 0);
|
||||
setSelectedSuggestion(newSelectedSuggestion);
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
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, 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,297 @@
|
||||
/**
|
||||
* 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 { $createHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
|
||||
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isRangeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DEPRECATED_$isGridSelection,
|
||||
LexicalEditor,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { uploadFile } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||
|
||||
import { $createImageNode } from '../nodes/image-node';
|
||||
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
|
||||
|
||||
import { ToolbarButton } from './floating-text-format-toolbar-plugin';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
createHorizontalLine: { id: 'compose_form.lexical.create_horizontal_line', defaultMessage: 'Create horizontal line' },
|
||||
uploadMedia: { id: 'compose_form.lexical.upload_media', defaultMessage: 'Upload media' },
|
||||
});
|
||||
|
||||
interface IUploadButton {
|
||||
onSelectFile: (src: string) => void
|
||||
}
|
||||
|
||||
const UploadButton: React.FC<IUploadButton> = ({ onSelectFile }) => {
|
||||
const intl = useIntl();
|
||||
const { configuration } = useInstance();
|
||||
const dispatch = useAppDispatch();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
|
||||
const fileElement = useRef<HTMLInputElement>(null);
|
||||
const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>;
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.target.files?.length) {
|
||||
setDisabled(true);
|
||||
|
||||
// @ts-ignore
|
||||
dispatch(uploadFile(
|
||||
e.target.files.item(0) as File,
|
||||
intl,
|
||||
({ url }) => {
|
||||
onSelectFile(url);
|
||||
setDisabled(false);
|
||||
},
|
||||
() => setDisabled(false),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileElement.current?.click();
|
||||
};
|
||||
|
||||
const src = require('@tabler/icons/photo.svg');
|
||||
|
||||
return (
|
||||
<label>
|
||||
<ToolbarButton
|
||||
onClick={handleClick}
|
||||
aria-label={intl.formatMessage(messages.uploadMedia)}
|
||||
icon={src}
|
||||
/>
|
||||
<input
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
multiple
|
||||
accept={attachmentTypes ? attachmentTypes.filter(type => type.startsWith('image/')).toArray().join(',') : 'image/*'}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const BlockTypeFloatingToolbar = ({
|
||||
editor,
|
||||
anchorElem,
|
||||
}: {
|
||||
editor: LexicalEditor
|
||||
anchorElem: HTMLElement
|
||||
}): JSX.Element => {
|
||||
const intl = useIntl();
|
||||
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
|
||||
const instance = useInstance();
|
||||
|
||||
const allowInlineImages = instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images']);
|
||||
|
||||
const updateBlockTypeFloatingToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
|
||||
if (popupCharStylesEditorElem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
!nativeSelection.anchorNode?.textContent &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
setFloatingElemPosition((nativeSelection.focusNode as HTMLParagraphElement)?.getBoundingClientRect(), popupCharStylesEditorElem, anchorElem);
|
||||
}
|
||||
}, [editor, anchorElem]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateBlockTypeFloatingToolbar();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, [editor, updateBlockTypeFloatingToolbar, anchorElem]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateBlockTypeFloatingToolbar();
|
||||
});
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateBlockTypeFloatingToolbar();
|
||||
});
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateBlockTypeFloatingToolbar();
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateBlockTypeFloatingToolbar]);
|
||||
|
||||
const createHorizontalLine = () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
|
||||
const selectionNode = selection.anchor.getNode();
|
||||
selectionNode.replace($createHorizontalRuleNode());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createImage = (src: string) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
|
||||
const imageNode = $createImageNode({ altText: '', src });
|
||||
$insertNodes([imageNode]);
|
||||
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupCharStylesEditorRef}
|
||||
className='absolute left-0 top-0 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 opacity-0 shadow-lg transition-[opacity] dark:bg-gray-900'
|
||||
>
|
||||
{editor.isEditable() && (
|
||||
<>
|
||||
{allowInlineImages && <UploadButton onSelectFile={createImage} />}
|
||||
<ToolbarButton
|
||||
onClick={createHorizontalLine}
|
||||
aria-label={intl.formatMessage(messages.createHorizontalLine)}
|
||||
icon={require('@tabler/icons/line-dashed.svg')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useFloatingBlockTypeToolbar = (
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null => {
|
||||
const [isEmptyBlock, setIsEmptyBlock] = useState(false);
|
||||
|
||||
const updatePopup = useCallback(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
// Should not to pop up the floating toolbar when using IME input
|
||||
if (editor.isComposing()) {
|
||||
return;
|
||||
}
|
||||
const selection = $getSelection();
|
||||
const nativeSelection = window.getSelection();
|
||||
const rootElement = editor.getRootElement();
|
||||
|
||||
if (
|
||||
nativeSelection !== null &&
|
||||
(!$isRangeSelection(selection) ||
|
||||
rootElement === null ||
|
||||
!rootElement.contains(nativeSelection.anchorNode))
|
||||
) {
|
||||
setIsEmptyBlock(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
|
||||
setIsEmptyBlock(anchorNode.getType() === 'paragraph' && anchorNode.getTextContentSize() === 0);
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('selectionchange', updatePopup);
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', updatePopup);
|
||||
};
|
||||
}, [updatePopup]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
updatePopup();
|
||||
}),
|
||||
editor.registerRootListener(() => {
|
||||
if (editor.getRootElement() === null) {
|
||||
setIsEmptyBlock(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [editor, updatePopup]);
|
||||
|
||||
if (!isEmptyBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<BlockTypeFloatingToolbar
|
||||
editor={editor}
|
||||
anchorElem={anchorElem}
|
||||
/>,
|
||||
anchorElem,
|
||||
);
|
||||
};
|
||||
|
||||
const FloatingBlockTypeToolbarPlugin = ({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element | null => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingBlockTypeToolbar(editor, anchorElem);
|
||||
};
|
||||
|
||||
export default FloatingBlockTypeToolbarPlugin;
|
||||
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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 { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
GridSelection,
|
||||
LexicalEditor,
|
||||
NodeSelection,
|
||||
RangeSelection,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
|
||||
import { getSelectedNode } from '../utils/get-selected-node';
|
||||
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
|
||||
import { sanitizeUrl } from '../utils/url';
|
||||
|
||||
const FloatingLinkEditor = ({
|
||||
editor,
|
||||
anchorElem,
|
||||
}: {
|
||||
editor: LexicalEditor
|
||||
anchorElem: HTMLElement
|
||||
}): JSX.Element => {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [isEditMode, setEditMode] = useState(false);
|
||||
const [lastSelection, setLastSelection] = useState<
|
||||
RangeSelection | GridSelection | NodeSelection | null
|
||||
>(null);
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent)) {
|
||||
setLinkUrl(parent.getURL());
|
||||
} else if ($isLinkNode(node)) {
|
||||
setLinkUrl(node.getURL());
|
||||
} else {
|
||||
setLinkUrl('');
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (editorElem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
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();
|
||||
}
|
||||
|
||||
setFloatingElemPosition(rect, editorElem, anchorElem);
|
||||
setLastSelection(selection);
|
||||
} else if (!activeElement || activeElement.className !== 'link-input') {
|
||||
if (rootElement !== null) {
|
||||
setFloatingElemPosition(null, editorElem, anchorElem);
|
||||
}
|
||||
setLastSelection(null);
|
||||
setEditMode(false);
|
||||
setLinkUrl('');
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [anchorElem, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editorRef}
|
||||
className='absolute left-0 top-0 z-10 w-full max-w-sm rounded-lg bg-white opacity-0 shadow-md transition-opacity will-change-transform dark:bg-gray-900'
|
||||
>
|
||||
<div className='relative mx-3 my-2 box-border block rounded-2xl border-0 bg-gray-100 px-3 py-2 text-sm text-gray-800 outline-0 dark:bg-gray-800 dark:text-gray-100'>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<input
|
||||
className='-mx-3 -my-2 w-full border-0 bg-transparent px-3 py-2 text-sm text-gray-900 outline-0 dark:text-gray-100'
|
||||
ref={inputRef}
|
||||
value={linkUrl}
|
||||
onChange={(event) => {
|
||||
setLinkUrl(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (lastSelection !== null) {
|
||||
if (linkUrl !== '') {
|
||||
editor.dispatchCommand(
|
||||
TOGGLE_LINK_COMMAND,
|
||||
sanitizeUrl(linkUrl),
|
||||
);
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
}
|
||||
setEditMode(false);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditMode(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
}}
|
||||
>
|
||||
<Icon className='h-5 w-5' src={require('@tabler/icons/x.svg')} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a className='mr-8 block overflow-hidden text-ellipsis whitespace-nowrap text-primary-600 no-underline hover:underline dark:text-accent-blue' href={linkUrl} target='_blank' rel='noopener noreferrer'>
|
||||
{linkUrl}
|
||||
</a>
|
||||
<div
|
||||
className='absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
>
|
||||
<Icon className='h-5 w-5' src={require('@tabler/icons/pencil.svg')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useFloatingLinkEditorToolbar = (
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null => {
|
||||
const [activeEditor, setActiveEditor] = useState(editor);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode);
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
|
||||
|
||||
// We don't want this menu to open for auto links.
|
||||
if (linkParent !== null && autoLinkParent === null) {
|
||||
setIsLink(true);
|
||||
} else {
|
||||
setIsLink(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar();
|
||||
setActiveEditor(newEditor);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor, updateToolbar]);
|
||||
|
||||
return isLink
|
||||
? createPortal(
|
||||
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
|
||||
anchorElem,
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const FloatingLinkEditorPlugin = ({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element | null => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingLinkEditorToolbar(editor, anchorElem);
|
||||
};
|
||||
|
||||
export default FloatingLinkEditorPlugin;
|
||||
@ -0,0 +1,566 @@
|
||||
/**
|
||||
* 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 { $createCodeNode, $isCodeHighlightNode } from '@lexical/code';
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
||||
import {
|
||||
$isListNode,
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
ListNode,
|
||||
REMOVE_LIST_COMMAND,
|
||||
} from '@lexical/list';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
$createHeadingNode,
|
||||
$createQuoteNode,
|
||||
$isHeadingNode,
|
||||
HeadingTagType,
|
||||
} from '@lexical/rich-text';
|
||||
import {
|
||||
$setBlocksType,
|
||||
} from '@lexical/selection';
|
||||
import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DEPRECATED_$isGridSelection,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
LexicalEditor,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import { useInstance } from 'soapbox/hooks';
|
||||
|
||||
import { getDOMRangeRect } from '../utils/get-dom-range-rect';
|
||||
import { getSelectedNode } from '../utils/get-selected-node';
|
||||
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
|
||||
|
||||
const messages = defineMessages({
|
||||
formatBold: { id: 'compose_form.lexical.format_bold', defaultMessage: 'Format bold' },
|
||||
formatItalic: { id: 'compose_form.lexical.format_italic', defaultMessage: 'Format italic' },
|
||||
formatUnderline: { id: 'compose_form.lexical.format_underline', defaultMessage: 'Format underline' },
|
||||
formatStrikethrough: { id: 'compose_form.lexical.format_strikethrough', defaultMessage: 'Format strikethrough' },
|
||||
insertCodeBlock: { id: 'compose_form.lexical.insert_code_block', defaultMessage: 'Insert code block' },
|
||||
insertLink: { id: 'compose_form.lexical.insert_link', defaultMessage: 'Insert link' },
|
||||
});
|
||||
|
||||
const blockTypeToIcon = {
|
||||
bullet: require('@tabler/icons/list.svg'),
|
||||
check: require('@tabler/icons/list-check.svg'),
|
||||
code: require('@tabler/icons/code.svg'),
|
||||
h1: require('@tabler/icons/h-1.svg'),
|
||||
h2: require('@tabler/icons/h-2.svg'),
|
||||
h3: require('@tabler/icons/h-3.svg'),
|
||||
h4: require('@tabler/icons/h-4.svg'),
|
||||
h5: require('@tabler/icons/h-5.svg'),
|
||||
h6: require('@tabler/icons/h-6.svg'),
|
||||
number: require('@tabler/icons/list-numbers.svg'),
|
||||
paragraph: require('@tabler/icons/align-left.svg'),
|
||||
quote: require('@tabler/icons/blockquote.svg'),
|
||||
};
|
||||
|
||||
const blockTypeToBlockName = {
|
||||
bullet: 'Bulleted List',
|
||||
check: 'Check List',
|
||||
code: 'Code Block',
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
h4: 'Heading 4',
|
||||
h5: 'Heading 5',
|
||||
h6: 'Heading 6',
|
||||
number: 'Numbered List',
|
||||
paragraph: 'Normal',
|
||||
quote: 'Quote',
|
||||
};
|
||||
|
||||
interface IToolbarButton extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
active?: boolean
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const ToolbarButton: React.FC<IToolbarButton> = ({ active, icon, ...props }) => (
|
||||
<button
|
||||
className={clsx(
|
||||
'flex cursor-pointer rounded-lg border-0 bg-none p-1 align-middle hover:bg-gray-100 disabled:cursor-not-allowed disabled:hover:bg-none hover:dark:bg-primary-700',
|
||||
{ 'bg-gray-100/30 dark:bg-gray-800/30': active },
|
||||
)}
|
||||
type='button'
|
||||
{...props}
|
||||
>
|
||||
<Icon className='h-5 w-5' src={icon} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const BlockTypeDropdown = ({ editor, anchorElem, blockType, icon }: {
|
||||
editor: LexicalEditor
|
||||
anchorElem: HTMLElement
|
||||
blockType: keyof typeof blockTypeToBlockName
|
||||
icon: string
|
||||
}) => {
|
||||
const instance = useInstance();
|
||||
|
||||
const [showDropDown, setShowDropDown] = useState(false);
|
||||
|
||||
const formatParagraph = () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createParagraphNode());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatHeading = (headingSize: HeadingTagType) => {
|
||||
if (blockType !== headingSize) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createHeadingNode(headingSize));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatBulletList = () => {
|
||||
if (blockType !== 'bullet') {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumberedList = () => {
|
||||
if (blockType !== 'number') {
|
||||
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const formatQuote = () => {
|
||||
if (blockType !== 'quote') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createQuoteNode());
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatCode = () => {
|
||||
if (blockType !== 'code') {
|
||||
editor.update(() => {
|
||||
let selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
|
||||
if (selection.isCollapsed()) {
|
||||
$setBlocksType(selection, () => $createCodeNode());
|
||||
} else {
|
||||
const textContent = selection.getTextContent();
|
||||
const codeNode = $createCodeNode();
|
||||
selection.insertNodes([codeNode]);
|
||||
selection = $getSelection();
|
||||
if ($isRangeSelection(selection))
|
||||
selection.insertRawText(textContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowDropDown(!showDropDown)}
|
||||
className='relative flex cursor-pointer rounded-lg border-0 bg-none p-1 align-middle hover:bg-gray-100 disabled:cursor-not-allowed disabled:hover:bg-none hover:dark:bg-primary-700'
|
||||
aria-label=''
|
||||
type='button'
|
||||
>
|
||||
<Icon src={icon} />
|
||||
<Icon src={require('@tabler/icons/chevron-down.svg')} className='-bottom-2 h-4 w-4' />
|
||||
{showDropDown && (
|
||||
<div
|
||||
className='absolute left-0 top-9 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 shadow-lg transition-[opacity] dark:bg-gray-900'
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={formatParagraph}
|
||||
active={blockType === 'paragraph'}
|
||||
icon={blockTypeToIcon.paragraph}
|
||||
/>
|
||||
{instance.pleroma.getIn(['metadata', 'markup', 'allow_headings']) === true && (
|
||||
<>
|
||||
<ToolbarButton
|
||||
onClick={() => formatHeading('h1')}
|
||||
active={blockType === 'h1'}
|
||||
icon={blockTypeToIcon.h1}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => formatHeading('h2')}
|
||||
active={blockType === 'h2'}
|
||||
icon={blockTypeToIcon.h2}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => formatHeading('h3')}
|
||||
active={blockType === 'h3'}
|
||||
icon={blockTypeToIcon.h3}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ToolbarButton
|
||||
onClick={formatBulletList}
|
||||
active={blockType === 'bullet'}
|
||||
icon={blockTypeToIcon.bullet}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={formatNumberedList}
|
||||
active={blockType === 'number'}
|
||||
icon={blockTypeToIcon.number}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={formatQuote}
|
||||
active={blockType === 'quote'}
|
||||
icon={blockTypeToIcon.quote}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={formatCode}
|
||||
active={blockType === 'code'}
|
||||
icon={blockTypeToIcon.code}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TextFormatFloatingToolbar = ({
|
||||
editor,
|
||||
anchorElem,
|
||||
blockType,
|
||||
isLink,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
isCode,
|
||||
isStrikethrough,
|
||||
}: {
|
||||
editor: LexicalEditor
|
||||
anchorElem: HTMLElement
|
||||
blockType: keyof typeof blockTypeToBlockName
|
||||
isBold: boolean
|
||||
isCode: boolean
|
||||
isItalic: boolean
|
||||
isLink: boolean
|
||||
isStrikethrough: boolean
|
||||
isUnderline: boolean
|
||||
}): JSX.Element => {
|
||||
const intl = useIntl();
|
||||
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
if (!isLink) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
}
|
||||
}, [editor, isLink]);
|
||||
|
||||
const updateTextFormatFloatingToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
|
||||
if (popupCharStylesEditorElem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
!nativeSelection.isCollapsed &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
|
||||
|
||||
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem);
|
||||
}
|
||||
}, [editor, anchorElem]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, [editor, updateTextFormatFloatingToolbar, anchorElem]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateTextFormatFloatingToolbar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupCharStylesEditorRef}
|
||||
className='absolute left-0 top-0 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 opacity-0 shadow-lg transition-[opacity] dark:bg-gray-900'
|
||||
>
|
||||
{editor.isEditable() && (
|
||||
<>
|
||||
<BlockTypeDropdown
|
||||
editor={editor}
|
||||
anchorElem={anchorElem}
|
||||
blockType={blockType}
|
||||
icon={blockTypeToIcon[blockType]}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
}}
|
||||
active={isBold}
|
||||
aria-label={intl.formatMessage(messages.formatBold)}
|
||||
icon={require('@tabler/icons/bold.svg')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
}}
|
||||
active={isItalic}
|
||||
aria-label={intl.formatMessage(messages.formatItalic)}
|
||||
icon={require('@tabler/icons/italic.svg')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
}}
|
||||
active={isUnderline}
|
||||
aria-label={intl.formatMessage(messages.formatUnderline)}
|
||||
icon={require('@tabler/icons/underline.svg')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
||||
}}
|
||||
active={isStrikethrough}
|
||||
aria-label={intl.formatMessage(messages.formatStrikethrough)}
|
||||
icon={require('@tabler/icons/strikethrough.svg')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
||||
}}
|
||||
active={isCode}
|
||||
aria-label={intl.formatMessage(messages.insertCodeBlock)}
|
||||
icon={require('@tabler/icons/code.svg')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={insertLink}
|
||||
active={isLink}
|
||||
aria-label={intl.formatMessage(messages.insertLink)}
|
||||
icon={require('@tabler/icons/link.svg')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useFloatingTextFormatToolbar = (
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null => {
|
||||
const [blockType, setBlockType] =
|
||||
useState<keyof typeof blockTypeToBlockName>('paragraph');
|
||||
const [isText, setIsText] = useState(false);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||
const [isCode, setIsCode] = useState(false);
|
||||
|
||||
const updatePopup = useCallback(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
// Should not to pop up the floating toolbar when using IME input
|
||||
if (editor.isComposing()) {
|
||||
return;
|
||||
}
|
||||
const selection = $getSelection();
|
||||
const nativeSelection = window.getSelection();
|
||||
const rootElement = editor.getRootElement();
|
||||
|
||||
if (
|
||||
nativeSelection !== null &&
|
||||
(!$isRangeSelection(selection) ||
|
||||
rootElement === null ||
|
||||
!rootElement.contains(nativeSelection.anchorNode))
|
||||
) {
|
||||
setIsText(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
let element =
|
||||
anchorNode.getKey() === 'root'
|
||||
? anchorNode
|
||||
: $findMatchingParent(anchorNode, (e) => {
|
||||
const parent = e.getParent();
|
||||
return parent !== null && $isRootOrShadowRoot(parent);
|
||||
});
|
||||
|
||||
if (element === null) {
|
||||
element = anchorNode.getTopLevelElementOrThrow();
|
||||
}
|
||||
|
||||
const elementKey = element.getKey();
|
||||
const elementDOM = editor.getElementByKey(elementKey);
|
||||
|
||||
const node = getSelectedNode(selection);
|
||||
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat('bold'));
|
||||
setIsItalic(selection.hasFormat('italic'));
|
||||
setIsUnderline(selection.hasFormat('underline'));
|
||||
setIsStrikethrough(selection.hasFormat('strikethrough'));
|
||||
setIsCode(selection.hasFormat('code'));
|
||||
|
||||
if (elementDOM !== null) {
|
||||
if ($isListNode(element)) {
|
||||
const parentList = $getNearestNodeOfType<ListNode>(
|
||||
anchorNode,
|
||||
ListNode,
|
||||
);
|
||||
const type = parentList
|
||||
? parentList.getListType()
|
||||
: element.getListType();
|
||||
setBlockType(type);
|
||||
} else {
|
||||
const type = $isHeadingNode(element)
|
||||
? element.getTag()
|
||||
: element.getType();
|
||||
if (type in blockTypeToBlockName) {
|
||||
setBlockType(type as keyof typeof blockTypeToBlockName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update links
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
setIsLink(true);
|
||||
} else {
|
||||
setIsLink(false);
|
||||
}
|
||||
|
||||
if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
|
||||
setIsText($isTextNode(node));
|
||||
} else {
|
||||
setIsText(false);
|
||||
}
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('selectionchange', updatePopup);
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', updatePopup);
|
||||
};
|
||||
}, [updatePopup]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
updatePopup();
|
||||
}),
|
||||
editor.registerRootListener(() => {
|
||||
if (editor.getRootElement() === null) {
|
||||
setIsText(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [editor, updatePopup]);
|
||||
|
||||
if (!isText || isLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<TextFormatFloatingToolbar
|
||||
editor={editor}
|
||||
anchorElem={anchorElem}
|
||||
blockType={blockType}
|
||||
isLink={isLink}
|
||||
isBold={isBold}
|
||||
isItalic={isItalic}
|
||||
isStrikethrough={isStrikethrough}
|
||||
isUnderline={isUnderline}
|
||||
isCode={isCode}
|
||||
/>,
|
||||
anchorElem,
|
||||
);
|
||||
};
|
||||
|
||||
const FloatingTextFormatToolbarPlugin = ({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element | null => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingTextFormatToolbar(editor, anchorElem);
|
||||
};
|
||||
|
||||
export default FloatingTextFormatToolbarPlugin;
|
||||
38
src/features/compose/editor/plugins/focus-plugin.tsx
Normal file
38
src/features/compose/editor/plugins/focus-plugin.tsx
Normal file
@ -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;
|
||||
16
src/features/compose/editor/plugins/link-plugin.tsx
Normal file
16
src/features/compose/editor/plugins/link-plugin.tsx
Normal file
@ -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;
|
||||
60
src/features/compose/editor/plugins/mention-plugin.tsx
Normal file
60
src/features/compose/editor/plugins/mention-plugin.tsx
Normal file
@ -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;
|
||||
33
src/features/compose/editor/plugins/state-plugin.tsx
Normal file
33
src/features/compose/editor/plugins/state-plugin.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { 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 }) => {
|
||||
dispatch(setEditorState(composeId, editorState.isEmpty() ? null : JSON.stringify(editorState.toJSON())));
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default StatePlugin;
|
||||
28
src/features/compose/editor/utils/get-dom-range-rect.ts
Normal file
28
src/features/compose/editor/utils/get-dom-range-rect.ts
Normal file
@ -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;
|
||||
};
|
||||
26
src/features/compose/editor/utils/get-selected-node.ts
Normal file
26
src/features/compose/editor/utils/get-selected-node.ts
Normal file
@ -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;
|
||||
}
|
||||
};
|
||||
4
src/features/compose/editor/utils/is-html-element.ts
Normal file
4
src/features/compose/editor/utils/is-html-element.ts
Normal file
@ -0,0 +1,4 @@
|
||||
const isHTMLElement = (x: unknown): x is HTMLElement => x instanceof HTMLElement;
|
||||
|
||||
export default isHTMLElement;
|
||||
export { isHTMLElement };
|
||||
57
src/features/compose/editor/utils/point.ts
Normal file
57
src/features/compose/editor/utils/point.ts
Normal file
@ -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 };
|
||||
163
src/features/compose/editor/utils/rect.ts
Normal file
163
src/features/compose/editor/utils/rect.ts
Normal file
@ -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)`;
|
||||
};
|
||||
32
src/features/compose/editor/utils/url.ts
Normal file
32
src/features/compose/editor/utils/url.ts
Normal file
@ -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);
|
||||
};
|
||||
9
src/features/compose/util/counter.ts
Normal file
9
src/features/compose/util/counter.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { urlRegex } from './url-regex';
|
||||
|
||||
const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
|
||||
|
||||
export function countableText(inputText: string) {
|
||||
return inputText
|
||||
.replace(urlRegex, urlPlaceholder)
|
||||
.replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '$1@$3');
|
||||
}
|
||||
197
src/features/compose/util/url-regex.ts
Normal file
197
src/features/compose/util/url-regex.ts
Normal file
@ -0,0 +1,197 @@
|
||||
const regexen: { [x: string]: string | RegExp } = {};
|
||||
|
||||
const regexSupplant = function(regex: string | RegExp, flags = '') {
|
||||
if (typeof regex !== 'string') {
|
||||
if (regex.global && flags.indexOf('g') < 0) {
|
||||
flags += 'g';
|
||||
}
|
||||
if (regex.ignoreCase && flags.indexOf('i') < 0) {
|
||||
flags += 'i';
|
||||
}
|
||||
if (regex.multiline && flags.indexOf('m') < 0) {
|
||||
flags += 'm';
|
||||
}
|
||||
|
||||
regex = regex.source;
|
||||
}
|
||||
return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
|
||||
let newRegex = regexen[name] || '';
|
||||
if (typeof newRegex !== 'string') {
|
||||
newRegex = newRegex.source;
|
||||
}
|
||||
return newRegex;
|
||||
}), flags);
|
||||
};
|
||||
|
||||
const stringSupplant = function(str: string, values: { [x: string]: any }) {
|
||||
return str.replace(/#\{(\w+)\}/g, function(match, name) {
|
||||
return values[name] || '';
|
||||
});
|
||||
};
|
||||
|
||||
export const urlRegex = (function() {
|
||||
regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/; // eslint-disable-line no-control-regex
|
||||
regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
regexen.punct = /!'#%&@,:;<=>_~{}\$\?\^\*\+\-\.\(\)\[\]\|\/\\/;
|
||||
regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/);
|
||||
regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
|
||||
regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
|
||||
regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
|
||||
regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
|
||||
regexen.validGTLD = regexSupplant(RegExp(
|
||||
'(?:(?:' +
|
||||
'삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
|
||||
'政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
|
||||
'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
|
||||
'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
|
||||
'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
|
||||
'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
|
||||
'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
|
||||
'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
|
||||
'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
|
||||
'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
|
||||
'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
|
||||
'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
|
||||
'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
|
||||
'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
|
||||
'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
|
||||
'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
|
||||
'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
|
||||
'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
|
||||
'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
|
||||
'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
|
||||
'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
|
||||
'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
|
||||
'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
|
||||
'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
|
||||
'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
|
||||
'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
|
||||
'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
|
||||
'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
|
||||
'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
|
||||
'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
|
||||
'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
|
||||
'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
|
||||
'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
|
||||
'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
|
||||
'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
|
||||
'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
|
||||
'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
|
||||
'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
|
||||
'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
|
||||
'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
|
||||
'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
|
||||
'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
|
||||
'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
|
||||
'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
|
||||
'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
|
||||
'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
|
||||
'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
|
||||
'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
|
||||
'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
|
||||
'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
|
||||
'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
|
||||
'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
|
||||
'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
|
||||
'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
|
||||
'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
|
||||
'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
|
||||
'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
|
||||
'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
|
||||
'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
|
||||
'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
|
||||
'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
|
||||
'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
|
||||
'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
|
||||
'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
|
||||
'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
|
||||
'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
|
||||
'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
|
||||
'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
|
||||
'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
|
||||
'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
|
||||
'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
|
||||
'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
|
||||
'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
|
||||
'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
|
||||
'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
|
||||
'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
|
||||
'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
|
||||
'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
|
||||
'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
|
||||
'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
|
||||
'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
|
||||
'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
|
||||
'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
|
||||
'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
|
||||
'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
|
||||
'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
|
||||
'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
|
||||
'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
|
||||
')(?=[^0-9a-zA-Z@]|$))'));
|
||||
regexen.validCCTLD = regexSupplant(RegExp(
|
||||
'(?:(?:' +
|
||||
'한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
|
||||
'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
|
||||
'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
|
||||
'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
|
||||
'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
|
||||
're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
|
||||
'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
|
||||
'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
|
||||
'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
|
||||
'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
|
||||
'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
|
||||
')(?=[^0-9a-zA-Z@]|$))'));
|
||||
regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
|
||||
regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
|
||||
regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
|
||||
regexen.validPortNumber = /[0-9]+/;
|
||||
regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
|
||||
regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}()?]/i);
|
||||
// Allow URL paths to contain up to two nested levels of balanced parens
|
||||
// 1. Used in Wikipedia URLs like /Primer_(film)
|
||||
// 2. Used in IIS sessions like /S(dfd346)/
|
||||
// 3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
|
||||
regexen.validUrlBalancedParens = regexSupplant(
|
||||
'\\(' +
|
||||
'(?:' +
|
||||
'#{validGeneralUrlPathChars}+' +
|
||||
'|' +
|
||||
// allow one nested level of balanced parentheses
|
||||
'(?:' +
|
||||
'#{validGeneralUrlPathChars}*' +
|
||||
'\\(' +
|
||||
'#{validGeneralUrlPathChars}+' +
|
||||
'\\)' +
|
||||
'#{validGeneralUrlPathChars}*' +
|
||||
')' +
|
||||
')' +
|
||||
'\\)',
|
||||
'i');
|
||||
// Valid end-of-path characters (so /foo. does not gobble the period).
|
||||
// 1. Allow =&# for empty URL parameters and other URL-join artifacts
|
||||
regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}()?!*';:=,.$%[\]#{pd}~&|@]|(?:#{validUrlBalancedParens})/i);
|
||||
// Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
|
||||
regexen.validUrlPath = regexSupplant('(?:' +
|
||||
'(?:' +
|
||||
'#{validGeneralUrlPathChars}*' +
|
||||
'(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
|
||||
'#{validUrlPathEndingChars}' +
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
')|(?:@#{validGeneralUrlPathChars}+\/)' +
|
||||
')', 'i');
|
||||
regexen.validUrlQueryChars = /[a-z0-9!?*'@();:&=+$/%#[\]\-_.,~|]/i;
|
||||
regexen.validUrlQueryEndingChars = /[a-z0-9_&=#/]/i;
|
||||
regexen.validUrl = regexSupplant(
|
||||
'(' + // $1 URL
|
||||
'(https?:\\/\\/)' + // $2 Protocol
|
||||
'(#{validDomain})' + // $3 Domain(s)
|
||||
'(?::(#{validPortNumber}))?' + // $4 Port number (optional)
|
||||
'(\\/#{validUrlPath}*)?' + // $5 URL Path
|
||||
'(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $6 Query String
|
||||
')',
|
||||
'gi');
|
||||
return regexen.validUrl;
|
||||
}());
|
||||
Reference in New Issue
Block a user