Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-web-mermaid-label-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix mermaid diagram labels overflowing and overlapping their nodes when labels are long.
6 changes: 5 additions & 1 deletion apps/kimi-web/src/components/chat/Markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { MarkdownIt } from 'markstream-vue';
import { useIsDark } from '../../composables/useIsDark';
import type { FilePreviewRequest } from '../../types';
import { collectFilePathAliases, findFilePathLinks } from '../../lib/filePathLinks';
import { injectMermaidHtmlLabelsOff } from '../../lib/mermaidDirectives';
import { markdownRenderPlan } from '../../lib/markdownPerformance';
import { copyCodeBlockFallback, copyTextToClipboard } from '../../lib/clipboard';
import * as katexWorkerModule from 'markstream-vue/workers/katexRenderer.worker?worker&type=module';
Expand Down Expand Up @@ -386,7 +387,10 @@ type Segment =
const DIFF_FENCE_RE = /(^|\n)(?:```|~~~)diff\b[^\n]*\n([\s\S]*?)(?:\n)?(?:```|~~~)(?=\n|$)/g;

const segments = computed<Segment[]>(() => {
const text = rewriteImageSrcs(props.text ?? '');
// htmlLabels-off injection runs before the diff split so every mermaid
// fence (streaming or settled) renders with native SVG text — see
// mermaidDirectives.ts for why.
const text = injectMermaidHtmlLabelsOff(rewriteImageSrcs(props.text ?? ''));
const out: Segment[] = [];
let lastIndex = 0;
DIFF_FENCE_RE.lastIndex = 0;
Expand Down
130 changes: 130 additions & 0 deletions apps/kimi-web/src/lib/mermaidDirectives.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// apps/kimi-web/src/lib/mermaidDirectives.test.ts
import { describe, expect, it } from 'vitest';
import { injectMermaidHtmlLabelsOff } from './mermaidDirectives';

const DIRECTIVE = '%%{init: {"htmlLabels": false}}%%';

/** Extract the content of every ```mermaid fence from the injected text. */
function mermaidBodies(text: string): string[] {
const bodies: string[] = [];
const re = /(?:^|\n) {0,3}(?:`{3,}|~{3,})mermaid[ \t]*\r?\n([\s\S]*?)(?:\r?\n)? {0,3}(?:`{3,}|~{3,})(?=\r?\n|$)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) bodies.push(m[1] ?? '');
return bodies;
}

describe('injectMermaidHtmlLabelsOff', () => {
it('injects comment + directive right after the fence opening line', () => {
const out = injectMermaidHtmlLabelsOff('```mermaid\nflowchart TD\n A-->B\n```\n');
expect(out).toBe(
'```mermaid\n' +
'%% kimi-web: htmlLabels=off (workaround for markstream flattening foreignObject soft-wraps)\n' +
`${DIRECTIVE}\n` +
'flowchart TD\n A-->B\n```\n',
);
});

it('leaves non-mermaid fences (js / diff / no language) untouched', () => {
const text = '```js\nconst a = 1;\n```\n\n```diff\n- a\n+ b\n```\n\n```\nplain\n```\n';
expect(injectMermaidHtmlLabelsOff(text)).toBe(text);
});

it('returns text without mermaid fences byte-for-byte unchanged', () => {
const text = 'some prose\n\n- a list\n\n> a quote about mermaid diagrams\n';
expect(injectMermaidHtmlLabelsOff(text)).toBe(text);
});

it('injects into every mermaid fence when there are several', () => {
const out = injectMermaidHtmlLabelsOff(
'```mermaid\nflowchart TD\n A-->B\n```\nmiddle\n```mermaid\nsequenceDiagram\n A->>B: hi\n```\n',
);
expect(out.match(new RegExp(DIRECTIVE.replaceAll(/[{}[\]]/g, '\\$&'), 'g'))).toHaveLength(2);
});

it('supports ~~~ fences', () => {
const out = injectMermaidHtmlLabelsOff('~~~mermaid\nflowchart TD\n A-->B\n~~~\n');
expect(out).toContain(`~~~mermaid\n%% kimi-web`);
expect(out).toContain(DIRECTIVE);
});

it('supports fences indented by up to 3 spaces, keeping the indent', () => {
const out = injectMermaidHtmlLabelsOff(' ```mermaid\n flowchart TD\n A-->B\n ```\n');
expect(out).toContain(` \`\`\`mermaid\n %% kimi-web`);
expect(out).toContain(` ${DIRECTIVE}\n`);
});

it('supports CRLF line endings and keeps them', () => {
const out = injectMermaidHtmlLabelsOff('```mermaid\r\nflowchart TD\r\n A-->B\r\n```\r\n');
expect(out).toContain('```mermaid\r\n%% kimi-web');
expect(out).toContain(`${DIRECTIVE}\r\n`);
expect(out).not.toContain('%%\n'); // no bare-LF mixed into the injected lines
});

it('does not touch inline code or language names merely containing "mermaid"', () => {
const text = '```mermaidx\nflowchart TD\n```\n\n`mermaid` inline\n';
expect(injectMermaidHtmlLabelsOff(text)).toBe(text);
});

it('core invariant: injected diagram code does NOT start with "%%{"', () => {
// markstream only prepends its theme directive when the code does not
// already start with a directive (`trimStart().startsWith("%%{")`).
// Breaking this invariant would drop dark/light theming.
const out = injectMermaidHtmlLabelsOff('```mermaid\nflowchart TD\n A-->B\n```\n');
for (const body of mermaidBodies(out)) {
expect(body.trimStart().startsWith('%%{')).toBe(false);
}
});

it('injects before the graph declaration even for streaming partial input', () => {
const out = injectMermaidHtmlLabelsOff('```mermaid\nflowchart TD\n A--');
const body = out.split('\n').slice(1, 3);
expect(body[0]).toMatch(/^%% kimi-web/);
expect(body[1]).toBe(DIRECTIVE);
});

it('keeps working when the model already opened its own init directive', () => {
const out = injectMermaidHtmlLabelsOff(
'```mermaid\n%%{init: {"theme": "forest"}}%%\nflowchart TD\n A-->B\n```\n',
);
// Our comment + directive still lands first; the model's own directive
// survives afterwards and may override htmlLabels (author intent wins).
expect(out).toContain(`\`\`\`mermaid\n%% kimi-web`);
expect(out.indexOf(DIRECTIVE)).toBeLessThan(out.indexOf('%%{init: {"theme": "forest"}}%%'));
});

it('does NOT rewrite a mermaid sample inside a longer enclosing fence', () => {
// A ```mermaid line that is the CONTENT of a ````markdown block must stay
// untouched — injecting there would alter user-visible example text.
const text =
'````markdown\n示例:\n\n```mermaid\nflowchart TD\n A-->B\n```\n````\n';
expect(injectMermaidHtmlLabelsOff(text)).toBe(text);
});

it('does NOT inject into an unclosed outer fence (streaming partial input)', () => {
const text = '````markdown\n```mermaid\nflowchart TD\n A--';
expect(injectMermaidHtmlLabelsOff(text)).toBe(text);
});

it('injects a real mermaid fence that follows an enclosed sample', () => {
const text =
'````markdown\n```mermaid\nnot a real fence\n```\n````\n\n```mermaid\nflowchart TD\n A-->B\n```\n';
const out = injectMermaidHtmlLabelsOff(text);
// Exactly one injection, into the second (real) fence.
expect(out.match(/kimi-web: htmlLabels=off/g)).toHaveLength(1);
expect(out).toContain('````\n\n```mermaid\n%% kimi-web');
});

it('treats a closing fence with fewer marks than the opener as content', () => {
// The ``` line does not close a ```` fence, so the later ```mermaid is
// still sample content and must not be injected.
const text = '````text\n```\n```mermaid\nflowchart TD\n````\n';
expect(injectMermaidHtmlLabelsOff(text)).toBe(text);
});

it('a tilde fence does not close a backtick fence', () => {
const text = '```text\n~~~\n```mermaid\nflowchart TD\n```\n';
// The ~~~ line is content; ```mermaid is content too (inside the
// backtick fence), so nothing is injected.
expect(injectMermaidHtmlLabelsOff(text)).toBe(text);
});
});
75 changes: 75 additions & 0 deletions apps/kimi-web/src/lib/mermaidDirectives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// apps/kimi-web/src/lib/mermaidDirectives.ts
// Injects a `%%{init: {"htmlLabels": false}}%%` directive into every
// ```mermaid fence before the text reaches markstream.
//
// Why: markstream sanitizes mermaid's SVG via stream-markdown-parser's
// `replaceForeignObjectLabels`, which flattens each <foreignObject> label into
// a single-line SVG <text> (it only splits on literal <br>). Mermaid wraps
// long labels with CSS (max-width 200px soft wrap, no <br> in the DOM) and
// sizes the node rect to the WRAPPED label — so after flattening, the full
// single-line text renders centered on a rect sized for 200px and overflows
// both sides, overlapping adjacent nodes (worst with long CJK labels). With
// htmlLabels off, mermaid lays out native SVG text with real width-based line
// breaking (tspans), and the sanitizer has no foreignObject to flatten.
//
// The key MUST be the global `htmlLabels`, NOT `flowchart.htmlLabels`: in
// mermaid 11.15 the flowchart-scoped key is deprecated and silently shadowed
// by the global default — setting it changes nothing (verified empirically:
// the diagram still renders with foreignObject). The global key works both
// via mermaid.initialize() and via the init directive.
//
// Key invariant: the injected content must NOT start with `%%{`. markstream
// prepends its own `%%{init: {"theme": ...}}%%` only when the diagram code
// does not already start with a directive (`trimStart().startsWith("%%{")`),
// so the comment line comes first to keep its dark/light theme injection
// working. Mermaid merges multiple init directives, so both take effect.
//
// Fences are tracked CommonMark-style (same marker char, closing length >=
// opening length, backtick info strings cannot contain backticks) so a
// ```mermaid line that is the CONTENT of a longer enclosing fence (e.g. a
// ````markdown example) is left untouched — injecting there would rewrite
// user-visible sample text instead of a rendered diagram.

const INJECT_COMMENT = '%% kimi-web: htmlLabels=off (workaround for markstream flattening foreignObject soft-wraps)';
const INJECT_DIRECTIVE = '%%{init: {"htmlLabels": false}}%%';

const FENCE_LINE_RE = /^( {0,3})(`{3,}|~{3,})([^`]*)$/;

/**
* Return `text` with the htmlLabels-off directive injected right after the
* opening line of every real ```mermaid fence (``` or ~~~, up to 3 leading
* spaces). Pure and idempotent over raw model text: callers always pass the
* original markdown, so repeated renders never accumulate injections. Text
* without mermaid fences is returned byte-for-byte unchanged.
*/
export function injectMermaidHtmlLabelsOff(text: string): string {
if (!text.includes('mermaid')) return text;

const lines = text.split(/(?<=\n)/);
let openChar: '`' | '~' | null = null;
let openLen = 0;
const out: string[] = [];

for (const line of lines) {
out.push(line);
const body = line.endsWith('\r\n') ? line.slice(0, -2) : line.endsWith('\n') ? line.slice(0, -1) : line;
const m = FENCE_LINE_RE.exec(body);
if (!m) continue;
const [, indent = '', marks = '', info = ''] = m;
const char = marks[0] as '`' | '~';

if (openChar === null) {
openChar = char;
openLen = marks.length;
if (info.trim() === 'mermaid') {
const nl = line.endsWith('\r\n') ? '\r\n' : '\n';
out.push(`${indent}${INJECT_COMMENT}${nl}${indent}${INJECT_DIRECTIVE}${nl}`);
}
} else if (char === openChar && marks.length >= openLen && info.trim() === '') {
openChar = null;
openLen = 0;
}
}

return out.join('');
}