Project

General

Profile

Feature #44605 » 0001-Add-a-link-to-download-a-rendered-Mermaid-diagram-as.patch

Go MAEDA, 2026-10-14 05:54

View differences:

app/assets/stylesheets/application.css
1994 1994
  white-space: pre-wrap;
1995 1995
}
1996 1996

  
1997
div.pre-wrapper a.copy-pre-content-link {
1997
div.pre-wrapper a.copy-pre-content-link,
1998
div.pre-wrapper a.mermaid-download-link {
1998 1999
  position: absolute;
1999 2000
  inset-block-start: 3px;
2000 2001
  inset-inline-end: calc(1em + 3px);
......
2007 2008
  padding-inline: 3px;
2008 2009
}
2009 2010

  
2010
div.pre-wrapper:hover a.copy-pre-content-link {
2011
div.pre-wrapper a.mermaid-download-link {
2012
  /* To the left of the copy button: its width (18px icon + padding + border) plus a 3px gap */
2013
  inset-inline-end: calc(1em + 3px + 26px + 3px);
2014
}
2015

  
2016
div.pre-wrapper:hover a.copy-pre-content-link,
2017
div.pre-wrapper:hover a.mermaid-download-link {
2011 2018
  display: block;
2012 2019
}
2013 2020

  
app/helpers/application_helper.rb
1941 1941
    javascript_tag(
1942 1942
      "rm = window.rm || {};" \
1943 1943
      "rm.I18n = rm.I18n || {};" \
1944
      "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}'});"
1944
      "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}', buttonDownload: '#{l(:button_download)}'});"
1945 1945
    )
1946 1946
  end
1947 1947

  
app/javascript/controllers/mermaid_controller.js
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
}
test/system/mermaid_rendering_test.rb
69 69
      assert_selector '.flash.error', text: 'Failed to render mermaid diagram'
70 70
      assert_selector 'pre'
71 71
      assert_no_selector 'div.mermaid svg'
72
      assert_no_selector 'a.mermaid-download-link', visible: :all
72 73
    end
73 74
  end
74 75

  
76
  def test_should_download_mermaid_diagram_as_png
77
    skip unless mermaid_installed?
78

  
79
    issue = Issue.find(1)
80
    issue.update_column(:description, "```mermaid\ngraph TD;\nA-->B;\n```")
81

  
82
    log_user('jsmith', 'jsmith')
83
    visit "/issues/#{issue.id}"
84

  
85
    within('div.description') do
86
      find('div.mermaid svg').hover
87
      find('a.mermaid-download-link').click
88
    end
89

  
90
    png_signature = "\x89PNG\r\n\x1A\n".b
91
    assert_equal png_signature, File.binread(downloaded_file('mermaid-flowchart@2x.png'))[0, 8]
92
  end
93

  
75 94
  def test_should_render_mermaid_in_description_preview
76 95
    skip unless mermaid_installed?
77 96

  
(3-3/3)