parent
88c3864682
commit
fe0273b1a6
23 changed files with 418 additions and 97 deletions
|
@ -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 { createContext, useContext } from "react";
|
||||
|
||||
import { SubSelection } from "./types";
|
||||
|
||||
export function getDefaultContextValue(): { selection: SubSelection } {
|
||||
return {
|
||||
selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposerContextState {
|
||||
selection: SubSelection;
|
||||
}
|
||||
|
||||
export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue());
|
||||
ComposerContext.displayName = "ComposerContext";
|
||||
|
||||
export function useComposerContext() {
|
||||
return useContext(ComposerContext);
|
||||
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, RefObject } from "react";
|
||||
import React, { forwardRef, RefObject, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
|
||||
|
@ -23,6 +23,7 @@ import { EditionButtons } from "./components/EditionButtons";
|
|||
import { useWysiwygEditActionHandler } from "./hooks/useWysiwygEditActionHandler";
|
||||
import { useEditing } from "./hooks/useEditing";
|
||||
import { useInitialContent } from "./hooks/useInitialContent";
|
||||
import { ComposerContext, getDefaultContextValue } from "./ComposerContext";
|
||||
|
||||
interface ContentProps {
|
||||
disabled: boolean;
|
||||
|
@ -45,6 +46,7 @@ interface EditWysiwygComposerProps {
|
|||
|
||||
// Default needed for React.lazy
|
||||
export default function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) {
|
||||
const defaultContextValue = useRef(getDefaultContextValue());
|
||||
const initialContent = useInitialContent(editorStateTransfer);
|
||||
const isReady = !editorStateTransfer || initialContent !== undefined;
|
||||
|
||||
|
@ -55,23 +57,25 @@ export default function EditWysiwygComposer({ editorStateTransfer, className, ..
|
|||
}
|
||||
|
||||
return (
|
||||
<WysiwygComposer
|
||||
className={classNames("mx_EditWysiwygComposer", className)}
|
||||
initialContent={initialContent}
|
||||
onChange={onChange}
|
||||
onSend={editMessage}
|
||||
{...props}
|
||||
>
|
||||
{(ref) => (
|
||||
<>
|
||||
<Content disabled={props.disabled} ref={ref} />
|
||||
<EditionButtons
|
||||
onCancelClick={endEditing}
|
||||
onSaveClick={editMessage}
|
||||
isSaveDisabled={isSaveDisabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</WysiwygComposer>
|
||||
<ComposerContext.Provider value={defaultContextValue.current}>
|
||||
<WysiwygComposer
|
||||
className={classNames("mx_EditWysiwygComposer", className)}
|
||||
initialContent={initialContent}
|
||||
onChange={onChange}
|
||||
onSend={editMessage}
|
||||
{...props}
|
||||
>
|
||||
{(ref) => (
|
||||
<>
|
||||
<Content disabled={props.disabled} ref={ref} />
|
||||
<EditionButtons
|
||||
onCancelClick={endEditing}
|
||||
onSaveClick={editMessage}
|
||||
isSaveDisabled={isSaveDisabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</WysiwygComposer>
|
||||
</ComposerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ForwardedRef, forwardRef, MutableRefObject } from "react";
|
||||
import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react";
|
||||
|
||||
import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler";
|
||||
import { WysiwygComposer } from "./components/WysiwygComposer";
|
||||
|
@ -24,6 +24,7 @@ import { E2EStatus } from "../../../../utils/ShieldUtils";
|
|||
import E2EIcon from "../E2EIcon";
|
||||
import { AboveLeftOf } from "../../../structures/ContextMenu";
|
||||
import { Emoji } from "./components/Emoji";
|
||||
import { ComposerContext, getDefaultContextValue } from "./ComposerContext";
|
||||
|
||||
interface ContentProps {
|
||||
disabled?: boolean;
|
||||
|
@ -57,19 +58,20 @@ export default function SendWysiwygComposer({
|
|||
...props
|
||||
}: SendWysiwygComposerProps) {
|
||||
const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
|
||||
const defaultContextValue = useRef(getDefaultContextValue());
|
||||
|
||||
return (
|
||||
<Composer
|
||||
className="mx_SendWysiwygComposer"
|
||||
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
|
||||
rightComponent={(selectPreviousSelection) => (
|
||||
<Emoji menuPosition={menuPosition} selectPreviousSelection={selectPreviousSelection} />
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{(ref, composerFunctions) => (
|
||||
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
|
||||
)}
|
||||
</Composer>
|
||||
<ComposerContext.Provider value={defaultContextValue.current}>
|
||||
<Composer
|
||||
className="mx_SendWysiwygComposer"
|
||||
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
|
||||
rightComponent={<Emoji menuPosition={menuPosition} />}
|
||||
{...props}
|
||||
>
|
||||
{(ref, composerFunctions) => (
|
||||
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
|
||||
)}
|
||||
</Composer>
|
||||
</ComposerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ interface EditorProps {
|
|||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
}
|
||||
|
||||
export const Editor = memo(
|
||||
|
@ -35,7 +35,7 @@ export const Editor = memo(
|
|||
ref,
|
||||
) {
|
||||
const isExpanded = useIsExpanded(ref as MutableRefObject<HTMLDivElement | null>, HEIGHT_BREAKING_POINT);
|
||||
const { onFocus, onBlur, selectPreviousSelection, onInput } = useSelection();
|
||||
const { onFocus, onBlur, onInput } = useSelection();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -63,7 +63,7 @@ export const Editor = memo(
|
|||
onInput={onInput}
|
||||
/>
|
||||
</div>
|
||||
{rightComponent?.(selectPreviousSelection)}
|
||||
{rightComponent}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
|
|
|
@ -22,25 +22,28 @@ import dis from "../../../../../dispatcher/dispatcher";
|
|||
import { ComposerInsertPayload } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { Action } from "../../../../../dispatcher/actions";
|
||||
import { useRoomContext } from "../../../../../contexts/RoomContext";
|
||||
import { useComposerContext } from "../ComposerContext";
|
||||
import { setSelection } from "../utils/selection";
|
||||
|
||||
interface EmojiProps {
|
||||
selectPreviousSelection: () => void;
|
||||
menuPosition: AboveLeftOf;
|
||||
}
|
||||
|
||||
export function Emoji({ selectPreviousSelection, menuPosition }: EmojiProps) {
|
||||
export function Emoji({ menuPosition }: EmojiProps) {
|
||||
const roomContext = useRoomContext();
|
||||
const composerContext = useComposerContext();
|
||||
|
||||
return (
|
||||
<EmojiButton
|
||||
menuPosition={menuPosition}
|
||||
addEmoji={(emoji) => {
|
||||
selectPreviousSelection();
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
action: Action.ComposerInsert,
|
||||
text: emoji,
|
||||
timelineRenderingType: roomContext.timelineRenderingType,
|
||||
});
|
||||
setSelection(composerContext.selection).then(() =>
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
action: Action.ComposerInsert,
|
||||
text: emoji,
|
||||
timelineRenderingType: roomContext.timelineRenderingType,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -23,12 +23,15 @@ import { Icon as ItalicIcon } from "../../../../../../res/img/element-icons/room
|
|||
import { Icon as UnderlineIcon } from "../../../../../../res/img/element-icons/room/composer/underline.svg";
|
||||
import { Icon as StrikeThroughIcon } from "../../../../../../res/img/element-icons/room/composer/strikethrough.svg";
|
||||
import { Icon as InlineCodeIcon } from "../../../../../../res/img/element-icons/room/composer/inline_code.svg";
|
||||
import { Icon as LinkIcon } from "../../../../../../res/img/element-icons/room/composer/link.svg";
|
||||
import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton";
|
||||
import { Alignment } from "../../../elements/Tooltip";
|
||||
import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
|
||||
import { KeyCombo } from "../../../../../KeyBindingsManager";
|
||||
import { _td } from "../../../../../languageHandler";
|
||||
import { ButtonEvent } from "../../../elements/AccessibleButton";
|
||||
import { openLinkModal } from "./LinkModal";
|
||||
import { useComposerContext } from "../ComposerContext";
|
||||
|
||||
interface TooltipProps {
|
||||
label: string;
|
||||
|
@ -76,6 +79,8 @@ interface FormattingButtonsProps {
|
|||
}
|
||||
|
||||
export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps) {
|
||||
const composerContext = useComposerContext();
|
||||
|
||||
return (
|
||||
<div className="mx_FormattingButtons">
|
||||
<Button
|
||||
|
@ -112,6 +117,12 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
|
|||
onClick={() => composer.inlineCode()}
|
||||
icon={<InlineCodeIcon className="mx_FormattingButtons_Icon" />}
|
||||
/>
|
||||
<Button
|
||||
isActive={actionStates.link === "reversed"}
|
||||
label={_td("Link")}
|
||||
onClick={() => openLinkModal(composer, composerContext)}
|
||||
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
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 { FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
|
||||
import React, { ChangeEvent, useState } from "react";
|
||||
|
||||
import { _td } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
import QuestionDialog from "../../../dialogs/QuestionDialog";
|
||||
import Field from "../../../elements/Field";
|
||||
import { ComposerContextState } from "../ComposerContext";
|
||||
import { isSelectionEmpty, setSelection } from "../utils/selection";
|
||||
|
||||
export function openLinkModal(composer: FormattingFunctions, composerContext: ComposerContextState) {
|
||||
const modal = Modal.createDialog(
|
||||
LinkModal,
|
||||
{ composerContext, composer, onClose: () => modal.close(), isTextEnabled: isSelectionEmpty() },
|
||||
"mx_CompoundDialog",
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function isEmpty(text: string) {
|
||||
return text.length < 1;
|
||||
}
|
||||
|
||||
interface LinkModalProps {
|
||||
composer: FormattingFunctions;
|
||||
isTextEnabled: boolean;
|
||||
onClose: () => void;
|
||||
composerContext: ComposerContextState;
|
||||
}
|
||||
|
||||
export function LinkModal({ composer, isTextEnabled, onClose, composerContext }: LinkModalProps) {
|
||||
const [fields, setFields] = useState({ text: "", link: "" });
|
||||
const isSaveDisabled = (isTextEnabled && isEmpty(fields.text)) || isEmpty(fields.link);
|
||||
|
||||
return (
|
||||
<QuestionDialog
|
||||
className="mx_LinkModal"
|
||||
title={_td("Create a link")}
|
||||
button={_td("Save")}
|
||||
buttonDisabled={isSaveDisabled}
|
||||
hasCancelButton={true}
|
||||
onFinished={async (isClickOnSave: boolean) => {
|
||||
if (isClickOnSave) {
|
||||
await setSelection(composerContext.selection);
|
||||
composer.link(fields.link, isTextEnabled ? fields.text : undefined);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
description={
|
||||
<div className="mx_LinkModal_content">
|
||||
{isTextEnabled && (
|
||||
<Field
|
||||
autoFocus={true}
|
||||
label={_td("Text")}
|
||||
value={fields.text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setFields((fields) => ({ ...fields, text: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
autoFocus={!isTextEnabled}
|
||||
label={_td("Link")}
|
||||
value={fields.link}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setFields((fields) => ({ ...fields, link: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -33,7 +33,7 @@ interface PlainTextComposerProps {
|
|||
initialContent?: string;
|
||||
className?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
children?: (ref: MutableRefObject<HTMLDivElement | null>, composerFunctions: ComposerFunctions) => ReactNode;
|
||||
}
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ interface WysiwygComposerProps {
|
|||
initialContent?: string;
|
||||
className?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
children?: (ref: MutableRefObject<HTMLDivElement | null>, wysiwyg: FormattingFunctions) => ReactNode;
|
||||
}
|
||||
|
||||
|
|
|
@ -14,18 +14,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MutableRefObject, useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import useFocus from "../../../../../hooks/useFocus";
|
||||
import { setSelection } from "../utils/selection";
|
||||
import { useComposerContext, ComposerContextState } from "../ComposerContext";
|
||||
|
||||
type SubSelection = Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">;
|
||||
|
||||
function setSelectionRef(selectionRef: MutableRefObject<SubSelection>) {
|
||||
function setSelectionContext(composerContext: ComposerContextState) {
|
||||
const selection = document.getSelection();
|
||||
|
||||
if (selection) {
|
||||
selectionRef.current = {
|
||||
composerContext.selection = {
|
||||
anchorNode: selection.anchorNode,
|
||||
anchorOffset: selection.anchorOffset,
|
||||
focusNode: selection.focusNode,
|
||||
|
@ -35,17 +33,12 @@ function setSelectionRef(selectionRef: MutableRefObject<SubSelection>) {
|
|||
}
|
||||
|
||||
export function useSelection() {
|
||||
const selectionRef = useRef<SubSelection>({
|
||||
anchorNode: null,
|
||||
anchorOffset: 0,
|
||||
focusNode: null,
|
||||
focusOffset: 0,
|
||||
});
|
||||
const composerContext = useComposerContext();
|
||||
const [isFocused, focusProps] = useFocus();
|
||||
|
||||
useEffect(() => {
|
||||
function onSelectionChange() {
|
||||
setSelectionRef(selectionRef);
|
||||
setSelectionContext(composerContext);
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
|
@ -53,15 +46,11 @@ export function useSelection() {
|
|||
}
|
||||
|
||||
return () => document.removeEventListener("selectionchange", onSelectionChange);
|
||||
}, [isFocused]);
|
||||
}, [isFocused, composerContext]);
|
||||
|
||||
const onInput = useCallback(() => {
|
||||
setSelectionRef(selectionRef);
|
||||
}, []);
|
||||
setSelectionContext(composerContext);
|
||||
}, [composerContext]);
|
||||
|
||||
const selectPreviousSelection = useCallback(() => {
|
||||
setSelection(selectionRef.current);
|
||||
}, []);
|
||||
|
||||
return { ...focusProps, selectPreviousSelection, onInput };
|
||||
return { ...focusProps, onInput };
|
||||
}
|
||||
|
|
|
@ -18,3 +18,5 @@ export type ComposerFunctions = {
|
|||
clear: () => void;
|
||||
insertText: (text: string) => void;
|
||||
};
|
||||
|
||||
export type SubSelection = Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">;
|
||||
|
|
|
@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export function setSelection(selection: Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">) {
|
||||
import { SubSelection } from "../types";
|
||||
|
||||
export function setSelection(selection: SubSelection) {
|
||||
if (selection.anchorNode && selection.focusNode) {
|
||||
const range = new Range();
|
||||
range.setStart(selection.anchorNode, selection.anchorOffset);
|
||||
|
@ -23,4 +25,12 @@ export function setSelection(selection: Pick<Selection, "anchorNode" | "anchorOf
|
|||
document.getSelection()?.removeAllRanges();
|
||||
document.getSelection()?.addRange(range);
|
||||
}
|
||||
|
||||
// Waiting for the next loop to ensure that the selection is effective
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
export function isSelectionEmpty() {
|
||||
const selection = document.getSelection();
|
||||
return Boolean(selection?.isCollapsed);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue