@ichigo_san/graphing
Version:
A lightweight UML-style diagram editor built with React Flow and Tailwind CSS
662 lines (626 loc) • 22.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.ExportService = void 0;
var _htmlToImage = require("html-to-image");
class ExportService {
constructor() {
this.exportFormats = new Map();
this.setupDefaultFormats();
}
/**
* Setup default export formats
*/
setupDefaultFormats() {
this.registerFormat('json', this.exportToJSON.bind(this));
this.registerFormat('png', this.exportToPNG.bind(this));
this.registerFormat('jpg', this.exportToJPG.bind(this));
this.registerFormat('svg', this.exportToSVG.bind(this));
this.registerFormat('drawio', this.exportToDrawioXML.bind(this));
}
/**
* Register a new export format
* @param {string} format - Format name
* @param {Function} exporter - Export function
*/
registerFormat(format, exporter) {
this.exportFormats.set(format, exporter);
}
/**
* Get available export formats
* @returns {Array} Array of format names
*/
getAvailableFormats() {
return Array.from(this.exportFormats.keys());
}
/**
* Export diagram to specified format
* @param {Object} diagramData - Diagram data to export
* @param {string} format - Export format
* @param {Object} options - Export options
* @returns {Promise<Object>} Export result
*/
async export(diagramData, format, options = {}) {
const exporter = this.exportFormats.get(format);
if (!exporter) {
throw new Error(`Export format '${format}' not supported`);
}
try {
const result = await exporter(diagramData, options);
return {
success: true,
data: result,
format: format,
timestamp: new Date().toISOString()
};
} catch (error) {
return {
success: false,
error: error.message,
format: format
};
}
}
/**
* Export diagram to JSON format
* @param {Object} diagramData - Diagram data
* @param {Object} options - Export options
* @returns {Promise<Object>} JSON export result
*/
async exportToJSON(diagramData, options = {}) {
const {
includeMetadata = true,
prettyPrint = true
} = options;
const exportData = {
...diagramData
};
if (includeMetadata) {
exportData.metadata = {
name: 'Architecture Diagram',
description: 'Exported architecture diagram',
version: '1.0',
exportDate: new Date().toISOString(),
exportFormat: 'json',
...options.metadata
};
}
const jsonString = prettyPrint ? JSON.stringify(exportData, null, 2) : JSON.stringify(exportData);
return {
type: 'json',
data: jsonString,
filename: options.filename || 'architecture-diagram.json',
mimeType: 'application/json'
};
}
/**
* Export diagram to PNG format with transparency support
* @param {Object} diagramData - Diagram data
* @param {Object} options - Export options
* @returns {Promise<Object>} PNG export result
*/
async exportToPNG(diagramData, options = {}) {
const {
width,
height,
backgroundColor = 'transparent',
quality = 1.0,
filename = 'architecture-diagram.png',
scale = 1,
includeBackground = false
} = options;
try {
const pngData = await this.generateImageFromDiagram(diagramData, {
format: 'png',
width: width,
height: height,
backgroundColor: includeBackground ? backgroundColor : 'transparent',
quality,
scale,
includeBackground
});
// Calculate actual dimensions from bounds
const bounds = this.calculateDiagramBounds(diagramData);
const margin = 50;
const actualWidth = width || bounds.width + margin * 2;
const actualHeight = height || bounds.height + margin * 2;
return {
type: 'png',
data: pngData,
filename,
mimeType: 'image/png',
dimensions: {
width: actualWidth * scale,
height: actualHeight * scale
},
quality,
scale
};
} catch (error) {
throw new Error(`PNG export failed: ${error.message}`);
}
}
/**
* Export diagram to JPG format with quality settings
* @param {Object} diagramData - Diagram data
* @param {Object} options - Export options
* @returns {Promise<Object>} JPG export result
*/
async exportToJPG(diagramData, options = {}) {
const {
width,
height,
backgroundColor = '#ffffff',
quality = 0.9,
filename = 'architecture-diagram.jpg',
scale = 1
} = options;
// Validate quality (0.6 to 1.0)
const validatedQuality = Math.max(0.6, Math.min(1.0, quality));
try {
const jpgData = await this.generateImageFromDiagram(diagramData, {
format: 'jpg',
width: width,
height: height,
backgroundColor,
quality: validatedQuality,
scale
});
// Calculate actual dimensions from bounds
const bounds = this.calculateDiagramBounds(diagramData);
const margin = 50;
const actualWidth = width || bounds.width + margin * 2;
const actualHeight = height || bounds.height + margin * 2;
return {
type: 'jpg',
data: jpgData,
filename,
mimeType: 'image/jpeg',
dimensions: {
width: actualWidth * scale,
height: actualHeight * scale
},
quality: validatedQuality,
scale
};
} catch (error) {
throw new Error(`JPG export failed: ${error.message}`);
}
}
/**
* Export diagram to SVG format
* @param {Object} diagramData - Diagram data
* @param {Object} options - Export options
* @returns {Promise<Object>} SVG export result
*/
async exportToSVG(diagramData, options = {}) {
const {
width,
height,
backgroundColor = '#ffffff',
filename = 'architecture-diagram.svg'
} = options;
try {
const svgData = await this.generateImageFromDiagram(diagramData, {
format: 'svg',
width: width,
height: height,
backgroundColor
});
// Calculate actual dimensions from bounds
const bounds = this.calculateDiagramBounds(diagramData);
const margin = 50;
const actualWidth = width || bounds.width + margin * 2;
const actualHeight = height || bounds.height + margin * 2;
return {
type: 'svg',
data: svgData,
filename,
mimeType: 'image/svg+xml',
dimensions: {
width: actualWidth,
height: actualHeight
}
};
} catch (error) {
throw new Error(`SVG export failed: ${error.message}`);
}
}
/**
* Export diagram to Draw.io XML format
* @param {Object} diagramData - Diagram data
* @param {Object} options - Export options
* @returns {Promise<Object>} Draw.io XML export result
*/
async exportToDrawioXML(diagramData, options = {}) {
const {
includeMetadata = true,
filename = 'architecture-diagram.drawio'
} = options;
try {
const xmlData = this.generateDrawioXML(diagramData, includeMetadata);
return {
type: 'drawio',
data: xmlData,
filename,
mimeType: 'application/xml'
};
} catch (error) {
throw new Error(`Draw.io XML export failed: ${error.message}`);
}
}
/**
* Generate image from diagram data using html-to-image
* @param {Object} diagramData - Diagram data
* @param {Object} options - Export options
* @returns {Promise<string>} Base64 image data
*/
async generateImageFromDiagram(diagramData, options = {}) {
const {
format,
width,
height,
backgroundColor,
quality,
scale = 1,
includeBackground = false
} = options;
try {
// Find the React Flow container element - try multiple selectors
let reactFlowContainer = document.querySelector('.react-flow__viewport') || document.querySelector('[data-testid="rf__viewport"]') || document.querySelector('.react-flow') || document.querySelector('.react-flow__renderer');
if (!reactFlowContainer) {
// Try to find any element with react-flow classes
reactFlowContainer = document.querySelector('[class*="react-flow"]');
}
if (!reactFlowContainer) {
throw new Error('React Flow container not found. Please ensure the diagram is rendered.');
}
// Calculate diagram bounds from nodes and containers
const bounds = this.calculateDiagramBounds(diagramData);
// Add margin around the diagram
const margin = 50;
const diagramWidth = Math.max(bounds.width + margin * 2, 800);
const diagramHeight = Math.max(bounds.height + margin * 2, 600);
// Use provided dimensions or calculate from bounds
const exportWidth = width || diagramWidth;
const exportHeight = height || diagramHeight;
// Create export options based on format with enhanced text quality
const devicePixelRatio = window.devicePixelRatio || 1;
const highQualityPixelRatio = Math.max(devicePixelRatio * 2, 3); // Minimum 3x for crisp text
const exportOptions = {
width: exportWidth * scale,
height: exportHeight * scale,
quality: quality || 1.0,
pixelRatio: highQualityPixelRatio,
// Enhanced pixel ratio for sharp text
backgroundColor: includeBackground ? backgroundColor : undefined,
style: {
transform: `translate(${-bounds.x + margin}px, ${-bounds.y + margin}px) scale(${scale})`,
transformOrigin: 'top left',
width: `${diagramWidth}px`,
height: `${diagramHeight}px`,
// Enhanced text rendering for crisp quality
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility',
fontVariantLigatures: 'none',
// Force high-quality rendering
imageRendering: 'crisp-edges'
},
filter: node => {
// Filter out unwanted elements during export
let classNameString = '';
if (node.className) {
if (typeof node.className === 'string') {
classNameString = node.className;
} else if (node.className.toString) {
// Handle DOMTokenList or other objects
classNameString = node.className.toString();
} else {
classNameString = '';
}
}
return !classNameString.includes('react-flow__controls') && !classNameString.includes('react-flow__minimap') && !classNameString.includes('react-flow__panel') && !classNameString.includes('export-exclude');
}
};
let imageData;
switch (format.toLowerCase()) {
case 'png':
imageData = await (0, _htmlToImage.toPng)(reactFlowContainer, exportOptions);
break;
case 'jpg':
imageData = await (0, _htmlToImage.toJpeg)(reactFlowContainer, exportOptions);
break;
case 'svg':
imageData = await (0, _htmlToImage.toSvg)(reactFlowContainer, exportOptions);
break;
default:
throw new Error(`Unsupported format: ${format}`);
}
return imageData;
} catch (error) {
console.error('Image generation failed:', error);
throw new Error(`Failed to generate ${format.toUpperCase()} image: ${error.message}`);
}
}
/**
* Calculate diagram bounds from nodes and containers
* @param {Object} diagramData - Diagram data
* @returns {Object} Bounds object with x, y, width, height
*/
calculateDiagramBounds(diagramData) {
const {
nodes = [],
containers = []
} = diagramData;
if (nodes.length === 0 && containers.length === 0) {
return {
x: 0,
y: 0,
width: 800,
height: 600
};
}
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
// Calculate bounds from nodes
nodes.forEach(node => {
if (node.position) {
var _node$size, _node$__rf, _node$size2, _node$__rf2;
const nodeWidth = ((_node$size = node.size) === null || _node$size === void 0 ? void 0 : _node$size.width) || ((_node$__rf = node.__rf) === null || _node$__rf === void 0 ? void 0 : _node$__rf.width) || 150;
const nodeHeight = ((_node$size2 = node.size) === null || _node$size2 === void 0 ? void 0 : _node$size2.height) || ((_node$__rf2 = node.__rf) === null || _node$__rf2 === void 0 ? void 0 : _node$__rf2.height) || 80;
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + nodeWidth);
maxY = Math.max(maxY, node.position.y + nodeHeight);
}
});
// Calculate bounds from containers
containers.forEach(container => {
if (container.position) {
var _container$size, _container$size2;
const containerWidth = ((_container$size = container.size) === null || _container$size === void 0 ? void 0 : _container$size.width) || 400;
const containerHeight = ((_container$size2 = container.size) === null || _container$size2 === void 0 ? void 0 : _container$size2.height) || 300;
minX = Math.min(minX, container.position.x);
minY = Math.min(minY, container.position.y);
maxX = Math.max(maxX, container.position.x + containerWidth);
maxY = Math.max(maxY, container.position.y + containerHeight);
}
});
// Ensure we have valid bounds
if (minX === Infinity) minX = 0;
if (minY === Infinity) minY = 0;
if (maxX === -Infinity) maxX = 800;
if (maxY === -Infinity) maxY = 600;
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}
/**
* Generate draw.io XML from diagram data
* @param {Object} diagramData - Diagram data
* @param {boolean} includeMetadata - Whether to include metadata
* @returns {string} Draw.io XML string
*/
generateDrawioXML(diagramData, includeMetadata = true) {
const {
containers = [],
nodes = [],
connections = []
} = diagramData;
let xml = `xml version="1.0" encoding="UTF-8"
<mxfile host="app.diagrams.net" modified="${new Date().toISOString()}" agent="Architecture Diagram Editor" version="1.0">
<diagram name="Architecture Diagram" id="diagram1">
<mxGraphModel dx="1422" dy="794" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>`;
// Add containers
containers.forEach(container => {
var _container$size3, _container$size4;
const valueText = [container.label || '', container.description || ''].filter(Boolean).join('\n');
xml += `
<mxCell id="${container.id}" value="${valueText}" style="swimlane;fillColor=${container.color || '#ffffff'};strokeColor=${container.borderColor || '#000000'};strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="${container.position.x}" y="${container.position.y}" width="${((_container$size3 = container.size) === null || _container$size3 === void 0 ? void 0 : _container$size3.width) || 400}" height="${((_container$size4 = container.size) === null || _container$size4 === void 0 ? void 0 : _container$size4.height) || 300}" as="geometry"/>
</mxCell>`;
});
// Add nodes
nodes.forEach(node => {
var _node$size3, _node$size4;
const shape = this.getDrawioShape(node.type);
const valueText = [node.label || '', node.description || ''].filter(Boolean).join('\n');
const parentId = node.parentContainer || '1';
xml += `
<mxCell id="${node.id}" value="${valueText}" style="${shape}fillColor=${node.color || '#ffffff'};strokeColor=${node.borderColor || '#000000'};strokeWidth=2;" vertex="1" parent="${parentId}">
<mxGeometry x="${node.position.x}" y="${node.position.y}" width="${((_node$size3 = node.size) === null || _node$size3 === void 0 ? void 0 : _node$size3.width) || 120}" height="${((_node$size4 = node.size) === null || _node$size4 === void 0 ? void 0 : _node$size4.height) || 80}" as="geometry"/>
</mxCell>`;
});
// Add connections
connections.forEach(connection => {
var _connection$style, _connection$style2, _connection$style3;
const strokeWidth = ((_connection$style = connection.style) === null || _connection$style === void 0 ? void 0 : _connection$style.strokeWidth) || 2;
const strokeColor = ((_connection$style2 = connection.style) === null || _connection$style2 === void 0 ? void 0 : _connection$style2.stroke) || '#000000';
const strokeDash = (_connection$style3 = connection.style) !== null && _connection$style3 !== void 0 && _connection$style3.strokeDasharray ? 'dashed=1;' : '';
xml += `
<mxCell id="${connection.id}" value="${connection.label || ''}" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=${strokeWidth};strokeColor=${strokeColor};${strokeDash}" edge="1" parent="1" source="${connection.source}" target="${connection.target}">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`;
});
xml += `
</root>
</mxGraphModel>
</diagram>
</mxfile>`;
return xml;
}
/**
* Get draw.io shape style for node type
* @param {string} nodeType - Node type
* @returns {string} Draw.io shape style
*/
getDrawioShape(nodeType) {
const shapeMap = {
'component': 'rounded=1;whiteSpace=wrap;html=1;',
'container': 'swimlane;whiteSpace=wrap;html=1;',
'diamond': 'rhombus;whiteSpace=wrap;html=1;',
'circle': 'ellipse;whiteSpace=wrap;html=1;',
'hexagon': 'shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;',
'triangle': 'triangle;whiteSpace=wrap;html=1;',
'universalShape': 'rounded=1;whiteSpace=wrap;html=1;'
};
return shapeMap[nodeType] || shapeMap['component'];
}
/**
* Create download link for exported data
* @param {Object} exportResult - Export result object
* @returns {void}
*/
createDownloadLink(exportResult) {
const {
data,
filename,
mimeType
} = exportResult;
if (!data) {
throw new Error('No data to download');
}
// Create blob from data
let blob;
if (typeof data === 'string' && data.startsWith('data:')) {
// Handle base64 data URLs
const byteString = atob(data.split(',')[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
blob = new Blob([ab], {
type: mimeType
});
} else {
// Handle string data
blob = new Blob([data], {
type: mimeType
});
}
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Validate export options for a specific format
* @param {string} format - Export format
* @param {Object} options - Export options
* @returns {Object} Validated options
*/
validateExportOptions(format, options = {}) {
const validatedOptions = {
...options
};
switch (format.toLowerCase()) {
case 'png':
validatedOptions.quality = Math.max(0.1, Math.min(1.0, options.quality || 1.0));
validatedOptions.scale = Math.max(0.5, Math.min(4.0, options.scale || 1.0));
break;
case 'jpg':
validatedOptions.quality = Math.max(0.6, Math.min(1.0, options.quality || 0.9));
validatedOptions.scale = Math.max(0.5, Math.min(4.0, options.scale || 1.0));
break;
case 'svg':
validatedOptions.scale = Math.max(0.5, Math.min(4.0, options.scale || 1.0));
break;
}
return validatedOptions;
}
/**
* Get format information
* @param {string} format - Export format
* @returns {Object} Format information
*/
getFormatInfo(format) {
const formatInfo = {
png: {
name: 'PNG',
description: 'Portable Network Graphics with transparency support',
mimeType: 'image/png',
extensions: ['.png'],
supportsTransparency: true,
qualityRange: {
min: 0.1,
max: 1.0,
default: 1.0
},
scaleRange: {
min: 0.5,
max: 4.0,
default: 1.0
}
},
jpg: {
name: 'JPEG',
description: 'Joint Photographic Experts Group format',
mimeType: 'image/jpeg',
extensions: ['.jpg', '.jpeg'],
supportsTransparency: false,
qualityRange: {
min: 0.6,
max: 1.0,
default: 0.9
},
scaleRange: {
min: 0.5,
max: 4.0,
default: 1.0
}
},
svg: {
name: 'SVG',
description: 'Scalable Vector Graphics',
mimeType: 'image/svg+xml',
extensions: ['.svg'],
supportsTransparency: true,
qualityRange: {
min: 0.1,
max: 1.0,
default: 1.0
},
scaleRange: {
min: 0.5,
max: 4.0,
default: 1.0
}
},
json: {
name: 'JSON',
description: 'JavaScript Object Notation',
mimeType: 'application/json',
extensions: ['.json'],
supportsTransparency: false
},
drawio: {
name: 'Draw.io XML',
description: 'Draw.io compatible XML format',
mimeType: 'application/xml',
extensions: ['.drawio', '.xml'],
supportsTransparency: false
}
};
return formatInfo[format.toLowerCase()] || null;
}
}
exports.ExportService = ExportService;