Skip to content
Merged
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
68 changes: 62 additions & 6 deletions src/frontend/src/components/chat/MermaidBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { ReloadOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons';
import { t } from '../../i18n';

const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.25;

function clampZoom(value: number): number {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
}

let mermaidModule: typeof import('mermaid') | null = null;
let mermaidLoading: Promise<typeof import('mermaid')> | null = null;
let mermaidInitialized = false;
Expand Down Expand Up @@ -35,7 +44,14 @@ interface MermaidRenderState {

export function MermaidBlock({ chart }: { chart: string }) {
const [renderState, setRenderState] = useState<MermaidRenderState | null>(null);
const [zoom, setZoom] = useState(1);
const currentRender = renderState?.chart === chart ? renderState : null;
// Keep the innerHTML prop stable while zoom changes. Reassigning innerHTML
// would recreate the SVG and replay its entrance animation on every click.
const svgHtml = useMemo(
() => typeof currentRender?.svg === 'string' ? { __html: currentRender.svg } : null,
[currentRender?.svg],
);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -68,15 +84,55 @@ export function MermaidBlock({ chart }: { chart: string }) {
);
}

if (typeof currentRender?.svg === 'string') {
if (svgHtml) {
// React must remain the sole owner of this subtree. Writing to a ref's
// innerHTML here would remove the loading node behind React's back and
// make the next reconciliation fail with a removeChild NotFoundError.
return (
<div
className="jx-mermaid jx-mermaid--rendered"
dangerouslySetInnerHTML={{ __html: currentRender.svg }}
/>
<div className="jx-mermaid jx-mermaid--rendered">
<div className="jx-mermaid-toolbar">
<button
type="button"
className="jx-mermaid-zoomButton"
title={t('缩小')}
aria-label={t('缩小')}
disabled={zoom <= MIN_ZOOM}
onClick={() => setZoom((value) => clampZoom(value - ZOOM_STEP))}
>
<ZoomOutOutlined />
</button>
<span className="jx-mermaid-zoomValue" aria-live="polite">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
className="jx-mermaid-zoomButton"
title={t('放大')}
aria-label={t('放大')}
disabled={zoom >= MAX_ZOOM}
onClick={() => setZoom((value) => clampZoom(value + ZOOM_STEP))}
>
<ZoomInOutlined />
</button>
<button
type="button"
className="jx-mermaid-zoomButton"
title={t('重置')}
aria-label={t('重置')}
disabled={zoom === 1}
onClick={() => setZoom(1)}
>
<ReloadOutlined />
</button>
</div>
<div className="jx-mermaid-viewport">
<div
className="jx-mermaid-svgHost"
style={{ '--jx-mermaid-width': `${zoom * 100}%` } as CSSProperties}
dangerouslySetInnerHTML={svgHtml}
/>
</div>
</div>
);
}

Expand Down
33 changes: 21 additions & 12 deletions src/frontend/src/components/citation/CitationMarkdownBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ const CitationMarkdownBlock = memo(function CitationMarkdownBlock({
() => isMarkdown ? mdToHtml(normalizedText) : '',
[normalizedText, isMarkdown],
);
// Keep the prop object stable when only portal state changes. Otherwise a
// Mermaid scan followed by setState can make React assign innerHTML again,
// detaching the freshly discovered portal target before it is used.
const renderedHtmlProp = useMemo(() => ({ __html: renderedHtml }), [renderedHtml]);
const shouldRenderMermaid = !messageIsStreaming
&& isMarkdown
&& hasMermaid(normalizedText);
const visibleMermaidCharts = shouldRenderMermaid ? mermaidCharts : EMPTY_MERMAID;

// Lazy-load KaTeX when LaTeX content detected
useEffect(() => {
Expand All @@ -80,28 +88,29 @@ const CitationMarkdownBlock = memo(function CitationMarkdownBlock({
}
}, [normalizedText, isMarkdown]);

// After render, scan for mermaid placeholders
// After render, scan for mermaid placeholders. While text is streaming,
// `dangerouslySetInnerHTML` replaces the markdown subtree for every delta.
// A portal mounted into one of those placeholders would therefore be
// destroyed and recreated repeatedly, making the diagram jump between the
// small loading box and the full SVG. Keep the placeholder stable during
// streaming and render each diagram once after the message settles.
useEffect(() => {
if (!isMarkdown || !hasMermaid(normalizedText) || !containerRef.current) {
// Use stable reference to avoid re-render from new empty array
setMermaidCharts(prev => prev.length === 0 ? prev : EMPTY_MERMAID);
return;
}
if (!shouldRenderMermaid || !containerRef.current) return;
// Small delay to let DOM update
const timer = setTimeout(() => {
if (!containerRef.current) return;
const charts = extractMermaidCharts(containerRef.current);
setMermaidCharts(charts);
}, 0);
return () => clearTimeout(timer);
}, [normalizedText, isMarkdown]);
}, [normalizedText, shouldRenderMermaid]);

if (!hasCit) {
if (isMarkdown) {
return (
<div className={className} ref={containerRef as RefObject<HTMLDivElement>}>
<div dangerouslySetInnerHTML={{ __html: renderedHtml }} />
{mermaidCharts.map((mc, i) =>
<div dangerouslySetInnerHTML={renderedHtmlProp} />
{visibleMermaidCharts.map((mc, i) =>
createPortal(<MermaidBlock key={i} chart={mc.chart} />, mc.element)
)}
</div>
Expand All @@ -111,7 +120,7 @@ const CitationMarkdownBlock = memo(function CitationMarkdownBlock({
return (
<span className={className} ref={containerRef as RefObject<HTMLSpanElement>}>
{normalizedText}
{mermaidCharts.map((mc, i) =>
{visibleMermaidCharts.map((mc, i) =>
createPortal(<MermaidBlock key={i} chart={mc.chart} />, mc.element)
)}
</span>
Expand Down Expand Up @@ -141,7 +150,7 @@ const CitationMarkdownBlock = memo(function CitationMarkdownBlock({
citations={citations}
onCitationAction={onCitationAction}
/>
{mermaidCharts.map((mc, i) =>
{visibleMermaidCharts.map((mc, i) =>
createPortal(<MermaidBlock key={i} chart={mc.chart} />, mc.element)
)}
</div>
Expand All @@ -156,7 +165,7 @@ const CitationMarkdownBlock = memo(function CitationMarkdownBlock({
citations={citations}
onCitationAction={onCitationAction}
/>
{mermaidCharts.map((mc, i) =>
{visibleMermaidCharts.map((mc, i) =>
createPortal(<MermaidBlock key={i} chart={mc.chart} />, mc.element)
)}
</span>
Expand Down
74 changes: 71 additions & 3 deletions src/frontend/src/styles/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -3352,14 +3352,82 @@ textarea.jx-composer{
background: transparent;
border-color: transparent;
padding: 8px 0;
overflow: visible;
}
/* 渲染完成:svg 淡入(复用全局原语 keyframes) */
.jx-mermaid--rendered svg {
.jx-mermaid--rendered .jx-mermaid-svgHost svg {
animation: jx-kf-fadeIn var(--motion-duration-normal) var(--motion-ease-standard) both;
}
.jx-mermaid svg {
max-width: 100%;
.jx-mermaid-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
min-height: 28px;
margin-bottom: 8px;
}
.jx-mermaid-zoomButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: var(--color-text-secondary);
background: var(--bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-xs);
cursor: pointer;
}
.jx-mermaid-zoomButton:hover:not(:disabled) {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-bg);
}
.jx-mermaid-zoomButton:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.jx-mermaid-zoomButton:disabled {
color: var(--color-text-placeholder);
cursor: not-allowed;
opacity: .55;
}
.jx-mermaid-zoomValue {
min-width: 48px;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 28px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.jx-mermaid-viewport {
width: 100%;
max-height: min(68vh, 720px);
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.jx-mermaid-svgHost {
width: 100%;
min-width: 0;
}
.jx-mermaid-svgHost svg {
display: block;
width: var(--jx-mermaid-width, 100%) !important;
max-width: none !important;
height: auto;
margin: 0 auto;
}
.jx-mermaid[data-chart] > .jx-mermaid {
min-height: 0;
margin: 0;
padding: 0;
background: transparent;
border: 0;
}
.jx-mermaid[data-chart] {
overflow: visible;
}
.jx-mermaid-loading {
color: var(--color-text-tertiary);
Expand Down
22 changes: 19 additions & 3 deletions src/frontend/src/styles/onboarding.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
position:relative;
width:100%;
height:100%;
min-height:680px;
min-height:0;
overflow:auto;
color:var(--color-text);
background:#fff;
Expand Down Expand Up @@ -50,10 +50,11 @@
.jx-firstRun-shell{
width:min(1200px,calc(100% - 48px));
height:calc(100vh - 72px);
min-height:608px;
min-height:0;
margin:72px auto 0;
display:grid;
grid-template-columns:248px minmax(0,1fr);
grid-template-rows:minmax(0,1fr);
overflow:hidden;
background:#fff;
}
Expand Down Expand Up @@ -158,9 +159,11 @@

.jx-firstRun-card{
min-width:0;
min-height:0;
padding:64px clamp(48px,7vw,96px) 32px;
display:grid;
grid-template-rows:auto minmax(0,1fr) auto;
overflow:hidden;
}
.jx-firstRun-cardHeader,
.jx-firstRun-cardBody,
Expand Down Expand Up @@ -196,7 +199,20 @@
.jx-firstRun-cardBody{
min-height:0;
padding:42px 0 28px;
overflow:auto;
overflow-x:hidden;
overflow-y:auto;
scrollbar-gutter:stable;
scrollbar-color:#b8b8b8 transparent;
overscroll-behavior:contain;
}
.jx-firstRun-cardBody::-webkit-scrollbar{width:8px}
.jx-firstRun-cardBody::-webkit-scrollbar-thumb{
background:#c4c4c4;
border:2px solid #fff;
border-radius:999px;
}
.jx-firstRun-cardBody::-webkit-scrollbar-thumb:hover{
background:#a8a8a8;
}
.jx-firstRun-actions{
padding-top:20px;
Expand Down
Loading