control-ui: improve Mermaid interactions
This commit is contained in:
parent
2cc12a016c
commit
636d4e9412
@ -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;
|
||||
}
|
||||
|
||||
36
ui/src/ui/code-block-copy.test.ts
Normal file
36
ui/src/ui/code-block-copy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
ui/src/ui/code-block-copy.ts
Normal file
26
ui/src/ui/code-block-copy.ts
Normal 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);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
@ -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", () => {
|
||||
|
||||
@ -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
35
ui/src/ui/mermaid.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
${
|
||||
|
||||
@ -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`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user