control-ui: improve Mermaid interactions

This commit is contained in:
chembo.huang 2026-03-20 00:08:47 +08:00
parent 2cc12a016c
commit 636d4e9412
10 changed files with 258 additions and 19 deletions

View File

@ -9,6 +9,7 @@
.chat-text :where(.mermaid-block__render) {
overflow-x: auto;
padding: 12px;
cursor: zoom-in;
}
.chat-text :where(.mermaid-block__render svg) {
@ -17,6 +18,11 @@
height: auto;
}
.chat-text :where(.mermaid-block__render:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.chat-text :where(.mermaid-block__source) {
border-top: 1px solid var(--border);
}
@ -33,3 +39,55 @@
.chat-text :where(.mermaid-block__source > summary::-webkit-details-marker) {
display: none;
}
.mermaid-block__dialog {
width: min(92vw, 1200px);
max-width: 1200px;
max-height: 88vh;
padding: 0;
border: 1px solid var(--border);
border-radius: calc(var(--radius-lg) + 4px);
background: var(--panel);
color: var(--text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.mermaid-block__dialog::backdrop {
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(4px);
}
.mermaid-block__dialog-panel {
display: flex;
flex-direction: column;
max-height: 88vh;
}
.mermaid-block__dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.mermaid-block__dialog-title {
font-size: 14px;
font-weight: 600;
}
.mermaid-block__dialog-body {
overflow: auto;
padding: 20px;
background: color-mix(in srgb, var(--secondary) 65%, transparent);
}
.mermaid-block__dialog-body svg {
display: block;
min-width: min(960px, 100%);
width: max-content;
max-width: none;
height: auto;
margin: 0 auto;
}

View File

@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleCodeBlockCopyClick } from "./code-block-copy.ts";
describe("handleCodeBlockCopyClick", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("copies code from a clicked code-block button", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
document.body.innerHTML = `
<div class="sidebar-markdown">
<button type="button" class="code-block-copy" data-code="console.log('copied')">
<span>Copy</span>
</button>
</div>
`;
const button = document.querySelector<HTMLButtonElement>(".code-block-copy");
expect(button).not.toBeNull();
const event = new MouseEvent("click", { bubbles: true });
button?.dispatchEvent(event);
handleCodeBlockCopyClick(event);
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("console.log('copied')");
expect(button?.classList.contains("copied")).toBe(true);
});
});

View File

@ -0,0 +1,26 @@
const COPIED_FOR_MS = 1500;
export function handleCodeBlockCopyClick(e: Event): void {
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>(".code-block-copy");
if (!btn) {
return;
}
const code = btn.dataset.code ?? "";
if (!code) {
return;
}
void navigator.clipboard.writeText(code).then(
() => {
btn.classList.add("copied");
window.setTimeout(() => {
if (!btn.isConnected) {
return;
}
btn.classList.remove("copied");
}, COPIED_FOR_MS);
},
() => {},
);
}

View File

@ -36,8 +36,10 @@ describe("toSanitizedMarkdownHtml", () => {
);
expect(html).toContain('class="mermaid-block"');
expect(html).toContain('class="mermaid-block__render"');
expect(html).toContain('class="mermaid-block__dialog"');
expect(html).toContain('class="language-mermaid"');
expect(html).toContain("Mermaid source");
expect(html).toContain("Click to enlarge");
});
it("flattens remote markdown images into alt text", () => {

View File

@ -11,6 +11,7 @@ const allowedTags = [
"code",
"del",
"details",
"dialog",
"div",
"em",
"h1",
@ -48,7 +49,9 @@ const allowedAttrs = [
"data-code",
"type",
"aria-label",
"aria-modal",
"role",
"tabindex",
];
const sanitizeOptions = {
ALLOWED_TAGS: allowedTags,
@ -199,7 +202,7 @@ htmlEscapeRenderer.code = ({
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
if (normalizedLang === "mermaid") {
return `<div class="mermaid-block"><div class="mermaid-block__render" aria-label="Mermaid diagram" role="img"></div><details class="mermaid-block__source"><summary>Mermaid source</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details></div>`;
return `<div class="mermaid-block"><div class="mermaid-block__render" aria-label="Mermaid diagram. Click to enlarge." role="button" tabindex="0" title="Click to enlarge"></div><dialog class="mermaid-block__dialog" aria-modal="true"><div class="mermaid-block__dialog-panel"><div class="mermaid-block__dialog-header"><div class="mermaid-block__dialog-title">Mermaid diagram</div><button type="button" class="btn btn--sm mermaid-block__dialog-close" aria-label="Close Mermaid preview">Close</button></div><div class="mermaid-block__dialog-body"></div></div></dialog><details class="mermaid-block__source"><summary>Mermaid source</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details></div>`;
}
const trimmed = text.trim();

35
ui/src/ui/mermaid.test.ts Normal file
View File

@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installMermaidInteractions } from "./mermaid.ts";
describe("installMermaidInteractions", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("opens the mermaid preview dialog when the rendered diagram is clicked", () => {
installMermaidInteractions(document);
document.body.innerHTML = `
<div class="mermaid-block" data-mermaid-status="ready">
<div class="mermaid-block__render" role="button" tabindex="0">
<svg><text>Diagram</text></svg>
</div>
<dialog class="mermaid-block__dialog">
<div class="mermaid-block__dialog-body"></div>
</dialog>
</div>
`;
const dialog = document.querySelector<HTMLDialogElement>(".mermaid-block__dialog");
const showModal = vi.fn();
Object.defineProperty(dialog!, "showModal", {
configurable: true,
value: showModal,
});
document.querySelector<HTMLElement>(".mermaid-block__render")?.click();
expect(showModal).toHaveBeenCalledOnce();
expect(dialog?.querySelector(".mermaid-block__dialog-body")?.innerHTML).toContain("<svg");
});
});

View File

@ -8,6 +8,7 @@ type MermaidApi = {
let mermaidApiPromise: Promise<MermaidApi> | null = null;
let renderScheduled = false;
let renderCounter = 0;
const interactionDocs = new WeakSet<Document>();
async function loadMermaidApi(): Promise<MermaidApi> {
if (!mermaidApiPromise) {
@ -17,6 +18,10 @@ async function loadMermaidApi(): Promise<MermaidApi> {
api.initialize({
startOnLoad: false,
securityLevel: "strict",
// Keep labels as native SVG text instead of foreignObject-backed HTML.
// The rendered SVG is sanitized before insertion, and HTML labels can
// lose their contents during that pass, leaving empty shapes behind.
htmlLabels: false,
});
return api;
})
@ -30,7 +35,90 @@ async function loadMermaidApi(): Promise<MermaidApi> {
return mermaidApiPromise;
}
export function installMermaidInteractions(root: ParentNode = document): void {
const doc = root instanceof Document ? root : root.ownerDocument;
if (!doc || interactionDocs.has(doc)) {
return;
}
interactionDocs.add(doc);
doc.addEventListener("click", (e) => {
const target = e.target;
if (!(target instanceof Element)) {
return;
}
const closeButton = target.closest<HTMLButtonElement>(".mermaid-block__dialog-close");
if (closeButton) {
closeMermaidDialog(closeButton.closest(".mermaid-block__dialog"));
return;
}
const dialog = target.closest<HTMLDialogElement>(".mermaid-block__dialog");
if (dialog && target === dialog) {
closeMermaidDialog(dialog);
return;
}
const renderTarget = target.closest<HTMLElement>(".mermaid-block__render");
if (!renderTarget) {
return;
}
const block = renderTarget.closest<HTMLElement>(".mermaid-block");
if (!block || block.dataset.mermaidStatus !== "ready") {
return;
}
openMermaidDialog(block);
});
doc.addEventListener("keydown", (e) => {
const target = e.target;
if (!(target instanceof HTMLElement) || !target.classList.contains("mermaid-block__render")) {
return;
}
if (e.key !== "Enter" && e.key !== " ") {
return;
}
const block = target.closest<HTMLElement>(".mermaid-block");
if (!block || block.dataset.mermaidStatus !== "ready") {
return;
}
e.preventDefault();
openMermaidDialog(block);
});
}
function openMermaidDialog(block: HTMLElement): void {
const dialog = block.querySelector<HTMLDialogElement>(".mermaid-block__dialog");
const body = block.querySelector<HTMLElement>(".mermaid-block__dialog-body");
const renderTarget = block.querySelector<HTMLElement>(".mermaid-block__render");
if (!dialog || !body || !renderTarget) {
return;
}
body.innerHTML = renderTarget.innerHTML;
if (typeof dialog.showModal === "function") {
dialog.showModal();
return;
}
dialog.setAttribute("open", "");
}
function closeMermaidDialog(dialog: HTMLDialogElement | null): void {
if (!dialog) {
return;
}
if (typeof dialog.close === "function") {
dialog.close();
return;
}
dialog.removeAttribute("open");
}
export function scheduleMermaidRender(root: ParentNode = document): void {
installMermaidInteractions(root);
if (renderScheduled) {
return;
}

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { handleCodeBlockCopyClick } from "../code-block-copy.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
@ -485,7 +486,10 @@ export function renderAgentFiles(params: {
}}
>${icons.x} Close</button>
</div>
<div class="md-preview-dialog__body sidebar-markdown">
<div
class="md-preview-dialog__body sidebar-markdown"
@click=${handleCodeBlockCopyClick}
>
${unsafeHTML(toSanitizedMarkdownHtml(draft))}
</div>
</div>

View File

@ -26,6 +26,7 @@ import {
type SlashCommandDef,
} from "../chat/slash-commands.ts";
import { isSttSupported, startStt, stopStt } from "../chat/speech.ts";
import { handleCodeBlockCopyClick } from "../code-block-copy.ts";
import { icons } from "../icons.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
@ -834,21 +835,6 @@ export function renderChat(props: ChatProps) {
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const handleCodeBlockCopy = (e: Event) => {
const btn = (e.target as HTMLElement).closest(".code-block-copy");
if (!btn) {
return;
}
const code = (btn as HTMLElement).dataset.code ?? "";
navigator.clipboard.writeText(code).then(
() => {
btn.classList.add("copied");
setTimeout(() => btn.classList.remove("copied"), 1500);
},
() => {},
);
};
const chatItems = buildChatItems(props);
const isEmpty = chatItems.length === 0 && !props.loading;
@ -858,7 +844,7 @@ export function renderChat(props: ChatProps) {
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
@click=${handleCodeBlockCopy}
@click=${handleCodeBlockCopyClick}
>
<div class="chat-thread-inner">
${

View File

@ -1,5 +1,6 @@
import { html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { handleCodeBlockCopyClick } from "../code-block-copy.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
@ -19,7 +20,7 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
${icons.x}
</button>
</div>
<div class="sidebar-content">
<div class="sidebar-content" @click=${handleCodeBlockCopyClick}>
${
props.error
? html`