mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 09:01:39 -06:00
52eb013330
* feat(ui): unify fenced code block styling * fix(ui): preserve fenced code copy icon * fix(ui): refine unified fenced code visuals * fix(ui): increase code fence squircle radius * fix(ui): inset fence scrolling and add edge fade * fix(ui): fade overflowing code without surface color * fix(ui): update code overflow fades by scroll position * fix(ui): preserve transcript scroll handler with fence fades * fix(ui): initialize code overflow fades after render * fix(ui): align JSON tree hierarchy with reference * fix(ui): remove transcript bullets from JSON tree * fix(ui): preserve fenced code contracts * test(ui): align fenced code feedback contracts * fix(ui): remove fenced code overflow lifecycle * fix(ui): preserve compact JSON disclosure * fix(ui): preserve shared fence variants * fix(ui): address fenced code review findings --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
195 lines
7.3 KiB
TypeScript
195 lines
7.3 KiB
TypeScript
import hljs from "highlight.js/lib/core";
|
|
import bash from "highlight.js/lib/languages/bash";
|
|
import cpp from "highlight.js/lib/languages/cpp";
|
|
import css from "highlight.js/lib/languages/css";
|
|
import diff from "highlight.js/lib/languages/diff";
|
|
import go from "highlight.js/lib/languages/go";
|
|
import java from "highlight.js/lib/languages/java";
|
|
import javascript from "highlight.js/lib/languages/javascript";
|
|
import json from "highlight.js/lib/languages/json";
|
|
import markdown from "highlight.js/lib/languages/markdown";
|
|
import python from "highlight.js/lib/languages/python";
|
|
import rust from "highlight.js/lib/languages/rust";
|
|
import typescript from "highlight.js/lib/languages/typescript";
|
|
import xml from "highlight.js/lib/languages/xml";
|
|
import yaml from "highlight.js/lib/languages/yaml";
|
|
import { t } from "../i18n/index.ts";
|
|
import { copyToClipboard } from "../lib/clipboard.ts";
|
|
import type { MarkdownRenderEnv } from "./markdown-render-options.ts";
|
|
import { escapeMarkdownHtml, isMarkdownBlockArtText } from "./markdown-text.ts";
|
|
|
|
const blockArtCopyPayloadPrefix = "openclaw:block-art-code:";
|
|
const blockArtCodeBlockCopyPayloadEncoding = "block-art-json";
|
|
const JSON_COLLAPSE_LINE_THRESHOLD = 40;
|
|
const codeBlockCopyAttempts = new WeakMap<HTMLElement, number>();
|
|
const codeBlockCopyResetTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();
|
|
|
|
for (const [language, definition] of Object.entries({
|
|
bash,
|
|
cpp,
|
|
css,
|
|
diff,
|
|
go,
|
|
java,
|
|
javascript,
|
|
json,
|
|
markdown,
|
|
python,
|
|
rust,
|
|
typescript,
|
|
xml,
|
|
yaml,
|
|
})) {
|
|
hljs.registerLanguage(language, definition);
|
|
}
|
|
hljs.registerAliases("shell", { languageName: "bash" });
|
|
|
|
function shouldRenderCodeBlockCopy(env: unknown): boolean {
|
|
return (env as Partial<MarkdownRenderEnv> | undefined)?.codeBlockChrome !== "none";
|
|
}
|
|
|
|
function encodeBlockArtCodeBlockCopyPayload(value: string): string {
|
|
return `${blockArtCopyPayloadPrefix}${JSON.stringify(value)}`;
|
|
}
|
|
|
|
function decodeCodeBlockCopyPayload(value: string, encoding?: string): string {
|
|
if (
|
|
encoding !== blockArtCodeBlockCopyPayloadEncoding ||
|
|
!value.startsWith(blockArtCopyPayloadPrefix)
|
|
) {
|
|
return value;
|
|
}
|
|
try {
|
|
const decoded = JSON.parse(value.slice(blockArtCopyPayloadPrefix.length));
|
|
return typeof decoded === "string" ? decoded : value;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
export function handleMarkdownCodeBlockCopy(event: Event): void {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
const button = target.closest<HTMLElement>(".code-block-copy");
|
|
if (!button) {
|
|
return;
|
|
}
|
|
const code = decodeCodeBlockCopyPayload(button.dataset.code ?? "", button.dataset.codeEncoding);
|
|
const attempt = (codeBlockCopyAttempts.get(button) ?? 0) + 1;
|
|
codeBlockCopyAttempts.set(button, attempt);
|
|
void copyToClipboard(code).then((copied) => {
|
|
// Clipboard writes can finish out of click order; older attempts must not own feedback.
|
|
if (codeBlockCopyAttempts.get(button) !== attempt) {
|
|
return;
|
|
}
|
|
button.classList.toggle("copied", copied);
|
|
button.classList.toggle("copy-failed", !copied);
|
|
button.setAttribute("aria-label", t(copied ? "common.copied" : "common.copyFailed"));
|
|
clearTimeout(codeBlockCopyResetTimers.get(button));
|
|
const resetTimer = setTimeout(
|
|
() => {
|
|
button.classList.remove("copied");
|
|
button.classList.remove("copy-failed");
|
|
button.setAttribute("aria-label", t("common.copyCode"));
|
|
codeBlockCopyResetTimers.delete(button);
|
|
},
|
|
copied ? 1500 : 2000,
|
|
);
|
|
codeBlockCopyResetTimers.set(button, resetTimer);
|
|
});
|
|
}
|
|
|
|
/** Highlight a snippet; output is escaped hljs markup safe for unsafeHTML in a code block. */
|
|
export function highlightCodeHtml(text: string, lang: string): string {
|
|
const language = lang.trim().toLowerCase();
|
|
try {
|
|
if (language && hljs.getLanguage(language)) {
|
|
return hljs.highlight(text, { language, ignoreIllegals: true }).value;
|
|
}
|
|
if (!language && text.trim()) {
|
|
const result = hljs.highlightAuto(text);
|
|
if (result.relevance >= 2) {
|
|
return result.value;
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall back to escaped plaintext; malformed input should not break chat rendering.
|
|
}
|
|
return escapeMarkdownHtml(text);
|
|
}
|
|
|
|
/** Highlight a JSON/JSON5 snippet; output is escaped hljs markup safe for unsafeHTML in a code block. */
|
|
export function highlightJsonHtml(text: string): string {
|
|
return highlightCodeHtml(text, "json");
|
|
}
|
|
|
|
function codeClassAttribute(lang: string, highlighted: string): string {
|
|
const classes = [
|
|
highlighted.includes("hljs-") ? "hljs" : "",
|
|
lang ? `language-${lang}` : "",
|
|
].filter(Boolean);
|
|
return classes.length > 0 ? ` class="${escapeMarkdownHtml(classes.join(" "))}"` : "";
|
|
}
|
|
|
|
function renderCodeElement(
|
|
text: string,
|
|
lang: string,
|
|
options: { blockArt?: boolean } = {},
|
|
): string {
|
|
if (options.blockArt || isMarkdownBlockArtText(text)) {
|
|
return `<pre><code class="markdown-block-art">${escapeMarkdownHtml(text)}</code></pre>`;
|
|
}
|
|
const highlighted = highlightCodeHtml(text, lang);
|
|
const classAttr = codeClassAttribute(lang, highlighted);
|
|
return `<pre><code${classAttr}>${highlighted}</code></pre>`;
|
|
}
|
|
|
|
function renderCodeBlockHeader(lang: string, copyButton: string): string {
|
|
const language = escapeMarkdownHtml(lang || t("chat.codeBlock.languageFallback"));
|
|
return `<div class="code-block-header"><span class="code-block-lang">${language}</span><div class="code-block-actions">${copyButton}</div></div>`;
|
|
}
|
|
|
|
export function renderMarkdownCodeBlock(
|
|
text: string,
|
|
lang: string,
|
|
env: unknown,
|
|
options: { blockArt?: boolean; copyText?: string } = {},
|
|
): string {
|
|
const blockArt = options.blockArt || isMarkdownBlockArtText(text);
|
|
const codeBlock = renderCodeElement(text, lang, { blockArt });
|
|
if (!shouldRenderCodeBlockCopy(env)) {
|
|
return codeBlock;
|
|
}
|
|
const copyText = options.copyText ?? text;
|
|
const copyPayload = blockArt ? encodeBlockArtCodeBlockCopyPayload(copyText) : copyText;
|
|
const attrSafe = escapeMarkdownHtml(copyPayload);
|
|
const encodingAttr = blockArt
|
|
? ` data-code-encoding="${blockArtCodeBlockCopyPayloadEncoding}"`
|
|
: "";
|
|
const copyButton = `<button type="button" class="code-block-copy" data-code="${attrSafe}"${encodingAttr} aria-label="${escapeMarkdownHtml(t("common.copyCode"))}"><span class="code-block-copy__idle" aria-hidden="true"></span><span class="code-block-copy__done" aria-hidden="true"></span><span class="code-block-copy__failed" aria-hidden="true">!</span></button>`;
|
|
const header = renderCodeBlockHeader(lang, copyButton);
|
|
|
|
const trimmed = text.trim();
|
|
const isJson =
|
|
lang === "json" ||
|
|
(!lang &&
|
|
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
|
|
|
|
if (isJson) {
|
|
const lineCount = markdownCodeBlockCopyText(text).split("\n").length;
|
|
if (lineCount > JSON_COLLAPSE_LINE_THRESHOLD) {
|
|
const label = escapeMarkdownHtml(t("chat.codeBlock.jsonLines", { count: String(lineCount) }));
|
|
return `<details class="json-collapse code-block-wrapper"><summary class="code-block-header"><span>${label}</span>${copyButton}</summary>${codeBlock}</details>`;
|
|
}
|
|
}
|
|
|
|
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
|
|
}
|
|
|
|
export function markdownCodeBlockCopyText(content: string): string {
|
|
return content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
}
|