Making custom Pluto.jl interactive components! 🎈

Making custom Pluto.jl interactive components! 🎈

A month or so back I did a demonstration for a new feature in Pluto, and part of my presentation involved a basic Pluto notebook that would classify handwritten digits that you could draw directly into a Pluto notebook. One of the questions I got was "how do you make that custom drawing input"? I shared my notebook with those who asked, but it made me realize that very little documentation exists on how these parts of Pluto's front end actually works.

Today I'll guide you through the process for making your own custom Pluto components beyond just my drawing input, and hopefully that process will reveal just how flexible Pluto notebooks can be.

A Brief Summary of @bind

In case you've never used @bind (if not you're missing out), here's a short summary of what it does. If you have used it, feel free to skip over this section.

The @bind macro in Pluto takes in two arguments. The first is the name of the variable you want to assign, and the second is an HTML element that can yield a value. For example, say you want to assign a variable string_length that controls the length of some random string (called my_string). In typical Julia code that would look something like this:

random_string(n; alphabet="abcdefghijklmnopqrstuvwxyz") = join([alphabet[rand(1:length(alphabet))] for i in 1:n])

string_length = 10  # Number of characters in our string
my_string = random_string(string_length)

The definition for random_string just selects a random letter from an alphabet n times and joins those letters together to produce a random string. In Pluto each of these statements would be placed in a separate cell, depicted below.

Now let's make use of @bind so that users can input their own string lengths with a slider. Instead of assigning string_length in the standard way, we will do so with @bind.

@bind string_length html"""<input value="5" type="range" min="1" max="25"/>"""

On the left we @bind, in the middle we state that the variable we want to assign is string_length, and on the right we tell Pluto how that input should be received. In this case we will use an HTML input element with type range (a slider). We set it's default value to 5, its min (minimum) to 1, and its max (maximum) to 25. Here's what the notebook looks like now:

Dragging the slider around will change the contents and length of the string in realtime! If you've ever used widgets in Jupyter, @bind functions similarly. The key difference is that updates to @bind cause responses in other cells beyond just the cell containing @bind.

Making our own components

One of the nice things about @bind is that it works with all the HTML input elements out of the box. But not all user input can be handled with HTML inputs. For example the drawing input that I mentioned building earlier had to be custom-built. In this section I'll walk through exactly how to build a similar custom component that responds to the user's mouse being moved. The end goal is to have a component which looks like a box that the user can move their mouse around in and get an [x, y] coordinate pair in Julia representing the mouse positioning. Here's an image of the final result:

Overview

We saw in the previous section that fundamentally @bind just listens to changes in HTML input elements and puts their value in a Julia variable. But as it turns out, if we implement some extra logic, any HTML element can behave as if it's an element.

At a high level, all we will be doing is assigning a <div> element a value property in JavaScript, and artificially trigger an event called oninput which alerts @bind that a value has changed. As such much of the work in creating these components is not Julia, but rather JavaScript.

Basic Implementation

In the examples I will simply show the html""" <!-- html goes here --> """ part of the @bind statement, since the variable name will stay the same. To get started, create a Pluto cell with the following contents:

@bind my_custom_input html"""<div></div>"""

The right-hand HTML string contains a single div element. For the time being it does nothing since we haven't implemented any logic for it yet. Now create a second cell which displays the value of my_custom_input. For me that looks like this:

We now clearly see that my_custom_input holds no value, which makes sense because div elements by default have an undefined value. But let's change that by adding some JavaScript inside the div tag.

html"""
<div id="my_custom_input_div">
    <script>
    	const mouseInput = document.getElementById("my_custom_input_div");
    </script>
</div>
"""

Now we have a script containing a line of JavaScript that will assign our div element to a variable. But still our @bind cell appears to have no output, so let's style the div to give it a box shape.

<div id="my_custom_input_div" style="width: 300px; height: 300px; border: 1px solid black"><!-- other content omitted --></div>

Here's what our component now looks like:

It's nothing pretty but it will get the job done. The user can clearly see where the box starts and stops with the outline. Let's continue writing the script code now by giving the component a default value other than missing. I'll arbitrarily choose [0, 0].

<div id="my_custom_input_div" style="width: 300px; height: 300px; border: 1px solid black">
    <script>
    	const mouseInput = document.getElementById("my_custom_input_div");
        mouseInput.value = [0, 0];
    </script>
</div>

Simple! After rerunning the @bind cell we can now see the value of my_custom_input is now [0, 0]. The next step, which is once again relatively straighforward, is listening for mouse movement and setting the value of the div once more.

<div id="my_custom_input_div" style="width: 300px; height: 300px; border: 1px solid black">
    <script>
    	const mouseInput = document.getElementById("my_custom_input_div");
        mouseInput.value = [0, 0];
        
        mouseInput.addEventListener('mousemove', function(e) {
            mouseInput.value = [Math.floor(e.x), Math.floor(e.y)];
            mouseInput.dispatchEvent(new Event('input'));
        });
    </script>
</div>

Once again after rerunning the @bind cell you should now see the value of my_custom_input change whenever you move your mouse over the box! One very important thing to note is on line 8. In addition to setting the value of the mouseInput variable we also have to dispatch an event. Similar to how we listen to mousemove on line 6, Pluto listens to the input event for changes to an element's value. Since div elements aren't natural inputs in HTML, we have to trigger this ourselves on line 8.

You may notice moving your mouse around that the coordinates at the top left should equal [0, 0], but actually don't. This is because the coordinates are relative to the top left of the user's browser rather than the component. We'll have to use one more trick to fix this. I updated the mousemove event listener function's contents to the following:

const rect = mouseInput.getBoundingClientRect();
mouseInput.value = [Math.floor(e.x-rect.left+1), Math.floor(e.y-rect.top+1)];
mouseInput.dispatchEvent(new Event('input'));

On the first line we get the "bounding rectangle" on the page for our component. This calculates pixel values for the element's width, height, and position on the page. In our case we just subtract the element's distance from the left of the page from the coordinate value, and do the same for the y coordinate but from the top of the page. We add one pixel to each because of our component's 1px border.

And that's it! My final @bind statement looks like this:

At the bottom you can see the current position of my mouse (which unfortunately is invisible in screenshots), which is stored in the value of my_custom_input.

Using it more than once

If you only ever need to use a component once, then the above is just fine. But having to write out all that HTML every time you want to create one of those components would be a nightmare! Instead of doing that though, we can define the HTML as a Julia function and simply call that function in the @bind call. For example, @bind my_custom_input MouseMoveInput().

This approach comes with numerous benefits, such as being able to add in parameters to our component. What if we wanted to change the width and height? Just add in a function parameter!

Here's an example of what my implementation of MouseMoveInput looks like.

function MouseMoveInput(width=300, height=300)
	id = random_string(16)
	@htl("""
	<div id=$(id) style="width: $(width)px; height: $(height)px; border: 1px solid black">
		<script>
			const mouseInput = document.getElementById("$(id)");
	
			mouseInput.value = [0, 0];
			
			mouseInput.addEventListener('mousemove', function(e) {
				const rect = mouseInput.getBoundingClientRect();
				mouseInput.value = [Math.floor(e.x-rect.left+1), Math.floor(e.y-rect.top+1)];
				mouseInput.dispatchEvent(new Event('input'));
			});
		</script>
	</div>
	""")
end

You'll notice right off the bat that a couple differences are present. For one, we have to make a random string (random_string is defined in the @bind recap) and use that as a unique id for our div instead of using the same one over and over. HTML documents should never have more than one element with the same ID in it, and the getElementById function no longer works if there are.

Secondly I couldn't use the html""" """ syntax because I needed string interpolation (using $(<variable name>) in Julia to insert the random id into our HTML). This macro is exported from the HypertextLiteral package, so you'll have to install and import it into the Pluto notebook for this code to work.

Finally I added in two parameters which let you change the width and height of the box, in case 300x300 pixels is too large or small.

Final Notes

It's worth noting a couple of important things before sending you off to build your own custom @bind inputs.

If you want to take this even further and write even more complex components, know that you can also make use of Pluto's Preact frontend. Since Preact apps require no compilation step, you can write your components in React code for more complex state management and other advanced features. For our basic mouse movement component this would have been way overkill. But for custom dashboards or complex input elements, writing your component as a React component may be the best way to go.

Also, a package worth mentioning is PlutoUI, which if you haven't seen already, actually wraps many of the HTML input element types in functions similar to our MouseMoveInput. This can both save a lot of time and effort writing HTML as well as clean up your notebook code.

Conclusion

If you want to see other examples of custom @bind components, this talk at PlutoCon 2021 by Fons van der Plas gives a good overview of how JavaScript works inside Pluto. The stars really are the limit on this one. Literally anything you can make in HTML you can turn into an input element that Pluto @bind can listen to!