Improve design of the rich text editor (#9533)
New design for rich text composer
This commit is contained in:
parent
9101b42de8
commit
5ca9accce2
31 changed files with 668 additions and 270 deletions
75
src/components/views/rooms/EmojiButton.tsx
Normal file
75
src/components/views/rooms/EmojiButton.tsx
Normal 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 }
|
||||
</>;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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} />
|
||||
) }
|
||||
|
|
|
@ -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>;
|
||||
},
|
||||
),
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue