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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const NotificationButton: React.FC<NotificationButtonProps> = ({ className = ''
].filter(Boolean).join(' ')}
onClick={() => notificationService.toggleCenter()}
type="button"
data-testid="notification-button"
>
{activeNotification ? (
<>
Expand Down
12 changes: 12 additions & 0 deletions src/web-ui/src/locales/en-US/mermaid-editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@
"loading": {
"processing": "Processing..."
},
"export": {
"loading": {
"svgTitle": "Exporting SVG",
"svgMessage": "Generating SVG file...",
"pngTitle": "Exporting PNG",
"pngMessage": "Rendering diagram and generating PNG..."
},
"success": {
"svg": "SVG exported successfully",
"png": "PNG exported successfully"
}
},
"panel": {
"diagramName": "Mermaid Diagram"
},
Expand Down
12 changes: 12 additions & 0 deletions src/web-ui/src/locales/zh-CN/mermaid-editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@
"loading": {
"processing": "处理中..."
},
"export": {
"loading": {
"svgTitle": "正在导出 SVG",
"svgMessage": "正在生成 SVG 文件...",
"pngTitle": "正在导出 PNG",
"pngMessage": "正在渲染图表并生成 PNG..."
},
"success": {
"svg": "SVG 导出成功",
"png": "PNG 导出成功"
}
},
"panel": {
"diagramName": "Mermaid图表"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ export const NotificationCenter: React.FC = () => {
showCloseButton={false}
size="large"
>
<div className="notification-center">
<div className="notification-center" data-testid="notification-center">

<div className="notification-center__header">
<h2 className="notification-center__title">{t('components:notificationCenter.title')}</h2>
Expand Down Expand Up @@ -401,7 +401,7 @@ export const NotificationCenter: React.FC = () => {
<div className="notification-center__content">

{activeTaskNotifications.length > 0 && (
<div className="notification-center__active-section">
<div className="notification-center__active-section" data-testid="notification-center-active-section">
<div className="notification-center__active-section-title">
{t('components:notificationCenter.activeTasks.title', { count: activeTaskNotifications.length })}
</div>
Expand Down
169 changes: 156 additions & 13 deletions src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { CubeLoading } from '@/component-library/components/CubeLoading';
import { Sparkles } from 'lucide-react';
import { aiApi } from '../../../infrastructure/api';
import { useI18n } from '@/infrastructure/i18n';
import { notificationService } from '@/shared/notification-system';

import { downloadDir, join } from '@tauri-apps/api/path';
import { writeFile } from '@tauri-apps/plugin-fs';
import './MermaidEditor.css';

/** Escape regex special characters. */
Expand All @@ -29,6 +33,130 @@ const detectDiagramType = (code: string): MermaidDiagramContext['diagramType'] =
return 'other';
};

const SVG_NS = 'http://www.w3.org/2000/svg';
const XHTML_NS = 'http://www.w3.org/1999/xhtml';

const sanitizeFileName = (name: string) => name.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim() || 'diagram';

const createTimestampSuffix = () => {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
};

const saveExportBlob = async (blob: Blob, baseName: string, extension: 'svg' | 'png') => {
const downloadsPath = await downloadDir();
const fileName = `${sanitizeFileName(baseName)}_${createTimestampSuffix()}.${extension}`;
const filePath = await join(downloadsPath, fileName);
const bytes = new Uint8Array(await blob.arrayBuffer());
await writeFile(filePath, bytes);
return filePath;
};

/**
* Prepare a self-contained SVG element for export.
*
* cloneNode(true) already preserves:
* - Mermaid's internal <style> block (with resolved theme colors)
* - All inline styles set by applyBaseStyles (fill, stroke, etc.)
*
* We only need to: clean up interactive-only styles, set proper namespaces,
* add a background rect, and ensure dimensions are correct.
*
* IMPORTANT: Do NOT bulk-copy computed styles (getComputedStyle) onto elements —
* doing so injects hundreds of irrelevant CSS properties (width, display,
* overflow, …) that break SVG layout and rendering.
*/
const prepareExportSvg = (
sourceSvg: SVGElement,
dims: { width: number; height: number }
) => {
const cloned = sourceSvg.cloneNode(true) as SVGElement;

const rootStyles = window.getComputedStyle(document.documentElement);
const previewContainer = sourceSvg.parentElement;
const previewBg = previewContainer
? window.getComputedStyle(previewContainer).backgroundColor
: '';
const exportBgColor =
(previewBg && previewBg !== 'transparent' && previewBg !== 'rgba(0, 0, 0, 0)')
? previewBg
: rootStyles.getPropertyValue('--mermaid-bg').trim()
|| rootStyles.getPropertyValue('--color-bg-primary').trim()
|| '#ffffff';

// Strip interactive-only styles irrelevant for static export.
cloned.style.transform = '';
cloned.style.transition = '';
cloned.style.transformOrigin = '';
cloned.style.position = '';
cloned.style.left = '';
cloned.style.top = '';
cloned.style.userSelect = '';
cloned.style.flexShrink = '';
cloned.style.width = '';
cloned.style.height = '';
cloned.style.minWidth = '';
cloned.style.minHeight = '';

cloned.setAttribute('width', String(dims.width));
cloned.setAttribute('height', String(dims.height));
cloned.setAttribute('xmlns', SVG_NS);
cloned.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');

if (!cloned.getAttribute('viewBox')) {
cloned.setAttribute('viewBox', `0 0 ${dims.width} ${dims.height}`);
}

// Ensure foreignObject HTML children carry the XHTML namespace
// (required for standalone SVG rendering / Image data-URI loading).
cloned.querySelectorAll('foreignObject').forEach(fo => {
Array.from(fo.children).forEach(child => {
if (!child.getAttribute('xmlns')) {
child.setAttribute('xmlns', XHTML_NS);
}
});
});

// Remove interactive helper attributes & styles from all descendants.
cloned.querySelectorAll('*').forEach(el => {
const htmlEl = el as HTMLElement;
if (htmlEl.style) {
htmlEl.style.cursor = '';
htmlEl.style.pointerEvents = '';
htmlEl.style.transition = '';
}
el.removeAttribute('data-original-fill');
el.removeAttribute('data-original-stroke');
el.removeAttribute('data-original-color');
});

// Insert a background rect that covers the full viewBox (which may have
// negative x/y offsets for padding), not just 0,0 → width,height.
const backgroundRect = document.createElementNS(SVG_NS, 'rect');
const viewBox = cloned.getAttribute('viewBox');
if (viewBox) {
const parts = viewBox.trim().split(/[\s,]+/).map(Number);
if (parts.length >= 4) {
backgroundRect.setAttribute('x', String(parts[0]));
backgroundRect.setAttribute('y', String(parts[1]));
backgroundRect.setAttribute('width', String(parts[2]));
backgroundRect.setAttribute('height', String(parts[3]));
}
}
if (!backgroundRect.getAttribute('width')) {
backgroundRect.setAttribute('width', String(dims.width));
backgroundRect.setAttribute('height', String(dims.height));
}
backgroundRect.setAttribute('fill', exportBgColor);
const firstContent = Array.from(cloned.children).find(
c => !['style', 'defs'].includes(c.tagName.toLowerCase())
);
cloned.insertBefore(backgroundRect, firstContent ?? null);

return cloned;
};

export const MermaidEditor: React.FC<MermaidEditorProps> = React.memo(({
initialSourceCode,
onSave,
Expand All @@ -40,6 +168,7 @@ export const MermaidEditor: React.FC<MermaidEditorProps> = React.memo(({
}) => {
const { t } = useI18n('mermaid-editor');


const {
actions: { setSourceCode, setShowSourceEditor, setShowComponentLibrary, setLoading, setError },
sourceCode,
Expand Down Expand Up @@ -118,21 +247,35 @@ export const MermaidEditor: React.FC<MermaidEditorProps> = React.memo(({
}, [isDirty, onSave, sourceCode, setLoading, setError]);

const handleExport = useCallback(async (format: string) => {
if (!onExport) return;
const loadingCtrl = notificationService.loading({
title: t('export.loading.pngTitle'),
message: t('export.loading.pngMessage'),
});

try {
setLoading(true);
let exportData = sourceCode;
if (format === 'svg') {
const svgEl = document.querySelector('.mermaid-preview svg');
if (svgEl) exportData = new XMLSerializer().serializeToString(svgEl);
}
await onExport(format, exportData);
setError(null);

const svgEl = previewRef.current?.getSvgElement();
const dims = previewRef.current?.getSvgDimensions();
if (!svgEl) throw new Error(t('errors.exportFailed'));

const w = dims?.width ?? 800;
const h = dims?.height ?? 600;
const cloned = prepareExportSvg(svgEl, { width: w, height: h });
const svgData = new XMLSerializer().serializeToString(cloned);

const { mermaidService } = await import('../services/MermaidService');
const blob = await mermaidService.exportAsPNG(sourceCode, 2, svgData, { width: w, height: h });
const filePath = await saveExportBlob(blob, t('panel.diagramName'), 'png');
loadingCtrl.complete();
notificationService.success(filePath, { title: t('export.success.png') });
await onExport?.(format, '');
} catch (err) {
setError(err instanceof Error ? err.message : t('errors.exportFailed'));
} finally {
setLoading(false);
const msg = err instanceof Error ? err.message : t('errors.exportFailed');
loadingCtrl.fail(`PNG ${t('errors.exportFailed')}: ${msg}`);
setError(msg);
}
}, [onExport, sourceCode, setLoading, setError]);
}, [onExport, sourceCode, setError, t]);

const handleComponentSelect = useCallback((component: MermaidComponent) => {
setSourceCode(sourceCode + '\n' + component.code);
Expand Down Expand Up @@ -389,7 +532,7 @@ export const MermaidEditor: React.FC<MermaidEditorProps> = React.memo(({
}, [floatingToolbar.isVisible]);

return (
<div className={`mermaid-editor ${className}`} onClick={handleContainerClick}>
<div className={`mermaid-editor ${className}`} onClick={handleContainerClick} data-testid="mermaid-editor">
<MermaidEditorHeader
showComponentLibrary={showComponentLibrary}
isDirty={isDirty}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,61 +243,6 @@
color: var(--color-text-primary, #e8e8e8) !important;
}

/* ==================== Export dropdown ==================== */
.export-dropdown {
position: relative;
}

.export-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--size-gap-1, 4px);
min-width: 100px;
background: var(--color-bg-elevated, #18181a);
border: 1px solid var(--border-medium, rgba(255, 255, 255, 0.12));
border-radius: var(--size-radius-sm, 6px);
box-shadow: var(--shadow-lg, 0 8px 16px rgba(0, 0, 0, 0.6));
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: all 0.15s ease;
z-index: 100;
}

.export-dropdown:hover .export-menu,
.export-dropdown:focus-within .export-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}

.export-menu button {
display: block;
width: 100%;
padding: var(--size-gap-2, 8px) var(--size-gap-3, 12px);
background: transparent;
border: none;
color: var(--color-text-secondary, #a0a0a0);
font-size: var(--font-size-xs, 12px);
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}

.export-menu button:hover {
background: var(--element-bg-base, rgba(255, 255, 255, 0.08));
color: var(--color-text-primary, #e8e8e8);
}

.export-menu button:first-child {
border-radius: 5px 5px 0 0;
}

.export-menu button:last-child {
border-radius: 0 0 5px 5px;
}

/* ==================== Responsive ==================== */
@media (max-width: 768px) {
.mermaid-editor-header {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import React from 'react';
import { Layers, Save, Download, Plus, Minus, Home, Edit3, MessageSquarePlus, Wrench } from 'lucide-react';
import { IconButton, Button } from '@/component-library';
import { IconButton } from '@/component-library';
import { useI18n } from '@/infrastructure/i18n';
import './MermaidEditorHeader.css';

Expand Down Expand Up @@ -159,20 +159,16 @@ export const MermaidEditorHeader: React.FC<MermaidEditorHeaderProps> = ({
<Save size={16} />
</IconButton>

<div className="export-dropdown">
<IconButton
className="export-btn"
tooltip={t('header.export')}
tooltipPlacement="bottom"
size="small"
>
<Download size={16} />
</IconButton>
<div className="export-menu">
<Button variant="ghost" size="small" onClick={() => onExport('svg')}>SVG</Button>
<Button variant="ghost" size="small" onClick={() => onExport('png')}>PNG</Button>
</div>
</div>
<IconButton
className="export-btn"
onClick={() => onExport('png')}
tooltip={t('header.export')}
tooltipPlacement="bottom"
size="small"
data-testid="mermaid-export-png"
>
<Download size={16} />
</IconButton>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface MermaidPreviewRef {
resetView: () => void;
fitToContainer: () => void;
getZoomLevel: () => number;
getSvgElement: () => SVGElement | null;
getSvgDimensions: () => { width: number; height: number } | null;
}

const RENDER_DEBOUNCE_DELAY = 200;
Expand Down Expand Up @@ -192,6 +194,8 @@ export const MermaidPreview = React.memo(forwardRef<MermaidPreviewRef, MermaidPr
useImperativeHandle(ref, () => ({
...controls,
fitToContainer: handleFitToContainer,
getSvgElement: () => svgRef.current,
getSvgDimensions: () => svgDimensionsRef.current,
}), [controls, handleFitToContainer]);

const renderDiagram = useCallback(async () => {
Expand Down
Loading
Loading