T24: Add try/catch to export functions, use shared types, return ExportResult
This commit is contained in:
@@ -1,64 +1,14 @@
|
|||||||
import type { YjsDocument } from '../crdt/YjsDocument';
|
import type { YjsDocument } from '../crdt/YjsDocument';
|
||||||
|
import type {
|
||||||
|
CADLayer,
|
||||||
|
CADProperties,
|
||||||
|
CADElement,
|
||||||
|
ExportData,
|
||||||
|
ExportOptions,
|
||||||
|
ExportResult,
|
||||||
|
} from '../types/cad.types';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CADLayer {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
visible: boolean;
|
|
||||||
locked: boolean;
|
|
||||||
color?: string;
|
|
||||||
lineType?: string;
|
|
||||||
transparency?: number;
|
|
||||||
sortOrder?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CADProperties {
|
|
||||||
fill?: string;
|
|
||||||
stroke?: string;
|
|
||||||
strokeWidth?: number;
|
|
||||||
rotation?: number;
|
|
||||||
lineType?: 'solid' | 'dashed' | 'dotted';
|
|
||||||
radius?: number;
|
|
||||||
x1?: number;
|
|
||||||
y1?: number;
|
|
||||||
x2?: number;
|
|
||||||
y2?: number;
|
|
||||||
points?: Array<{ x: number; y: number }>;
|
|
||||||
text?: string;
|
|
||||||
fontSize?: number;
|
|
||||||
startAngle?: number;
|
|
||||||
endAngle?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CADElement {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
layerId: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
properties: CADProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportData {
|
|
||||||
version: string;
|
|
||||||
exportedAt: number;
|
|
||||||
projectMeta: Record<string, unknown>;
|
|
||||||
layers: CADLayer[];
|
|
||||||
elements: CADElement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportOptions {
|
|
||||||
includeHiddenLayers?: boolean;
|
|
||||||
includeInvisibleElements?: boolean;
|
|
||||||
pageSize?: 'a4' | 'a3' | 'letter';
|
|
||||||
landscape?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
const clean = hex.replace('#', '');
|
const clean = hex.replace('#', '');
|
||||||
@@ -150,412 +100,452 @@ function downloadBlob(content: string | Blob, filename: string, mimeType: string
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── JSON Export ──────────────────────────────────────────────────────────────
|
// ── JSON Export ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): void {
|
export function exportJSON(yjsDoc: YjsDocument, filename = 'cad-export.json'): ExportResult {
|
||||||
const data = collectExportData(yjsDoc, {});
|
try {
|
||||||
const json = JSON.stringify(data, null, 2);
|
const data = collectExportData(yjsDoc, {});
|
||||||
downloadBlob(json, filename, 'application/json');
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
downloadBlob(json, filename, 'application/json');
|
||||||
|
return { success: true, data: json };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `exportJSON failed: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportJSONString(yjsDoc: YjsDocument): string {
|
export function exportJSONString(yjsDoc: YjsDocument): ExportResult {
|
||||||
const data = collectExportData(yjsDoc, {});
|
try {
|
||||||
return JSON.stringify(data, null, 2);
|
const data = collectExportData(yjsDoc, {});
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
return { success: true, data: json };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `exportJSONString failed: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SVG Export ───────────────────────────────────────────────────────────────
|
// ── SVG Export ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): void {
|
export function exportSVG(yjsDoc: YjsDocument, filename = 'cad-export.svg', options: ExportOptions = {}): ExportResult {
|
||||||
const data = collectExportData(yjsDoc, options);
|
try {
|
||||||
|
const data = collectExportData(yjsDoc, options);
|
||||||
|
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
for (const el of data.elements) {
|
for (const el of data.elements) {
|
||||||
minX = Math.min(minX, el.x);
|
minX = Math.min(minX, el.x);
|
||||||
minY = Math.min(minY, el.y);
|
minY = Math.min(minY, el.y);
|
||||||
maxX = Math.max(maxX, el.x + el.width);
|
maxX = Math.max(maxX, el.x + el.width);
|
||||||
maxY = Math.max(maxY, el.y + el.height);
|
maxY = Math.max(maxY, el.y + el.height);
|
||||||
}
|
}
|
||||||
if (data.elements.length === 0) {
|
if (data.elements.length === 0) {
|
||||||
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
||||||
}
|
|
||||||
|
|
||||||
const padding = 10;
|
|
||||||
const vbX = minX - padding;
|
|
||||||
const vbY = minY - padding;
|
|
||||||
const vbW = (maxX - minX) + padding * 2;
|
|
||||||
const vbH = (maxY - minY) + padding * 2;
|
|
||||||
|
|
||||||
const svgParts: string[] = [];
|
|
||||||
svgParts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
|
||||||
svgParts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vbX} ${vbY} ${vbW} ${vbH}" width="${vbW}" height="${vbH}">`);
|
|
||||||
|
|
||||||
svgParts.push('<defs><style>');
|
|
||||||
for (const layer of data.layers) {
|
|
||||||
const color = layer.color || '#000000';
|
|
||||||
const dash = layer.lineType === 'dashed' ? 'stroke-dasharray:8,4;' : layer.lineType === 'dotted' ? 'stroke-dasharray:2,3;' : '';
|
|
||||||
svgParts.push(`.layer-${layer.id} { stroke: ${color}; ${dash} }`);
|
|
||||||
}
|
|
||||||
svgParts.push('</style></defs>');
|
|
||||||
|
|
||||||
for (const el of data.elements) {
|
|
||||||
const layer = getLayerById(data.layers, el.layerId);
|
|
||||||
const stroke = el.properties.stroke || layer?.color || '#000000';
|
|
||||||
const fill = el.properties.fill || 'none';
|
|
||||||
const strokeWidth = el.properties.strokeWidth || 1;
|
|
||||||
const transform = el.properties.rotation ? ` transform="rotate(${el.properties.rotation} ${el.x + el.width / 2} ${el.y + el.height / 2})"` : '';
|
|
||||||
|
|
||||||
const svgY = (vbY + vbH) - el.y;
|
|
||||||
|
|
||||||
switch (el.type) {
|
|
||||||
case 'line': {
|
|
||||||
const x1 = el.properties.x1 ?? el.x;
|
|
||||||
const y1 = (vbY + vbH) - (el.properties.y1 ?? el.y);
|
|
||||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
|
||||||
const y2 = (vbY + vbH) - (el.properties.y2 ?? (el.y + el.height));
|
|
||||||
svgParts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'circle': {
|
|
||||||
const radius = el.properties.radius ?? el.width / 2;
|
|
||||||
const cx = el.x + radius;
|
|
||||||
const cy = svgY - radius;
|
|
||||||
svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'rect': {
|
|
||||||
svgParts.push(`<rect x="${el.x}" y="${svgY - el.height}" width="${el.width}" height="${el.height}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'polyline': {
|
|
||||||
if (el.properties.points && el.properties.points.length > 0) {
|
|
||||||
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
|
||||||
svgParts.push(`<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'polygon': {
|
|
||||||
if (el.properties.points && el.properties.points.length > 0) {
|
|
||||||
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
|
||||||
svgParts.push(`<polygon points="${pts}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'text': {
|
|
||||||
const text = escapeXml(el.properties.text || '');
|
|
||||||
const fontSize = el.properties.fontSize || 14;
|
|
||||||
svgParts.push(`<text x="${el.x}" y="${svgY}" font-size="${fontSize}" fill="${stroke}"${transform}>${text}</text>`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'arc': {
|
|
||||||
const rx = el.width / 2;
|
|
||||||
const ry = el.height / 2;
|
|
||||||
const cx = el.x + rx;
|
|
||||||
const cy = svgY - ry;
|
|
||||||
svgParts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
svgParts.push('</svg>');
|
const padding = 10;
|
||||||
const svg = svgParts.join('\n');
|
const vbX = minX - padding;
|
||||||
downloadBlob(svg, filename, 'image/svg+xml');
|
const vbY = minY - padding;
|
||||||
|
const vbW = (maxX - minX) + padding * 2;
|
||||||
|
const vbH = (maxY - minY) + padding * 2;
|
||||||
|
|
||||||
|
const svgParts: string[] = [];
|
||||||
|
svgParts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||||
|
svgParts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vbX} ${vbY} ${vbW} ${vbH}" width="${vbW}" height="${vbH}">`);
|
||||||
|
|
||||||
|
svgParts.push('<defs><style>');
|
||||||
|
for (const layer of data.layers) {
|
||||||
|
const color = layer.color || '#000000';
|
||||||
|
const dash = layer.lineType === 'dashed' ? 'stroke-dasharray:8,4;' : layer.lineType === 'dotted' ? 'stroke-dasharray:2,3;' : '';
|
||||||
|
svgParts.push(`.layer-${layer.id} { stroke: ${color}; ${dash} }`);
|
||||||
|
}
|
||||||
|
svgParts.push('</style></defs>');
|
||||||
|
|
||||||
|
for (const el of data.elements) {
|
||||||
|
const layer = getLayerById(data.layers, el.layerId);
|
||||||
|
const stroke = el.properties.stroke || layer?.color || '#000000';
|
||||||
|
const fill = el.properties.fill || 'none';
|
||||||
|
const strokeWidth = el.properties.strokeWidth || 1;
|
||||||
|
const transform = el.properties.rotation ? ` transform="rotate(${el.properties.rotation} ${el.x + el.width / 2} ${el.y + el.height / 2})"` : '';
|
||||||
|
|
||||||
|
const svgY = (vbY + vbH) - el.y;
|
||||||
|
|
||||||
|
switch (el.type) {
|
||||||
|
case 'line': {
|
||||||
|
const x1 = el.properties.x1 ?? el.x;
|
||||||
|
const y1 = (vbY + vbH) - (el.properties.y1 ?? el.y);
|
||||||
|
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||||
|
const y2 = (vbY + vbH) - (el.properties.y2 ?? (el.y + el.height));
|
||||||
|
svgParts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'circle': {
|
||||||
|
const radius = el.properties.radius ?? el.width / 2;
|
||||||
|
const cx = el.x + radius;
|
||||||
|
const cy = svgY - radius;
|
||||||
|
svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rect': {
|
||||||
|
svgParts.push(`<rect x="${el.x}" y="${svgY - el.height}" width="${el.width}" height="${el.height}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polyline': {
|
||||||
|
if (el.properties.points && el.properties.points.length > 0) {
|
||||||
|
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
||||||
|
svgParts.push(`<polyline points="${pts}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polygon': {
|
||||||
|
if (el.properties.points && el.properties.points.length > 0) {
|
||||||
|
const pts = el.properties.points.map(p => `${p.x},${(vbY + vbH) - p.y}`).join(' ');
|
||||||
|
svgParts.push(`<polygon points="${pts}" fill="${fill === 'transparent' ? 'none' : fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const text = escapeXml(el.properties.text || '');
|
||||||
|
const fontSize = el.properties.fontSize || 14;
|
||||||
|
svgParts.push(`<text x="${el.x}" y="${svgY}" font-size="${fontSize}" fill="${stroke}"${transform}>${text}</text>`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arc': {
|
||||||
|
const rx = el.width / 2;
|
||||||
|
const ry = el.height / 2;
|
||||||
|
const cx = el.x + rx;
|
||||||
|
const cy = svgY - ry;
|
||||||
|
svgParts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"${transform} />`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svgParts.push('</svg>');
|
||||||
|
const svg = svgParts.join('\n');
|
||||||
|
downloadBlob(svg, filename, 'image/svg+xml');
|
||||||
|
return { success: true, data: svg };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `exportSVG failed: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DXF Export ───────────────────────────────────────────────────────────────
|
// ── DXF Export ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): void {
|
export function exportDXF(yjsDoc: YjsDocument, filename = 'cad-export.dxf', options: ExportOptions = {}): ExportResult {
|
||||||
const data = collectExportData(yjsDoc, options);
|
try {
|
||||||
const lines: string[] = [];
|
const data = collectExportData(yjsDoc, options);
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
lines.push('0'); lines.push('SECTION');
|
lines.push('0'); lines.push('SECTION');
|
||||||
lines.push('2'); lines.push('HEADER');
|
lines.push('2'); lines.push('HEADER');
|
||||||
lines.push('9'); lines.push('$ACADVER');
|
lines.push('9'); lines.push('$ACADVER');
|
||||||
lines.push('1'); lines.push('AC1009');
|
lines.push('1'); lines.push('AC1009');
|
||||||
lines.push('9'); lines.push('$INSBASE');
|
lines.push('9'); lines.push('$INSBASE');
|
||||||
lines.push('10'); lines.push('0.0');
|
lines.push('10'); lines.push('0.0');
|
||||||
lines.push('20'); lines.push('0.0');
|
lines.push('20'); lines.push('0.0');
|
||||||
lines.push('30'); lines.push('0.0');
|
lines.push('30'); lines.push('0.0');
|
||||||
lines.push('0'); lines.push('ENDSEC');
|
lines.push('0'); lines.push('ENDSEC');
|
||||||
|
|
||||||
lines.push('0'); lines.push('SECTION');
|
lines.push('0'); lines.push('SECTION');
|
||||||
lines.push('2'); lines.push('TABLES');
|
lines.push('2'); lines.push('TABLES');
|
||||||
lines.push('0'); lines.push('TABLE');
|
lines.push('0'); lines.push('TABLE');
|
||||||
lines.push('2'); lines.push('LAYER');
|
lines.push('2'); lines.push('LAYER');
|
||||||
lines.push('70'); lines.push(String(data.layers.length));
|
lines.push('70'); lines.push(String(data.layers.length));
|
||||||
|
|
||||||
for (const layer of data.layers) {
|
for (const layer of data.layers) {
|
||||||
const color = layer.color || '#000000';
|
const color = layer.color || '#000000';
|
||||||
const rgb = hexToRgb(color);
|
const rgb = hexToRgb(color);
|
||||||
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
||||||
lines.push('0'); lines.push('LAYER');
|
lines.push('0'); lines.push('LAYER');
|
||||||
lines.push('2'); lines.push(layer.name || layer.id);
|
lines.push('2'); lines.push(layer.name || layer.id);
|
||||||
lines.push('70'); lines.push('0');
|
lines.push('70'); lines.push('0');
|
||||||
lines.push('62'); lines.push(String(aci));
|
lines.push('62'); lines.push(String(aci));
|
||||||
lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS');
|
lines.push('6'); lines.push(layer.lineType || 'CONTINUOUS');
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('0'); lines.push('ENDTAB');
|
lines.push('0'); lines.push('ENDTAB');
|
||||||
lines.push('0'); lines.push('ENDSEC');
|
lines.push('0'); lines.push('ENDSEC');
|
||||||
|
|
||||||
lines.push('0'); lines.push('SECTION');
|
lines.push('0'); lines.push('SECTION');
|
||||||
lines.push('2'); lines.push('ENTITIES');
|
lines.push('2'); lines.push('ENTITIES');
|
||||||
|
|
||||||
for (const el of data.elements) {
|
for (const el of data.elements) {
|
||||||
const layer = getLayerById(data.layers, el.layerId);
|
const layer = getLayerById(data.layers, el.layerId);
|
||||||
const layerName = layer?.name || layer?.id || '0';
|
const layerName = layer?.name || layer?.id || '0';
|
||||||
const color = el.properties.stroke || layer?.color || '#000000';
|
const color = el.properties.stroke || layer?.color || '#000000';
|
||||||
const rgb = hexToRgb(color);
|
const rgb = hexToRgb(color);
|
||||||
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
const aci = rgbToAci(rgb.r, rgb.g, rgb.b);
|
||||||
|
|
||||||
switch (el.type) {
|
switch (el.type) {
|
||||||
case 'line': {
|
case 'line': {
|
||||||
const x1 = el.properties.x1 ?? el.x;
|
const x1 = el.properties.x1 ?? el.x;
|
||||||
const y1 = el.properties.y1 ?? el.y;
|
const y1 = el.properties.y1 ?? el.y;
|
||||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||||
const y2 = el.properties.y2 ?? (el.y + el.height);
|
const y2 = el.properties.y2 ?? (el.y + el.height);
|
||||||
lines.push('0'); lines.push('LINE');
|
lines.push('0'); lines.push('LINE');
|
||||||
lines.push('8'); lines.push(layerName);
|
|
||||||
lines.push('62'); lines.push(String(aci));
|
|
||||||
lines.push('10'); lines.push(String(x1));
|
|
||||||
lines.push('20'); lines.push(String(y1));
|
|
||||||
lines.push('30'); lines.push('0.0');
|
|
||||||
lines.push('11'); lines.push(String(x2));
|
|
||||||
lines.push('21'); lines.push(String(y2));
|
|
||||||
lines.push('31'); lines.push('0.0');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'circle': {
|
|
||||||
const radius = el.properties.radius ?? el.width / 2;
|
|
||||||
const cx = el.x + radius;
|
|
||||||
const cy = el.y + radius;
|
|
||||||
lines.push('0'); lines.push('CIRCLE');
|
|
||||||
lines.push('8'); lines.push(layerName);
|
|
||||||
lines.push('62'); lines.push(String(aci));
|
|
||||||
lines.push('10'); lines.push(String(cx));
|
|
||||||
lines.push('20'); lines.push(String(cy));
|
|
||||||
lines.push('30'); lines.push('0.0');
|
|
||||||
lines.push('40'); lines.push(String(radius));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'rect': {
|
|
||||||
const x = el.x, y = el.y, w = el.width, h = el.height;
|
|
||||||
lines.push('0'); lines.push('LWPOLYLINE');
|
|
||||||
lines.push('8'); lines.push(layerName);
|
|
||||||
lines.push('62'); lines.push(String(aci));
|
|
||||||
lines.push('90'); lines.push('4');
|
|
||||||
lines.push('70'); lines.push('1');
|
|
||||||
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y));
|
|
||||||
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y));
|
|
||||||
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y + h));
|
|
||||||
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y + h));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'text': {
|
|
||||||
const text = el.properties.text || '';
|
|
||||||
const fontSize = el.properties.fontSize || 14;
|
|
||||||
lines.push('0'); lines.push('TEXT');
|
|
||||||
lines.push('8'); lines.push(layerName);
|
|
||||||
lines.push('62'); lines.push(String(aci));
|
|
||||||
lines.push('10'); lines.push(String(el.x));
|
|
||||||
lines.push('20'); lines.push(String(el.y));
|
|
||||||
lines.push('30'); lines.push('0.0');
|
|
||||||
lines.push('40'); lines.push(String(fontSize));
|
|
||||||
lines.push('1'); lines.push(text);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'polyline': {
|
|
||||||
if (el.properties.points && el.properties.points.length > 0) {
|
|
||||||
const pts = el.properties.points;
|
|
||||||
lines.push('0'); lines.push('LWPOLYLINE');
|
|
||||||
lines.push('8'); lines.push(layerName);
|
lines.push('8'); lines.push(layerName);
|
||||||
lines.push('62'); lines.push(String(aci));
|
lines.push('62'); lines.push(String(aci));
|
||||||
lines.push('90'); lines.push(String(pts.length));
|
lines.push('10'); lines.push(String(x1));
|
||||||
lines.push('70'); lines.push('0');
|
lines.push('20'); lines.push(String(y1));
|
||||||
for (const p of pts) {
|
lines.push('30'); lines.push('0.0');
|
||||||
lines.push('10'); lines.push(String(p.x));
|
lines.push('11'); lines.push(String(x2));
|
||||||
lines.push('20'); lines.push(String(p.y));
|
lines.push('21'); lines.push(String(y2));
|
||||||
}
|
lines.push('31'); lines.push('0.0');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
case 'circle': {
|
||||||
}
|
const radius = el.properties.radius ?? el.width / 2;
|
||||||
case 'polygon': {
|
const cx = el.x + radius;
|
||||||
if (el.properties.points && el.properties.points.length > 0) {
|
const cy = el.y + radius;
|
||||||
const pts = el.properties.points;
|
lines.push('0'); lines.push('CIRCLE');
|
||||||
|
lines.push('8'); lines.push(layerName);
|
||||||
|
lines.push('62'); lines.push(String(aci));
|
||||||
|
lines.push('10'); lines.push(String(cx));
|
||||||
|
lines.push('20'); lines.push(String(cy));
|
||||||
|
lines.push('30'); lines.push('0.0');
|
||||||
|
lines.push('40'); lines.push(String(radius));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rect': {
|
||||||
|
const x = el.x, y = el.y, w = el.width, h = el.height;
|
||||||
lines.push('0'); lines.push('LWPOLYLINE');
|
lines.push('0'); lines.push('LWPOLYLINE');
|
||||||
lines.push('8'); lines.push(layerName);
|
lines.push('8'); lines.push(layerName);
|
||||||
lines.push('62'); lines.push(String(aci));
|
lines.push('62'); lines.push(String(aci));
|
||||||
lines.push('90'); lines.push(String(pts.length));
|
lines.push('90'); lines.push('4');
|
||||||
lines.push('70'); lines.push('1');
|
lines.push('70'); lines.push('1');
|
||||||
for (const p of pts) {
|
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y));
|
||||||
lines.push('10'); lines.push(String(p.x));
|
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y));
|
||||||
lines.push('20'); lines.push(String(p.y));
|
lines.push('10'); lines.push(String(x + w)); lines.push('20'); lines.push(String(y + h));
|
||||||
}
|
lines.push('10'); lines.push(String(x)); lines.push('20'); lines.push(String(y + h));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
case 'text': {
|
||||||
|
const text = el.properties.text || '';
|
||||||
|
const fontSize = el.properties.fontSize || 14;
|
||||||
|
lines.push('0'); lines.push('TEXT');
|
||||||
|
lines.push('8'); lines.push(layerName);
|
||||||
|
lines.push('62'); lines.push(String(aci));
|
||||||
|
lines.push('10'); lines.push(String(el.x));
|
||||||
|
lines.push('20'); lines.push(String(el.y));
|
||||||
|
lines.push('30'); lines.push('0.0');
|
||||||
|
lines.push('40'); lines.push(String(fontSize));
|
||||||
|
lines.push('1'); lines.push(text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polyline': {
|
||||||
|
if (el.properties.points && el.properties.points.length > 0) {
|
||||||
|
const pts = el.properties.points;
|
||||||
|
lines.push('0'); lines.push('LWPOLYLINE');
|
||||||
|
lines.push('8'); lines.push(layerName);
|
||||||
|
lines.push('62'); lines.push(String(aci));
|
||||||
|
lines.push('90'); lines.push(String(pts.length));
|
||||||
|
lines.push('70'); lines.push('0');
|
||||||
|
for (const p of pts) {
|
||||||
|
lines.push('10'); lines.push(String(p.x));
|
||||||
|
lines.push('20'); lines.push(String(p.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polygon': {
|
||||||
|
if (el.properties.points && el.properties.points.length > 0) {
|
||||||
|
const pts = el.properties.points;
|
||||||
|
lines.push('0'); lines.push('LWPOLYLINE');
|
||||||
|
lines.push('8'); lines.push(layerName);
|
||||||
|
lines.push('62'); lines.push(String(aci));
|
||||||
|
lines.push('90'); lines.push(String(pts.length));
|
||||||
|
lines.push('70'); lines.push('1');
|
||||||
|
for (const p of pts) {
|
||||||
|
lines.push('10'); lines.push(String(p.x));
|
||||||
|
lines.push('20'); lines.push(String(p.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arc': {
|
||||||
|
const radius = el.properties.radius ?? el.width / 2;
|
||||||
|
const cx = el.x + radius;
|
||||||
|
const cy = el.y + radius;
|
||||||
|
// M4 fix: Export actual startAngle (group code 50) and endAngle (group code 51)
|
||||||
|
const startAngle = el.properties.startAngle ?? 0;
|
||||||
|
const endAngle = el.properties.endAngle ?? 360;
|
||||||
|
lines.push('0'); lines.push('ARC');
|
||||||
|
lines.push('8'); lines.push(layerName);
|
||||||
|
lines.push('62'); lines.push(String(aci));
|
||||||
|
lines.push('10'); lines.push(String(cx));
|
||||||
|
lines.push('20'); lines.push(String(cy));
|
||||||
|
lines.push('30'); lines.push('0.0');
|
||||||
|
lines.push('40'); lines.push(String(radius));
|
||||||
|
lines.push('50'); lines.push(String(startAngle));
|
||||||
|
lines.push('51'); lines.push(String(endAngle));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case 'arc': {
|
|
||||||
const radius = el.properties.radius ?? el.width / 2;
|
|
||||||
const cx = el.x + radius;
|
|
||||||
const cy = el.y + radius;
|
|
||||||
// M4 fix: Export actual startAngle (group code 50) and endAngle (group code 51)
|
|
||||||
const startAngle = el.properties.startAngle ?? 0;
|
|
||||||
const endAngle = el.properties.endAngle ?? 360;
|
|
||||||
lines.push('0'); lines.push('ARC');
|
|
||||||
lines.push('8'); lines.push(layerName);
|
|
||||||
lines.push('62'); lines.push(String(aci));
|
|
||||||
lines.push('10'); lines.push(String(cx));
|
|
||||||
lines.push('20'); lines.push(String(cy));
|
|
||||||
lines.push('30'); lines.push('0.0');
|
|
||||||
lines.push('40'); lines.push(String(radius));
|
|
||||||
lines.push('50'); lines.push(String(startAngle));
|
|
||||||
lines.push('51'); lines.push(String(endAngle));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push('0'); lines.push('ENDSEC');
|
||||||
|
lines.push('0'); lines.push('EOF');
|
||||||
|
|
||||||
|
const dxf = lines.join('\n');
|
||||||
|
downloadBlob(dxf, filename, 'application/dxf');
|
||||||
|
return { success: true, data: dxf };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `exportDXF failed: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('0'); lines.push('ENDSEC');
|
|
||||||
lines.push('0'); lines.push('EOF');
|
|
||||||
|
|
||||||
const dxf = lines.join('\n');
|
|
||||||
downloadBlob(dxf, filename, 'application/dxf');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PDF Export ───────────────────────────────────────────────────────────────
|
// ── PDF Export ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function exportPDF(
|
export async function exportPDF(
|
||||||
yjsDoc: YjsDocument,
|
yjsDoc: YjsDocument,
|
||||||
filename = 'cad-export.pdf',
|
filename = 'cad-export.pdf',
|
||||||
options: ExportOptions = {}
|
options: ExportOptions = {}
|
||||||
): Promise<void> {
|
): Promise<ExportResult> {
|
||||||
const data = collectExportData(yjsDoc, options);
|
try {
|
||||||
|
const data = collectExportData(yjsDoc, options);
|
||||||
|
|
||||||
const { jsPDF } = await import('jspdf');
|
const { jsPDF } = await import('jspdf');
|
||||||
|
|
||||||
const pageSize = options.pageSize || 'a4';
|
const pageSize = options.pageSize || 'a4';
|
||||||
const orientation = options.landscape ? 'landscape' : 'portrait';
|
const orientation = options.landscape ? 'landscape' : 'portrait';
|
||||||
const pdf = new jsPDF({
|
const pdf = new jsPDF({
|
||||||
orientation,
|
orientation,
|
||||||
unit: 'mm',
|
unit: 'mm',
|
||||||
format: pageSize,
|
format: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||||
const margin = 10;
|
const margin = 10;
|
||||||
const drawableW = pageWidth - margin * 2;
|
const drawableW = pageWidth - margin * 2;
|
||||||
const drawableH = pageHeight - margin * 2;
|
const drawableH = pageHeight - margin * 2;
|
||||||
|
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
for (const el of data.elements) {
|
for (const el of data.elements) {
|
||||||
minX = Math.min(minX, el.x);
|
minX = Math.min(minX, el.x);
|
||||||
minY = Math.min(minY, el.y);
|
minY = Math.min(minY, el.y);
|
||||||
maxX = Math.max(maxX, el.x + el.width);
|
maxX = Math.max(maxX, el.x + el.width);
|
||||||
maxY = Math.max(maxY, el.y + el.height);
|
maxY = Math.max(maxY, el.y + el.height);
|
||||||
}
|
}
|
||||||
if (data.elements.length === 0) {
|
if (data.elements.length === 0) {
|
||||||
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
minX = 0; minY = 0; maxX = 100; maxY = 100;
|
||||||
}
|
|
||||||
|
|
||||||
const contentW = maxX - minX;
|
|
||||||
const contentH = maxY - minY;
|
|
||||||
const scale = Math.min(drawableW / contentW, drawableH / contentH, 1);
|
|
||||||
const offsetX = margin + (drawableW - contentW * scale) / 2;
|
|
||||||
const offsetY = margin + (drawableH - contentH * scale) / 2;
|
|
||||||
|
|
||||||
const tx = (x: number) => offsetX + (x - minX) * scale;
|
|
||||||
const ty = (y: number) => pageHeight - offsetY - (y - minY) * scale;
|
|
||||||
|
|
||||||
for (const el of data.elements) {
|
|
||||||
const layer = getLayerById(data.layers, el.layerId);
|
|
||||||
const strokeColor = el.properties.stroke || layer?.color || '#000000';
|
|
||||||
const rgb = hexToRgb(strokeColor);
|
|
||||||
pdf.setDrawColor(rgb.r, rgb.g, rgb.b);
|
|
||||||
pdf.setLineWidth((el.properties.strokeWidth || 1) * scale);
|
|
||||||
|
|
||||||
const fillColor = el.properties.fill;
|
|
||||||
if (fillColor && fillColor !== 'transparent') {
|
|
||||||
const fRgb = hexToRgb(fillColor);
|
|
||||||
pdf.setFillColor(fRgb.r, fRgb.g, fRgb.b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (el.type) {
|
const contentW = maxX - minX;
|
||||||
case 'line': {
|
const contentH = maxY - minY;
|
||||||
const x1 = el.properties.x1 ?? el.x;
|
const scale = Math.min(drawableW / contentW, drawableH / contentH, 1);
|
||||||
const y1 = el.properties.y1 ?? el.y;
|
const offsetX = margin + (drawableW - contentW * scale) / 2;
|
||||||
const x2 = el.properties.x2 ?? (el.x + el.width);
|
const offsetY = margin + (drawableH - contentH * scale) / 2;
|
||||||
const y2 = el.properties.y2 ?? (el.y + el.height);
|
|
||||||
pdf.line(tx(x1), ty(y1), tx(x2), ty(y2));
|
const tx = (x: number) => offsetX + (x - minX) * scale;
|
||||||
break;
|
const ty = (y: number) => pageHeight - offsetY - (y - minY) * scale;
|
||||||
|
|
||||||
|
for (const el of data.elements) {
|
||||||
|
const layer = getLayerById(data.layers, el.layerId);
|
||||||
|
const strokeColor = el.properties.stroke || layer?.color || '#000000';
|
||||||
|
const rgb = hexToRgb(strokeColor);
|
||||||
|
pdf.setDrawColor(rgb.r, rgb.g, rgb.b);
|
||||||
|
pdf.setLineWidth((el.properties.strokeWidth || 1) * scale);
|
||||||
|
|
||||||
|
const fillColor = el.properties.fill;
|
||||||
|
if (fillColor && fillColor !== 'transparent') {
|
||||||
|
const fRgb = hexToRgb(fillColor);
|
||||||
|
pdf.setFillColor(fRgb.r, fRgb.g, fRgb.b);
|
||||||
}
|
}
|
||||||
case 'circle': {
|
|
||||||
const radius = el.properties.radius ?? el.width / 2;
|
switch (el.type) {
|
||||||
const cx = tx(el.x + radius);
|
case 'line': {
|
||||||
const cy = ty(el.y + radius);
|
const x1 = el.properties.x1 ?? el.x;
|
||||||
pdf.circle(cx, cy, radius * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
const y1 = el.properties.y1 ?? el.y;
|
||||||
break;
|
const x2 = el.properties.x2 ?? (el.x + el.width);
|
||||||
}
|
const y2 = el.properties.y2 ?? (el.y + el.height);
|
||||||
case 'rect': {
|
pdf.line(tx(x1), ty(y1), tx(x2), ty(y2));
|
||||||
const x = tx(el.x);
|
break;
|
||||||
const y = ty(el.y + el.height);
|
|
||||||
pdf.rect(x, y, el.width * scale, el.height * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'polyline': {
|
|
||||||
if (el.properties.points && el.properties.points.length > 1) {
|
|
||||||
const pts = el.properties.points;
|
|
||||||
for (let i = 0; i < pts.length - 1; i++) {
|
|
||||||
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[i + 1].x), ty(pts[i + 1].y));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
case 'circle': {
|
||||||
}
|
const radius = el.properties.radius ?? el.width / 2;
|
||||||
case 'polygon': {
|
const cx = tx(el.x + radius);
|
||||||
if (el.properties.points && el.properties.points.length > 2) {
|
const cy = ty(el.y + radius);
|
||||||
const pts = el.properties.points;
|
pdf.circle(cx, cy, radius * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
||||||
for (let i = 0; i < pts.length; i++) {
|
break;
|
||||||
const next = (i + 1) % pts.length;
|
|
||||||
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[next].x), ty(pts[next].y));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
case 'rect': {
|
||||||
|
const x = tx(el.x);
|
||||||
|
const y = ty(el.y + el.height);
|
||||||
|
pdf.rect(x, y, el.width * scale, el.height * scale, fillColor && fillColor !== 'transparent' ? 'FD' : 'S');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polyline': {
|
||||||
|
if (el.properties.points && el.properties.points.length > 1) {
|
||||||
|
const pts = el.properties.points;
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[i + 1].x), ty(pts[i + 1].y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'polygon': {
|
||||||
|
if (el.properties.points && el.properties.points.length > 2) {
|
||||||
|
const pts = el.properties.points;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const next = (i + 1) % pts.length;
|
||||||
|
pdf.line(tx(pts[i].x), ty(pts[i].y), tx(pts[next].x), ty(pts[next].y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const text = el.properties.text || '';
|
||||||
|
const fontSize = (el.properties.fontSize || 14) * scale;
|
||||||
|
pdf.setFontSize(fontSize);
|
||||||
|
pdf.text(text, tx(el.x), ty(el.y));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arc': {
|
||||||
|
const radius = el.properties.radius ?? el.width / 2;
|
||||||
|
const cx = tx(el.x + radius);
|
||||||
|
const cy = ty(el.y + radius);
|
||||||
|
pdf.circle(cx, cy, radius * scale, 'S');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case 'text': {
|
|
||||||
const text = el.properties.text || '';
|
|
||||||
const fontSize = (el.properties.fontSize || 14) * scale;
|
|
||||||
pdf.setFontSize(fontSize);
|
|
||||||
pdf.text(text, tx(el.x), ty(el.y));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'arc': {
|
|
||||||
const radius = el.properties.radius ?? el.width / 2;
|
|
||||||
const cx = tx(el.x + radius);
|
|
||||||
const cy = ty(el.y + radius);
|
|
||||||
pdf.circle(cx, cy, radius * scale, 'S');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pdf.setFontSize(8);
|
||||||
|
pdf.setTextColor(128, 128, 128);
|
||||||
|
pdf.text(
|
||||||
|
`Exported: ${new Date().toISOString()} | Elements: ${data.elements.length} | Layers: ${data.layers.length}`,
|
||||||
|
margin,
|
||||||
|
pageHeight - 5
|
||||||
|
);
|
||||||
|
|
||||||
|
pdf.save(filename);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `exportPDF failed: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.setFontSize(8);
|
|
||||||
pdf.setTextColor(128, 128, 128);
|
|
||||||
pdf.text(
|
|
||||||
`Exported: ${new Date().toISOString()} | Elements: ${data.elements.length} | Layers: ${data.layers.length}`,
|
|
||||||
margin,
|
|
||||||
pageHeight - 5
|
|
||||||
);
|
|
||||||
|
|
||||||
pdf.save(filename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Unified Export ───────────────────────────────────────────────────────────
|
// ── Unified Export ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf';
|
export type ExportFormat = 'json' | 'svg' | 'dxf' | 'pdf';
|
||||||
|
|
||||||
@@ -564,7 +554,7 @@ export async function exportDrawing(
|
|||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
filename?: string,
|
filename?: string,
|
||||||
options?: ExportOptions
|
options?: ExportOptions
|
||||||
): Promise<void> {
|
): Promise<ExportResult> {
|
||||||
const ext = format;
|
const ext = format;
|
||||||
const name = filename || `cad-export.${ext}`;
|
const name = filename || `cad-export.${ext}`;
|
||||||
|
|
||||||
@@ -578,6 +568,9 @@ export async function exportDrawing(
|
|||||||
case 'pdf':
|
case 'pdf':
|
||||||
return exportPDF(yjsDoc, name, options);
|
return exportPDF(yjsDoc, name, options);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported export format: ${format}`);
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Unsupported export format: ${format}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user