* Conform to oxlint rule jsx-a11y/role-has-required-aria-props * Conform to oxlint rule jsx-a11y/role-supports-aria-props * Conform to oxlint rule jsx-a11y/interactive-supports-focus * Conform to oxlint rule jsx-a11y/click-events-have-key-events * Update snapshots
153 lines
5.6 KiB
TypeScript
153 lines
5.6 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import React, { type JSX, useState } from "react";
|
|
import classNames from "classnames";
|
|
import { type DOMNode, type Element as ParserElement, domToReact } from "html-react-parser";
|
|
import { textContent, getInnerHTML } from "domutils";
|
|
import { CollapseIcon, CopyIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
|
|
import { useSettingValue } from "../../../hooks/useSettings.ts";
|
|
import { CopyTextButton } from "../elements/CopyableText.tsx";
|
|
import AccessibleButton from "../elements/AccessibleButton.tsx";
|
|
import { _t } from "../../../i18n";
|
|
|
|
const MAX_HIGHLIGHT_LENGTH = 4096;
|
|
const MAX_LINES_BEFORE_COLLAPSE = 5;
|
|
|
|
interface Props {
|
|
preNode: ParserElement;
|
|
}
|
|
|
|
const ExpandCollapseButton: React.FC<{
|
|
expanded: boolean;
|
|
onClick(this: void): void;
|
|
}> = ({ expanded, onClick }) => {
|
|
return (
|
|
<AccessibleButton
|
|
element="button"
|
|
title={expanded ? _t("action|collapse") : _t("action|expand")}
|
|
onClick={onClick}
|
|
className="mx_EventTile_button"
|
|
>
|
|
{expanded ? <CollapseIcon /> : <ExpandIcon />}
|
|
</AccessibleButton>
|
|
);
|
|
};
|
|
|
|
const CodeBlock: React.FC<Props> = ({ preNode }) => {
|
|
const enableSyntaxHighlightLanguageDetection = useSettingValue("enableSyntaxHighlightLanguageDetection");
|
|
const showCodeLineNumbers = useSettingValue("showCodeLineNumbers");
|
|
const expandCodeByDefault = useSettingValue("expandCodeByDefault");
|
|
const [expanded, setExpanded] = useState(expandCodeByDefault);
|
|
|
|
const text = textContent(preNode);
|
|
|
|
let expandCollapseButton: JSX.Element | undefined;
|
|
if (text.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) {
|
|
expandCollapseButton = (
|
|
<ExpandCollapseButton
|
|
expanded={expanded}
|
|
onClick={() => {
|
|
setExpanded(!expanded);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const innerHTML = getInnerHTML(preNode);
|
|
let lineNumbers: JSX.Element | undefined;
|
|
if (showCodeLineNumbers) {
|
|
// Calculate number of lines in pre
|
|
const number = 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;
|
|
}
|
|
}
|
|
|
|
function highlightCodeRef(div: HTMLElement | null): void {
|
|
highlightCode(div);
|
|
}
|
|
|
|
let content = domToReact(preNode.children as DOMNode[]);
|
|
|
|
// Add code element if it's missing since we depend on it
|
|
if (!preNode.children.some((child) => child.type === "tag" && child.tagName.toUpperCase() === "CODE")) {
|
|
content = <code>{content}</code>;
|
|
}
|
|
|
|
return (
|
|
<div className="mx_EventTile_pre_container">
|
|
<pre
|
|
className={classNames({
|
|
mx_EventTile_collapsedCodeBlock: !expanded,
|
|
})}
|
|
>
|
|
{lineNumbers}
|
|
<div style={{ display: "contents" }} ref={highlightCodeRef}>
|
|
{content}
|
|
</div>
|
|
</pre>
|
|
{expandCollapseButton}
|
|
<CopyTextButton
|
|
getTextToCopy={() => text}
|
|
className={classNames("mx_EventTile_button mx_EventTile_copyButton", {
|
|
mx_EventTile_buttonBottom: !!expandCollapseButton,
|
|
})}
|
|
>
|
|
<CopyIcon />
|
|
</CopyTextButton>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CodeBlock;
|