| 5 |
5 |
*/
|
| 6 |
6 |
import { Controller } from "@hotwired/stimulus"
|
| 7 |
7 |
|
|
8 |
// Diagrams are saved larger than their original size so that they stay sharp when enlarged.
|
|
9 |
const PNG_SCALE = 2;
|
|
10 |
// Larger diagrams are scaled down so that saving them does not use up memory.
|
|
11 |
const MAX_PNG_PIXELS_PER_SIDE = 4096;
|
|
12 |
const MAX_PNG_TOTAL_PIXELS = 8_000_000;
|
|
13 |
|
| 8 |
14 |
let mermaidInitialized = false;
|
| 9 |
15 |
let mermaidLoadPromise = null;
|
| 10 |
16 |
|
| ... | ... | |
| 63 |
69 |
// Undo everything render() added so that a reconnect starts clean.
|
| 64 |
70 |
this.container?.remove();
|
| 65 |
71 |
this.errorElement?.remove();
|
| 66 |
|
this.container = this.errorElement = null;
|
|
72 |
this.downloadLink?.remove();
|
|
73 |
this.container = this.errorElement = this.downloadLink = null;
|
| 67 |
74 |
const pre = this.element.closest('pre');
|
| 68 |
75 |
if (pre) pre.style.display = '';
|
| 69 |
76 |
}
|
| ... | ... | |
| 87 |
94 |
// visible, so that the invalid diagram can be inspected and copied.
|
| 88 |
95 |
mermaid.run({ nodes: [container], suppressErrors: false }).then(() => {
|
| 89 |
96 |
pre.style.display = 'none';
|
|
97 |
if (this.container === container) this.addDownloadLink(pre, container);
|
| 90 |
98 |
}).catch((error) => {
|
| 91 |
99 |
container.remove();
|
| 92 |
100 |
if (this.container === container) this.container = null;
|
| ... | ... | |
| 105 |
113 |
this.errorElement = errorElement;
|
| 106 |
114 |
});
|
| 107 |
115 |
}
|
|
116 |
|
|
117 |
addDownloadLink(pre, container) {
|
|
118 |
const link = document.createElement('a');
|
|
119 |
link.className = 'mermaid-download-link icon-only';
|
|
120 |
link.title = rm.I18n.buttonDownload;
|
|
121 |
// A link without href cannot be operated with the keyboard.
|
|
122 |
link.href = '#';
|
|
123 |
link.append(createSVGIcon('download'));
|
|
124 |
link.addEventListener('click', (event) => {
|
|
125 |
event.preventDefault();
|
|
126 |
this.download();
|
|
127 |
});
|
|
128 |
|
|
129 |
// Put the link next to the copy button that CopypreScrubber adds to div.pre-wrapper.
|
|
130 |
const wrapper = pre.closest('.pre-wrapper');
|
|
131 |
if (wrapper) {
|
|
132 |
wrapper.append(link);
|
|
133 |
} else {
|
|
134 |
container.insertAdjacentElement('beforebegin', link);
|
|
135 |
}
|
|
136 |
this.downloadLink = link;
|
|
137 |
}
|
|
138 |
|
|
139 |
// The diagram is saved as a PNG image because the SVG generated by Mermaid.js
|
|
140 |
// relies on HTML and CSS and is not displayed properly outside web browsers.
|
|
141 |
async download() {
|
|
142 |
try {
|
|
143 |
const svg = this.container.querySelector('svg');
|
|
144 |
const scale = this.pngScaleFor(svg);
|
|
145 |
const canvas = await this.drawOnCanvas(svg, scale);
|
|
146 |
canvas.toBlob((png) => {
|
|
147 |
if (png) this.saveAs(png, this.filenameFor(svg, scale));
|
|
148 |
}, 'image/png');
|
|
149 |
} catch (error) {
|
|
150 |
console.error('Failed to save Mermaid diagram:', error);
|
|
151 |
}
|
|
152 |
}
|
|
153 |
|
|
154 |
pngScaleFor(svg) {
|
|
155 |
const { width, height } = svg.viewBox.baseVal;
|
|
156 |
return Math.min(
|
|
157 |
PNG_SCALE,
|
|
158 |
MAX_PNG_PIXELS_PER_SIDE / Math.max(width, height),
|
|
159 |
Math.sqrt(MAX_PNG_TOTAL_PIXELS / (width * height))
|
|
160 |
);
|
|
161 |
}
|
|
162 |
|
|
163 |
// Returns a name like mermaid-flowchart@2x.png, or mermaid-flowchart.png
|
|
164 |
// when the image has been scaled down.
|
|
165 |
filenameFor(svg, scale) {
|
|
166 |
// Mermaid.js sets the diagram type here, such as "sequence" or "flowchart-v2".
|
|
167 |
const diagramType = (svg.getAttribute('aria-roledescription') || '').replace(/-v\d+$/, '');
|
|
168 |
const name = /^[a-z0-9]+$/i.test(diagramType) ? diagramType : 'diagram';
|
|
169 |
const suffix = scale === PNG_SCALE ? `@${PNG_SCALE}x` : '';
|
|
170 |
return `mermaid-${name}${suffix}.png`;
|
|
171 |
}
|
|
172 |
|
|
173 |
async drawOnCanvas(svg, scale) {
|
|
174 |
const { width, height } = svg.viewBox.baseVal;
|
|
175 |
// Rounded down so that the limits are never exceeded.
|
|
176 |
const pngWidth = Math.floor(width * scale);
|
|
177 |
const pngHeight = Math.floor(height * scale);
|
|
178 |
|
|
179 |
// Unlike cloneNode(), this copy does not fetch <img> elements in labels again.
|
|
180 |
const standaloneSvg = document.implementation.createHTMLDocument('').importNode(svg, true);
|
|
181 |
// Gives the SVG the size of the PNG so that it is rendered at that size from
|
|
182 |
// the start, without the max-width that Mermaid.js sets to the original width.
|
|
183 |
standaloneSvg.setAttribute('width', pngWidth);
|
|
184 |
standaloneSvg.setAttribute('height', pngHeight);
|
|
185 |
standaloneSvg.style.removeProperty('max-width');
|
|
186 |
const source = new XMLSerializer().serializeToString(standaloneSvg);
|
|
187 |
|
|
188 |
const image = new Image();
|
|
189 |
// Not a blob: URL, with which Chrome refuses to export a canvas once an
|
|
190 |
// SVG containing <foreignObject> has been drawn on it.
|
|
191 |
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source);
|
|
192 |
await image.decode();
|
|
193 |
|
|
194 |
const canvas = document.createElement('canvas');
|
|
195 |
canvas.width = pngWidth;
|
|
196 |
canvas.height = pngHeight;
|
|
197 |
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
198 |
return canvas;
|
|
199 |
}
|
|
200 |
|
|
201 |
saveAs(blob, filename) {
|
|
202 |
const link = document.createElement('a');
|
|
203 |
link.href = URL.createObjectURL(blob);
|
|
204 |
link.download = filename;
|
|
205 |
link.click();
|
|
206 |
// Give the browser time to start the download before releasing the blob.
|
|
207 |
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
|
208 |
}
|
| 108 |
209 |
}
|