Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add link creation to rich text editor #9775

Merged
merged 24 commits into from
Dec 23, 2022
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7b3dcee
Add link buttons
florianduros Dec 12, 2022
996e422
Merge remote-tracking branch 'origin/develop' into feat/add-link-rich…
florianduros Dec 13, 2022
1012488
Add link modal
florianduros Dec 13, 2022
761bab2
Handle link click
florianduros Dec 13, 2022
bed5c76
Add insert link to rich text editor
florianduros Dec 14, 2022
8880d79
Upgrade matrix-wysiwyg version
florianduros Dec 15, 2022
211e307
Fix plain text composer test warning
florianduros Dec 15, 2022
1f2366a
Add missing act
florianduros Dec 15, 2022
8891658
Add test to link creation
florianduros Dec 15, 2022
9b9b91d
Update i18n
florianduros Dec 15, 2022
2cd4856
fix ts errors
florianduros Dec 15, 2022
2831d0f
Merge remote-tracking branch 'origin/develop' into feat/add-link-rich…
florianduros Dec 16, 2022
5061d7e
Add styling to LinkModal.tsx
florianduros Dec 16, 2022
a46122d
Fix ComposerContext unwanted value sharing
florianduros Dec 16, 2022
14de65b
Merge remote-tracking branch 'origin/develop' into feat/add-link-rich…
florianduros Dec 19, 2022
ad532cb
Review changes
florianduros Dec 20, 2022
38fb044
Remove custom style for Save button
florianduros Dec 20, 2022
421bc29
Add auto-focus for text field in LinkModal.tsx
florianduros Dec 20, 2022
c4f1935
Merge remote-tracking branch 'origin/develop' into feat/add-link-rich…
florianduros Dec 20, 2022
51db9e1
Merge branch 'develop' into feat/add-link-rich-text-editor
florianduros Dec 21, 2022
7875d49
Update i18n
florianduros Dec 21, 2022
1aeec8a
Merge remote-tracking branch 'origin/develop' into feat/add-link-rich…
florianduros Dec 22, 2022
a288fe1
Merge branch 'develop' into feat/add-link-rich-text-editor
florianduros Dec 23, 2022
a0f058f
Merge branch 'develop' into feat/add-link-rich-text-editor
florianduros Dec 23, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -307,6 +307,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);
}
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>
);
}
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
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