Replace legacy Tooltips with Compound tooltips (#28231)
* Ditch legacy Tooltips in favour of Compound Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove dead code Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Extract markdown CodeBlock into React component Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Upgrade compound Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
fad457362d
commit
26430a3a6a
29 changed files with 410 additions and 670 deletions
|
@ -79,8 +79,6 @@
|
|||
"@types/seedrandom": "3.0.8",
|
||||
"oidc-client-ts": "3.1.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"@vector-im/compound-design-tokens": "1.8.0",
|
||||
"@vector-im/compound-web": "7.0.0",
|
||||
"@floating-ui/react": "0.26.11",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"caniuse-lite": "1.0.30001668",
|
||||
|
@ -98,7 +96,7 @@
|
|||
"@sentry/browser": "^8.0.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@vector-im/compound-design-tokens": "^1.8.0",
|
||||
"@vector-im/compound-web": "^7.0.0",
|
||||
"@vector-im/compound-web": "^7.1.0",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||
|
|
|
@ -263,7 +263,6 @@ test.describe("Editing", () => {
|
|||
checkA11y,
|
||||
}) => {
|
||||
axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here
|
||||
axe.exclude(".mx_Tooltip_visible"); // XXX: this is fine but would be good to fix
|
||||
|
||||
await page.goto(`#/room/${room.roomId}`);
|
||||
|
||||
|
|
|
@ -96,7 +96,7 @@ test.describe("Registration", () => {
|
|||
});
|
||||
});
|
||||
await page.getByRole("textbox", { name: "Username", exact: true }).fill("_alice");
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Some characters not allowed" })).toBeVisible();
|
||||
await expect(page.getByRole("tooltip").filter({ hasText: "Some characters not allowed" })).toBeVisible();
|
||||
|
||||
await page.route("**/_matrix/client/*/register/available?username=bob", async (route) => {
|
||||
await route.fulfill({
|
||||
|
@ -108,9 +108,9 @@ test.describe("Registration", () => {
|
|||
});
|
||||
});
|
||||
await page.getByRole("textbox", { name: "Username", exact: true }).fill("bob");
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Someone already has that username" })).toBeVisible();
|
||||
await expect(page.getByRole("tooltip").filter({ hasText: "Someone already has that username" })).toBeVisible();
|
||||
|
||||
await page.getByRole("textbox", { name: "Username", exact: true }).fill("foobar");
|
||||
await expect(page.getByRole("alert")).not.toBeVisible();
|
||||
await expect(page.getByRole("tooltip")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -345,8 +345,7 @@ export const expect = baseExpect.extend({
|
|||
|
||||
if (!options?.showTooltips) {
|
||||
css += `
|
||||
[role="tooltip"],
|
||||
.mx_Tooltip_visible {
|
||||
[role="tooltip"] {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -217,7 +217,6 @@
|
|||
@import "./views/elements/_TagComposer.pcss";
|
||||
@import "./views/elements/_TextWithTooltip.pcss";
|
||||
@import "./views/elements/_ToggleSwitch.pcss";
|
||||
@import "./views/elements/_Tooltip.pcss";
|
||||
@import "./views/elements/_UseCaseSelection.pcss";
|
||||
@import "./views/elements/_UseCaseSelectionButton.pcss";
|
||||
@import "./views/elements/_Validation.pcss";
|
||||
|
|
|
@ -16,7 +16,8 @@ progress.mx_PassphraseField_progress {
|
|||
border: 0;
|
||||
height: 4px;
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
top: -10px;
|
||||
left: 0;
|
||||
|
||||
@mixin ProgressBarBorderRadius "2px";
|
||||
@mixin ProgressBarColour $PassphraseStrengthLow;
|
||||
|
|
|
@ -164,14 +164,6 @@ Please see LICENSE files in the repository root for full details.
|
|||
}
|
||||
}
|
||||
|
||||
.mx_Field_tooltip {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mx_Field_tooltip.mx_Field_valid {
|
||||
animation: mx_fadeout 1s 2s forwards;
|
||||
}
|
||||
|
||||
/* Customise other components when placed inside a Field */
|
||||
|
||||
.mx_Field .mx_Dropdown_input {
|
||||
|
|
|
@ -10,18 +10,6 @@ Please see LICENSE files in the repository root for full details.
|
|||
position: relative;
|
||||
width: min-content;
|
||||
|
||||
/* this isn't a floating tooltip so override some things to not need to bother with z-index and floating */
|
||||
.mx_Tooltip {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
z-index: unset;
|
||||
width: max-content;
|
||||
left: 72px;
|
||||
/* top edge starting at 50 % of parent - 50 % of itself -> centered vertically */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.mx_MiniAvatarUploader_indicator {
|
||||
position: absolute;
|
||||
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
/*
|
||||
Copyright 2019-2024 New Vector Ltd.
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@keyframes mx_fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mx_fadeout {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_Tooltip_chevron {
|
||||
position: absolute;
|
||||
left: -7px;
|
||||
top: calc(50% - 6px);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 7px solid transparent;
|
||||
border-right: 7px solid $menu-border-color;
|
||||
border-bottom: 7px solid transparent;
|
||||
}
|
||||
|
||||
.mx_Tooltip_chevron::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-right: 6px solid $menu-bg-color;
|
||||
border-bottom: 6px solid transparent;
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.mx_Tooltip {
|
||||
display: none;
|
||||
position: fixed;
|
||||
border-radius: 8px;
|
||||
z-index: 6000; /* Higher than context menu so tooltips can be used everywhere */
|
||||
padding: 10px;
|
||||
pointer-events: none;
|
||||
line-height: $font-14px;
|
||||
font-size: $font-12px;
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
word-break: break-word;
|
||||
|
||||
background-color: var(--cpd-color-alpha-gray-1400);
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
border: 0;
|
||||
text-align: center;
|
||||
|
||||
.mx_Tooltip_chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.mx_Tooltip_visible {
|
||||
animation: mx_fadein 0.2s forwards;
|
||||
}
|
||||
|
||||
&.mx_Tooltip_invisible {
|
||||
animation: mx_fadeout 0.1s forwards;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
text-align: start; /* for list items */
|
||||
}
|
||||
}
|
||||
|
||||
/* These tooltips use an older style with a chevron */
|
||||
.mx_Field_tooltip {
|
||||
background-color: $menu-bg-color;
|
||||
color: $primary-content;
|
||||
border: 1px solid $menu-border-color;
|
||||
text-align: unset;
|
||||
|
||||
.mx_Tooltip_chevron {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_Tooltip_title {
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
}
|
||||
|
||||
.mx_Tooltip_sub {
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
}
|
|
@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
|||
|
||||
.mx_Validation {
|
||||
position: relative;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.mx_Validation_details {
|
||||
|
|
|
@ -12,7 +12,6 @@ import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } fro
|
|||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { Writeable } from "../../@types/common";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
@ -611,35 +610,6 @@ export const useContextMenu = <T extends any = HTMLElement>(inputRef?: RefObject
|
|||
return [button.current ? isOpen : false, button, open, close, setIsOpen];
|
||||
};
|
||||
|
||||
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
|
||||
export function createMenu(
|
||||
ElementClass: typeof React.Component,
|
||||
props: Record<string, any>,
|
||||
): { close: (...args: any[]) => void } {
|
||||
const onFinished = function (...args: any[]): void {
|
||||
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
|
||||
props?.onFinished?.apply(null, args);
|
||||
};
|
||||
|
||||
const menu = (
|
||||
<TooltipProvider>
|
||||
<ContextMenu
|
||||
{...props}
|
||||
mountAsChild={true}
|
||||
hasBackground={false}
|
||||
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
|
||||
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
|
||||
>
|
||||
<ElementClass {...props} onFinished={onFinished} />
|
||||
</ContextMenu>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
ReactDOM.render(menu, getOrCreateContainer());
|
||||
|
||||
return { close: onFinished };
|
||||
}
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
||||
export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
|
|
|
@ -11,7 +11,7 @@ import { useContext, useState } from "react";
|
|||
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
import { _tDom } from "../../languageHandler";
|
||||
import { _t, _tDom } from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
@ -66,8 +66,8 @@ const UserWelcomeTop: React.FC = () => {
|
|||
<div>
|
||||
<MiniAvatarUploader
|
||||
hasAvatar={!!ownProfile.avatarUrl}
|
||||
hasAvatarLabel={_tDom("onboarding|has_avatar_label")}
|
||||
noAvatarLabel={_tDom("onboarding|no_avatar_label")}
|
||||
hasAvatarLabel={_t("onboarding|has_avatar_label")}
|
||||
noAvatarLabel={_t("onboarding|no_avatar_label")}
|
||||
setAvatarUrl={(url) => cli.setAvatarUrl(url)}
|
||||
isUserAvatar
|
||||
onClick={(ev) => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)}
|
||||
|
|
|
@ -6,13 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { PureComponent, RefCallback, RefObject } from "react";
|
||||
import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react";
|
||||
|
||||
import Field, { IInputProps } from "../elements/Field";
|
||||
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
||||
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
||||
import * as Email from "../../../email";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
|
||||
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
||||
id?: string;
|
||||
|
@ -23,7 +22,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
|||
label: TranslationKey;
|
||||
labelRequired: TranslationKey;
|
||||
labelInvalid: TranslationKey;
|
||||
tooltipAlignment?: Alignment;
|
||||
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
|
||||
|
||||
// When present, completely overrides the default validation rules.
|
||||
validationRules?: (fieldState: IFieldState) => Promise<IValidationResult>;
|
||||
|
|
|
@ -6,12 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { PureComponent, RefCallback, RefObject } from "react";
|
||||
import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react";
|
||||
|
||||
import Field, { IInputProps } from "../elements/Field";
|
||||
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
||||
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
|
||||
interface IProps extends Omit<IInputProps, "onValidate" | "label" | "element"> {
|
||||
id?: string;
|
||||
|
@ -23,7 +22,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "label" | "element"> {
|
|||
label: TranslationKey;
|
||||
labelRequired: TranslationKey;
|
||||
labelInvalid: TranslationKey;
|
||||
tooltipAlignment?: Alignment;
|
||||
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
|
||||
onChange(ev: React.FormEvent<HTMLElement>): void;
|
||||
onValidate?(result: IValidationResult): void;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { PureComponent, RefCallback, RefObject } from "react";
|
||||
import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import type { ZxcvbnResult } from "@zxcvbn-ts/core";
|
||||
|
@ -15,7 +15,6 @@ import withValidation, { IFieldState, IValidationResult } from "../elements/Vali
|
|||
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
||||
import Field, { IInputProps } from "../elements/Field";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
|
||||
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
||||
autoFocus?: boolean;
|
||||
|
@ -31,7 +30,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
|||
labelEnterPassword: TranslationKey;
|
||||
labelStrongPassword: TranslationKey;
|
||||
labelAllowedButUnsafe: TranslationKey;
|
||||
tooltipAlignment?: Alignment;
|
||||
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
|
||||
|
||||
onChange(ev: React.FormEvent<HTMLElement>): void;
|
||||
onValidate?(result: IValidationResult): void;
|
||||
|
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { BaseSyntheticEvent, ReactNode } from "react";
|
||||
import React, { BaseSyntheticEvent, ComponentProps, ReactNode } from "react";
|
||||
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
|
@ -26,7 +26,6 @@ import RegistrationEmailPromptDialog from "../dialogs/RegistrationEmailPromptDia
|
|||
import CountryDropdown from "./CountryDropdown";
|
||||
import PassphraseConfirmField from "./PassphraseConfirmField";
|
||||
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
|
||||
enum RegistrationField {
|
||||
Email = "field_email",
|
||||
|
@ -441,9 +440,9 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
|
|||
return true;
|
||||
}
|
||||
|
||||
private tooltipAlignment(): Alignment | undefined {
|
||||
private tooltipAlignment(): ComponentProps<typeof EmailField>["tooltipAlignment"] | undefined {
|
||||
if (this.props.mobileRegister) {
|
||||
return Alignment.Bottom;
|
||||
return "bottom";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface IProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default class GenericTextContextMenu extends React.Component<IProps> {
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_Tooltip mx_Tooltip_visible" style={{ display: "block" }}>
|
||||
{this.props.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -21,7 +21,7 @@ interface IProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true, className, ...props }) => {
|
||||
export const CopyTextButton: React.FC<Pick<IProps, "getTextToCopy" | "className">> = ({ getTextToCopy, className }) => {
|
||||
const [tooltip, setTooltip] = useState<string | undefined>(undefined);
|
||||
|
||||
const onCopyClickInternal = async (e: ButtonEvent): Promise<void> => {
|
||||
|
@ -37,6 +37,19 @@ const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
title={tooltip ?? _t("action|copy")}
|
||||
onClick={onCopyClickInternal}
|
||||
className={className}
|
||||
onTooltipOpenChange={(open) => {
|
||||
if (!open) onHideTooltip();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true, className, ...props }) => {
|
||||
const combinedClassName = classNames("mx_CopyableText", className, {
|
||||
mx_CopyableText_border: border,
|
||||
});
|
||||
|
@ -44,14 +57,7 @@ const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true
|
|||
return (
|
||||
<div className={combinedClassName} {...props}>
|
||||
{children}
|
||||
<AccessibleButton
|
||||
title={tooltip ?? _t("action|copy")}
|
||||
onClick={onCopyClickInternal}
|
||||
className="mx_CopyableText_copyButton"
|
||||
onTooltipOpenChange={(open) => {
|
||||
if (!open) onHideTooltip();
|
||||
}}
|
||||
/>
|
||||
<CopyTextButton getTextToCopy={getTextToCopy} className="mx_CopyableText_copyButton" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -11,14 +11,13 @@ import React, {
|
|||
TextareaHTMLAttributes,
|
||||
RefObject,
|
||||
createRef,
|
||||
KeyboardEvent,
|
||||
ComponentProps,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { debounce } from "lodash";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import { IFieldState, IValidationResult } from "./Validation";
|
||||
import Tooltip, { Alignment } from "./Tooltip";
|
||||
import { Key } from "../../../Keyboard";
|
||||
|
||||
// Invoke validation from user input (when typing, etc.) at most once every N ms.
|
||||
const VALIDATION_THROTTLE_MS = 200;
|
||||
|
@ -57,11 +56,11 @@ interface IProps {
|
|||
forceValidity?: boolean;
|
||||
// If specified, contents will appear as a tooltip on the element and
|
||||
// validation feedback tooltips will be suppressed.
|
||||
tooltipContent?: React.ReactNode;
|
||||
tooltipContent?: JSX.Element | string;
|
||||
// If specified the tooltip will be shown regardless of feedback
|
||||
forceTooltipVisible?: boolean;
|
||||
// If specified, the tooltip with be aligned accorindly with the field, defaults to Right.
|
||||
tooltipAlignment?: Alignment;
|
||||
tooltipAlignment?: ComponentProps<typeof Tooltip>["placement"];
|
||||
// If specified alongside tooltipContent, the class name to apply to the
|
||||
// tooltip itself.
|
||||
tooltipClassName?: string;
|
||||
|
@ -112,7 +111,7 @@ type PropShapes = IInputProps | ISelectProps | ITextareaProps | INativeOnChangeI
|
|||
|
||||
interface IState {
|
||||
valid?: boolean;
|
||||
feedback?: React.ReactNode;
|
||||
feedback?: JSX.Element | string;
|
||||
feedbackVisible: boolean;
|
||||
focused: boolean;
|
||||
}
|
||||
|
@ -127,6 +126,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
validateOnFocus: true,
|
||||
validateOnBlur: true,
|
||||
validateOnChange: true,
|
||||
tooltipAlignment: "right",
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -233,16 +233,10 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
return this.props.inputRef ?? this._inputRef;
|
||||
}
|
||||
|
||||
private onKeyDown = (evt: KeyboardEvent<HTMLDivElement>): void => {
|
||||
// If the tooltip is displayed to show a feedback and Escape is pressed
|
||||
// The tooltip is hided
|
||||
if (this.state.feedbackVisible && evt.key === Key.ESCAPE) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.setState({
|
||||
feedbackVisible: false,
|
||||
});
|
||||
}
|
||||
private onTooltipOpenChange = (open: boolean): void => {
|
||||
this.setState({
|
||||
feedbackVisible: open,
|
||||
});
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
|
@ -268,31 +262,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
} = this.props;
|
||||
|
||||
// Handle displaying feedback on validity
|
||||
let fieldTooltip: JSX.Element | undefined;
|
||||
const tooltipProps: Pick<React.ComponentProps<typeof Tooltip>, "aria-live" | "aria-atomic"> = {};
|
||||
let tooltipOpen = false;
|
||||
if (tooltipContent || this.state.feedback) {
|
||||
const tooltipId = `${this.id}_tooltip`;
|
||||
const visible = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible;
|
||||
if (visible) {
|
||||
inputProps["aria-describedby"] = tooltipId;
|
||||
}
|
||||
tooltipOpen = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible;
|
||||
|
||||
let role: React.AriaRole;
|
||||
if (tooltipContent) {
|
||||
role = "tooltip";
|
||||
} else {
|
||||
role = this.state.valid ? "status" : "alert";
|
||||
if (!tooltipContent) {
|
||||
tooltipProps["aria-atomic"] = "true";
|
||||
tooltipProps["aria-live"] = this.state.valid ? "polite" : "assertive";
|
||||
}
|
||||
|
||||
fieldTooltip = (
|
||||
<Tooltip
|
||||
id={tooltipId}
|
||||
tooltipClassName={classNames("mx_Field_tooltip", "mx_Tooltip_noMargin", tooltipClassName)}
|
||||
visible={visible}
|
||||
label={tooltipContent || this.state.feedback}
|
||||
alignment={tooltipAlignment || Alignment.Right}
|
||||
role={role}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
|
||||
|
@ -332,12 +310,20 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className={fieldClasses} onKeyDown={this.onKeyDown}>
|
||||
<div className={fieldClasses}>
|
||||
{prefixContainer}
|
||||
{fieldInput}
|
||||
<Tooltip
|
||||
{...tooltipProps}
|
||||
placement={tooltipAlignment}
|
||||
description=""
|
||||
caption={tooltipContent || this.state.feedback}
|
||||
open={tooltipOpen}
|
||||
onOpenChange={this.onTooltipOpenChange}
|
||||
>
|
||||
{fieldInput}
|
||||
</Tooltip>
|
||||
<label htmlFor={this.id}>{this.props.label}</label>
|
||||
{postfixContainer}
|
||||
{fieldTooltip}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details.
|
|||
import classNames from "classnames";
|
||||
import { EventType } from "matrix-js-sdk/src/matrix";
|
||||
import React, { useContext, useRef, useState, MouseEvent, ReactNode } from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import { useTimeout } from "../../../hooks/useTimeout";
|
||||
import { TranslatedString } from "../../../languageHandler";
|
||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Spinner from "./Spinner";
|
||||
|
@ -22,8 +22,8 @@ export const AVATAR_SIZE = "52px";
|
|||
|
||||
interface IProps {
|
||||
hasAvatar: boolean;
|
||||
noAvatarLabel?: TranslatedString;
|
||||
hasAvatarLabel?: TranslatedString;
|
||||
noAvatarLabel?: string;
|
||||
hasAvatarLabel?: string;
|
||||
setAvatarUrl(url: string): Promise<unknown>;
|
||||
isUserAvatar?: boolean;
|
||||
onClick?(ev: MouseEvent<HTMLInputElement>): void;
|
||||
|
@ -82,34 +82,24 @@ const MiniAvatarUploader: React.FC<IProps> = ({
|
|||
accept="image/*"
|
||||
/>
|
||||
|
||||
<AccessibleButton
|
||||
className={classNames("mx_MiniAvatarUploader", {
|
||||
mx_MiniAvatarUploader_busy: busy,
|
||||
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
|
||||
})}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
uploadRef.current?.click();
|
||||
}}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{children}
|
||||
|
||||
<div className="mx_MiniAvatarUploader_indicator">
|
||||
{busy ? <Spinner w={20} h={20} /> : <div className="mx_MiniAvatarUploader_cameraIcon" />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames("mx_Tooltip", {
|
||||
mx_Tooltip_visible: visible,
|
||||
mx_Tooltip_invisible: !visible,
|
||||
<Tooltip label={label!} open={visible} onOpenChange={setHover}>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_MiniAvatarUploader", {
|
||||
mx_MiniAvatarUploader_busy: busy,
|
||||
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
|
||||
})}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
uploadRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<div className="mx_Tooltip_chevron" />
|
||||
{label}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
{children}
|
||||
|
||||
<div className="mx_MiniAvatarUploader_indicator">
|
||||
{busy ? <Spinner w={20} h={20} /> : <div className="mx_MiniAvatarUploader_cameraIcon" />}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,194 +0,0 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import { objectHasDiff } from "../../../utils/objects";
|
||||
|
||||
export enum Alignment {
|
||||
Natural, // Pick left or right
|
||||
Left,
|
||||
Right,
|
||||
Top, // Centered
|
||||
Bottom, // Centered
|
||||
InnerBottom, // Inside the target, at the bottom
|
||||
TopRight, // On top of the target, right aligned
|
||||
}
|
||||
|
||||
export interface ITooltipProps {
|
||||
// Class applied to the element used to position the tooltip
|
||||
className?: string;
|
||||
// Class applied to the tooltip itself
|
||||
tooltipClassName?: string;
|
||||
// Whether the tooltip is visible or hidden.
|
||||
// The hidden state allows animating the tooltip away via CSS.
|
||||
// Defaults to visible if unset.
|
||||
visible?: boolean;
|
||||
// the react element to put into the tooltip
|
||||
label: React.ReactNode;
|
||||
alignment?: Alignment; // defaults to Natural
|
||||
// id describing tooltip
|
||||
// used to associate tooltip with target for a11y
|
||||
id?: string;
|
||||
// If the parent is over this width, act as if it is only this wide
|
||||
maxParentWidth?: number;
|
||||
// aria-role passed to the tooltip
|
||||
role?: React.AriaRole;
|
||||
}
|
||||
|
||||
type State = Partial<Pick<CSSProperties, "display" | "right" | "top" | "transform" | "left">>;
|
||||
|
||||
/**
|
||||
* @deprecated Use [compound tooltip](https://element-hq.github.io/compound-web/?path=/docs/tooltip--docs) instead
|
||||
*/
|
||||
export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
|
||||
private static container: HTMLElement;
|
||||
private parent: Element | null = null;
|
||||
|
||||
// XXX: This is because some components (Field) are unable to `import` the Tooltip class,
|
||||
// so we expose the Alignment options off of us statically.
|
||||
public static readonly Alignment = Alignment;
|
||||
|
||||
public static readonly defaultProps = {
|
||||
visible: true,
|
||||
alignment: Alignment.Natural,
|
||||
};
|
||||
|
||||
public constructor(props: ITooltipProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
|
||||
// Create a wrapper for the tooltips and attach it to the body element
|
||||
if (!Tooltip.container) {
|
||||
Tooltip.container = document.createElement("div");
|
||||
Tooltip.container.className = "mx_Tooltip_wrapper";
|
||||
document.body.appendChild(Tooltip.container);
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
window.addEventListener("scroll", this.updatePosition, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
this.parent = (ReactDOM.findDOMNode(this)?.parentNode as Element) ?? null;
|
||||
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: ITooltipProps): void {
|
||||
if (objectHasDiff(prevProps, this.props)) {
|
||||
this.updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the wrapper element, as the tooltip has finished using it
|
||||
public componentWillUnmount(): void {
|
||||
window.removeEventListener("scroll", this.updatePosition, {
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Add the parent's position to the tooltips, so it's correctly
|
||||
// positioned, also taking into account any window zoom
|
||||
private updatePosition = (): void => {
|
||||
// When the tooltip is hidden, no need to thrash the DOM with `style` attribute updates (performance)
|
||||
if (!this.props.visible || !this.parent) return;
|
||||
|
||||
const parentBox = this.parent.getBoundingClientRect();
|
||||
const width = UIStore.instance.windowWidth;
|
||||
const spacing = 6;
|
||||
const parentWidth = this.props.maxParentWidth
|
||||
? Math.min(parentBox.width, this.props.maxParentWidth)
|
||||
: parentBox.width;
|
||||
const baseTop = parentBox.top + window.scrollY;
|
||||
const centerTop = parentBox.top + window.scrollY + parentBox.height / 2;
|
||||
const right = width - parentBox.left - window.scrollX;
|
||||
const left = parentBox.right + window.scrollX;
|
||||
const horizontalCenter = parentBox.left - window.scrollX + parentWidth / 2;
|
||||
|
||||
const style: State = {};
|
||||
switch (this.props.alignment) {
|
||||
case Alignment.Natural:
|
||||
if (parentBox.right > width / 2) {
|
||||
style.right = right + spacing;
|
||||
style.top = centerTop;
|
||||
style.transform = "translateY(-50%)";
|
||||
break;
|
||||
}
|
||||
// fall through to Right
|
||||
case Alignment.Right:
|
||||
style.left = left + spacing;
|
||||
style.top = centerTop;
|
||||
style.transform = "translateY(-50%)";
|
||||
break;
|
||||
case Alignment.Left:
|
||||
style.right = right + spacing;
|
||||
style.top = centerTop;
|
||||
style.transform = "translateY(-50%)";
|
||||
break;
|
||||
case Alignment.Top:
|
||||
style.top = baseTop - spacing;
|
||||
// Attempt to center the tooltip on the element while clamping
|
||||
// its horizontal translation to keep it on screen
|
||||
// eslint-disable-next-line max-len
|
||||
style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))), -100%)`;
|
||||
break;
|
||||
case Alignment.Bottom:
|
||||
style.top = baseTop + parentBox.height + spacing;
|
||||
// Attempt to center the tooltip on the element while clamping
|
||||
// its horizontal translation to keep it on screen
|
||||
// eslint-disable-next-line max-len
|
||||
style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`;
|
||||
break;
|
||||
case Alignment.InnerBottom:
|
||||
style.top = baseTop + parentBox.height - 50;
|
||||
// Attempt to center the tooltip on the element while clamping
|
||||
// its horizontal translation to keep it on screen
|
||||
// eslint-disable-next-line max-len
|
||||
style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`;
|
||||
break;
|
||||
case Alignment.TopRight:
|
||||
style.top = baseTop - spacing;
|
||||
style.right = width - parentBox.right - window.scrollX;
|
||||
style.transform = "translateY(-100%)";
|
||||
break;
|
||||
}
|
||||
|
||||
this.setState(style);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, {
|
||||
mx_Tooltip_visible: this.props.visible,
|
||||
mx_Tooltip_invisible: !this.props.visible,
|
||||
});
|
||||
|
||||
const style = { ...this.state };
|
||||
// Hide the entire container when not visible.
|
||||
// This prevents flashing of the tooltip if it is not meant to be visible on first mount.
|
||||
style.display = this.props.visible ? "block" : "none";
|
||||
|
||||
const tooltip = (
|
||||
<div id={this.props.id} role={this.props.role || "tooltip"} className={tooltipClasses} style={style}>
|
||||
<div className="mx_Tooltip_chevron" />
|
||||
{this.props.label}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div className={this.props.className}>{ReactDOM.createPortal(tooltip, Tooltip.container)}</div>;
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactChild, ReactNode } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import memoizeOne from "memoize-one";
|
||||
|
||||
|
@ -44,7 +44,7 @@ export interface IFieldState {
|
|||
|
||||
export interface IValidationResult {
|
||||
valid?: boolean;
|
||||
feedback?: React.ReactChild;
|
||||
feedback?: JSX.Element | string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -189,7 +189,7 @@ export default function withValidation<T = void, D = void>({
|
|||
summary = content ? <div className="mx_Validation_description">{content}</div> : undefined;
|
||||
}
|
||||
|
||||
let feedback: ReactChild | undefined;
|
||||
let feedback: JSX.Element | undefined;
|
||||
if (summary || details) {
|
||||
feedback = (
|
||||
<div className="mx_Validation">
|
||||
|
|
136
src/components/views/messages/CodeBlock.tsx
Normal file
136
src/components/views/messages/CodeBlock.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { useSettingValue } from "../../../hooks/useSettings.ts";
|
||||
import { CopyTextButton } from "../elements/CopyableText.tsx";
|
||||
|
||||
const MAX_HIGHLIGHT_LENGTH = 4096;
|
||||
const MAX_LINES_BEFORE_COLLAPSE = 5;
|
||||
|
||||
interface Props {
|
||||
children: HTMLElement;
|
||||
onHeightChanged?(): void;
|
||||
}
|
||||
|
||||
const ExpandCollapseButton: React.FC<{
|
||||
expanded: boolean;
|
||||
onClick(): void;
|
||||
}> = ({ expanded, onClick }) => {
|
||||
return (
|
||||
<span
|
||||
className={classNames("mx_EventTile_button", {
|
||||
mx_EventTile_expandButton: !expanded,
|
||||
mx_EventTile_collapseButton: expanded,
|
||||
})}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
|
||||
const enableSyntaxHighlightLanguageDetection = useSettingValue<boolean>("enableSyntaxHighlightLanguageDetection");
|
||||
const showCodeLineNumbers = useSettingValue<boolean>("showCodeLineNumbers");
|
||||
const expandCodeByDefault = useSettingValue<boolean>("expandCodeByDefault");
|
||||
const [expanded, setExpanded] = useState(expandCodeByDefault);
|
||||
|
||||
let expandCollapseButton: JSX.Element | undefined;
|
||||
if (children.textContent && children.textContent.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) {
|
||||
expandCollapseButton = (
|
||||
<ExpandCollapseButton
|
||||
expanded={expanded}
|
||||
onClick={() => {
|
||||
setExpanded(!expanded);
|
||||
// By expanding/collapsing we changed the height, therefore we call this
|
||||
onHeightChanged?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let lineNumbers: JSX.Element | undefined;
|
||||
if (showCodeLineNumbers) {
|
||||
// Calculate number of lines in pre
|
||||
const number = children.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
|
||||
// Iterate through lines starting with 1 (number of the first line is 1)
|
||||
lineNumbers = (
|
||||
<span className="mx_EventTile_lineNumbers">
|
||||
{Array.from({ length: number }, (_, i) => i + 1).map((i) => (
|
||||
<span key={i}>{i}</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function highlightCode(div: HTMLElement | null): Promise<void> {
|
||||
const code = div?.getElementsByTagName("code")[0];
|
||||
if (!code) return;
|
||||
const { default: highlight } = await import("highlight.js");
|
||||
|
||||
if (code.textContent && code.textContent.length > MAX_HIGHLIGHT_LENGTH) {
|
||||
console.log(
|
||||
`Code block is bigger than highlight limit (${code.textContent.length} > ${MAX_HIGHLIGHT_LENGTH}): not highlighting`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let advertisedLang: string | undefined;
|
||||
for (const cl of code.className.split(/\s+/)) {
|
||||
if (cl.startsWith("language-")) {
|
||||
const maybeLang = cl.split("-", 2)[1];
|
||||
if (highlight.getLanguage(maybeLang)) {
|
||||
advertisedLang = maybeLang;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (advertisedLang) {
|
||||
// If the code says what language it is, highlight it in that language
|
||||
// We don't use highlightElement here because we can't force language detection
|
||||
// off. It should use the one we've found in the CSS class but we'd rather pass
|
||||
// it in explicitly to make sure.
|
||||
code.innerHTML = highlight.highlight(code.textContent ?? "", { language: advertisedLang }).value;
|
||||
} else if (enableSyntaxHighlightLanguageDetection) {
|
||||
// User has language detection enabled, so highlight the block with auto-highlighting enabled.
|
||||
// We pass highlightjs the text to highlight rather than letting it
|
||||
// work on the DOM with highlightElement because that also adds CSS
|
||||
// classes to the pre/code element that we don't want (the CSS
|
||||
// conflicts with our own).
|
||||
code.innerHTML = highlight.highlightAuto(code.textContent ?? "").value;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<pre
|
||||
className={classNames({
|
||||
mx_EventTile_collapsedCodeBlock: !expanded,
|
||||
})}
|
||||
>
|
||||
{lineNumbers}
|
||||
<div
|
||||
style={{ display: "contents" }}
|
||||
dangerouslySetInnerHTML={{ __html: children.innerHTML }}
|
||||
ref={highlightCode}
|
||||
/>
|
||||
</pre>
|
||||
{expandCollapseButton}
|
||||
<CopyTextButton
|
||||
getTextToCopy={() => children.getElementsByTagName("code")[0]?.textContent ?? null}
|
||||
className={classNames("mx_EventTile_button mx_EventTile_copyButton", {
|
||||
mx_EventTile_buttonBottom: !!expandCollapseButton,
|
||||
})}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeBlock;
|
|
@ -16,17 +16,12 @@ import { formatDate } from "../../../DateUtils";
|
|||
import Modal from "../../../Modal";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import * as ContextMenu from "../../structures/ContextMenu";
|
||||
import { ChevronFace, toRightOf } from "../../structures/ContextMenu";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { pillifyLinks, unmountPills } from "../../../utils/pillify";
|
||||
import { tooltipifyLinks, unmountTooltips } from "../../../utils/tooltipify";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../../utils/strings";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
|
||||
import Spoiler from "../elements/Spoiler";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
||||
|
@ -40,8 +35,7 @@ import { getParentEventId } from "../../../utils/Reply";
|
|||
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||
import { IEventTileOps } from "../rooms/EventTile";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
const MAX_HIGHLIGHT_LENGTH = 4096;
|
||||
import CodeBlock from "./CodeBlock";
|
||||
|
||||
interface IState {
|
||||
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
|
||||
|
@ -54,9 +48,9 @@ interface IState {
|
|||
export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
private readonly contentRef = createRef<HTMLDivElement>();
|
||||
|
||||
private unmounted = false;
|
||||
private pills: Element[] = [];
|
||||
private tooltips: Element[] = [];
|
||||
private reactRoots: Element[] = [];
|
||||
|
||||
public static contextType = RoomContext;
|
||||
public declare context: React.ContextType<typeof RoomContext>;
|
||||
|
@ -76,7 +70,6 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
|||
// Function is only called from render / componentDidMount → contentRef is set
|
||||
const content = this.contentRef.current!;
|
||||
|
||||
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
|
||||
this.activateSpoilers([content]);
|
||||
|
||||
HtmlUtils.linkifyElement(content);
|
||||
|
@ -103,27 +96,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
|||
}
|
||||
// Wrap a div around <pre> so that the copy button can be correctly positioned
|
||||
// when the <pre> overflows and is scrolled horizontally.
|
||||
const div = this.wrapInDiv(pres[i]);
|
||||
this.handleCodeBlockExpansion(pres[i]);
|
||||
this.addCodeExpansionButton(div, pres[i]);
|
||||
this.addCodeCopyButton(div);
|
||||
if (showLineNumbers) {
|
||||
this.addLineNumbers(pres[i]);
|
||||
}
|
||||
this.wrapPreInReact(pres[i]);
|
||||
}
|
||||
}
|
||||
// Highlight code
|
||||
const codes = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("code");
|
||||
if (codes.length > 0) {
|
||||
// Do this asynchronously: parsing code takes time and we don't
|
||||
// need to block the DOM update on it.
|
||||
window.setTimeout(() => {
|
||||
if (this.unmounted) return;
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
this.highlightCode(codes[i]);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -133,141 +108,15 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
|||
pre.appendChild(code);
|
||||
}
|
||||
|
||||
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
|
||||
// Calculate how many percent does the pre element take up.
|
||||
// If it's less than 30% we don't add the expansion button.
|
||||
// We also round the number as it sometimes can be 29.99...
|
||||
const percentageOfViewport = Math.round((pre.offsetHeight / UIStore.instance.windowHeight) * 100);
|
||||
// TODO: additionally show the button if it's an expanded quoted message
|
||||
if (percentageOfViewport < 30) return;
|
||||
|
||||
const button = document.createElement("span");
|
||||
button.className = "mx_EventTile_button ";
|
||||
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
|
||||
button.className += "mx_EventTile_expandButton";
|
||||
} else {
|
||||
button.className += "mx_EventTile_collapseButton";
|
||||
}
|
||||
|
||||
button.onclick = async (): Promise<void> => {
|
||||
button.className = "mx_EventTile_button ";
|
||||
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
|
||||
pre.className = "";
|
||||
button.className += "mx_EventTile_collapseButton";
|
||||
} else {
|
||||
pre.className = "mx_EventTile_collapsedCodeBlock";
|
||||
button.className += "mx_EventTile_expandButton";
|
||||
}
|
||||
|
||||
// By expanding/collapsing we changed
|
||||
// the height, therefore we call this
|
||||
this.props.onHeightChanged?.();
|
||||
};
|
||||
|
||||
div.appendChild(button);
|
||||
}
|
||||
|
||||
private addCodeCopyButton(div: HTMLDivElement): void {
|
||||
const button = document.createElement("span");
|
||||
button.className = "mx_EventTile_button mx_EventTile_copyButton ";
|
||||
|
||||
// Check if expansion button exists. If so we put the copy button to the bottom
|
||||
const expansionButtonExists = div.getElementsByClassName("mx_EventTile_button");
|
||||
if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
|
||||
|
||||
button.onclick = async (): Promise<void> => {
|
||||
const copyCode = button.parentElement?.getElementsByTagName("code")[0];
|
||||
const successful = copyCode?.textContent ? await copyPlaintext(copyCode.textContent) : false;
|
||||
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
|
||||
...toRightOf(buttonRect, 0),
|
||||
chevronFace: ChevronFace.None,
|
||||
message: successful ? _t("common|copied") : _t("error|failed_copy"),
|
||||
});
|
||||
button.onmouseleave = close;
|
||||
};
|
||||
|
||||
div.appendChild(button);
|
||||
}
|
||||
|
||||
private wrapInDiv(pre: HTMLPreElement): HTMLDivElement {
|
||||
const div = document.createElement("div");
|
||||
div.className = "mx_EventTile_pre_container";
|
||||
private wrapPreInReact(pre: HTMLPreElement): void {
|
||||
const root = document.createElement("div");
|
||||
root.className = "mx_EventTile_pre_container";
|
||||
this.reactRoots.push(root);
|
||||
|
||||
// Insert containing div in place of <pre> block
|
||||
pre.parentNode?.replaceChild(div, pre);
|
||||
// Append <pre> block and copy button to container
|
||||
div.appendChild(pre);
|
||||
pre.parentNode?.replaceChild(root, pre);
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
private handleCodeBlockExpansion(pre: HTMLPreElement): void {
|
||||
if (!SettingsStore.getValue("expandCodeByDefault")) {
|
||||
pre.className = "mx_EventTile_collapsedCodeBlock";
|
||||
}
|
||||
}
|
||||
|
||||
private addLineNumbers(pre: HTMLPreElement): void {
|
||||
// Calculate number of lines in pre
|
||||
const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
|
||||
const lineNumbers = document.createElement("span");
|
||||
lineNumbers.className = "mx_EventTile_lineNumbers";
|
||||
// Iterate through lines starting with 1 (number of the first line is 1)
|
||||
for (let i = 1; i <= number; i++) {
|
||||
const s = document.createElement("span");
|
||||
s.textContent = i.toString();
|
||||
lineNumbers.appendChild(s);
|
||||
}
|
||||
pre.prepend(lineNumbers);
|
||||
pre.append(document.createElement("span"));
|
||||
}
|
||||
|
||||
private async highlightCode(code: HTMLElement): Promise<void> {
|
||||
const { default: highlight } = await import("highlight.js");
|
||||
|
||||
if (code.textContent && code.textContent.length > MAX_HIGHLIGHT_LENGTH) {
|
||||
console.log(
|
||||
"Code block is bigger than highlight limit (" +
|
||||
code.textContent.length +
|
||||
" > " +
|
||||
MAX_HIGHLIGHT_LENGTH +
|
||||
"): not highlighting",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let advertisedLang;
|
||||
for (const cl of code.className.split(/\s+/)) {
|
||||
if (cl.startsWith("language-")) {
|
||||
const maybeLang = cl.split("-", 2)[1];
|
||||
if (highlight.getLanguage(maybeLang)) {
|
||||
advertisedLang = maybeLang;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (advertisedLang) {
|
||||
// If the code says what language it is, highlight it in that language
|
||||
// We don't use highlightElement here because we can't force language detection
|
||||
// off. It should use the one we've found in the CSS class but we'd rather pass
|
||||
// it in explicitly to make sure.
|
||||
code.innerHTML = highlight.highlight(code.textContent ?? "", { language: advertisedLang }).value;
|
||||
} else if (
|
||||
SettingsStore.getValue("enableSyntaxHighlightLanguageDetection") &&
|
||||
code.parentElement instanceof HTMLPreElement
|
||||
) {
|
||||
// User has language detection enabled and the code is within a pre
|
||||
// we only auto-highlight if the code block is in a pre), so highlight
|
||||
// the block with auto-highlighting enabled.
|
||||
// We pass highlightjs the text to highlight rather than letting it
|
||||
// work on the DOM with highlightElement because that also adds CSS
|
||||
// classes to the pre/code element that we don't want (the CSS
|
||||
// conflicts with our own).
|
||||
code.innerHTML = highlight.highlightAuto(code.textContent ?? "").value;
|
||||
}
|
||||
ReactDOM.render(<CodeBlock onHeightChanged={this.props.onHeightChanged}>{pre}</CodeBlock>, root);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
|
||||
|
@ -281,12 +130,16 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
|||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
unmountPills(this.pills);
|
||||
unmountTooltips(this.tooltips);
|
||||
|
||||
for (const root of this.reactRoots) {
|
||||
ReactDOM.unmountComponentAtNode(root);
|
||||
}
|
||||
|
||||
this.pills = [];
|
||||
this.tooltips = [];
|
||||
this.reactRoots = [];
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
|
||||
|
|
|
@ -120,7 +120,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
|
|||
this.setState({ idServer: u });
|
||||
};
|
||||
|
||||
private getTooltip = (): ReactNode => {
|
||||
private getTooltip = (): JSX.Element | undefined => {
|
||||
if (this.state.checking) {
|
||||
return (
|
||||
<div>
|
||||
|
@ -131,7 +131,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
|
|||
} else if (this.state.error) {
|
||||
return <strong className="warning">{this.state.error}</strong>;
|
||||
} else {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -57,12 +57,12 @@ describe("Field", () => {
|
|||
// When invalid
|
||||
fireEvent.focus(screen.getByRole("textbox"));
|
||||
|
||||
// Expect 'alert' role
|
||||
await expect(screen.findByRole("alert")).resolves.toBeInTheDocument();
|
||||
// Expect 'aria-live=assertive'
|
||||
await expect(screen.findByRole("tooltip")).resolves.toHaveAttribute("aria-live", "assertive");
|
||||
|
||||
// Close the feedback is Escape is pressed
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should mark the feedback as status if valid", async () => {
|
||||
|
@ -77,12 +77,12 @@ describe("Field", () => {
|
|||
// When valid
|
||||
fireEvent.focus(screen.getByRole("textbox"));
|
||||
|
||||
// Expect 'status' role
|
||||
await expect(screen.findByRole("status")).resolves.toBeInTheDocument();
|
||||
// Expect 'aria-live=polite' role
|
||||
await expect(screen.findByRole("tooltip")).resolves.toHaveAttribute("aria-live", "polite");
|
||||
|
||||
// Close the feedback is Escape is pressed
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should mark the feedback as tooltip if custom tooltip set", async () => {
|
||||
|
|
|
@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
|||
import React from "react";
|
||||
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked, MockedObject } from "jest-mock";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { render, waitFor } from "jest-matrix-react";
|
||||
|
||||
import { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom } from "../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
|
@ -279,6 +279,17 @@ describe("<TextualBody />", () => {
|
|||
expect(content).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should syntax highlight code blocks", async () => {
|
||||
const ev = mkFormattedMessage(
|
||||
"```py\n# Python Program to calculate the square root\n\n# Note: change this value for a different result\nnum = 8 \n\n# To take the input from the user\n#num = float(input('Enter a number: '))\n\nnum_sqrt = num ** 0.5\nprint('The square root of %0.3f is %0.3f'%(num ,num_sqrt))",
|
||||
"<pre><code class=\"language-py\"># Python Program to calculate the square root\n\n# Note: change this value for a different result\nnum = 8 \n\n# To take the input from the user\n#num = float(input('Enter a number: '))\n\nnum_sqrt = num ** 0.5\nprint('The square root of %0.3f is %0.3f'%(num ,num_sqrt))\n</code></pre>\n",
|
||||
);
|
||||
const { container } = getComponent({ mxEvent: ev }, matrixClient);
|
||||
await waitFor(() => expect(container.querySelector(".hljs-built_in")).toBeInTheDocument());
|
||||
const content = container.querySelector(".mx_EventTile_body");
|
||||
expect(content).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// If pills were rendered within a Portal/same shadow DOM then it'd be easier to test
|
||||
it("pills get injected correctly into the DOM", () => {
|
||||
const ev = mkFormattedMessage("Hey User", 'Hey <a href="https://matrix.to/#/@user:server">Member</a>');
|
||||
|
|
|
@ -50,14 +50,20 @@ exports[`<TextualBody /> renders formatted m.text correctly linkification is not
|
|||
1
|
||||
</span>
|
||||
</span>
|
||||
<code>
|
||||
https://matrix.org/
|
||||
<div
|
||||
style="display: contents;"
|
||||
>
|
||||
<code>
|
||||
https://matrix.org/
|
||||
|
||||
</code>
|
||||
<span />
|
||||
</code>
|
||||
</div>
|
||||
</pre>
|
||||
<span
|
||||
class="mx_EventTile_button mx_EventTile_copyButton "
|
||||
<div
|
||||
aria-label="Copy"
|
||||
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -254,14 +260,20 @@ exports[`<TextualBody /> renders formatted m.text correctly pills do not appear
|
|||
1
|
||||
</span>
|
||||
</span>
|
||||
<code>
|
||||
@room
|
||||
<div
|
||||
style="display: contents;"
|
||||
>
|
||||
<code>
|
||||
@room
|
||||
|
||||
</code>
|
||||
<span />
|
||||
</code>
|
||||
</div>
|
||||
</pre>
|
||||
<span
|
||||
class="mx_EventTile_button mx_EventTile_copyButton "
|
||||
<div
|
||||
aria-label="Copy"
|
||||
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -321,6 +333,133 @@ exports[`<TextualBody /> renders formatted m.text correctly renders formatted bo
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`<TextualBody /> renders formatted m.text correctly should syntax highlight code blocks 1`] = `
|
||||
<div
|
||||
class="mx_EventTile_body markdown-body translate"
|
||||
dir="auto"
|
||||
>
|
||||
<div
|
||||
class="mx_EventTile_pre_container"
|
||||
>
|
||||
<pre
|
||||
class="mx_EventTile_collapsedCodeBlock"
|
||||
>
|
||||
<span
|
||||
class="mx_EventTile_lineNumbers"
|
||||
>
|
||||
<span>
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
2
|
||||
</span>
|
||||
<span>
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
5
|
||||
</span>
|
||||
<span>
|
||||
6
|
||||
</span>
|
||||
<span>
|
||||
7
|
||||
</span>
|
||||
<span>
|
||||
8
|
||||
</span>
|
||||
<span>
|
||||
9
|
||||
</span>
|
||||
<span>
|
||||
10
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
style="display: contents;"
|
||||
>
|
||||
<code
|
||||
class="language-py"
|
||||
>
|
||||
<span
|
||||
class="hljs-comment"
|
||||
>
|
||||
# Python Program to calculate the square root
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
<span
|
||||
class="hljs-comment"
|
||||
>
|
||||
# Note: change this value for a different result
|
||||
</span>
|
||||
|
||||
num =
|
||||
<span
|
||||
class="hljs-number"
|
||||
>
|
||||
8
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
<span
|
||||
class="hljs-comment"
|
||||
>
|
||||
# To take the input from the user
|
||||
</span>
|
||||
|
||||
|
||||
<span
|
||||
class="hljs-comment"
|
||||
>
|
||||
#num = float(input('Enter a number: '))
|
||||
</span>
|
||||
|
||||
|
||||
num_sqrt = num **
|
||||
<span
|
||||
class="hljs-number"
|
||||
>
|
||||
0.5
|
||||
</span>
|
||||
|
||||
|
||||
<span
|
||||
class="hljs-built_in"
|
||||
>
|
||||
print
|
||||
</span>
|
||||
(
|
||||
<span
|
||||
class="hljs-string"
|
||||
>
|
||||
'The square root of %0.3f is %0.3f'
|
||||
</span>
|
||||
%(num ,num_sqrt))
|
||||
|
||||
</code>
|
||||
</div>
|
||||
</pre>
|
||||
<span
|
||||
class="mx_EventTile_button mx_EventTile_expandButton"
|
||||
/>
|
||||
<div
|
||||
aria-label="Copy"
|
||||
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton mx_EventTile_buttonBottom"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<TextualBody /> renders formatted m.text correctly spoilers get injected properly into the DOM 1`] = `
|
||||
<div
|
||||
class="mx_EventTile_body markdown-body translate"
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -3490,15 +3490,15 @@
|
|||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
|
||||
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
|
||||
|
||||
"@vector-im/compound-design-tokens@1.8.0", "@vector-im/compound-design-tokens@^1.8.0":
|
||||
"@vector-im/compound-design-tokens@^1.8.0":
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.8.0.tgz#bc844cb6b9842c1eb8e5c42f5cedcaf51a49b86f"
|
||||
integrity sha512-PtQMG7kDzwtjw/fLKD63uWP5rJ8cgWc/aXarfEzxYUf9ceWxBajnYOBI2jDqtE3WIUe9uTVBzNEvmOBG/VIgTA==
|
||||
|
||||
"@vector-im/compound-web@7.0.0", "@vector-im/compound-web@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.0.0.tgz#2e31711ad6407a667b08ebf67c54f643902d47eb"
|
||||
integrity sha512-ctK+SQdGyaPeylxC2rVePkVfQZK1ftjWc9XbzYoIbZyu4mihgjHgLhd1i02QsNGIAvpxMDxqHjVD8SsrOB2/0g==
|
||||
"@vector-im/compound-web@^7.1.0":
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.1.0.tgz#d1e2ef9bd7e08e8ac165aabcc40bca528cb5e80d"
|
||||
integrity sha512-b2q5lSemKnWCA0rHTWyfw5I+ZsQzAJCTL6Zya79vArptaQBxLksjAubErsOG80uRqwAiNUZUx+13eaxKXZI1Sw==
|
||||
dependencies:
|
||||
"@floating-ui/react" "^0.26.24"
|
||||
"@radix-ui/react-context-menu" "^2.2.1"
|
||||
|
|
Loading…
Reference in a new issue