This commit is contained in:
Daniel.B Grados 2025-04-12 21:10:08 +02:00 committed by GitHub
commit 620d8ba609
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1111 additions and 162 deletions

11
components.d.ts vendored
View file

@ -127,22 +127,27 @@ declare module '@vue/runtime-core' {
MenuBarItem: typeof import('./src/tools/html-wysiwyg-editor/editor/menu-bar-item.vue')['default'] MenuBarItem: typeof import('./src/tools/html-wysiwyg-editor/editor/menu-bar-item.vue')['default']
MenuIconItem: typeof import('./src/components/MenuIconItem.vue')['default'] MenuIconItem: typeof import('./src/components/MenuIconItem.vue')['default']
MenuLayout: typeof import('./src/components/MenuLayout.vue')['default'] MenuLayout: typeof import('./src/components/MenuLayout.vue')['default']
MermaidExporter: typeof import('./src/tools/mermaid-exporter/mermaid-exporter.vue')['default']
MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default']
MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default']
NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NButton: typeof import('naive-ui')['NButton']
NCode: typeof import('naive-ui')['NCode']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDivider: typeof import('naive-ui')['NDivider'] NDivider: typeof import('naive-ui')['NDivider']
NEllipsis: typeof import('naive-ui')['NEllipsis'] NEllipsis: typeof import('naive-ui')['NEllipsis']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NH1: typeof import('naive-ui')['NH1'] NH1: typeof import('naive-ui')['NH1']
NH3: typeof import('naive-ui')['NH3'] NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NLayout: typeof import('naive-ui')['NLayout'] NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu'] NMenu: typeof import('naive-ui')['NMenu']
NSpace: typeof import('naive-ui')['NSpace'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NTable: typeof import('naive-ui')['NTable'] NSlider: typeof import('naive-ui')['NSlider']
NSwitch: typeof import('naive-ui')['NSwitch']
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default']

View file

@ -78,6 +78,7 @@
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"marked": "^10.0.0", "marked": "^10.0.0",
"mathjs": "^11.9.1", "mathjs": "^11.9.1",
"mermaid": "^11.6.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"monaco-editor": "^0.43.0", "monaco-editor": "^0.43.0",
"naive-ui": "^2.35.0", "naive-ui": "^2.35.0",

1031
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter'; import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as mermaidExporter } from './mermaid-exporter';
import { tool as emailNormalizer } from './email-normalizer'; import { tool as emailNormalizer } from './email-normalizer';
import { tool as asciiTextDrawer } from './ascii-text-drawer'; import { tool as asciiTextDrawer } from './ascii-text-drawer';
@ -116,6 +117,7 @@ export const toolsByCategory: ToolCategory[] = [
xmlToJson, xmlToJson,
jsonToXml, jsonToXml,
markdownToHtml, markdownToHtml,
mermaidExporter,
], ],
}, },
{ {

View file

@ -0,0 +1,12 @@
import { Markdown } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Mermaid exporter',
path: '/mermaid-exporter',
description: 'Convert Markdown (Mermaid) to image and allow to export to PNG, JPG & SVG',
keywords: ['mermaid', 'exporter', 'markdown', 'MD'],
component: () => import('./mermaid-exporter.vue'),
icon: Markdown,
createdAt: new Date('2025-04-11'),
});

View file

@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test';
test.describe('Mermaid Diagram Renderer', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mermaid-exporter');
});
test('should render the initial diagram', async ({ page }) => {
const diagramContainer = page.locator('.diagram-container svg');
await expect(diagramContainer).toBeVisible();
await expect(diagramContainer).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg');
});
test('should update diagram when user edits Mermaid code', async ({ page }) => {
const input = page.getByLabel('Your Mermaid to convert:');
const updatedMermaid = `
graph LR
X[Start] --> Y{Working?}
Y -- Yes --> Z[Success!]
Y -- No --> W[Fix again!]
`;
await input.fill(updatedMermaid);
await page.waitForTimeout(500);
const svg = page.locator('.diagram-container svg');
await expect(svg).toContainText('Start');
await expect(svg).toContainText('Success!');
});
test('should allow exporting in SVG, PNG, and JPG', async ({ page }) => {
const formats = ['svg', 'png', 'jpg'];
for (const format of formats) {
const button = page.getByRole('button', { name: new RegExp(`Export as ${format}`, 'i') });
await expect(button).toBeVisible();
await button.click();
}
});
});

View file

@ -0,0 +1,177 @@
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue';
import mermaid from 'mermaid';
mermaid.initialize({ startOnLoad: false });
const mermaidCode = ref<string>(`graph TD
A[Start] --> B{Is it working?}
B -- Yes --> C[Great!]
B -- No --> D[Fix it!]
`);
const mermaidContainer = ref<HTMLElement | null>(null);
async function renderMermaid(): Promise<void> {
if (mermaidContainer.value) {
mermaidContainer.value.innerHTML = '';
try {
mermaid.parse(mermaidCode.value);
const { svg } = await mermaid.render('graphDiv', mermaidCode.value);
mermaidContainer.value.innerHTML = svg;
}
catch (error: unknown) {
mermaidContainer.value.innerHTML = '<p class="error">Invalid Mermaid syntax</p>';
console.error('Mermaid error:', error);
}
}
}
watch(mermaidCode, () => {
nextTick(() => {
renderMermaid();
});
});
onMounted(() => {
renderMermaid();
});
function fixSvgSize(svg: string): string {
const match = svg.match(/viewBox="([\d\s.-]+)"/);
if (!match) {
return svg;
}
// eslint-disable-next-line unused-imports/no-unused-vars
const [minX, minY, width, height] = match[1].split(/\s+/).map(Number);
svg = svg.replace(/width="[^"]*"/, `width="${width}"`);
svg = svg.replace(/height="[^"]*"/, `height="${height}"`);
if (!/width="/.test(svg)) {
svg = svg.replace('<svg', `<svg width="${width}"`);
}
if (!/height="/.test(svg)) {
svg = svg.replace('<svg', `<svg height="${height}"`);
}
return svg;
}
function exportAs(format: 'svg' | 'png' | 'jpg'): void {
const container = mermaidContainer.value;
if (!container) {
return;
}
const svgElement = container.querySelector('svg');
if (!svgElement) {
return;
}
let svgData = new XMLSerializer().serializeToString(svgElement);
if (!svgData.includes('xmlns=')) {
svgData = svgData.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
svgData = fixSvgSize(svgData);
if (format === 'svg') {
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'diagram.svg';
link.click();
URL.revokeObjectURL(url);
return;
}
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const reader = new FileReader();
const scaleFactor = 3;
reader.onloadend = () => {
const base64data = reader.result as string;
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = image.width * scaleFactor;
canvas.height = image.height * scaleFactor;
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
ctx.setTransform(scaleFactor, 0, 0, scaleFactor, 0, 0);
ctx.drawImage(image, 0, 0);
const mime = format === 'png' ? 'image/png' : 'image/jpeg';
const link = document.createElement('a');
link.download = `diagram.${format}`;
link.href = canvas.toDataURL(mime);
link.click();
};
image.src = base64data;
};
reader.readAsDataURL(blob);
}
</script>
<template>
<div>
<c-input-text
v-model:value="mermaidCode"
class=""
multiline raw-text
placeholder="Write your Mermaid code here..."
rows="8"
autofocus
label="Your Mermaid to convert:"
/>
<n-divider />
<div flex justify-center class="diagram-container">
<div ref="mermaidContainer" />
</div>
<div flex justify-center class="buttons">
<n-button @click="exportAs('png')">
Export as PNG
</n-button>
<n-button @click="exportAs('jpg')">
Export as JPG
</n-button>
<n-button @click="exportAs('svg')">
Export as SVG
</n-button>
</div>
</div>
</template>
<style lang="less" scoped>
.diagram-container {
border: 1px solid var(--theme-default-color);
padding: 15px;
border-radius: 6px;
background-color: var(--theme-default-color);
overflow-x: auto;
margin-bottom: 20px;
.error {
color: var(--theme-error-color);
font-weight: bold;
}
}
.buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
</style>