Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Add link creation to rich text editor (#9775)
Browse files Browse the repository at this point in the history
Add link creation to RTE
  • Loading branch information
florianduros committed Dec 23, 2022
1 parent 88c3864 commit fe0273b
Show file tree
Hide file tree
Showing 23 changed files with 418 additions and 97 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.3.0",
"@matrix-org/matrix-wysiwyg": "^0.9.0",
"@matrix-org/matrix-wysiwyg": "^0.11.0",
"@matrix-org/react-sdk-module-api": "^0.0.3",
"@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0",
Expand Down
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@
@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss";
@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss";
@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
@import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss";
@import "./views/settings/_AvatarSetting.pcss";
@import "./views/settings/_CrossSigningPanel.pcss";
@import "./views/settings/_CryptographyPanel.pcss";
Expand Down
29 changes: 29 additions & 0 deletions res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
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.
*/

.mx_LinkModal {
padding: $spacing-32;

.mx_Dialog_content {
margin-top: 30px;
margin-bottom: 42px;
}

.mx_LinkModal_content {
display: flex;
flex-direction: column;
}
}
3 changes: 3 additions & 0 deletions res/img/element-icons/room/composer/link.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions src/components/views/rooms/wysiwyg_composer/ComposerContext.ts
Original file line number Diff line number Diff line change
@@ -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);
}
42 changes: 23 additions & 19 deletions src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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;

Expand All @@ -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>
);
}
28 changes: 15 additions & 13 deletions src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface EditorProps {
disabled: boolean;
placeholder?: string;
leftComponent?: ReactNode;
rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
rightComponent?: ReactNode;
}

export const Editor = memo(
Expand All @@ -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
Expand Down Expand Up @@ -63,7 +63,7 @@ export const Editor = memo(
onInput={onInput}
/>
</div>
{rightComponent?.(selectPreviousSelection)}
{rightComponent}
</div>
);
}),
Expand Down
19 changes: 11 additions & 8 deletions src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +79,8 @@ interface FormattingButtonsProps {
}

export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps) {
const composerContext = useComposerContext();

return (
<div className="mx_FormattingButtons">
<Button
Expand Down Expand Up @@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
}
/>
);
}
Loading

0 comments on commit fe0273b

Please sign in to comment.