7 GUIs: Implementing a Circle Drawer in LiveView

This is the sixth post handling the 7 GUI tasks in LiveView. Here are some highlights of creating a Circle Drawer in LiveView with undo and redo functionality.

These are the task’s requirements:

  • Build a page with a canvas.
  • Clicking on the canvas should create an unfilled circle with a fixed diameter.
  • Clicking anywhere inside an existing circle should fill it with color.
  • Right-clicking on the selected circle should open a menu with a slider to adjust its diameter. (The diameter adjustment should happen immediately.)
  • The page should have undo and redo buttons.
  • Clicking undo should undo the last significant change (i.e. circle creation or diameter adjustment).
  • Clicking redo should reapply the last undo change (unless the user made new changes in the meantime).

Drawing circles

Four circles drawn on a canvas

We could have implemented a canvas and drawn circles in at least two ways.

I originally used a <canvas> HTML element and drew 2D circles. That worked for drawing, but it was limiting because I had to draw with JavaScript. Because I knew we’d have to undo and redo drawings, I wanted to keep the circle state in our LiveView.

So, I opted for rendering <svg> and <circle> elements in our LiveView template instead. That turned out to be much simpler:

<svg id="circle-drawer" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
  <%= for %{x: x, y: y, r: r} <- @canvas.circles do %>
    <circle cx={x} cy={y} r={r} fill="#ddd" />
  <% end %>

  <%= if selected = @canvas.selected do %>
    <circle id="selected-circle" cx={selected.x} cy={selected.y} r={selected.r} fill="#deg"/>
  <% end %>
</svg>

As you can probably infer from the template, we have a @canvas assign that contains an enumerable list of circles (each with x and y coordinates and radius r). And the canvas has a potentially selected circle, which gets a different fill attribute to color the circle as required by the task.

At this point, the LiveView’s mount/3 function looks something like this:

def mount(_, _, socket) do
  socket
  |> assign(:canvas, CircleDrawer.new_canvas())
  |> ok()
end

where CircleDrawer.new_canvas() returns a %Canvas{circles: [], selected: nil} struct.

Getting the right coordinates

The code above will draw existing circles on the canvas, but how do we add circles? That’s where things start to get tricky.

To create a circle, we need the SVG coordinates for the circle’s origin. To get those, we need to get the click’s x and y coordinates. But that’s not all. We then need to transform those into coordinates that our <svg> can understand.

To accomplish that, I introduced a CircleDrawer LiveView hook in our <svg> tag:

<svg phx-hook="CircleDrawer" id="circle-drawer" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">

When that hook is mounted, we add a "click" event listener and use an SVGPoint with its matrixTransform method to transform the client coordinates into SVG coordinates:

Hooks.CircleDrawer = {
  mounted() {
    let svg = this.el;

    this.el.addEventListener("click", (e) => {
      let pt = svg.createSVGPoint();

      // pass event coordinates
      pt.x = e.clientX;
      pt.y = e.clientY;

      // transform to SVG coordinates
      let svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
      let x = svgP.x;
      let y = svgP.y;

      this.pushEvent("canvas-click", {x, y})
    });
  }
}

Once we have the SVG coordinates, we use pushEvent to push a "canvas-click" event with the circle’s coordinates to our LiveView.

Now, our LiveView can handle the event and create a new circle:

def handle_event("canvas-click", %{"x" => x, "y" => y}, socket) do
  canvas = socket.assigns.canvas

  circle = CircleDrawer.new_circle(x, y)
  updated_canvas = CircleDrawer.add_circle(canvas, circle)

  socket
  |> assign(:canvas, updated_canvas)
  |> noreply()
end

Our CircleDrawer.new_circle/2 returns a %Circle{x: x, y: y, r: r} struct with the given x and y coordinates and the default radius. We add that circle to the canvas via CircleDrawer.add_circle/2, and we set the updated canvas as the @canvas assign.

Voilà!

Selecting a circle

Five circles drawn on a canvas. One is selected and darker than the rest.

Now that we have the plumbing for sending x and y coordinates to our LiveView, we can work on selecting a circle.

Since we’re already handling click events within the <svg> canvas through our CircleDrawer hook, we can use those same coordinates to see if they land inside one of our existing circles.

To do that, we update our handle_event/3 function for the "canvas-click" event to check for an existing circle:

def handle_event("canvas-click", %{"x" => x, "y" => y}, socket) do
  canvas = socket.assigns.canvas

  # check if existing
  case CircleDrawer.existing_circle(canvas, {x, y}) do
    %{x: _x, y: _y} = circle ->
      # handle new case
      updated_canvas = CircleDrawer.select_circle(canvas, circle)

      socket
      |> assign(:canvas, updated_canvas)
      |> noreply()

    _ ->
      # this is our previous "create circle" code
      circle = CircleDrawer.new_circle(x, y)
      updated_canvas = CircleDrawer.add_circle(canvas, circle)

      socket
      |> assign(:canvas, updated_canvas)
      |> noreply()
  end
end

If the coordinates x and y land inside an existing circle’s circumference, CircleDrawer.existing_circle/2 will return the circle struct. In that case, we select that circle in our canvas (via CircleDrawer.select_circle/2) and update our @canvas assign.

But if the coordinates fall outside the circumferences of our circles, we create a new circle – just like we did before.

Of course, one of the trickiest parts lies in determining if the coordinates land inside a circle’s circumference. To do that, we rely on some math inside our CircleDrawer.existing_circle/2 function. We use the distance formula (our within_circle?/2 function) to determine if the coordinates are inside a given circle:

def existing_circle(canvas, coordinates) do
  Enum.find(canvas.circles, &within_circle?(&1, coordinates))
end

defp within_circle?(%Circle{x: circle_x, y: circle_y, r: radius}, {x, y}) do
  :math.pow(x - circle_x, 2) + :math.pow(y - circle_y, 2) <= :math.pow(radius, 2)
end

Adjusting the diameter

Several circles. One selected. A modal opened with a range input to change the diameter

The task requires that we open a diameter-adjusting modal when a user right-clicks on the selected circle.

The only way I know to do that is to listen for the "contextmenu" event. Unfortunately, I don’t think there’s a way to do that directly with LiveView. So, I introduced another hook for the selected circle:

<%= if selected = @canvas.selected do %>
  <circle phx-hook="SelectedCircle" id="selected-circle" cx={selected.x} cy={selected.y} r={selected.r} fill="#deg"/>
<% end %>

That hook adds a listener for the "contextmenu" event and is responsible for showing the modal – i.e. setting the modal and its content to display: block:

Hooks.SelectedCircle = {
  mounted() {
    this.el.addEventListener("contextmenu", (e) => {
      e.preventDefault();

      let modal = document.getElementById("modal");
      let content = document.getElementById("modal-content");
      modal.style.display = "block";
      content.style.display = "block";
      return false
    })
  }
}

Excellent. A right-click on the selected circle will now open up our modal.

Updating the diameter

A range input changing the diamater of a circle, making it very large

Now that a right-click on the selected circle opens the modal, we need to think about updating the diameter of the circle.

This is the inner content of the modal:

<div>
  <%= if selected = @canvas.selected do %>
    <h3>
      Adjust diameter of circle at (<%= prettify_coordinates(selected.x) %>, <%= prettify_coordinates(selected.y) %>)
    </h3>
    <div>
      <input type="range" value={selected.r} id="diameter-slider" name="diameter-slider" min="0" max="10" step="1">
    </div>
  <% end %>
</div>

You can see that our modal has an <input type="range"> where the value is the selected circle’s radius (r).

We want changes in the <input> element to update the circle’s radius – which will, in turn, change its diameter. We could do that by sending every "input" event to LiveView. But that’s a bit heavy-handed. We don’t want to send every event to the server.

The task requires that the diameter adjusted happen immediately – so we want to listen to all "input" events – but that doesn’t mean we need to record every radius value in the server. Instead, we can listen to every "input" event on the client side, update the radius of the <circle> immediately, but only send the final value of r to the server.

To do that, we add another hook and call it CircleDiameterSlider:

<input phx-hook="CircleDiameterSlider" type="range" value={selected.r} id="diameter-slider" name="diameter-slider" min="0" max="10" step="1">

When that hook is mounted, we add two event listeners.

The first event listener listens to the "input" event, and it updates the radius attribute of our <circle>. Thus, the change is seen immediately (as required), and it all happens in JavaScript – avoiding sending unnecessary data to the server:

Hooks.CircleDiameterSlider = {
  mounted() {
    this.el.addEventListener("input", (e) => {
      let radius = e.target.value;
      let selected = document.getElementById("selected-circle");

      selected.setAttribute('r', radius)
    });
  }
}

The second event listener listens for a custom "update-selected-radius" event. We trigger that even when we’re done selecting the new radius. At that point, we fetch the latest value of the selected circle’s radius and push it to the server with the "selected-circle-radius-updated" event:

Hooks.CircleDiameterSlider = {
  mounted() {
    // ... other event listener

    this.el.addEventListener("update-selected-radius", (e) => {
      let radius = e.target.value;

      this.pushEvent("selected-circle-radius-updated", {r: radius});
    });
  }
}

With that set, when should we trigger the "update-selected-radius" event?

Since there’s no “submit” button to save the new diameter of the circle, we trigger the event when the modal closes. We can do that nicely with LiveView.JS commands.

This is the modal’s content wrapper:

<div id="modal" class="phx-modal hidden" phx-remove={hide_modal_and_update()}>
  <div
    id="modal-content"
    phx-click-away={hide_modal_and_update()}
    phx-window-keydown={hide_modal_and_update()}
    phx-key="escape"
  >
    <button phx-click={hide_modal_and_update()}></button>

    # content
  </div>
</div>

As you can see, we use a hide_modal_and_update/0 function for any action that closes the modal. That function is defined as follows:

defp hide_modal_and_update do
  %JS{}
  |> JS.dispatch("update-selected-radius", to: "#diameter-slider")
  |> JS.hide(transition: "fade-out", to: "#modal")
  |> JS.hide(transition: "fade-out-scale", to: "#modal-content")
end

So, when we close the modal, we dispatch the "update-selected-radius" event with JS.dispatch/3, and then we hide the modal (and its content) with JS.hide/2 – all in a beautiful pipeline.

Receiving the "selected-circle-radius-updated" event

Now that we’ve managed to update the <circle> radius purely in JavaScript and only send the final r value when we close the modal, we need to handle the "selected-circle-radius-updated" event that is sent to our LiveView:

def handle_event("selected-circle-radius-updated", %{"r" => r}, socket) do
  canvas = socket.assigns.canvas
  r = to_number(r)

  updated_circle = CircleDrawer.update_radius(canvas.selected, r)
  updated_canvas = CircleDrawer.update_selected(canvas, updated_circle)

  socket
  |> assign(:canvas, updated_canvas)
  |> noreply()
end

We take the new radius r (transforming it to be a number because it comes in as a string) and update the selected circle’s radius. Then, we update the selected circle in the canvas and update our @canvas assign.

Since we’ve already updated the circle’s radius via JavaScript, LiveView’s re-rendering of the new @canvas assign should not result in visual changes. But by doing this, we update our canonical record of the circle so that future re-renders work as expected. And when we introduce undo/redo functionality, we’ll be able to include a circle’s diameter change as part of the history of undo/redo changes.

Undo and Redo

Though we touch upon this last, the undo and redo functionality is one of the core goals of this GUI task:

Circle Drawer’s goal is, among other things, to test how good the common challenge of implementing an undo/redo functionality for a GUI application can be solved.

When building the circle drawer, I thought that when it came to undo/redo, I’d keep a list of operations performed and then reapply the transformations needed.

I thought it’d be some kind of stack of operations:

[
  {:add_circle, {x, y, r}}
  {:change_radius, {x, y, r}, new_r}
  ...

  etc

  ...
  {:select_circle, {x, y, r}}
]

But when I got around to implementing the undo/redo, I realized there was an easier way.

Our canvas structs are immutable (thanks Elixir!) So, every time we add a circle, select a circle, or change the radius, we create a new %Canvas{}. And we can use that to our advantage.

All we have to do is keep the list of old canvases that existed before each change. So, instead of having a stack of historical operations, we can keep a stack historical canvases:

[
  %Canvas{circles: [], selected: nil]},
  %Canvas{circles: [%Circle{x, y, r}], selected: nil]},
  %Canvas{circles: [%Circle{x, y, r}, %Circle{x, y, r}], selected: nil]},
  ...

  etc

  ...
  %Canvas{circles: [%Circle{x, y, r}, ...], selected: %Circle{x, y, r}]},
]

Then, when we want to undo an operation, we take the last canvas in the undo history and store the current canvas in the redo history. If we want to redo an operation, we take the last canvas from the redo history and store the current canvas in the undo history.

Storing canvases

With the latter approach in mind, we add two more assigns to our LiveView: :undo_history and :redo_history. Each assign starts as an empty list created through CircleDrawer.History.build/0.

Thus, our mount/3 now looks like this:

def mount(_, _, socket) do
  socket
  |> assign(:canvas, CircleDrawer.new_canvas())
  |> assign(:undo_history, CircleDrawer.History.build())
  |> assign(:redo_history, CircleDrawer.History.build())
  |> ok()
end

Now, when we perform any canvas-changing operation, we need to add a canvas to the history.

Storing changes in "canvas-click" event.

Whether we select an existing circle or create a new one, we store the canvas prior to the action in the undo_history assign (using our History.add_event/2 helper function):

 def handle_event("canvas-click", %{"x" => x, "y" => y}, socket) do
+  undo_history = socket.assigns.undo_history
   canvas = socket.assigns.canvas

   case CircleDrawer.existing_circle(canvas, {x, y}) do
     %{x: _x, y: _y} = circle ->
+      undo_history = CircleDrawer.History.add_event(undo_history, canvas)
       updated_canvas = CircleDrawer.select_circle(canvas, circle)

       socket
       |> assign(:canvas, updated_canvas)
+      |> assign(:undo_history, undo_history)
       |> noreply()

     _ ->
       circle = CircleDrawer.new_circle(x, y)
+      undo_history = CircleDrawer.History.add_event(undo_history, canvas)
       updated_canvas = CircleDrawer.add_circle(canvas, circle)

       socket
       |> assign(:canvas, updated_canvas)
+      |> assign(:undo_history, undo_history)
       |> noreply()
   end
 end

Storing changes in the "selected-circle-radius-updated" event.

Similarly, any time we change the radius, we add the canvas before the update to the undo_history:

 def handle_event("selected-circle-radius-updated", %{"r" => r}, socket) do
+  undo_history = socket.assigns.undo_history
   canvas = socket.assigns.canvas
   r = to_number(r)
-
+  undo_history = CircleDrawer.History.add_event(undo_history, canvas)
   updated_circle = CircleDrawer.update_radius(canvas.selected, r)
   updated_canvas = CircleDrawer.update_selected(canvas, updated_circle)

   socket
   |> assign(:canvas, updated_canvas)
+  |> assign(:undo_history, undo_history)
   |> noreply()
 end

Undoing and redoing

Having stored all changes, we can now allow users to undo and redo operations. First, we add two buttons in our template with phx-click attributes:

<div>
  <button id="undo" phx-click="undo">Undo</button>
  <button id="redo" phx-click="redo">Redo</button>
</div>

Next, when someone clicks Undo, we pop a canvas from our undo_history, set it as the @canvas, and add the current canvas to our redo_history:

def handle_event("undo", _, socket) do
  undo_history = socket.assigns.undo_history
  canvas = socket.assigns.canvas
  redo_history = socket.assigns.redo_history

  case CircleDrawer.History.pop_event(undo_history) do
    {previous_canvas, undo_history} ->
      redo_history = CircleDrawer.History.add_event(redo_history, canvas)

      socket
      |> assign(:canvas, previous_canvas)
      |> assign(:undo_history, undo_history)
      |> assign(:redo_history, redo_history)
      |> noreply()

    :no_more_history ->
      socket |> noreply()
  end
end

And finally, when someone clicks Redo, we pop a canvas from our redo_history, set it as the @canvas, and add the current canvas to the undo_history:

def handle_event("redo", _, socket) do
  undo_history = socket.assigns.undo_history
  canvas = socket.assigns.canvas
  redo_history = socket.assigns.redo_history

  case CircleDrawer.History.pop_event(redo_history) do
    {previous_canvas, redo_history} ->
      undo_history = CircleDrawer.History.add_event(undo_history, canvas)

      socket
      |> assign(:canvas, previous_canvas)
      |> assign(:undo_history, undo_history)
      |> assign(:redo_history, redo_history)
      |> noreply()

    :no_more_history ->
      socket |> noreply()
  end
end

Thus, the undo/redo solution comes almost completely for free. Thanks to Elixir’s immutability and LiveView’s reactivity, all we had to do was push and pop elements into stacks. And that’s what the 7 GUI task calls ideal!

In an ideal solution the undo/redo functionality comes for free resp. just comes out as a natural consequence of the language / toolkit / paradigm.

Resources

If you’re interested in seeing more, these are the links to my 7 GUIs repo (with all my examples) and the commit for the CircleDrawer:

You can also find my posts for the previous five tasks:

And for a full description of all the tasks, take a look at the 7 GUIs website.

Want my latest thoughts, posts, and projects in your inbox?

    I will never send you spam. Unsubscribe any time.