Adding continuous rendering to the PlantUML server
Hacking the frontend for better UX
The problem with the PlantUML server
As of late, I've re-discovered PlantUML and how much easier it is to generate diagrams with text instead of clicking around with the mouse. I haven't been following its development for quite some years now, and I'm amazed by how many more diagram types it supports. And also there is official support for all kinds of AWS icons which comes handy for communicating architecture.
But editing the diagrams is still painfully slow. The online demo server is a form with a submit button so that every time you make an edit you need to click on the button. Which brings the focus away from the editor, so you also need to click back to it:
It's just slow.
There is an online service at the bottom of the main page that does auto-regeneration, but that caps the number of images you can generate. And if you start using it, you'll keep hitting the limit:
But hey, this is a client-side problem. And as it builds on HTML and Javascript, it can be modified without touching the server-side.
And the best thing is that the same techniques can be used for all sorts of frontend-augmentations. If you find yourself doing the same thing over and over again, there is a good chance that could be automated without touching any of the more complicated parts of the system. You usually don't even need to check out the source code.
Getting notifications when there is a change on the page, hiding and showing elements, sending HTTP requests are all possible and while every frontend is different, these tools can be adapted to most of them.
So, my quest was to write a simple script that transforms the edit -> submit -> see workflow to an auto-regenerating one:
How to use
Since the online service is capped, you need to run your own PlantUML server for this:
docker run -it -p 3000:8080 plantuml/plantuml-server:jetty
After this, all you need is a browser:
- open
http://localhost:3000
- open DevTools (F12)
- open the Console
- paste the code
- enter
- close DevTools (F12)
- enjoy!
Source code
(also on GitHub)
// https://codeburst.io/throttling-and-debouncing-in-javascript-b01cad5c8edf
const throttle=(t,e)=>{let n,o;return function(){const a=this,c=arguments;o?(clearTimeout(n),n=setTimeout(function(){Date.now()-o>=e&&(t.apply(a,c),o=Date.now())},e-(Date.now()-o))):(t.apply(a,c),o=Date.now())}};
const form = document.querySelector("form");
form.addEventListener("submit", (e) => e.preventDefault());
form.querySelector("input[type=submit]").style.display = "none";
[...document.querySelectorAll("form ~ *")].filter((e) => e.id !== "diagram").forEach((e) => e.remove());
let lastReq = undefined;
const observer = new MutationObserver(throttle(async () => {
const currentTime = new Date().getTime();
lastReq = currentTime;
form.querySelector("input[type=submit]").click();
const value = document.querySelector("textarea").value;
const html = await (await fetch("/form", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: `text=${encodeURIComponent(value)}`
})).text();
if (lastReq === currentTime) {
document.querySelector("#diagram img").src =
new DOMParser()
.parseFromString(html, 'text/html')
.querySelector("#diagram img")
.src;
}
}
, 200));
observer.observe(form, {
attributes: false,
childList: true,
subtree: true
});
How it works
The three main parts are:
- Getting the editor state
- Replacing the diagram image
- Detecting changes
Getting the editor state
This one is a bit tricky, as the editor is not a simple <textarea> but a CodeMirror instance. It offers an API but the variable that holds the editor instance is not available.
But it does support forms so it must use simple HTML elements. It works by capturing the form's onsubmit
event and then update a hidden <textarea>. The
solution is straightforward then: programmatically submit the form then prevent it from sending after the CodeMirror code ran.
Translated to code:
const form = document.querySelector("form");
// prevent the form from submitting
form.addEventListener("submit", (e) => e.preventDefault());
// click on the submit button
form.querySelector("input[type=submit]").click();
// get the current value
const value = document.querySelector("textarea").value;
Replacing the diagram image
With the current diagram code, the next step is to emulate the form submit with a fetch
then extract the new image URL from the result. Then we need
to extract the URL from the response, and finally replace the <img> src.
The form is submitted as a POST request to /form
with the body containing the diagram code in the form of text=...
:
const html = await (await fetch("/form", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: `text=${encodeURIComponent(value)}`
})).text();
The html
has a similar structure as the current page, so all we need is a DOMParser
to extract the <img> element and its attribute:
new DOMParser()
.parseFromString(html, 'text/html')
.querySelector("#diagram img")
.src;
And finally, update the attribute on the current page:
document.querySelector("#diagram img").src = ...;
Note: This approach parses the HTML returned by the form instead of constructing the new image src locally as the online demo server does. That approach works also but would require some libraries to include which I didn't want to do. Luckily, the local server is fast enough so it does not do any noticeable slowdown.
Detecting changes
The last piece is to detect when the code in the editor is changed and only update the image when it did. It requires three components:
- A
MutationObserver
to detect the changes - A
throttle
to limit the number of requests - A requestId to discard old results
A MutationObserver
is a neat and versatile tool that is especially suited for hacks like this. It needs a DOM element, some configuration, and a callback
function, and it will call the function whenever the element is changed. It can detect attribute as well as element changes for the whole subtree.
const observer = new MutationObserver(() => {
...
});
observer.observe(form, {
attributes: false, // don't notify for attribute changes
childList: true,
subtree: true
});
But it will call the function for all changes, which can flood the listener. To prevent this, the function should be throttled, meaning it will be called once every X ms. When you write the code it won't send a POST request for every character, but it will be fast that you still see the latest result.
There are many implementations of the throttle
method. You can find ready-made implementations in different
libraries, but it's not that long to copy-paste either. I've found a simple one here.
With the function in place, the only thing left is to rate-limit the MutationObserver
callback:
const throttle = ...;
const observer = new MutationObserver(throttle(() => {
...
}));
The last problem to solve is the asynchronicity of the fetch
call. There are no guarantees that network responses come back in the order they are dispatched,
and that can result in overwriting with an earlier response.
To handle this, the easiest solution is to introduce a requestID and check if the latest dispatched ID matches. If the last requestID is different, simply discard the result.
In code, the easiest requestID is the current time.
let lastReq = undefined;
const observer = new MutationObserver(throttle(async () => {
// requestID
const currentTime = new Date().getTime();
lastReq = currentTime;
await fetch(...);
if (lastReq === currentTime) {
// handle the result
}
}));
Conclusion
Speed makes or breaks a tool. If I need to manually do something to see the results and it takes a considerable amount of time then I'm unlikely to use it. PlantUML is just one example that I recently looked into, but many others rely on a web interface. And sometimes all it needs is an hour of hacking to convert something that is nice-but-slow into something that is a pleasure to use. The techniques described in this post are some of the building blocks.