Lexical: Use in ComposeEventModal, style improvements, types

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-04-11 23:22:34 +02:00
parent 2549e72843
commit b15640603c
17 changed files with 100 additions and 123 deletions

View file

@ -35,7 +35,6 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
if (!item) return;
if (onClick) onClick();
if (item.to) {
event.preventDefault();
history.push(item.to);
@ -106,4 +105,4 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
);
};
export default DropdownMenuItem;
export default DropdownMenuItem;

View file

@ -15,7 +15,6 @@ const Portal: React.FC<IPortal> = ({ children }) => {
setIsRendered(true);
}, []);
if (!isRendered) {
return null;
}
@ -28,4 +27,4 @@ const Portal: React.FC<IPortal> = ({ children }) => {
);
};
export default Portal;
export default Portal;

View file

@ -99,7 +99,6 @@ const findElementPosition = (el: HTMLElement) => {
};
};
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
const box = findElementPosition(el);
const boxW = el.offsetWidth;
@ -121,4 +120,4 @@ const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Po
};
};
export default Slider;
export default Slider;

View file

@ -11,7 +11,6 @@ import { CompatRouter } from 'react-router-dom-v5-compat';
// @ts-ignore: it doesn't have types
import { ScrollContext } from 'react-router-scroll-4';
import { loadInstance } from 'soapbox/actions/instance';
import { fetchMe } from 'soapbox/actions/me';
import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';

View file

@ -12,5 +12,4 @@ function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
};
}
export { parseEntitiesPath };
export { parseEntitiesPath };

View file

@ -37,7 +37,6 @@ const UserIndex: React.FC = () => {
updateQuery();
}, []);
const hasMore = items.count() < total && !!next;
const showLoading = isLoading && items.isEmpty();

View file

@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
import { IAccount } from 'soapbox/queries/accounts';

View file

@ -1,6 +1,6 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, MessageDescriptor, useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
@ -45,9 +45,6 @@ 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' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
@ -74,12 +71,11 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
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 { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt, group_id: groupId } = compose;
const { text, 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;
@ -93,17 +89,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const editorStateRef = useRef<string>(null);
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;
};
@ -139,11 +124,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const handleSubmit = (e?: React.FormEvent<Element>) => {
dispatch(changeCompose(id, editorStateRef.current!));
// 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('');
@ -167,10 +147,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
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']));
};
@ -245,7 +221,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
let publishText: string | JSX.Element = '';
let publishIcon: string | undefined = undefined;
let textareaPlaceholder: MessageDescriptor;
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
@ -263,14 +238,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
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 && (
@ -301,10 +268,14 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
<div>
<ComposeEditor
ref={editorStateRef}
condensed={condensed}
onFocus={handleComposeFocus}
autoFocus={shouldAutoFocus}
className='my-2'
composeId={id}
condensed={condensed}
eventDiscussion={!!event}
autoFocus={shouldAutoFocus}
hasPoll={hasPoll}
onFocus={handleComposeFocus}
onPaste={onPaste}
/>
{!condensed && (
<Stack space={4} className='compose-form__modifiers'>
@ -323,26 +294,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
)}
</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'
>
<></>
</AutosuggestTextarea>
<QuotedStatusContainer composeId={id} />
{extra && <div className={clsx({ 'hidden': condensed })}>{extra}</div>}

View file

@ -46,7 +46,29 @@ const StatePlugin = ({ composeId }: { composeId: string }) => {
return null;
};
const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onFocus, autoFocus }, editorStateRef) => {
interface IComposeEditor {
className?: string
composeId: string
condensed?: boolean
eventDiscussion?: boolean
hasPoll?: boolean
autoFocus?: boolean
onFocus?: React.FocusEventHandler<HTMLDivElement>
onPaste?: (files: FileList) => void
placeholder?: JSX.Element | string
}
const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
className,
composeId,
condensed,
eventDiscussion,
hasPoll,
autoFocus,
onFocus,
onPaste,
placeholder,
}, editorStateRef) => {
const dispatch = useAppDispatch();
const features = useFeatures();
@ -111,14 +133,29 @@ const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onF
}
};
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='lexical relative' data-markup>
<div className={clsx('lexical relative', className)} data-markup>
<RichTextPlugin
contentEditable={
<div className='editor' ref={onRef} onFocus={onFocus}>
<div className='editor' ref={onRef} onFocus={onFocus} onPaste={handlePaste}>
<ContentEditable
className={clsx('mr-4 py-2 outline-none transition-[min-height] motion-reduce:transition-none', {
className={clsx('mr-4 outline-none transition-[min-height] motion-reduce:transition-none', {
'min-fh-[40px]': condensed,
'min-h-[100px]': !condensed,
})}
@ -127,8 +164,10 @@ const ComposeEditor = React.forwardRef<string, any>(({ composeId, condensed, onF
</div>
}
placeholder={(
<div className='pointer-events-none absolute top-2 select-none text-gray-600 dark:placeholder:text-gray-600'>
<FormattedMessage id='compose_form.placeholder' defaultMessage="What's on your mind" />
<div
className='pointer-events-none absolute top-0 select-none text-gray-600 dark:placeholder:text-gray-600'
>
{textareaPlaceholder}
</div>
)}
ErrorBoundary={LexicalErrorBoundary}

View file

@ -51,7 +51,7 @@ export type MenuRenderFn = (
anchorElementRef: MutableRefObject<HTMLElement | null>,
) => ReactPortal | JSX.Element | null;
function tryToPositionRange(leadOffset: number, range: Range): boolean {
const tryToPositionRange = (leadOffset: number, range: Range): boolean => {
const domSelection = window.getSelection();
if (domSelection === null || !domSelection.isCollapsed) {
return false;
@ -72,12 +72,12 @@ function tryToPositionRange(leadOffset: number, range: Range): boolean {
}
return true;
}
};
function isSelectionOnEntityBoundary(
const isSelectionOnEntityBoundary = (
editor: LexicalEditor,
offset: number,
): boolean {
): boolean => {
if (offset !== 0) {
return false;
}
@ -91,21 +91,21 @@ function isSelectionOnEntityBoundary(
}
return false;
});
}
};
function startTransition(callback: () => void) {
const startTransition = (callback: () => void) => {
if (React.startTransition) {
React.startTransition(callback);
} else {
callback();
}
}
};
// Got from https://stackoverflow.com/a/42543908/2013580
export function getScrollParent(
export const getScrollParent = (
element: HTMLElement,
includeHidden: boolean,
): HTMLElement | HTMLBodyElement {
): HTMLElement | HTMLBodyElement => {
let style = getComputedStyle(element);
const excludeStaticParent = style.position === 'absolute';
const overflowRegex = includeHidden
@ -130,24 +130,24 @@ export function getScrollParent(
}
}
return document.body;
}
};
function isTriggerVisibleInNearestScrollContainer(
const isTriggerVisibleInNearestScrollContainer = (
targetElement: HTMLElement,
containerElement: HTMLElement,
): boolean {
): 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.
export function useDynamicPositioning(
export const useDynamicPositioning = (
resolution: Resolution | null,
targetElement: HTMLElement | null,
onReposition: () => void,
onVisibilityChange?: (isInView: boolean) => void,
) {
) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (targetElement && resolution) {
@ -161,9 +161,9 @@ export function useDynamicPositioning(
targetElement,
rootScrollParent,
);
const handleScroll = function () {
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(() => {
onReposition();
ticking = false;
});
@ -194,22 +194,17 @@ export function useDynamicPositioning(
};
}
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
}
};
function LexicalPopoverMenu({
anchorElementRef,
menuRenderFn,
}: {
const LexicalPopoverMenu = ({ anchorElementRef, menuRenderFn }: {
anchorElementRef: MutableRefObject<HTMLElement>
menuRenderFn: MenuRenderFn
}): JSX.Element | null {
return menuRenderFn(anchorElementRef);
}
}): JSX.Element | null => menuRenderFn(anchorElementRef);
function useMenuAnchorRef(
const useMenuAnchorRef = (
resolution: Resolution | null,
setResolution: (r: Resolution | null) => void,
): MutableRefObject<HTMLElement> {
): MutableRefObject<HTMLElement> => {
const [editor] = useLexicalComposerContext();
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
const positionMenu = useCallback(() => {
@ -272,7 +267,7 @@ function useMenuAnchorRef(
);
return anchorElementRef;
}
};
export type AutosuggestPluginProps = {
composeId: string
@ -280,11 +275,11 @@ export type AutosuggestPluginProps = {
setSuggestionsHidden: (value: boolean) => void
};
export function AutosuggestPlugin({
export const AutosuggestPlugin = ({
composeId,
suggestionsHidden,
setSuggestionsHidden,
}: AutosuggestPluginProps): JSX.Element | null {
}: AutosuggestPluginProps): JSX.Element | null => {
const { suggestions } = useCompose(composeId);
const dispatch = useAppDispatch();
@ -473,4 +468,4 @@ export function AutosuggestPlugin({
}
/>
);
}
};

View file

@ -12,7 +12,6 @@ import { useCallback, useEffect } from 'react';
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
import type { TextNode } from 'lexical';
export const MENTION_REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');

View file

@ -75,5 +75,4 @@ const EmojiPickerDropdownContainer = (
);
};
export default EmojiPickerDropdownContainer;

View file

@ -11,7 +11,6 @@ const messages = defineMessages({
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
interface IIconPickerMenu {
icons: Record<string, Array<string>>
onClose: () => void

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
@ -21,9 +21,10 @@ import { closeModal, openModal } from 'soapbox/actions/modals';
import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location';
import LocationSearch from 'soapbox/components/location-search';
import { checkEventComposeContent } from 'soapbox/components/modal-root';
import { Button, Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Spinner, Stack, Tabs, Text, Textarea, Toggle } from 'soapbox/components/ui';
import { Button, Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Spinner, Stack, Tabs, Text, Toggle } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { isCurrentOrFutureDate } from 'soapbox/features/compose/components/schedule-form';
import ComposeEditor from 'soapbox/features/compose/editor';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { DatePicker } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
@ -94,13 +95,14 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const editorStateRef = useRef<string>(null);
const [tab, setTab] = useState<'edit' | 'pending'>('edit');
const banner = useAppSelector((state) => state.compose_event.banner);
const isUploading = useAppSelector((state) => state.compose_event.is_uploading);
const name = useAppSelector((state) => state.compose_event.name);
const description = useAppSelector((state) => state.compose_event.status);
const startTime = useAppSelector((state) => state.compose_event.start_time);
const endTime = useAppSelector((state) => state.compose_event.end_time);
const approvalRequired = useAppSelector((state) => state.compose_event.approval_required);
@ -114,10 +116,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
dispatch(changeEditEventName(target.value));
};
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
dispatch(changeEditEventDescription(target.value));
};
const onChangeStartTime = (date: Date) => {
dispatch(changeEditEventStartTime(date));
};
@ -170,6 +168,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
};
const handleSubmit = () => {
dispatch(changeEditEventDescription(editorStateRef.current!));
dispatch(submitEvent());
};
@ -238,11 +237,11 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />}
hintText={<FormattedMessage id='compose_event.fields.description_hint' defaultMessage='Markdown syntax is supported' />}
>
<Textarea
autoComplete='off'
<ComposeEditor
ref={editorStateRef}
className='block w-full rounded-md border-gray-400 bg-white px-3 py-2 text-base text-gray-900 placeholder:text-gray-600 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus:border-primary-500 dark:focus:ring-primary-500 sm:text-sm'
composeId='compose-event-modal'
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}
value={description}
onChange={onChangeDescription}
/>
</FormGroup>
<FormGroup

View file

@ -135,7 +135,6 @@ const normalizeLocked = (group: ImmutableMap<string, any>) => {
return group.set('locked', locked);
};
/** Rewrite `<p></p>` to empty string. */
const fixNote = (group: ImmutableMap<string, any>) => {
if (group.get('note') === '<p></p>') {

View file

@ -54,6 +54,7 @@ import {
COMPOSE_EDITOR_STATE_SET,
COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET } from '../actions/events';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me';
import { SETTING_CHANGE, FE_NAME } from '../actions/settings';
import { TIMELINE_DELETE } from '../actions/timelines';
@ -507,6 +508,10 @@ export default function compose(state = initialState, action: AnyAction) {
return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value));
case COMPOSE_EDITOR_STATE_SET:
return updateCompose(state, action.id, compose => compose.set('editorState', action.editorState));
case EVENT_COMPOSE_CANCEL:
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', ''));
case EVENT_FORM_SET:
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', action.text));
default:
return state;
}

View file

@ -24,8 +24,7 @@ const getScopes = (state: RootState) => {
return getInstanceScopes(state.instance);
};
export {
getInstanceScopes,
getScopes,
};
};