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:
Michael Telatynski 2024-10-18 15:57:39 +01:00 committed by GitHub
parent fad457362d
commit 26430a3a6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 410 additions and 670 deletions

View file

@ -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 {