Improve design of the rich text editor (#9533)

New design for rich text composer
This commit is contained in:
Florian Duros 2022-11-04 16:36:50 +01:00 committed by GitHub
parent 9101b42de8
commit 5ca9accce2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 668 additions and 270 deletions

View file

@ -0,0 +1,75 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from "classnames";
import React, { useContext } from "react";
import { _t } from "../../../languageHandler";
import ContextMenu, { aboveLeftOf, AboveLeftOf, useContextMenu } from "../../structures/ContextMenu";
import EmojiPicker from "../emojipicker/EmojiPicker";
import { CollapsibleButton } from "./CollapsibleButton";
import { OverflowMenuContext } from "./MessageComposerButtons";
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
menuPosition: AboveLeftOf;
className?: string;
}
export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonProps) {
const overflowMenuCloser = useContext(OverflowMenuContext);
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: React.ReactElement | null = null;
if (menuDisplayed && button.current) {
const position = (
menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect())
);
contextMenu = <ContextMenu
{...position}
onFinished={() => {
closeMenu();
overflowMenuCloser?.();
}}
managed={false}
>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
const computedClassName = classNames(
"mx_EmojiButton",
className,
{
"mx_EmojiButton_highlight": menuDisplayed,
},
);
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return <>
<CollapsibleButton
className={computedClassName}
iconClassName="mx_EmojiButton_icon"
onClick={openMenu}
title={_t("Emoji")}
inputRef={button}
/>
{ contextMenu }
</>;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import React, { createRef, ReactNode } from 'react';
import classNames from 'classnames';
import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
@ -31,7 +31,7 @@ import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, AboveLeftOf } from "../../structures/ContextMenu";
import { aboveLeftOf } from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
@ -420,33 +420,48 @@ export class MessageComposer extends React.Component<IProps, IState> {
return this.state.showStickersButton && !isLocalRoom(this.props.room);
}
public render() {
const controls = [
this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
null,
];
let menuPosition: AboveLeftOf | undefined;
private getMenuPosition() {
if (this.ref.current) {
const hasFormattingButtons = this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled;
const contentRect = this.ref.current.getBoundingClientRect();
menuPosition = aboveLeftOf(contentRect);
// Here we need to remove the all the extra space above the editor
// Instead of doing a querySelector or pass a ref to find the compute the height formatting buttons
// We are using an arbitrary value, the formatting buttons height doesn't change during the lifecycle of the component
// It's easier to just use a constant here instead of an over-engineering way to find the height
const heightToRemove = hasFormattingButtons ? 36 : 0;
const fixedRect = new DOMRect(
contentRect.x,
contentRect.y + heightToRemove,
contentRect.width,
contentRect.height - heightToRemove);
return aboveLeftOf(fixedRect);
}
}
public render() {
const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus);
const e2eIcon = hasE2EIcon &&
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" />;
const controls: ReactNode[] = [];
const menuPosition = this.getMenuPosition();
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
let composer: ReactNode;
if (canSendMessages) {
if (this.state.isWysiwygLabEnabled) {
controls.push(
if (this.state.isWysiwygLabEnabled && menuPosition) {
composer =
<SendWysiwygComposer key="controls_input"
disabled={this.state.haveRecording}
onChange={this.onWysiwygChange}
onSend={this.sendMessage}
isRichTextEnabled={this.state.isRichTextEnabled}
initialContent={this.state.initialComposerContent}
/>,
);
e2eStatus={this.props.e2eStatus}
menuPosition={menuPosition}
/>;
} else {
controls.push(
composer =
<SendMessageComposer
ref={this.messageComposerInput}
key="controls_input"
@ -458,8 +473,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
onChange={this.onChange}
disabled={this.state.haveRecording}
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
/>,
);
/>;
}
controls.push(<VoiceRecordComposerTile
@ -529,8 +543,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
const classes = classNames({
"mx_MessageComposer": true,
"mx_MessageComposer--compact": this.props.compact,
"mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined,
"mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled,
"mx_MessageComposer_e2eStatus": hasE2EIcon,
"mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled,
});
return (
@ -541,45 +555,48 @@ export class MessageComposer extends React.Component<IProps, IState> {
replyToEvent={this.props.replyToEvent}
permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row">
{ controls }
{ canSendMessages && <MessageComposerButtons
addEmoji={this.addEmoji}
haveRecording={this.state.haveRecording}
isMenuOpen={this.state.isMenuOpen}
isStickerPickerOpen={this.state.isStickerPickerOpen}
menuPosition={menuPosition}
relation={this.props.relation}
onRecordStartEndClick={() => {
this.voiceRecordingButton.current?.onRecordStartEndClick();
if (this.context.narrow) {
{ e2eIcon }
{ composer }
<div className="mx_MessageComposer_actions">
{ controls }
{ canSendMessages && <MessageComposerButtons
addEmoji={this.addEmoji}
haveRecording={this.state.haveRecording}
isMenuOpen={this.state.isMenuOpen}
isStickerPickerOpen={this.state.isStickerPickerOpen}
menuPosition={menuPosition}
relation={this.props.relation}
onRecordStartEndClick={() => {
this.voiceRecordingButton.current?.onRecordStartEndClick();
if (this.context.narrow) {
this.toggleButtonMenu();
}
}}
setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={!window.electron}
showPollsButton={this.state.showPollsButton}
showStickersButton={this.showStickersButton}
isRichTextEnabled={this.state.isRichTextEnabled}
onComposerModeClick={this.onRichTextToggle}
toggleButtonMenu={this.toggleButtonMenu}
showVoiceBroadcastButton={this.state.showVoiceBroadcastButton}
onStartVoiceBroadcastClick={() => {
startNewVoiceBroadcastRecording(
this.props.room,
MatrixClientPeg.get(),
VoiceBroadcastRecordingsStore.instance(),
);
this.toggleButtonMenu();
}
}}
setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={!window.electron}
showPollsButton={this.state.showPollsButton}
showStickersButton={this.showStickersButton}
showComposerModeButton={this.state.isWysiwygLabEnabled}
isRichTextEnabled={this.state.isRichTextEnabled}
onComposerModeClick={this.onRichTextToggle}
toggleButtonMenu={this.toggleButtonMenu}
showVoiceBroadcastButton={this.state.showVoiceBroadcastButton}
onStartVoiceBroadcastClick={() => {
startNewVoiceBroadcastRecording(
this.props.room,
MatrixClientPeg.get(),
VoiceBroadcastRecordingsStore.instance(),
);
this.toggleButtonMenu();
}}
/> }
{ showSendButton && (
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={this.state.haveRecording ? _t("Send voice message") : undefined}
/>
) }
}}
/> }
{ showSendButton && (
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={this.state.haveRecording ? _t("Send voice message") : undefined}
/>
) }
</div>
</div>
</div>
</div>

View file

@ -25,9 +25,8 @@ import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread';
import { _t } from '../../../languageHandler';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { CollapsibleButton } from './CollapsibleButton';
import ContextMenu, { aboveLeftOf, AboveLeftOf, useContextMenu } from '../../structures/ContextMenu';
import { AboveLeftOf } from '../../structures/ContextMenu';
import dis from '../../../dispatcher/dispatcher';
import EmojiPicker from '../emojipicker/EmojiPicker';
import ErrorDialog from "../dialogs/ErrorDialog";
import LocationButton from '../location/LocationButton';
import Modal from "../../../Modal";
@ -39,6 +38,8 @@ import RoomContext from '../../../contexts/RoomContext';
import { useDispatcher } from "../../../hooks/useDispatcher";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import IconizedContextMenu, { IconizedContextMenuOptionList } from '../context_menus/IconizedContextMenu';
import { EmojiButton } from './EmojiButton';
import { useSettingValue } from '../../../hooks/useSettings';
interface IProps {
addEmoji: (emoji: string) => boolean;
@ -56,7 +57,6 @@ interface IProps {
showVoiceBroadcastButton: boolean;
onStartVoiceBroadcastClick: () => void;
isRichTextEnabled: boolean;
showComposerModeButton: boolean;
onComposerModeClick: () => void;
}
@ -67,6 +67,8 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
const matrixClient: MatrixClient = useContext(MatrixClientContext);
const { room, roomId, narrow } = useContext(RoomContext);
const isWysiwygLabEnabled = useSettingValue<boolean>('feature_wysiwyg_composer');
if (props.haveRecording) {
return null;
}
@ -75,7 +77,9 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
let moreButtons: ReactElement[];
if (narrow) {
mainButtons = [
emojiButton(props),
isWysiwygLabEnabled ?
<ComposerModeButton key="composerModeButton" isRichTextEnabled={props.isRichTextEnabled} onClick={props.onComposerModeClick} /> :
emojiButton(props),
];
moreButtons = [
uploadButton(), // props passed via UploadButtonContext
@ -87,9 +91,9 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
];
} else {
mainButtons = [
emojiButton(props),
props.showComposerModeButton &&
<ComposerModeButton key="composerModeButton" isRichTextEnabled={props.isRichTextEnabled} onClick={props.onComposerModeClick} />,
isWysiwygLabEnabled ?
<ComposerModeButton key="composerModeButton" isRichTextEnabled={props.isRichTextEnabled} onClick={props.onComposerModeClick} /> :
emojiButton(props),
uploadButton(), // props passed via UploadButtonContext
];
moreButtons = [
@ -139,58 +143,10 @@ function emojiButton(props: IProps): ReactElement {
key="emoji_button"
addEmoji={props.addEmoji}
menuPosition={props.menuPosition}
className="mx_MessageComposer_button"
/>;
}
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
menuPosition: AboveLeftOf;
}
const EmojiButton: React.FC<IEmojiButtonProps> = ({ addEmoji, menuPosition }) => {
const overflowMenuCloser = useContext(OverflowMenuContext);
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: React.ReactElement | null = null;
if (menuDisplayed) {
const position = (
menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect())
);
contextMenu = <ContextMenu
{...position}
onFinished={() => {
closeMenu();
overflowMenuCloser?.();
}}
managed={false}
>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
const className = classNames(
"mx_MessageComposer_button",
{
"mx_MessageComposer_button_highlight": menuDisplayed,
},
);
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return <React.Fragment>
<CollapsibleButton
className={className}
iconClassName="mx_MessageComposer_emoji"
onClick={openMenu}
title={_t("Emoji")}
inputRef={button}
/>
{ contextMenu }
</React.Fragment>;
};
function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
}
@ -408,7 +364,7 @@ interface WysiwygToggleButtonProps {
}
function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) {
const title = isRichTextEnabled ? _t("Show plain text") : _t("Show formatting");
const title = isRichTextEnabled ? _t("Hide formatting") : _t("Show formatting");
return <CollapsibleButton
className="mx_MessageComposer_button"

View file

@ -14,21 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, RefObject } from 'react';
import React, { ForwardedRef, forwardRef, MutableRefObject } from 'react';
import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler';
import { WysiwygComposer } from './components/WysiwygComposer';
import { PlainTextComposer } from './components/PlainTextComposer';
import { ComposerFunctions } from './types';
import { E2EStatus } from '../../../../utils/ShieldUtils';
import E2EIcon from '../E2EIcon';
import { EmojiButton } from '../EmojiButton';
import { AboveLeftOf } from '../../../structures/ContextMenu';
interface ContentProps {
disabled: boolean;
disabled?: boolean;
composerFunctions: ComposerFunctions;
}
const Content = forwardRef<HTMLElement, ContentProps>(
function Content({ disabled, composerFunctions }: ContentProps, forwardRef: RefObject<HTMLElement>) {
useWysiwygSendActionHandler(disabled, forwardRef, composerFunctions);
function Content(
{ disabled = false, composerFunctions }: ContentProps,
forwardRef: ForwardedRef<HTMLElement>,
) {
useWysiwygSendActionHandler(disabled, forwardRef as MutableRefObject<HTMLElement>, composerFunctions);
return null;
},
);
@ -37,14 +44,23 @@ interface SendWysiwygComposerProps {
initialContent?: string;
isRichTextEnabled: boolean;
disabled?: boolean;
e2eStatus?: E2EStatus;
onChange: (content: string) => void;
onSend: () => void;
menuPosition: AboveLeftOf;
}
export function SendWysiwygComposer({ isRichTextEnabled, ...props }: SendWysiwygComposerProps) {
export function SendWysiwygComposer(
{ isRichTextEnabled, e2eStatus, menuPosition, ...props }: SendWysiwygComposerProps) {
const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
return <Composer className="mx_SendWysiwygComposer" {...props}>
return <Composer
className="mx_SendWysiwygComposer"
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
// TODO add emoji support
rightComponent={<EmojiButton menuPosition={menuPosition} addEmoji={() => false} />}
{...props}
>
{ (ref, composerFunctions) => (
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
) }

View file

@ -14,27 +14,43 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, memo } from 'react';
import React, { forwardRef, memo, MutableRefObject, ReactNode } from 'react';
import { useIsExpanded } from '../hooks/useIsExpanded';
const HEIGHT_BREAKING_POINT = 20;
interface EditorProps {
disabled: boolean;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
}
export const Editor = memo(
forwardRef<HTMLDivElement, EditorProps>(
function Editor({ disabled }: EditorProps, ref,
function Editor({ disabled, leftComponent, rightComponent }: EditorProps, ref,
) {
return <div className="mx_WysiwygComposer_container">
<div className="mx_WysiwygComposer_content"
ref={ref!}
contentEditable={!disabled}
role="textbox"
aria-multiline="true"
aria-autocomplete="list"
aria-haspopup="listbox"
dir="auto"
aria-disabled={disabled}
/>
const isExpanded = useIsExpanded(ref as MutableRefObject<HTMLDivElement | null>, HEIGHT_BREAKING_POINT);
return <div
data-testid="WysiwygComposerEditor"
className="mx_WysiwygComposer_Editor"
data-is-expanded={isExpanded}
>
{ leftComponent }
<div className="mx_WysiwygComposer_Editor_container">
<div className="mx_WysiwygComposer_Editor_content"
ref={ref}
contentEditable={!disabled}
role="textbox"
aria-multiline="true"
aria-autocomplete="list"
aria-haspopup="listbox"
dir="auto"
aria-disabled={disabled}
/>
</div>
{ rightComponent }
</div>;
},
),

View file

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from 'classnames';
import React, { MutableRefObject, ReactNode } from 'react';
import { useComposerFunctions } from '../hooks/useComposerFunctions';
import { useIsFocused } from '../hooks/useIsFocused';
import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization';
import { usePlainTextListeners } from '../hooks/usePlainTextListeners';
import { useSetCursorPosition } from '../hooks/useSetCursorPosition';
@ -26,9 +28,11 @@ import { Editor } from "./Editor";
interface PlainTextComposerProps {
disabled?: boolean;
onChange?: (content: string) => void;
onSend: () => void;
onSend?: () => void;
initialContent?: string;
className?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
children?: (
ref: MutableRefObject<HTMLDivElement | null>,
composerFunctions: ComposerFunctions,
@ -36,21 +40,32 @@ interface PlainTextComposerProps {
}
export function PlainTextComposer({
className, disabled, onSend, onChange, children, initialContent }: PlainTextComposerProps,
className,
disabled = false,
onSend,
onChange,
children,
initialContent,
leftComponent,
rightComponent,
}: PlainTextComposerProps,
) {
const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend);
const composerFunctions = useComposerFunctions(ref);
usePlainTextInitialization(initialContent, ref);
useSetCursorPosition(disabled, ref);
const { isFocused, onFocus } = useIsFocused();
return <div
data-testid="PlainTextComposer"
className={className}
className={classNames(className, { [`${className}-focused`]: isFocused })}
onFocus={onFocus}
onBlur={onFocus}
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
>
<Editor ref={ref} disabled={disabled} />
<Editor ref={ref} disabled={disabled} leftComponent={leftComponent} rightComponent={rightComponent} />
{ children?.(ref, composerFunctions) }
</div>;
}

View file

@ -16,11 +16,13 @@ limitations under the License.
import React, { memo, MutableRefObject, ReactNode, useEffect } from 'react';
import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
import classNames from 'classnames';
import { FormattingButtons } from './FormattingButtons';
import { Editor } from './Editor';
import { useInputEventProcessor } from '../hooks/useInputEventProcessor';
import { useSetCursorPosition } from '../hooks/useSetCursorPosition';
import { useIsFocused } from '../hooks/useIsFocused';
interface WysiwygComposerProps {
disabled?: boolean;
@ -28,6 +30,8 @@ interface WysiwygComposerProps {
onSend: () => void;
initialContent?: string;
className?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
children?: (
ref: MutableRefObject<HTMLDivElement | null>,
wysiwyg: FormattingFunctions,
@ -35,7 +39,16 @@ interface WysiwygComposerProps {
}
export const WysiwygComposer = memo(function WysiwygComposer(
{ disabled = false, onChange, onSend, initialContent, className, children }: WysiwygComposerProps,
{
disabled = false,
onChange,
onSend,
initialContent,
className,
leftComponent,
rightComponent,
children,
}: WysiwygComposerProps,
) {
const inputEventProcessor = useInputEventProcessor(onSend);
@ -51,10 +64,12 @@ export const WysiwygComposer = memo(function WysiwygComposer(
const isReady = isWysiwygReady && !disabled;
useSetCursorPosition(!isReady, ref);
const { isFocused, onFocus } = useIsFocused();
return (
<div data-testid="WysiwygComposer" className={className}>
<div data-testid="WysiwygComposer" className={classNames(className, { [`${className}-focused`]: isFocused })} onFocus={onFocus} onBlur={onFocus}>
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
<Editor ref={ref} disabled={!isReady} />
<Editor ref={ref} disabled={!isReady} leftComponent={leftComponent} rightComponent={rightComponent} />
{ children?.(ref, wysiwyg) }
</div>
);

View file

@ -0,0 +1,37 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MutableRefObject, useEffect, useState } from "react";
export function useIsExpanded(ref: MutableRefObject<HTMLElement | null>, breakingPoint: number) {
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (ref.current) {
const editor = ref.current;
const resizeObserver = new ResizeObserver(entries => {
requestAnimationFrame(() => {
const height = entries[0]?.contentBoxSize?.[0].blockSize;
setIsExpanded(height >= breakingPoint);
});
});
resizeObserver.observe(editor);
return () => resizeObserver.unobserve(editor);
}
}, [ref, breakingPoint]);
return isExpanded;
}

View file

@ -0,0 +1,36 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FocusEvent, useCallback, useEffect, useRef, useState } from "react";
export function useIsFocused() {
const [isFocused, setIsFocused] = useState(false);
const timeoutIDRef = useRef<number>();
useEffect(() => () => clearTimeout(timeoutIDRef.current), [timeoutIDRef]);
const onFocus = useCallback((event: FocusEvent<HTMLElement>) => {
clearTimeout(timeoutIDRef.current);
if (event.type === 'focus') {
setIsFocused(true);
} else {
// To avoid a blink when we switch mode between plain text and rich text mode
// We delay the unfocused action
timeoutIDRef.current = setTimeout(() => setIsFocused(false), 100);
}
}, [setIsFocused, timeoutIDRef]);
return { isFocused, onFocus };
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import { RefObject, useEffect } from "react";
export function usePlainTextInitialization(initialContent: string, ref: RefObject<HTMLElement>) {
export function usePlainTextInitialization(initialContent = '', ref: RefObject<HTMLElement>) {
useEffect(() => {
if (ref.current) {
ref.current.innerText = initialContent;

View file

@ -22,18 +22,18 @@ function isDivElement(target: EventTarget): target is HTMLDivElement {
return target instanceof HTMLDivElement;
}
export function usePlainTextListeners(onChange: (content: string) => void, onSend: () => void) {
const ref = useRef<HTMLDivElement>();
export function usePlainTextListeners(onChange?: (content: string) => void, onSend?: () => void) {
const ref = useRef<HTMLDivElement | null>(null);
const send = useCallback((() => {
if (ref.current) {
ref.current.innerHTML = '';
}
onSend();
onSend?.();
}), [ref, onSend]);
const onInput = useCallback((event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
if (isDivElement(event.target)) {
onChange(event.target.innerHTML);
onChange?.(event.target.innerHTML);
}
}, [onChange]);

View file

@ -28,7 +28,7 @@ export function useWysiwygEditActionHandler(
composerElement: RefObject<HTMLElement>,
) {
const roomContext = useRoomContext();
const timeoutId = useRef<number>();
const timeoutId = useRef<number | null>(null);
const handler = useCallback((payload: ActionPayload) => {
// don't let the user into the composer if it is disabled - all of these branches lead

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { RefObject, useCallback, useRef } from "react";
import { MutableRefObject, useCallback, useRef } from "react";
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
@ -26,16 +26,16 @@ import { ComposerFunctions } from "../types";
export function useWysiwygSendActionHandler(
disabled: boolean,
composerElement: RefObject<HTMLElement>,
composerElement: MutableRefObject<HTMLElement>,
composerFunctions: ComposerFunctions,
) {
const roomContext = useRoomContext();
const timeoutId = useRef<number>();
const timeoutId = useRef<number | null>(null);
const handler = useCallback((payload: ActionPayload) => {
// don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer
if (disabled || !composerElement.current) return;
if (disabled || !composerElement?.current) return;
const context = payload.context ?? TimelineRenderingType.Room;

View file

@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MutableRefObject } from "react";
import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
export function focusComposer(
composerElement: React.MutableRefObject<HTMLElement>,
composerElement: MutableRefObject<HTMLElement | null>,
renderingType: TimelineRenderingType,
roomContext: IRoomState,
timeoutId: React.MutableRefObject<number>,
timeoutId: MutableRefObject<number | null>,
) {
if (renderingType === roomContext.timelineRenderingType) {
// Immediately set the focus, so if you start typing it

View file

@ -1829,6 +1829,7 @@
"This room is end-to-end encrypted": "This room is end-to-end encrypted",
"Everyone in this room is verified": "Everyone in this room is verified",
"Edit message": "Edit message",
"Emoji": "Emoji",
"Mod": "Mod",
"From a thread": "From a thread",
"This event could not be displayed": "This event could not be displayed",
@ -1878,13 +1879,12 @@
"You do not have permission to post to this room": "You do not have permission to post to this room",
"%(seconds)ss left": "%(seconds)ss left",
"Send voice message": "Send voice message",
"Emoji": "Emoji",
"Hide stickers": "Hide stickers",
"Sticker": "Sticker",
"Voice Message": "Voice Message",
"You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.",
"Poll": "Poll",
"Show plain text": "Show plain text",
"Hide formatting": "Hide formatting",
"Show formatting": "Show formatting",
"Bold": "Bold",
"Italics": "Italics",