From 743739e1f4d3e13ef99cefdc63b24bac6f25c397 Mon Sep 17 00:00:00 2001 From: Christopher Keele Date: Tue, 15 Oct 2024 03:07:02 -0500 Subject: [PATCH] Make mermaid diagrams downloadable captionable figures (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jonatan KÅ‚osko --- assets/packs/mermaid/main.css | 36 +++++++++++++++++++ assets/packs/mermaid/main.js | 51 +++++++++++++++++++++++--- lib/assets/mermaid/build/main.css | 1 + lib/assets/mermaid/build/main.js | 2 +- lib/kino/mermaid.ex | 26 ++++++++++---- lib/kino/process.ex | 60 +++++++++++++++++++++---------- lib/kino/shorts.ex | 6 ++-- test/kino/process_test.exs | 2 +- 8 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 assets/packs/mermaid/main.css create mode 100644 lib/assets/mermaid/build/main.css diff --git a/assets/packs/mermaid/main.css b/assets/packs/mermaid/main.css new file mode 100644 index 00000000..e5716737 --- /dev/null +++ b/assets/packs/mermaid/main.css @@ -0,0 +1,36 @@ +.container { + display: flex; + align-items: flex-start; + gap: 8px; + width: max-content; +} + +.container .download-btn { + cursor: pointer; + padding: 0.25rem; + color: #61758A; + background: none; + border: none; +} + +.container .download-btn:hover { + color: #0D1829; +} + +.container:not(:hover) .download-btn { + opacity: 0; +} + +.figure { + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + width: max-content; +} + +.caption { + margin-top: 8px; + font-size: 0.875rem; + color: #61758a; +} diff --git a/assets/packs/mermaid/main.js b/assets/packs/mermaid/main.js index 724759d8..8b1e62b5 100644 --- a/assets/packs/mermaid/main.js +++ b/assets/packs/mermaid/main.js @@ -1,18 +1,61 @@ import mermaid from "mermaid"; +import "./main.css"; mermaid.initialize({ startOnLoad: false }); -export function init(ctx, content) { +export function init(ctx, { diagram, caption, download }) { + ctx.importCSS("main.css"); + function render() { - mermaid.render("graph1", content).then(({ svg, bindFunctions }) => { - ctx.root.innerHTML = svg; + mermaid.render("diagram", diagram).then(({ svg, bindFunctions }) => { + // Fix for: https://github.com/mermaid-js/mermaid/issues/1766 + svg = svg.replace(/
/gi, "
"); + + let container = document.createElement("div"); + container.classList.add("container"); + ctx.root.appendChild(container); + + const figure = document.createElement("figure"); + figure.classList.add("figure"); + figure.innerHTML = svg; + container.appendChild(figure); + + if (caption) { + const figcaption = document.createElement("figcaption"); + figcaption.classList.add("caption"); + figcaption.textContent = caption; + figure.appendChild(figcaption); + } + + if (download) { + const downloadBtn = document.createElement("button"); + downloadBtn.classList.add("download-btn"); + downloadBtn.title = "Download"; + downloadBtn.innerHTML = ``; + container.appendChild(downloadBtn); + + downloadBtn.addEventListener("click", (event) => { + const blobURL = URL.createObjectURL( + new Blob([svg], { type: "image/svg+xml" }), + ); + + const a = document.createElement("a"); + a.style.display = "none"; + a.href = blobURL; + a.download = "diagram.svg"; + + container.appendChild(a); + a.click(); + container.removeChild(a); + }); + } if (bindFunctions) { bindFunctions(ctx.root); } // A workaround for https://github.com/mermaid-js/mermaid/issues/1758 - const svgEl = ctx.root.querySelector("svg"); + const svgEl = figure.querySelector("svg"); svgEl.removeAttribute("height"); }); } diff --git a/lib/assets/mermaid/build/main.css b/lib/assets/mermaid/build/main.css new file mode 100644 index 00000000..fd9f7b79 --- /dev/null +++ b/lib/assets/mermaid/build/main.css @@ -0,0 +1 @@ +.container{display:flex;align-items:flex-start;gap:8px;width:-moz-max-content;width:max-content}.container .download-btn{cursor:pointer;padding:.25rem;color:#61758a;background:none;border:none}.container .download-btn:hover{color:#0d1829}.container:not(:hover) .download-btn{opacity:0}.figure{margin:0;display:flex;flex-direction:column;align-items:center;width:-moz-max-content;width:max-content}.caption{margin-top:8px;font-size:.875rem;color:#61758a} diff --git a/lib/assets/mermaid/build/main.js b/lib/assets/mermaid/build/main.js index 6f7b20a1..774098fa 100644 --- a/lib/assets/mermaid/build/main.js +++ b/lib/assets/mermaid/build/main.js @@ -1 +1 @@ -import{J as d,b as p,c as a,sb as r}from"./chunk-ZRV56LX2.js";import{f as i}from"./chunk-JGKZPFWO.js";var g=i(p(),1),h=i(a(),1);var v=i(d(),1);r.initialize({startOnLoad:!1});function L(t,m){function o(){r.render("graph1",m).then(({svg:n,bindFunctions:e})=>{t.root.innerHTML=n,e&&e(t.root),t.root.querySelector("svg").removeAttribute("height")})}window.innerWidth===0?window.addEventListener("resize",()=>o(),{once:!0}):o()}export{L as init}; +import{J as w,b as f,c as h,sb as r}from"./chunk-ZRV56LX2.js";import{f as d}from"./chunk-JGKZPFWO.js";var b=d(f(),1),C=d(h(),1);var H=d(w(),1);r.initialize({startOnLoad:!1});function R(a,{diagram:p,caption:l,download:s}){a.importCSS("main.css");function c(){r.render("diagram",p).then(({svg:i,bindFunctions:m})=>{i=i.replace(/
/gi,"
");let e=document.createElement("div");e.classList.add("container"),a.root.appendChild(e);let o=document.createElement("figure");if(o.classList.add("figure"),o.innerHTML=i,e.appendChild(o),l){let t=document.createElement("figcaption");t.classList.add("caption"),t.textContent=l,o.appendChild(t)}if(s){let t=document.createElement("button");t.classList.add("download-btn"),t.title="Download",t.innerHTML='',e.appendChild(t),t.addEventListener("click",v=>{let g=URL.createObjectURL(new Blob([i],{type:"image/svg+xml"})),n=document.createElement("a");n.style.display="none",n.href=g,n.download="diagram.svg",e.appendChild(n),n.click(),e.removeChild(n)})}m&&m(a.root),o.querySelector("svg").removeAttribute("height")})}window.innerWidth===0?window.addEventListener("resize",()=>c(),{once:!0}):c()}export{R as init}; diff --git a/lib/kino/mermaid.ex b/lib/kino/mermaid.ex index 0c26f168..0390a904 100644 --- a/lib/kino/mermaid.ex +++ b/lib/kino/mermaid.ex @@ -1,10 +1,10 @@ defmodule Kino.Mermaid do @moduledoc ~S''' - A kino for rendering Mermaid graphs. + A kino for rendering Mermaid diagrams. > #### Relation to Kino.Markdown {: .info} > - > Mermaid graphs can also be generated dynamically with `Kino.Markdown`, + > Mermaid diagrams can also be generated dynamically with `Kino.Markdown`, > however the output of `Kino.Markdown` is never persisted in the > notebook source. `Kino.Mermaid` doesn't have this limitation. @@ -25,10 +25,24 @@ defmodule Kino.Mermaid do @type t :: Kino.JS.t() @doc """ - Creates a new kino displaying the given Mermaid graph. + Creates a new kino displaying the given Mermaid diagram. + + ## Options + + * `:caption` - an optional caption for the rendered diagram. + + * `:download` - whether or not to show a button for downloading + the diagram as a SVG. Defaults to `true`. + """ - @spec new(binary()) :: t() - def new(content) do - Kino.JS.new(__MODULE__, content, export: fn content -> {"mermaid", content} end) + @spec new(binary(), keyword()) :: t() + def new(diagram, opts \\ []) do + opts = Keyword.validate!(opts, caption: nil, download: true) + + Kino.JS.new( + __MODULE__, + %{diagram: diagram, caption: opts[:caption], download: opts[:download]}, + export: fn diagram -> {"mermaid", diagram} end + ) end end diff --git a/lib/kino/process.ex b/lib/kino/process.ex index b5d7cb50..21b3be21 100644 --- a/lib/kino/process.ex +++ b/lib/kino/process.ex @@ -35,6 +35,9 @@ defmodule Kino.Process do * `:render_ets_tables` - determines whether ETS tables associated with the supervision tree are rendered. Defaults to `false`. + * `:caption` - an optional caption for the diagram. Either a custom + caption as string, or `nil` to disable the default caption. + ## Examples To view the applications running in your instance run: @@ -86,13 +89,18 @@ defmodule Kino.Process do {:dictionary, dictionary} = process_info(root_supervisor, :dictionary) [ancestor] = dictionary[:"$ancestors"] - Mermaid.new(""" - graph #{direction}; - application_master(#{inspect(master)}):::supervisor ---> supervisor_ancestor; - supervisor_ancestor(#{inspect(ancestor)}):::supervisor ---> 0; - #{edges} - #{@mermaid_classdefs} - """) + caption = Keyword.get(opts, :caption, "Application tree for #{inspect(application)}") + + Mermaid.new( + """ + graph #{direction}; + application_master(#{inspect(master)}):::supervisor ---> supervisor_ancestor; + supervisor_ancestor(#{inspect(ancestor)}):::supervisor ---> 0; + #{edges} + #{@mermaid_classdefs} + """, + caption: caption + ) end @doc """ @@ -108,6 +116,9 @@ defmodule Kino.Process do * `:direction` - defines the direction of the graph visual. The value can either be `:top_down` or `:left_right`. Defaults to `:top_down`. + * `:caption` - an optional caption for the diagram. Either a custom + caption as string, or `nil` to disable the default caption. + ## Examples With a supervisor definition like so: @@ -162,11 +173,16 @@ defmodule Kino.Process do edges = traverse_supervisor(supervisor_pid, opts) - Mermaid.new(""" - graph #{direction}; - #{edges} - #{@mermaid_classdefs} - """) + caption = Keyword.get(opts, :caption, "Supervisor tree for #{inspect(supervisor)}") + + Mermaid.new( + """ + graph #{direction}; + #{edges} + #{@mermaid_classdefs} + """, + caption: caption + ) end @doc """ @@ -236,6 +252,9 @@ defmodule Kino.Process do is used. However, if the function returns a `String.t()`, then that will be used for the label. + * `:caption` - an optional caption for the diagram. Either a custom + caption as string, or `nil` to disable the default caption. + ## Examples To generate a trace of all the messages occurring during the execution of the @@ -412,13 +431,18 @@ defmodule Kino.Process do |> Enum.reverse() |> Enum.join("\n") + caption = Keyword.get(opts, :caption, "Messages traced from #{inspect(trace_pids)}") + sequence_diagram = - Mermaid.new(""" - %%{init: {'themeCSS': '.actor:last-of-type:not(:only-of-type) {dominant-baseline: hanging;}'} }%% - sequenceDiagram - #{participants} - #{messages} - """) + Mermaid.new( + """ + %%{init: {'themeCSS': '.actor:last-of-type:not(:only-of-type) {dominant-baseline: hanging;}'} }%% + sequenceDiagram + #{participants} + #{messages} + """, + caption: caption + ) {func_result, sequence_diagram} end diff --git a/lib/kino/shorts.ex b/lib/kino/shorts.ex index 95d1dad8..666aecb1 100644 --- a/lib/kino/shorts.ex +++ b/lib/kino/shorts.ex @@ -162,7 +162,7 @@ defmodule Kino.Shorts do def text(text), do: Kino.Text.new(text) @doc ~S''' - Renders Mermaid graphs. + Renders Mermaid diagrams. It is a wrapper around `Kino.Mermaid.new/1`. @@ -178,8 +178,8 @@ defmodule Kino.Shorts do C-->D; """) ''' - @spec mermaid(String.t()) :: Kino.Mermaid.t() - def mermaid(mermaid), do: Kino.Mermaid.new(mermaid) + @spec mermaid(String.t(), keyword()) :: Kino.Mermaid.t() + def mermaid(diagram, opts \\ []), do: Kino.Mermaid.new(diagram, opts) @doc """ A placeholder for static outputs that can be dynamically updated. diff --git a/test/kino/process_test.exs b/test/kino/process_test.exs index ca81eb85..0fdd48b9 100644 --- a/test/kino/process_test.exs +++ b/test/kino/process_test.exs @@ -227,7 +227,7 @@ defmodule Kino.ProcessTest do send(Kino.JS.DataStore, {:connect, self(), %{origin: "client:#{inspect(self())}", ref: ref}}) assert_receive {:connect_reply, data, %{ref: ^ref}} - data + data.diagram end defp supervision_tree_with_ets_table do