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",
|
"@types/seedrandom": "3.0.8",
|
||||||
"oidc-client-ts": "3.1.0",
|
"oidc-client-ts": "3.1.0",
|
||||||
"jwt-decode": "4.0.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",
|
"@floating-ui/react": "0.26.11",
|
||||||
"@radix-ui/react-id": "1.1.0",
|
"@radix-ui/react-id": "1.1.0",
|
||||||
"caniuse-lite": "1.0.30001668",
|
"caniuse-lite": "1.0.30001668",
|
||||||
|
@ -98,7 +96,7 @@
|
||||||
"@sentry/browser": "^8.0.0",
|
"@sentry/browser": "^8.0.0",
|
||||||
"@testing-library/react-hooks": "^8.0.1",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"@vector-im/compound-design-tokens": "^1.8.0",
|
"@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/core": "^3.0.4",
|
||||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||||
|
|
|
@ -263,7 +263,6 @@ test.describe("Editing", () => {
|
||||||
checkA11y,
|
checkA11y,
|
||||||
}) => {
|
}) => {
|
||||||
axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here
|
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}`);
|
await page.goto(`#/room/${room.roomId}`);
|
||||||
|
|
||||||
|
|
|
@ -96,7 +96,7 @@ test.describe("Registration", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.getByRole("textbox", { name: "Username", exact: true }).fill("_alice");
|
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 page.route("**/_matrix/client/*/register/available?username=bob", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
|
@ -108,9 +108,9 @@ test.describe("Registration", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.getByRole("textbox", { name: "Username", exact: true }).fill("bob");
|
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 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) {
|
if (!options?.showTooltips) {
|
||||||
css += `
|
css += `
|
||||||
[role="tooltip"],
|
[role="tooltip"] {
|
||||||
.mx_Tooltip_visible {
|
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -217,7 +217,6 @@
|
||||||
@import "./views/elements/_TagComposer.pcss";
|
@import "./views/elements/_TagComposer.pcss";
|
||||||
@import "./views/elements/_TextWithTooltip.pcss";
|
@import "./views/elements/_TextWithTooltip.pcss";
|
||||||
@import "./views/elements/_ToggleSwitch.pcss";
|
@import "./views/elements/_ToggleSwitch.pcss";
|
||||||
@import "./views/elements/_Tooltip.pcss";
|
|
||||||
@import "./views/elements/_UseCaseSelection.pcss";
|
@import "./views/elements/_UseCaseSelection.pcss";
|
||||||
@import "./views/elements/_UseCaseSelectionButton.pcss";
|
@import "./views/elements/_UseCaseSelectionButton.pcss";
|
||||||
@import "./views/elements/_Validation.pcss";
|
@import "./views/elements/_Validation.pcss";
|
||||||
|
|
|
@ -16,7 +16,8 @@ progress.mx_PassphraseField_progress {
|
||||||
border: 0;
|
border: 0;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -12px;
|
top: -10px;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
@mixin ProgressBarBorderRadius "2px";
|
@mixin ProgressBarBorderRadius "2px";
|
||||||
@mixin ProgressBarColour $PassphraseStrengthLow;
|
@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 */
|
/* Customise other components when placed inside a Field */
|
||||||
|
|
||||||
.mx_Field .mx_Dropdown_input {
|
.mx_Field .mx_Dropdown_input {
|
||||||
|
|
|
@ -10,18 +10,6 @@ Please see LICENSE files in the repository root for full details.
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min-content;
|
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 {
|
.mx_MiniAvatarUploader_indicator {
|
||||||
position: absolute;
|
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 {
|
.mx_Validation {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_Validation_details {
|
.mx_Validation_details {
|
||||||
|
|
|
@ -12,7 +12,6 @@ import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } fro
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import FocusLock from "react-focus-lock";
|
import FocusLock from "react-focus-lock";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
|
||||||
|
|
||||||
import { Writeable } from "../../@types/common";
|
import { Writeable } from "../../@types/common";
|
||||||
import UIStore from "../../stores/UIStore";
|
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];
|
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
|
// re-export the semantic helper components for simplicity
|
||||||
export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
||||||
export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton";
|
export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { useContext, useState } from "react";
|
||||||
|
|
||||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||||
import { getHomePageUrl } from "../../utils/pages";
|
import { getHomePageUrl } from "../../utils/pages";
|
||||||
import { _tDom } from "../../languageHandler";
|
import { _t, _tDom } from "../../languageHandler";
|
||||||
import SdkConfig from "../../SdkConfig";
|
import SdkConfig from "../../SdkConfig";
|
||||||
import dis from "../../dispatcher/dispatcher";
|
import dis from "../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
@ -66,8 +66,8 @@ const UserWelcomeTop: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<MiniAvatarUploader
|
<MiniAvatarUploader
|
||||||
hasAvatar={!!ownProfile.avatarUrl}
|
hasAvatar={!!ownProfile.avatarUrl}
|
||||||
hasAvatarLabel={_tDom("onboarding|has_avatar_label")}
|
hasAvatarLabel={_t("onboarding|has_avatar_label")}
|
||||||
noAvatarLabel={_tDom("onboarding|no_avatar_label")}
|
noAvatarLabel={_t("onboarding|no_avatar_label")}
|
||||||
setAvatarUrl={(url) => cli.setAvatarUrl(url)}
|
setAvatarUrl={(url) => cli.setAvatarUrl(url)}
|
||||||
isUserAvatar
|
isUserAvatar
|
||||||
onClick={(ev) => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)}
|
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.
|
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 Field, { IInputProps } from "../elements/Field";
|
||||||
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
||||||
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
||||||
import * as Email from "../../../email";
|
import * as Email from "../../../email";
|
||||||
import { Alignment } from "../elements/Tooltip";
|
|
||||||
|
|
||||||
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@ -23,7 +22,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
||||||
label: TranslationKey;
|
label: TranslationKey;
|
||||||
labelRequired: TranslationKey;
|
labelRequired: TranslationKey;
|
||||||
labelInvalid: TranslationKey;
|
labelInvalid: TranslationKey;
|
||||||
tooltipAlignment?: Alignment;
|
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
|
||||||
|
|
||||||
// When present, completely overrides the default validation rules.
|
// When present, completely overrides the default validation rules.
|
||||||
validationRules?: (fieldState: IFieldState) => Promise<IValidationResult>;
|
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.
|
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 Field, { IInputProps } from "../elements/Field";
|
||||||
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
||||||
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
import { _t, _td, TranslationKey } from "../../../languageHandler";
|
||||||
import { Alignment } from "../elements/Tooltip";
|
|
||||||
|
|
||||||
interface IProps extends Omit<IInputProps, "onValidate" | "label" | "element"> {
|
interface IProps extends Omit<IInputProps, "onValidate" | "label" | "element"> {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@ -23,7 +22,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "label" | "element"> {
|
||||||
label: TranslationKey;
|
label: TranslationKey;
|
||||||
labelRequired: TranslationKey;
|
labelRequired: TranslationKey;
|
||||||
labelInvalid: TranslationKey;
|
labelInvalid: TranslationKey;
|
||||||
tooltipAlignment?: Alignment;
|
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
|
||||||
onChange(ev: React.FormEvent<HTMLElement>): void;
|
onChange(ev: React.FormEvent<HTMLElement>): void;
|
||||||
onValidate?(result: IValidationResult): 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.
|
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 classNames from "classnames";
|
||||||
|
|
||||||
import type { ZxcvbnResult } from "@zxcvbn-ts/core";
|
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 { _t, _td, TranslationKey } from "../../../languageHandler";
|
||||||
import Field, { IInputProps } from "../elements/Field";
|
import Field, { IInputProps } from "../elements/Field";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import { Alignment } from "../elements/Tooltip";
|
|
||||||
|
|
||||||
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
@ -31,7 +30,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
|
||||||
labelEnterPassword: TranslationKey;
|
labelEnterPassword: TranslationKey;
|
||||||
labelStrongPassword: TranslationKey;
|
labelStrongPassword: TranslationKey;
|
||||||
labelAllowedButUnsafe: TranslationKey;
|
labelAllowedButUnsafe: TranslationKey;
|
||||||
tooltipAlignment?: Alignment;
|
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
|
||||||
|
|
||||||
onChange(ev: React.FormEvent<HTMLElement>): void;
|
onChange(ev: React.FormEvent<HTMLElement>): void;
|
||||||
onValidate?(result: IValidationResult): 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.
|
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 { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
@ -26,7 +26,6 @@ import RegistrationEmailPromptDialog from "../dialogs/RegistrationEmailPromptDia
|
||||||
import CountryDropdown from "./CountryDropdown";
|
import CountryDropdown from "./CountryDropdown";
|
||||||
import PassphraseConfirmField from "./PassphraseConfirmField";
|
import PassphraseConfirmField from "./PassphraseConfirmField";
|
||||||
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
||||||
import { Alignment } from "../elements/Tooltip";
|
|
||||||
|
|
||||||
enum RegistrationField {
|
enum RegistrationField {
|
||||||
Email = "field_email",
|
Email = "field_email",
|
||||||
|
@ -441,9 +440,9 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private tooltipAlignment(): Alignment | undefined {
|
private tooltipAlignment(): ComponentProps<typeof EmailField>["tooltipAlignment"] | undefined {
|
||||||
if (this.props.mobileRegister) {
|
if (this.props.mobileRegister) {
|
||||||
return Alignment.Bottom;
|
return "bottom";
|
||||||
}
|
}
|
||||||
return undefined;
|
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;
|
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 [tooltip, setTooltip] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const onCopyClickInternal = async (e: ButtonEvent): Promise<void> => {
|
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, {
|
const combinedClassName = classNames("mx_CopyableText", className, {
|
||||||
mx_CopyableText_border: border,
|
mx_CopyableText_border: border,
|
||||||
});
|
});
|
||||||
|
@ -44,14 +57,7 @@ const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true
|
||||||
return (
|
return (
|
||||||
<div className={combinedClassName} {...props}>
|
<div className={combinedClassName} {...props}>
|
||||||
{children}
|
{children}
|
||||||
<AccessibleButton
|
<CopyTextButton getTextToCopy={getTextToCopy} className="mx_CopyableText_copyButton" />
|
||||||
title={tooltip ?? _t("action|copy")}
|
|
||||||
onClick={onCopyClickInternal}
|
|
||||||
className="mx_CopyableText_copyButton"
|
|
||||||
onTooltipOpenChange={(open) => {
|
|
||||||
if (!open) onHideTooltip();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,14 +11,13 @@ import React, {
|
||||||
TextareaHTMLAttributes,
|
TextareaHTMLAttributes,
|
||||||
RefObject,
|
RefObject,
|
||||||
createRef,
|
createRef,
|
||||||
KeyboardEvent,
|
ComponentProps,
|
||||||
} from "react";
|
} from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { IFieldState, IValidationResult } from "./Validation";
|
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.
|
// Invoke validation from user input (when typing, etc.) at most once every N ms.
|
||||||
const VALIDATION_THROTTLE_MS = 200;
|
const VALIDATION_THROTTLE_MS = 200;
|
||||||
|
@ -57,11 +56,11 @@ interface IProps {
|
||||||
forceValidity?: boolean;
|
forceValidity?: boolean;
|
||||||
// If specified, contents will appear as a tooltip on the element and
|
// If specified, contents will appear as a tooltip on the element and
|
||||||
// validation feedback tooltips will be suppressed.
|
// validation feedback tooltips will be suppressed.
|
||||||
tooltipContent?: React.ReactNode;
|
tooltipContent?: JSX.Element | string;
|
||||||
// If specified the tooltip will be shown regardless of feedback
|
// If specified the tooltip will be shown regardless of feedback
|
||||||
forceTooltipVisible?: boolean;
|
forceTooltipVisible?: boolean;
|
||||||
// If specified, the tooltip with be aligned accorindly with the field, defaults to Right.
|
// 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
|
// If specified alongside tooltipContent, the class name to apply to the
|
||||||
// tooltip itself.
|
// tooltip itself.
|
||||||
tooltipClassName?: string;
|
tooltipClassName?: string;
|
||||||
|
@ -112,7 +111,7 @@ type PropShapes = IInputProps | ISelectProps | ITextareaProps | INativeOnChangeI
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
valid?: boolean;
|
valid?: boolean;
|
||||||
feedback?: React.ReactNode;
|
feedback?: JSX.Element | string;
|
||||||
feedbackVisible: boolean;
|
feedbackVisible: boolean;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
}
|
}
|
||||||
|
@ -127,6 +126,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||||
validateOnFocus: true,
|
validateOnFocus: true,
|
||||||
validateOnBlur: true,
|
validateOnBlur: true,
|
||||||
validateOnChange: true,
|
validateOnChange: true,
|
||||||
|
tooltipAlignment: "right",
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -233,16 +233,10 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||||
return this.props.inputRef ?? this._inputRef;
|
return this.props.inputRef ?? this._inputRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onKeyDown = (evt: KeyboardEvent<HTMLDivElement>): void => {
|
private onTooltipOpenChange = (open: boolean): void => {
|
||||||
// If the tooltip is displayed to show a feedback and Escape is pressed
|
this.setState({
|
||||||
// The tooltip is hided
|
feedbackVisible: open,
|
||||||
if (this.state.feedbackVisible && evt.key === Key.ESCAPE) {
|
});
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
this.setState({
|
|
||||||
feedbackVisible: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
|
@ -268,31 +262,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
// Handle displaying feedback on validity
|
// 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) {
|
if (tooltipContent || this.state.feedback) {
|
||||||
const tooltipId = `${this.id}_tooltip`;
|
tooltipOpen = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible;
|
||||||
const visible = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible;
|
|
||||||
if (visible) {
|
|
||||||
inputProps["aria-describedby"] = tooltipId;
|
|
||||||
}
|
|
||||||
|
|
||||||
let role: React.AriaRole;
|
if (!tooltipContent) {
|
||||||
if (tooltipContent) {
|
tooltipProps["aria-atomic"] = "true";
|
||||||
role = "tooltip";
|
tooltipProps["aria-live"] = this.state.valid ? "polite" : "assertive";
|
||||||
} else {
|
|
||||||
role = this.state.valid ? "status" : "alert";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
|
||||||
|
@ -332,12 +310,20 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={fieldClasses} onKeyDown={this.onKeyDown}>
|
<div className={fieldClasses}>
|
||||||
{prefixContainer}
|
{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>
|
<label htmlFor={this.id}>{this.props.label}</label>
|
||||||
{postfixContainer}
|
{postfixContainer}
|
||||||
{fieldTooltip}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details.
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { EventType } from "matrix-js-sdk/src/matrix";
|
import { EventType } from "matrix-js-sdk/src/matrix";
|
||||||
import React, { useContext, useRef, useState, MouseEvent, ReactNode } from "react";
|
import React, { useContext, useRef, useState, MouseEvent, ReactNode } from "react";
|
||||||
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
import RoomContext from "../../../contexts/RoomContext";
|
||||||
import { useTimeout } from "../../../hooks/useTimeout";
|
import { useTimeout } from "../../../hooks/useTimeout";
|
||||||
import { TranslatedString } from "../../../languageHandler";
|
|
||||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||||
import AccessibleButton from "./AccessibleButton";
|
import AccessibleButton from "./AccessibleButton";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
|
@ -22,8 +22,8 @@ export const AVATAR_SIZE = "52px";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
hasAvatar: boolean;
|
hasAvatar: boolean;
|
||||||
noAvatarLabel?: TranslatedString;
|
noAvatarLabel?: string;
|
||||||
hasAvatarLabel?: TranslatedString;
|
hasAvatarLabel?: string;
|
||||||
setAvatarUrl(url: string): Promise<unknown>;
|
setAvatarUrl(url: string): Promise<unknown>;
|
||||||
isUserAvatar?: boolean;
|
isUserAvatar?: boolean;
|
||||||
onClick?(ev: MouseEvent<HTMLInputElement>): void;
|
onClick?(ev: MouseEvent<HTMLInputElement>): void;
|
||||||
|
@ -82,34 +82,24 @@ const MiniAvatarUploader: React.FC<IProps> = ({
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccessibleButton
|
<Tooltip label={label!} open={visible} onOpenChange={setHover}>
|
||||||
className={classNames("mx_MiniAvatarUploader", {
|
<AccessibleButton
|
||||||
mx_MiniAvatarUploader_busy: busy,
|
className={classNames("mx_MiniAvatarUploader", {
|
||||||
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
|
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,
|
|
||||||
})}
|
})}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
uploadRef.current?.click();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx_Tooltip_chevron" />
|
{children}
|
||||||
{label}
|
|
||||||
</div>
|
<div className="mx_MiniAvatarUploader_indicator">
|
||||||
</AccessibleButton>
|
{busy ? <Spinner w={20} h={20} /> : <div className="mx_MiniAvatarUploader_cameraIcon" />}
|
||||||
|
</div>
|
||||||
|
</AccessibleButton>
|
||||||
|
</Tooltip>
|
||||||
</React.Fragment>
|
</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.
|
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 classNames from "classnames";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ export interface IFieldState {
|
||||||
|
|
||||||
export interface IValidationResult {
|
export interface IValidationResult {
|
||||||
valid?: boolean;
|
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;
|
summary = content ? <div className="mx_Validation_description">{content}</div> : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let feedback: ReactChild | undefined;
|
let feedback: JSX.Element | undefined;
|
||||||
if (summary || details) {
|
if (summary || details) {
|
||||||
feedback = (
|
feedback = (
|
||||||
<div className="mx_Validation">
|
<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 Modal from "../../../Modal";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import * as ContextMenu from "../../structures/ContextMenu";
|
|
||||||
import { ChevronFace, toRightOf } from "../../structures/ContextMenu";
|
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { pillifyLinks, unmountPills } from "../../../utils/pillify";
|
import { pillifyLinks, unmountPills } from "../../../utils/pillify";
|
||||||
import { tooltipifyLinks, unmountTooltips } from "../../../utils/tooltipify";
|
import { tooltipifyLinks, unmountTooltips } from "../../../utils/tooltipify";
|
||||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||||
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||||
import { copyPlaintext } from "../../../utils/strings";
|
|
||||||
import UIStore from "../../../stores/UIStore";
|
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
|
|
||||||
import Spoiler from "../elements/Spoiler";
|
import Spoiler from "../elements/Spoiler";
|
||||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||||
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
||||||
|
@ -40,8 +35,7 @@ import { getParentEventId } from "../../../utils/Reply";
|
||||||
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||||
import { IEventTileOps } from "../rooms/EventTile";
|
import { IEventTileOps } from "../rooms/EventTile";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
|
import CodeBlock from "./CodeBlock";
|
||||||
const MAX_HIGHLIGHT_LENGTH = 4096;
|
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
|
// 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> {
|
export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
private readonly contentRef = createRef<HTMLDivElement>();
|
private readonly contentRef = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
private unmounted = false;
|
|
||||||
private pills: Element[] = [];
|
private pills: Element[] = [];
|
||||||
private tooltips: Element[] = [];
|
private tooltips: Element[] = [];
|
||||||
|
private reactRoots: Element[] = [];
|
||||||
|
|
||||||
public static contextType = RoomContext;
|
public static contextType = RoomContext;
|
||||||
public declare context: React.ContextType<typeof 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
|
// Function is only called from render / componentDidMount → contentRef is set
|
||||||
const content = this.contentRef.current!;
|
const content = this.contentRef.current!;
|
||||||
|
|
||||||
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
|
|
||||||
this.activateSpoilers([content]);
|
this.activateSpoilers([content]);
|
||||||
|
|
||||||
HtmlUtils.linkifyElement(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
|
// Wrap a div around <pre> so that the copy button can be correctly positioned
|
||||||
// when the <pre> overflows and is scrolled horizontally.
|
// when the <pre> overflows and is scrolled horizontally.
|
||||||
const div = this.wrapInDiv(pres[i]);
|
this.wrapPreInReact(pres[i]);
|
||||||
this.handleCodeBlockExpansion(pres[i]);
|
|
||||||
this.addCodeExpansionButton(div, pres[i]);
|
|
||||||
this.addCodeCopyButton(div);
|
|
||||||
if (showLineNumbers) {
|
|
||||||
this.addLineNumbers(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);
|
pre.appendChild(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
|
private wrapPreInReact(pre: HTMLPreElement): void {
|
||||||
// Calculate how many percent does the pre element take up.
|
const root = document.createElement("div");
|
||||||
// If it's less than 30% we don't add the expansion button.
|
root.className = "mx_EventTile_pre_container";
|
||||||
// We also round the number as it sometimes can be 29.99...
|
this.reactRoots.push(root);
|
||||||
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";
|
|
||||||
|
|
||||||
// Insert containing div in place of <pre> block
|
// Insert containing div in place of <pre> block
|
||||||
pre.parentNode?.replaceChild(div, pre);
|
pre.parentNode?.replaceChild(root, pre);
|
||||||
// Append <pre> block and copy button to container
|
|
||||||
div.appendChild(pre);
|
|
||||||
|
|
||||||
return div;
|
ReactDOM.render(<CodeBlock onHeightChanged={this.props.onHeightChanged}>{pre}</CodeBlock>, root);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
|
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
|
||||||
|
@ -281,12 +130,16 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
this.unmounted = true;
|
|
||||||
unmountPills(this.pills);
|
unmountPills(this.pills);
|
||||||
unmountTooltips(this.tooltips);
|
unmountTooltips(this.tooltips);
|
||||||
|
|
||||||
|
for (const root of this.reactRoots) {
|
||||||
|
ReactDOM.unmountComponentAtNode(root);
|
||||||
|
}
|
||||||
|
|
||||||
this.pills = [];
|
this.pills = [];
|
||||||
this.tooltips = [];
|
this.tooltips = [];
|
||||||
|
this.reactRoots = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
|
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 });
|
this.setState({ idServer: u });
|
||||||
};
|
};
|
||||||
|
|
||||||
private getTooltip = (): ReactNode => {
|
private getTooltip = (): JSX.Element | undefined => {
|
||||||
if (this.state.checking) {
|
if (this.state.checking) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -131,7 +131,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
|
||||||
} else if (this.state.error) {
|
} else if (this.state.error) {
|
||||||
return <strong className="warning">{this.state.error}</strong>;
|
return <strong className="warning">{this.state.error}</strong>;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -57,12 +57,12 @@ describe("Field", () => {
|
||||||
// When invalid
|
// When invalid
|
||||||
fireEvent.focus(screen.getByRole("textbox"));
|
fireEvent.focus(screen.getByRole("textbox"));
|
||||||
|
|
||||||
// Expect 'alert' role
|
// Expect 'aria-live=assertive'
|
||||||
await expect(screen.findByRole("alert")).resolves.toBeInTheDocument();
|
await expect(screen.findByRole("tooltip")).resolves.toHaveAttribute("aria-live", "assertive");
|
||||||
|
|
||||||
// Close the feedback is Escape is pressed
|
// Close the feedback is Escape is pressed
|
||||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
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 () => {
|
it("Should mark the feedback as status if valid", async () => {
|
||||||
|
@ -77,12 +77,12 @@ describe("Field", () => {
|
||||||
// When valid
|
// When valid
|
||||||
fireEvent.focus(screen.getByRole("textbox"));
|
fireEvent.focus(screen.getByRole("textbox"));
|
||||||
|
|
||||||
// Expect 'status' role
|
// Expect 'aria-live=polite' role
|
||||||
await expect(screen.findByRole("status")).resolves.toBeInTheDocument();
|
await expect(screen.findByRole("tooltip")).resolves.toHaveAttribute("aria-live", "polite");
|
||||||
|
|
||||||
// Close the feedback is Escape is pressed
|
// Close the feedback is Escape is pressed
|
||||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
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 () => {
|
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 React from "react";
|
||||||
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { mocked, MockedObject } from "jest-mock";
|
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 { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom } from "../../../../test-utils";
|
||||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||||
|
@ -279,6 +279,17 @@ describe("<TextualBody />", () => {
|
||||||
expect(content).toMatchSnapshot();
|
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
|
// 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", () => {
|
it("pills get injected correctly into the DOM", () => {
|
||||||
const ev = mkFormattedMessage("Hey User", 'Hey <a href="https://matrix.to/#/@user:server">Member</a>');
|
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
|
1
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<code>
|
<div
|
||||||
https://matrix.org/
|
style="display: contents;"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
https://matrix.org/
|
||||||
|
|
||||||
</code>
|
</code>
|
||||||
<span />
|
</div>
|
||||||
</pre>
|
</pre>
|
||||||
<span
|
<div
|
||||||
class="mx_EventTile_button mx_EventTile_copyButton "
|
aria-label="Copy"
|
||||||
|
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -254,14 +260,20 @@ exports[`<TextualBody /> renders formatted m.text correctly pills do not appear
|
||||||
1
|
1
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<code>
|
<div
|
||||||
@room
|
style="display: contents;"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
@room
|
||||||
|
|
||||||
</code>
|
</code>
|
||||||
<span />
|
</div>
|
||||||
</pre>
|
</pre>
|
||||||
<span
|
<div
|
||||||
class="mx_EventTile_button mx_EventTile_copyButton "
|
aria-label="Copy"
|
||||||
|
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -321,6 +333,133 @@ exports[`<TextualBody /> renders formatted m.text correctly renders formatted bo
|
||||||
</div>
|
</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`] = `
|
exports[`<TextualBody /> renders formatted m.text correctly spoilers get injected properly into the DOM 1`] = `
|
||||||
<div
|
<div
|
||||||
class="mx_EventTile_body markdown-body translate"
|
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"
|
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
|
||||||
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
|
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"
|
version "1.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.8.0.tgz#bc844cb6b9842c1eb8e5c42f5cedcaf51a49b86f"
|
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.8.0.tgz#bc844cb6b9842c1eb8e5c42f5cedcaf51a49b86f"
|
||||||
integrity sha512-PtQMG7kDzwtjw/fLKD63uWP5rJ8cgWc/aXarfEzxYUf9ceWxBajnYOBI2jDqtE3WIUe9uTVBzNEvmOBG/VIgTA==
|
integrity sha512-PtQMG7kDzwtjw/fLKD63uWP5rJ8cgWc/aXarfEzxYUf9ceWxBajnYOBI2jDqtE3WIUe9uTVBzNEvmOBG/VIgTA==
|
||||||
|
|
||||||
"@vector-im/compound-web@7.0.0", "@vector-im/compound-web@^7.0.0":
|
"@vector-im/compound-web@^7.1.0":
|
||||||
version "7.0.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.0.0.tgz#2e31711ad6407a667b08ebf67c54f643902d47eb"
|
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.1.0.tgz#d1e2ef9bd7e08e8ac165aabcc40bca528cb5e80d"
|
||||||
integrity sha512-ctK+SQdGyaPeylxC2rVePkVfQZK1ftjWc9XbzYoIbZyu4mihgjHgLhd1i02QsNGIAvpxMDxqHjVD8SsrOB2/0g==
|
integrity sha512-b2q5lSemKnWCA0rHTWyfw5I+ZsQzAJCTL6Zya79vArptaQBxLksjAubErsOG80uRqwAiNUZUx+13eaxKXZI1Sw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/react" "^0.26.24"
|
"@floating-ui/react" "^0.26.24"
|
||||||
"@radix-ui/react-context-menu" "^2.2.1"
|
"@radix-ui/react-context-menu" "^2.2.1"
|
||||||
|
|
Loading…
Reference in a new issue