Add Mermaid diagram export actions (#9857)

* feat: add Mermaid diagram export actions

* fix: stabilize Mermaid export pipeline

* fix: format Mermaid image save handler

* fix: limit Mermaid actions to copy and download

* fix: polish Mermaid copy download controls

* fix: simplify Mermaid action menus

* fix: center Mermaid dropdown chevrons

* refactor: use shared dropdowns for Mermaid actions
This commit is contained in:
Marius
2026-05-07 13:06:28 +02:00
committed by GitHub
parent 0722158de1
commit 6fe38e47bc
32 changed files with 601 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support copying, previewing, and exporting rendered Mermaid diagrams.
@@ -6,6 +6,7 @@ Chat Markdown renders fenced `mermaid` code blocks as diagrams after a response
- Valid `mermaid` fences render inline as SVG diagrams.
- The original Mermaid source remains available through the existing code-block copy button.
- Rendered diagrams include Copy and Download menus for Mermaid source, SVG, and PNG formats.
- Invalid Mermaid syntax shows a contained error state and keeps the source visible.
- Diagrams are not rendered while a message is streaming, which avoids repeated parse/render work on every token.
- Diagram colors are derived from the active VS Code/Kilo CSS variables so light, dark, and high-contrast themes can render with matching backgrounds, text, borders, and link colors.
@@ -13,4 +14,4 @@ Chat Markdown renders fenced `mermaid` code blocks as diagrams after a response
## Limitations
- Mermaid is bundled by the current webview build, so bundle splitting remains a future optimization.
- Advanced legacy actions are not restored yet: AI syntax fixing, PNG open/save, export, and zoom modal.
- Advanced legacy actions are not restored yet: AI syntax fixing and zoom modal.
+9 -2
View File
@@ -16,7 +16,13 @@ import type { EditorContext, IndexingStatus } from "./services/cli-backend/types
import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController"
import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete"
import { buildWebviewHtml, getWebviewFontSize } from "./utils"
import { TelemetryProxy, type TelemetryPropertiesProvider, pushTelemetryState, watchTelemetryState } from "./services/telemetry" // prettier-ignore
import { saveImage } from "./kilo-provider/save-image"
import {
TelemetryProxy,
type TelemetryPropertiesProvider,
pushTelemetryState,
watchTelemetryState,
} from "./services/telemetry"
import {
sessionToWebview,
indexProvidersById,
@@ -747,7 +753,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
console.error("[Kilo New] handleForkSession failed:", e),
)
break
case "retryConnection":
console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...")
this.initializeConnection().catch((e) =>
@@ -760,6 +765,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "previewImage":
this.handlePreviewImage(message.dataUrl, message.filename)
break
case "saveImage":
return saveImage(this.getWorkspaceDirectory(this.currentSession?.id), message)
case "openFile":
if (message.filePath) {
this.handleOpenFile(message.filePath, message.line, message.column)
@@ -481,6 +481,7 @@ export class AgentManagerProvider implements Disposable {
return null
}
if (m.type === "previewImage") return msg
if (m.type === "saveImage") return msg
if (m.type === "agentManager.showExistingLocalTerminal") {
this.terminalManager.syncLocalOnSessionSwitch()
return null
@@ -564,6 +564,12 @@ interface PreviewImageIn {
filename: string
}
interface SaveImageIn {
type: "saveImage"
dataUrl: string
filename: string
}
interface LoadMessagesIn {
type: "loadMessages"
sessionID: string
@@ -750,6 +756,7 @@ export type AgentManagerInMessage =
| OpenFileIn
| GenericOpenFileIn
| PreviewImageIn
| SaveImageIn
| LoadMessagesIn
| SendMessageIn
| SendCommandIn
@@ -0,0 +1,25 @@
import * as path from "path"
import * as vscode from "vscode"
import { parseImage } from "../image-preview"
type ImageMessage = {
dataUrl: string
filename: string
}
export function saveImage(dir: string, msg: ImageMessage) {
void save(dir, msg).catch((err) => console.error("[Kilo New] KiloProvider: Failed to save image:", err))
}
async function save(dir: string, msg: ImageMessage) {
const img = parseImage(msg.dataUrl, msg.filename)
if (!img) return undefined
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(path.join(dir, img.name)),
filters: { Images: [img.ext] },
saveLabel: "Save",
})
if (!uri) return undefined
return vscode.workspace.fs.writeFile(uri, img.data)
}
@@ -83,7 +83,7 @@ import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ChatView } from "../src/components/chat"
import HistoryView from "../src/components/history/HistoryView"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { LanguageBridge, DataBridge } from "../src/App"
import { LanguageBridge, DataBridge, MermaidDownloadBridge } from "../src/App"
import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
import { nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, reconcileLocalSessions, LOCAL } from "./navigate"
@@ -122,7 +122,6 @@ import { createMarkdownRender } from "./review-preferences"
import { setTabWidths } from "./tab-widths"
import "./agent-manager.css"
import "./agent-manager-review.css"
const REVIEW_TAB_ID = "review"
interface SetupState {
@@ -3141,6 +3140,7 @@ export const AgentManagerApp: Component = () => {
<ThemeProvider defaultTheme="kilo-vscode">
<DialogProvider>
<VSCodeProvider>
<MermaidDownloadBridge />
<ServerProvider>
<LanguageBridge>
<MarkedProvider>
@@ -179,6 +179,27 @@ export const LanguageBridge: Component<{ children: any }> = (props) => {
)
}
type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }>
export const MermaidDownloadBridge: Component = () => {
const vscode = useVSCode()
onMount(() => {
const save = (event: Event) => {
const detail = (event as MermaidImageEvent).detail
if (!detail?.dataUrl || !detail.filename) return
event.preventDefault()
vscode.postMessage({ type: "saveImage", dataUrl: detail.dataUrl, filename: detail.filename })
}
window.addEventListener("kilo:save-image", save)
onCleanup(() => {
window.removeEventListener("kilo:save-image", save)
})
})
return null
}
// Inner app component that uses the contexts
const AppContent: Component = () => {
const [currentView, setCurrentView] = createSignal<ViewType>("newTask")
@@ -343,6 +364,7 @@ const App: Component = () => {
<ThemeProvider defaultTheme="kilo-vscode">
<DialogProvider>
<VSCodeProvider>
<MermaidDownloadBridge />
<ServerProvider>
<LanguageBridge>
<MarkedProvider>
@@ -802,6 +802,12 @@ export interface PreviewImageRequest {
filename: string
}
export interface SaveImageRequest {
type: "saveImage"
dataUrl: string
filename: string
}
// Set default base branch (webview → extension)
export interface SetDefaultBaseBranchRequest {
type: "agentManager.setDefaultBaseBranch"
@@ -1124,6 +1130,7 @@ export type WebviewMessage =
| RetryConnectionRequest
| OpenSubAgentViewerRequest
| PreviewImageRequest
| SaveImageRequest
| SetDefaultBaseBranchRequest
| AgentManagerOpenSessionsMessage
| RequestAutoApproveStateMessage
+23 -6
View File
@@ -7,7 +7,7 @@ import { ComponentProps, createEffect, createResource, createSignal, onCleanup,
import { isServer } from "solid-js/web"
import { stream } from "./markdown-stream"
import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change
import { hasMermaid, preserveMermaid, renderMermaid } from "../kilocode/markdown-mermaid" // kilocode_change
import { hasMermaid, preserveMermaid, renderMermaid, type MermaidLabels } from "../kilocode/markdown-mermaid" // kilocode_change
type Entry = {
hash: string
@@ -340,6 +340,23 @@ export function Markdown(
copied: i18n.t("ui.message.copied"),
}
// kilocode_change start: Mermaid diagram rendering
const mermaid = {
rendering: i18n.t("ui.mermaid.rendering"),
renderError: (message: string) => i18n.t("ui.mermaid.renderError", { message }),
errorDefault: i18n.t("ui.mermaid.errorDefault"),
errorEmpty: i18n.t("ui.mermaid.errorEmpty"),
copied: i18n.t("ui.message.copied"),
copy: i18n.t("ui.message.copy"),
download: i18n.t("ui.mermaid.download"),
copySource: i18n.t("ui.mermaid.copySource"),
copySvg: i18n.t("ui.mermaid.copySvg"),
copyPng: i18n.t("ui.mermaid.copyPng"),
downloadSvg: i18n.t("ui.mermaid.downloadSvg"),
downloadPng: i18n.t("ui.mermaid.downloadPng"),
}
// kilocode_change end
// kilocode_change start
const fast = tryFastRender(container, content, local.streaming, decorate, setupCodeCopy, () => labels, copyCleanup)
if (fast.handled) {
@@ -352,7 +369,7 @@ export function Markdown(
pendingLabels = undefined
}
copyCleanup = fast.copyCleanup
kickMermaid(container, local.streaming ?? false)
kickMermaid(container, local.streaming ?? false, mermaid)
kickHighlight(container, labels)
return
}
@@ -417,7 +434,7 @@ export function Markdown(
const fromHash = fromEl.getAttribute("data-source-hash")
const toCode = toEl.querySelector("code")?.textContent ?? ""
if (fromHash === fnv1a(toCode)) return false
// Source changed during streaming — fall through so morphdom replaces
// Source changed during streaming — fall through so morphdom replaces // kilocode_change
// the stale highlighted block with the updated plain block, which will
// be re-highlighted on the next deferredHighlight pass.
}
@@ -426,7 +443,7 @@ export function Markdown(
})
// kilocode_change end
kickMermaid(container, local.streaming ?? false) // kilocode_change
kickMermaid(container, local.streaming ?? false, mermaid) // kilocode_change
kickHighlight(container, nextLabels)
})
// kilocode_change end
@@ -456,7 +473,7 @@ export function Markdown(
// kilocode_change end
// kilocode_change start: Mermaid diagram rendering
function kickMermaid(container: HTMLDivElement, streaming: boolean) {
function kickMermaid(container: HTMLDivElement, streaming: boolean, labels: MermaidLabels) {
mermaidState.signal.aborted = true
mermaidState.gen++
if (!hasMermaid(container)) return
@@ -465,7 +482,7 @@ export function Markdown(
const gen = mermaidState.gen
const signal = { aborted: false }
mermaidState.signal = signal
void renderMermaid(container, signal).catch((err) => {
void renderMermaid(container, signal, labels).catch((err) => {
if (gen !== mermaidState.gen || signal.aborted) return
console.warn("Mermaid render failed", err)
})
+12
View File
@@ -96,6 +96,18 @@ export const dict = {
"ui.textField.copied": "تم النسخ",
"ui.imagePreview.alt": "معاينة الصورة",
// kilocode_change start
"ui.mermaid.rendering": "جارٍ عرض مخطط Mermaid...",
"ui.mermaid.renderError": "فشل عرض Mermaid: {{message}}",
"ui.mermaid.errorDefault": "تعذر عرض مخطط Mermaid.",
"ui.mermaid.errorEmpty": "عرض Mermaid مخططًا فارغًا.",
"ui.mermaid.download": "تنزيل",
"ui.mermaid.copySource": "نسخ مصدر Mermaid",
"ui.mermaid.copySvg": "نسخ SVG",
"ui.mermaid.copyPng": "نسخ PNG",
"ui.mermaid.downloadSvg": "تنزيل SVG",
"ui.mermaid.downloadPng": "تنزيل PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "محتوى قابل للتمرير",
"ui.tool.read": "قراءة",
+12
View File
@@ -96,6 +96,18 @@ export const dict = {
"ui.textField.copied": "Copiado",
"ui.imagePreview.alt": "Visualização de imagem",
// kilocode_change start
"ui.mermaid.rendering": "Renderizando diagrama Mermaid...",
"ui.mermaid.renderError": "Falha ao renderizar Mermaid: {{message}}",
"ui.mermaid.errorDefault": "Não foi possível renderizar o diagrama Mermaid.",
"ui.mermaid.errorEmpty": "Mermaid renderizou um diagrama vazio.",
"ui.mermaid.download": "Baixar",
"ui.mermaid.copySource": "Copiar código Mermaid",
"ui.mermaid.copySvg": "Copiar SVG",
"ui.mermaid.copyPng": "Copiar PNG",
"ui.mermaid.downloadSvg": "Baixar SVG",
"ui.mermaid.downloadPng": "Baixar PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "conteúdo rolável",
"ui.tool.read": "Ler",
+12
View File
@@ -100,6 +100,18 @@ export const dict = {
"ui.textField.copied": "Kopirano",
"ui.imagePreview.alt": "Pregled slike",
// kilocode_change start
"ui.mermaid.rendering": "Renderovanje Mermaid dijagrama...",
"ui.mermaid.renderError": "Renderovanje Mermaid dijagrama nije uspjelo: {{message}}",
"ui.mermaid.errorDefault": "Nije moguće renderovati Mermaid dijagram.",
"ui.mermaid.errorEmpty": "Mermaid je renderovao prazan dijagram.",
"ui.mermaid.download": "Preuzmi",
"ui.mermaid.copySource": "Kopiraj Mermaid izvor",
"ui.mermaid.copySvg": "Kopiraj SVG",
"ui.mermaid.copyPng": "Kopiraj PNG",
"ui.mermaid.downloadSvg": "Preuzmi SVG",
"ui.mermaid.downloadPng": "Preuzmi PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "sadržaj za pomjeranje",
"ui.tool.read": "Čitanje",
+12
View File
@@ -95,6 +95,18 @@ export const dict = {
"ui.textField.copied": "Kopieret",
"ui.imagePreview.alt": "Billedforhåndsvisning",
// kilocode_change start
"ui.mermaid.rendering": "Renderer Mermaid-diagram...",
"ui.mermaid.renderError": "Mermaid-rendering mislykkedes: {{message}}",
"ui.mermaid.errorDefault": "Kan ikke rendere Mermaid-diagram.",
"ui.mermaid.errorEmpty": "Mermaid renderede et tomt diagram.",
"ui.mermaid.download": "Download",
"ui.mermaid.copySource": "Kopiér Mermaid-kilde",
"ui.mermaid.copySvg": "Kopiér SVG",
"ui.mermaid.copyPng": "Kopiér PNG",
"ui.mermaid.downloadSvg": "Download SVG",
"ui.mermaid.downloadPng": "Download PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "rulbart indhold",
"ui.tool.read": "Læs",
+12
View File
@@ -101,6 +101,18 @@ export const dict = {
"ui.textField.copied": "Kopiert",
"ui.imagePreview.alt": "Bildvorschau",
// kilocode_change start
"ui.mermaid.rendering": "Mermaid-Diagramm wird gerendert...",
"ui.mermaid.renderError": "Mermaid-Rendering fehlgeschlagen: {{message}}",
"ui.mermaid.errorDefault": "Mermaid-Diagramm kann nicht gerendert werden.",
"ui.mermaid.errorEmpty": "Mermaid hat ein leeres Diagramm gerendert.",
"ui.mermaid.download": "Herunterladen",
"ui.mermaid.copySource": "Mermaid-Quelltext kopieren",
"ui.mermaid.copySvg": "SVG kopieren",
"ui.mermaid.copyPng": "PNG kopieren",
"ui.mermaid.downloadSvg": "SVG herunterladen",
"ui.mermaid.downloadPng": "PNG herunterladen",
// kilocode_change end
"ui.scrollView.ariaLabel": "scrollbarer Inhalt",
"ui.tool.read": "Lesen",
+12
View File
@@ -102,6 +102,18 @@ export const dict: Record<string, string> = {
"ui.textField.copied": "Copied",
"ui.imagePreview.alt": "Image preview",
// kilocode_change start
"ui.mermaid.rendering": "Rendering Mermaid diagram...",
"ui.mermaid.renderError": "Mermaid render failed: {{message}}",
"ui.mermaid.errorDefault": "Unable to render Mermaid diagram.",
"ui.mermaid.errorEmpty": "Mermaid rendered an empty diagram.",
"ui.mermaid.download": "Download",
"ui.mermaid.copySource": "Copy Mermaid source",
"ui.mermaid.copySvg": "Copy SVG",
"ui.mermaid.copyPng": "Copy PNG",
"ui.mermaid.downloadSvg": "Download SVG",
"ui.mermaid.downloadPng": "Download PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "scrollable content",
"ui.tool.read": "Read",
+12
View File
@@ -96,6 +96,18 @@ export const dict = {
"ui.textField.copied": "Copiado",
"ui.imagePreview.alt": "Vista previa de imagen",
// kilocode_change start
"ui.mermaid.rendering": "Renderizando diagrama Mermaid...",
"ui.mermaid.renderError": "Error al renderizar Mermaid: {{message}}",
"ui.mermaid.errorDefault": "No se puede renderizar el diagrama Mermaid.",
"ui.mermaid.errorEmpty": "Mermaid renderizó un diagrama vacío.",
"ui.mermaid.download": "Descargar",
"ui.mermaid.copySource": "Copiar código fuente Mermaid",
"ui.mermaid.copySvg": "Copiar SVG",
"ui.mermaid.copyPng": "Copiar PNG",
"ui.mermaid.downloadSvg": "Descargar SVG",
"ui.mermaid.downloadPng": "Descargar PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "contenido desplazable",
"ui.tool.read": "Leer",
+12
View File
@@ -96,6 +96,18 @@ export const dict = {
"ui.textField.copied": "Copié",
"ui.imagePreview.alt": "Aperçu de l'image",
// kilocode_change start
"ui.mermaid.rendering": "Rendu du diagramme Mermaid...",
"ui.mermaid.renderError": "Échec du rendu Mermaid : {{message}}",
"ui.mermaid.errorDefault": "Impossible de rendre le diagramme Mermaid.",
"ui.mermaid.errorEmpty": "Mermaid a rendu un diagramme vide.",
"ui.mermaid.download": "Télécharger",
"ui.mermaid.copySource": "Copier la source Mermaid",
"ui.mermaid.copySvg": "Copier le SVG",
"ui.mermaid.copyPng": "Copier le PNG",
"ui.mermaid.downloadSvg": "Télécharger le SVG",
"ui.mermaid.downloadPng": "Télécharger le PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "contenu défilable",
"ui.tool.read": "Lire",
+12
View File
@@ -95,6 +95,18 @@ export const dict = {
"ui.textField.copied": "コピーしました",
"ui.imagePreview.alt": "画像プレビュー",
// kilocode_change start
"ui.mermaid.rendering": "Mermaid 図をレンダリング中...",
"ui.mermaid.renderError": "Mermaid のレンダリングに失敗しました: {{message}}",
"ui.mermaid.errorDefault": "Mermaid 図をレンダリングできません。",
"ui.mermaid.errorEmpty": "Mermaid が空の図をレンダリングしました。",
"ui.mermaid.download": "ダウンロード",
"ui.mermaid.copySource": "Mermaid ソースをコピー",
"ui.mermaid.copySvg": "SVG をコピー",
"ui.mermaid.copyPng": "PNG をコピー",
"ui.mermaid.downloadSvg": "SVG をダウンロード",
"ui.mermaid.downloadPng": "PNG をダウンロード",
// kilocode_change end
"ui.scrollView.ariaLabel": "スクロール可能なコンテンツ",
"ui.tool.read": "読み込み",
+12
View File
@@ -96,6 +96,18 @@ export const dict = {
"ui.textField.copied": "복사됨",
"ui.imagePreview.alt": "이미지 미리보기",
// kilocode_change start
"ui.mermaid.rendering": "Mermaid 다이어그램 렌더링 중...",
"ui.mermaid.renderError": "Mermaid 렌더링 실패: {{message}}",
"ui.mermaid.errorDefault": "Mermaid 다이어그램을 렌더링할 수 없습니다.",
"ui.mermaid.errorEmpty": "Mermaid가 빈 다이어그램을 렌더링했습니다.",
"ui.mermaid.download": "다운로드",
"ui.mermaid.copySource": "Mermaid 소스 복사",
"ui.mermaid.copySvg": "SVG 복사",
"ui.mermaid.copyPng": "PNG 복사",
"ui.mermaid.downloadSvg": "SVG 다운로드",
"ui.mermaid.downloadPng": "PNG 다운로드",
// kilocode_change end
"ui.scrollView.ariaLabel": "스크롤 가능한 콘텐츠",
"ui.tool.read": "읽기",
+12
View File
@@ -97,6 +97,18 @@ export const dict: Record<string, string> = {
"ui.textField.copied": "Gekopieerd",
"ui.imagePreview.alt": "Afbeeldingsvoorbeeld",
// kilocode_change start
"ui.mermaid.rendering": "Mermaid-diagram renderen...",
"ui.mermaid.renderError": "Mermaid-rendering mislukt: {{message}}",
"ui.mermaid.errorDefault": "Kan Mermaid-diagram niet renderen.",
"ui.mermaid.errorEmpty": "Mermaid heeft een leeg diagram gerenderd.",
"ui.mermaid.download": "Downloaden",
"ui.mermaid.copySource": "Mermaid-bron kopiëren",
"ui.mermaid.copySvg": "SVG kopiëren",
"ui.mermaid.copyPng": "PNG kopiëren",
"ui.mermaid.downloadSvg": "SVG downloaden",
"ui.mermaid.downloadPng": "PNG downloaden",
// kilocode_change end
"ui.scrollView.ariaLabel": "scrollbare inhoud",
"ui.fileSearch.placeholder": "Zoeken",
+12
View File
@@ -99,6 +99,18 @@ export const dict: Record<Keys, string> = {
"ui.textField.copied": "Kopiert",
"ui.imagePreview.alt": "Bildeforhåndsvisning",
// kilocode_change start
"ui.mermaid.rendering": "Gjengir Mermaid-diagram...",
"ui.mermaid.renderError": "Mermaid-gjengivelse mislyktes: {{message}}",
"ui.mermaid.errorDefault": "Kan ikke gjengi Mermaid-diagram.",
"ui.mermaid.errorEmpty": "Mermaid gjenga et tomt diagram.",
"ui.mermaid.download": "Last ned",
"ui.mermaid.copySource": "Kopier Mermaid-kilde",
"ui.mermaid.copySvg": "Kopier SVG",
"ui.mermaid.copyPng": "Kopier PNG",
"ui.mermaid.downloadSvg": "Last ned SVG",
"ui.mermaid.downloadPng": "Last ned PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "rullbart innhold",
"ui.tool.read": "Les",
+12
View File
@@ -95,6 +95,18 @@ export const dict = {
"ui.textField.copied": "Skopiowano",
"ui.imagePreview.alt": "Podgląd obrazu",
// kilocode_change start
"ui.mermaid.rendering": "Renderowanie diagramu Mermaid...",
"ui.mermaid.renderError": "Renderowanie Mermaid nie powiodło się: {{message}}",
"ui.mermaid.errorDefault": "Nie można wyrenderować diagramu Mermaid.",
"ui.mermaid.errorEmpty": "Mermaid wyrenderował pusty diagram.",
"ui.mermaid.download": "Pobierz",
"ui.mermaid.copySource": "Kopiuj źródło Mermaid",
"ui.mermaid.copySvg": "Kopiuj SVG",
"ui.mermaid.copyPng": "Kopiuj PNG",
"ui.mermaid.downloadSvg": "Pobierz SVG",
"ui.mermaid.downloadPng": "Pobierz PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "treść przewijana",
"ui.tool.read": "Odczyt",
+12
View File
@@ -95,6 +95,18 @@ export const dict = {
"ui.textField.copied": "Скопировано",
"ui.imagePreview.alt": "Предпросмотр изображения",
// kilocode_change start
"ui.mermaid.rendering": "Отрисовка диаграммы Mermaid...",
"ui.mermaid.renderError": "Не удалось отрисовать Mermaid: {{message}}",
"ui.mermaid.errorDefault": "Не удалось отрисовать диаграмму Mermaid.",
"ui.mermaid.errorEmpty": "Mermaid отрисовал пустую диаграмму.",
"ui.mermaid.download": "Скачать",
"ui.mermaid.copySource": "Копировать исходный код Mermaid",
"ui.mermaid.copySvg": "Копировать SVG",
"ui.mermaid.copyPng": "Копировать PNG",
"ui.mermaid.downloadSvg": "Скачать SVG",
"ui.mermaid.downloadPng": "Скачать PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "прокручиваемый контент",
"ui.tool.read": "Чтение",
+12
View File
@@ -97,6 +97,18 @@ export const dict = {
"ui.textField.copied": "คัดลอกแล้ว",
"ui.imagePreview.alt": "ตัวอย่างรูปภาพ",
// kilocode_change start
"ui.mermaid.rendering": "กำลังเรนเดอร์ไดอะแกรม Mermaid...",
"ui.mermaid.renderError": "เรนเดอร์ Mermaid ไม่สำเร็จ: {{message}}",
"ui.mermaid.errorDefault": "ไม่สามารถเรนเดอร์ไดอะแกรม Mermaid ได้",
"ui.mermaid.errorEmpty": "Mermaid เรนเดอร์ไดอะแกรมว่าง",
"ui.mermaid.download": "ดาวน์โหลด",
"ui.mermaid.copySource": "คัดลอกซอร์ส Mermaid",
"ui.mermaid.copySvg": "คัดลอก SVG",
"ui.mermaid.copyPng": "คัดลอก PNG",
"ui.mermaid.downloadSvg": "ดาวน์โหลด SVG",
"ui.mermaid.downloadPng": "ดาวน์โหลด PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "เนื้อหาที่เลื่อนได้",
"ui.tool.read": "อ่าน",
+12
View File
@@ -102,6 +102,18 @@ export const dict = {
"ui.textField.copied": "Kopyalandı",
"ui.imagePreview.alt": "Görsel önizleme",
// kilocode_change start
"ui.mermaid.rendering": "Mermaid diyagramı işleniyor...",
"ui.mermaid.renderError": "Mermaid işleme başarısız: {{message}}",
"ui.mermaid.errorDefault": "Mermaid diyagramı işlenemiyor.",
"ui.mermaid.errorEmpty": "Mermaid boş bir diyagram işledi.",
"ui.mermaid.download": "İndir",
"ui.mermaid.copySource": "Mermaid kaynağını kopyala",
"ui.mermaid.copySvg": "SVG kopyala",
"ui.mermaid.copyPng": "PNG kopyala",
"ui.mermaid.downloadSvg": "SVG indir",
"ui.mermaid.downloadPng": "PNG indir",
// kilocode_change end
"ui.scrollView.ariaLabel": "kaydırılabilir içerik",
"ui.tool.read": "Oku",
+12
View File
@@ -102,6 +102,18 @@ export const dict = {
"ui.textField.copied": "Скопійовано",
"ui.imagePreview.alt": "Попередній перегляд зображення",
// kilocode_change start
"ui.mermaid.rendering": "Відтворення діаграми Mermaid...",
"ui.mermaid.renderError": "Не вдалося відтворити Mermaid: {{message}}",
"ui.mermaid.errorDefault": "Не вдалося відтворити діаграму Mermaid.",
"ui.mermaid.errorEmpty": "Mermaid відтворив порожню діаграму.",
"ui.mermaid.download": "Завантажити",
"ui.mermaid.copySource": "Копіювати вихідний код Mermaid",
"ui.mermaid.copySvg": "Копіювати SVG",
"ui.mermaid.copyPng": "Копіювати PNG",
"ui.mermaid.downloadSvg": "Завантажити SVG",
"ui.mermaid.downloadPng": "Завантажити PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "вміст з прокруткою",
"ui.fileSearch.placeholder": "Знайти",
+12
View File
@@ -100,6 +100,18 @@ export const dict = {
"ui.textField.copied": "已复制",
"ui.imagePreview.alt": "图片预览",
// kilocode_change start
"ui.mermaid.rendering": "正在渲染 Mermaid 图表...",
"ui.mermaid.renderError": "Mermaid 渲染失败:{{message}}",
"ui.mermaid.errorDefault": "无法渲染 Mermaid 图表。",
"ui.mermaid.errorEmpty": "Mermaid 渲染了一个空图表。",
"ui.mermaid.download": "下载",
"ui.mermaid.copySource": "复制 Mermaid 源码",
"ui.mermaid.copySvg": "复制 SVG",
"ui.mermaid.copyPng": "复制 PNG",
"ui.mermaid.downloadSvg": "下载 SVG",
"ui.mermaid.downloadPng": "下载 PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "可滚动内容",
"ui.tool.read": "读取",
+12
View File
@@ -100,6 +100,18 @@ export const dict = {
"ui.textField.copied": "已複製",
"ui.imagePreview.alt": "圖片預覽",
// kilocode_change start
"ui.mermaid.rendering": "正在渲染 Mermaid 圖表...",
"ui.mermaid.renderError": "Mermaid 渲染失敗:{{message}}",
"ui.mermaid.errorDefault": "無法渲染 Mermaid 圖表。",
"ui.mermaid.errorEmpty": "Mermaid 渲染了一個空圖表。",
"ui.mermaid.download": "下載",
"ui.mermaid.copySource": "複製 Mermaid 原始碼",
"ui.mermaid.copySvg": "複製 SVG",
"ui.mermaid.copyPng": "複製 PNG",
"ui.mermaid.downloadSvg": "下載 SVG",
"ui.mermaid.downloadPng": "下載 PNG",
// kilocode_change end
"ui.scrollView.ariaLabel": "可捲動內容",
"ui.tool.read": "讀取",
@@ -0,0 +1,82 @@
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import { Button } from "../components/button"
import { DropdownMenu } from "../components/dropdown-menu"
import type { MermaidLabels } from "./markdown-mermaid"
type Props = {
labels: MermaidLabels
onCopySource: () => Promise<void>
onCopySvg: () => Promise<void>
onCopyPng: () => Promise<void>
onDownloadSvg: () => void
onDownloadPng: () => Promise<void>
}
function Chevron() {
return (
<span data-slot="markdown-mermaid-chevron" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6" />
</svg>
</span>
)
}
function Trigger(props: { label: string; copied?: boolean; copiedLabel?: string }) {
return (
<DropdownMenu.Trigger as={Button} variant="secondary" size="small" class="markdown-mermaid-trigger">
<span>{props.copied ? props.copiedLabel : props.label}</span>
<Chevron />
</DropdownMenu.Trigger>
)
}
function Item(props: { label: string; onSelect: () => void }) {
return (
<DropdownMenu.Item onSelect={props.onSelect}>
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)
}
export function MermaidActions(props: Props) {
const [copied, setCopied] = createSignal(false)
const copy = (run: () => Promise<void>) => {
void run().then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<div data-slot="markdown-mermaid-actions">
<DropdownMenu gutter={4} placement="bottom-start">
<Trigger label={props.labels.copy} copied={copied()} copiedLabel={props.labels.copied} />
<DropdownMenu.Portal>
<DropdownMenu.Content>
<Item label={props.labels.copySource} onSelect={() => copy(props.onCopySource)} />
<Item label={props.labels.copySvg} onSelect={() => copy(props.onCopySvg)} />
<Item label={props.labels.copyPng} onSelect={() => copy(props.onCopyPng)} />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<DropdownMenu gutter={4} placement="bottom-start">
<Trigger label={props.labels.download} />
<DropdownMenu.Portal>
<DropdownMenu.Content>
<Item label={props.labels.downloadSvg} onSelect={props.onDownloadSvg} />
<Item label={props.labels.downloadPng} onSelect={() => void props.onDownloadPng()} />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
}
export function mountMermaidActions(el: HTMLElement, props: Props) {
const host = document.createElement("div")
host.setAttribute("data-slot", "markdown-mermaid-actions-root")
el.insertBefore(host, el.firstChild)
return render(() => <MermaidActions {...props} />, host)
}
@@ -4,6 +4,10 @@
border-radius: 6px;
background: var(--surface-base);
overflow: hidden;
> [data-slot="markdown-copy-button"] {
display: none;
}
}
[data-component="markdown-mermaid"] {
@@ -15,11 +19,43 @@
}
[data-component="markdown-mermaid"][data-state="rendered"] {
display: block;
}
[data-slot="markdown-mermaid-actions"] {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
padding-right: 32px;
}
[data-slot="markdown-mermaid-menu-chevron"] {
align-items: center;
color: var(--text-weak);
display: inline-flex;
flex: 0 0 12px;
height: 12px;
justify-content: center;
transform: rotate(0deg);
transition: transform 0.15s ease;
width: 12px;
}
[data-slot="markdown-mermaid-menu-chevron"] > svg {
display: block;
height: 12px;
width: 12px;
}
[data-expanded] [data-slot="markdown-mermaid-menu-chevron"] {
transform: rotate(180deg);
}
[data-component="markdown-mermaid"][data-state="rendered"] > svg {
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
+152 -9
View File
@@ -1,5 +1,6 @@
import DOMPurify from "dompurify"
import { fnv1a } from "../context/marked"
import { mountMermaidActions } from "./markdown-mermaid-actions"
const svgConfig = {
USE_PROFILES: { html: true, svg: true, svgFilters: true },
@@ -10,11 +11,43 @@ const svgConfig = {
type Mermaid = typeof import("mermaid").default
export type MermaidLabels = {
rendering: string
renderError: (message: string) => string
errorDefault: string
errorEmpty: string
copied: string
copy: string
download: string
copySource: string
copySvg: string
copyPng: string
downloadSvg: string
downloadPng: string
}
const labels: MermaidLabels = {
rendering: "Rendering Mermaid diagram...",
renderError: (message) => `Mermaid render failed: ${message}`,
errorDefault: "Unable to render Mermaid diagram.",
errorEmpty: "Mermaid rendered an empty diagram.",
copied: "Copied",
copy: "Copy",
download: "Download",
copySource: "Copy Mermaid source",
copySvg: "Copy SVG",
copyPng: "Copy PNG",
downloadSvg: "Download SVG",
downloadPng: "Download PNG",
}
const cache: { promise?: Promise<Mermaid>; id: number; queue: Promise<void> } = {
id: 0,
queue: Promise.resolve(),
}
const actions = new WeakMap<HTMLElement, () => void>()
async function load() {
if (!cache.promise) {
cache.promise = import("mermaid").then((mod) => mod.default)
@@ -164,10 +197,14 @@ function sanitize(svg: string) {
return DOMPurify.sanitize(svg, svgConfig)
}
function message(err: unknown) {
function mergeLabels(input?: Partial<MermaidLabels>) {
return { ...labels, ...input }
}
function message(err: unknown, labels: MermaidLabels) {
if (err instanceof Error) return err.message
if (typeof err === "string") return err
return "Unable to render Mermaid diagram."
return labels.errorDefault
}
function panel(wrapper: HTMLElement) {
@@ -183,14 +220,117 @@ function panel(wrapper: HTMLElement) {
return el
}
function fail(wrapper: HTMLElement, pre: HTMLPreElement, err: unknown) {
function fail(wrapper: HTMLElement, pre: HTMLPreElement, err: unknown, labels: MermaidLabels) {
const el = panel(wrapper)
el.setAttribute("data-state", "error")
el.textContent = `Mermaid render failed: ${message(err)}`
el.textContent = labels.renderError(message(err, labels))
wrapper.setAttribute("data-mermaid-state", "error")
pre.hidden = false
}
function cleanupActions(el: HTMLElement) {
const dispose = actions.get(el)
if (!dispose) return
dispose()
actions.delete(el)
}
function serialize(svg: SVGSVGElement) {
const clone = svg.cloneNode(true) as SVGSVGElement
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg")
return new XMLSerializer().serializeToString(clone)
}
function dataUrl(type: string, content: string) {
return `data:${type};base64,${btoa(unescape(encodeURIComponent(content)))}`
}
function size(svg: SVGSVGElement) {
const box = svg.viewBox.baseVal
const rect = svg.getBoundingClientRect()
const width = Math.max(Math.ceil(box?.width || rect.width || 1), 1)
const height = Math.max(Math.ceil(box?.height || rect.height || 1), 1)
return { width, height }
}
async function png(svg: SVGSVGElement) {
const source = serialize(svg)
const url = dataUrl("image/svg+xml", source)
const img = new Image()
const dims = size(svg)
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error("Unable to export Mermaid diagram."))
img.src = url
})
const canvas = document.createElement("canvas")
canvas.width = dims.width
canvas.height = dims.height
const ctx = canvas.getContext("2d")
if (!ctx) throw new Error("Unable to export Mermaid diagram.")
ctx.drawImage(img, 0, 0, dims.width, dims.height)
return canvas.toDataURL("image/png")
}
function download(url: string, filename: string) {
const link = document.createElement("a")
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
}
function save(url: string, filename: string) {
const event = new CustomEvent("kilo:save-image", {
bubbles: true,
cancelable: true,
detail: { dataUrl: url, filename },
})
window.dispatchEvent(event)
if (event.defaultPrevented) return
download(url, filename)
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text)
}
async function copyPng(svg: SVGSVGElement) {
const url = await png(svg)
const blob = await (await fetch(url)).blob()
if (typeof ClipboardItem === "undefined") {
await navigator.clipboard.writeText(serialize(svg))
return
}
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])
}
function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, labels: MermaidLabels) {
const svg = el.querySelector("svg")
if (!(svg instanceof SVGSVGElement)) return
cleanupActions(el)
const old = el.querySelector('[data-slot="markdown-mermaid-actions-root"]')
old?.remove()
const sourceText = pre.querySelector("code")?.textContent ?? source
const sourceSvg = () => serialize(svg)
const sourceSvgUrl = () => dataUrl("image/svg+xml", sourceSvg())
actions.set(
el,
mountMermaidActions(el, {
labels,
onCopySource: () => copyText(sourceText),
onCopySvg: () => copyText(sourceSvg()),
onCopyPng: () => copyPng(svg),
onDownloadSvg: () => save(sourceSvgUrl(), "mermaid-diagram.svg"),
onDownloadPng: async () => save(await png(svg), "mermaid-diagram.png"),
}),
)
}
export function preserveMermaid(fromEl: Element, toEl: Element) {
if (!(fromEl instanceof HTMLElement)) return false
if (!(toEl instanceof HTMLElement)) return false
@@ -217,7 +357,8 @@ async function svg(renderer: Mermaid, source: string, cfg: ReturnType<typeof con
})
}
export async function renderMermaid(root: HTMLDivElement, signal: { aborted: boolean }) {
export async function renderMermaid(root: HTMLDivElement, signal: { aborted: boolean }, input?: Partial<MermaidLabels>) {
const label = mergeLabels(input)
const blocks = Array.from(root.querySelectorAll('pre > code[data-lang="mermaid"]'))
if (blocks.length === 0) return
@@ -228,7 +369,7 @@ export async function renderMermaid(root: HTMLDivElement, signal: { aborted: boo
if (!(pre instanceof HTMLPreElement)) continue
if (!(wrapper instanceof HTMLElement)) continue
if (wrapper.getAttribute("data-component") !== "markdown-code") continue
fail(wrapper, pre, err)
fail(wrapper, pre, err, label)
}
})
if (!renderer) return
@@ -270,7 +411,7 @@ export async function renderMermaid(root: HTMLDivElement, signal: { aborted: boo
const el = panel(wrapper)
if (!keep) {
el.setAttribute("data-state", "rendering")
el.textContent = "Rendering Mermaid diagram..."
el.textContent = label.rendering
pre.hidden = false
} else {
pre.hidden = true
@@ -281,15 +422,17 @@ export async function renderMermaid(root: HTMLDivElement, signal: { aborted: boo
if (signal.aborted || !root.isConnected || !wrapper.isConnected) return
const safe = sanitize(result.svg)
if (!safe) throw new Error("Mermaid rendered an empty diagram.")
if (!safe) throw new Error(label.errorEmpty)
cleanupActions(el)
el.setAttribute("data-state", "rendered")
el.innerHTML = safe
renderActions(el, pre, source, label)
wrapper.setAttribute("data-mermaid-state", "rendered")
pre.hidden = true
} catch (err) {
if (signal.aborted || !root.isConnected || !wrapper.isConnected) return
fail(wrapper, pre, err)
fail(wrapper, pre, err, label)
}
}
}