Draggable hotspots with tooltips, LiveView or not?

Some time ago I created a poc/prototype of an app named Hotspots, which allows the user
to upload an image and add (and remove) hotspots to it. These hotspots are placed on top of the image and can be dragged in place. When clicking a hotspot it becomes active and renders a tooltip and a form where its content can be updated.

The prototype was build using Phoenix + Vue.js, with Tippy.js and Draggable feature from interact.js:

Now I’m trying to start over from scratch, and was hoping to utilize LiveViews/Hooks instead of a Vue.js (or any other JS framework). But I’m stuck on the part where I need to add, remove and modify spots which need to be made interactive using JavaScript (tooltip and draggable). Whenever I try to update a hotspot 's position or content, it loses interaction because the DOM changes and the JavaScript loses its bindings. I also tried utilizing phx-update="ignore" but can’t find a way to make it all play nice together.

The main question I have is if LiveViews with Hooks is suitable for this job?
Or would something like AlpineJS help to achieve this?
Advice is very welcome as I’m pretty much stuck. Many thanks in advance!

What I have so far

  • A Hotspot embeds_many :spots
  • A hook to initialize tooltips and making spots draggable (only initially at the moment).
  • The hook uses pushEventTo to update coordinates on the changeset in from the LiveView.
  • Some snippets of my code below.

hotspot.ex

schema "hotspots" do
  field(:title, :string)
  field(:image, Hotspot.Image.Type)

  embeds_many :spots, Spot, on_replace: :delete do
    field(:x, :decimal)
    field(:y, :decimal)
    field(:title, :string)
    field(:content, :string)
  end

  belongs_to(:created_by, User)

  timestamps()
end

def changeset(%__MODULE__{} = hotspot, attrs) do
  hotspot
  |> cast(attrs, [:title])
  |> cast_attachments(attrs, [:image])
  |> cast_embed(:spots,
    with: &spot_changeset/2,
    required: false
  )
  |> validate_required([:created_by_id, :title, :image])
  |> foreign_key_constraint(:created_by_id)
end

def spot_changeset(spot, attrs) do
  spot
  |> cast(attrs, [:x, :y, :title, :content])
  |> validate_required([:x, :y])
end

template.leex

<div>
  <figure class="hotspots">
    <%= tag(:image, src: @hotspot.image, class: "image") %>

    <%= for spot <- @hotspot.spots do %>
      <div id="spot-<%= spot.id %>"
           class="spot"
           style="<%= "left: #{spot.x}%; top: #{spot.y}%;" %>"
           phx-hook="Spot"
           phx-click="spot-click"
           phx-value-id="<%= spot.id %>">

        <div class="spot-tooltip">
          <h3><%= spot.title %></h3>
          <p><%= spot.content %></p>
        </div>

      </div>
    <% end %>
  </figure>

  <%= form_for @changeset, "#", [phx_change: "update"], fn f -> %>
    <%= label f, :title %><br>
    <%= text_input f, :title %><br>
    <br>
    
    <%= inputs_for f, :spots, fn s -> %>
      <%= if s.data.id == assigns[:active_spot_id] do %>
        <%= text_input s, :title %><br>
        <%= text_input s, :content %>
      <% end %>
    <% end %>
    
  <% end %>
</div>

live.ex

def mount(
      _params,
      %{"hotspot_id" => hotspot_id, "current_user_id" => current_user_id},
      socket
    ) do
  current_user = Accounts.get_user!(current_user_id)
  hotspot = Hotspots.get(hotspot_id, current_user)
  changeset = Hotspot.changeset(hotspot)

  socket =
    assign(socket,
      hotspot: hotspot,
      changeset: changeset,
      current_user: current_user
    )

  {:ok, socket}
end

def handle_event(
      "update",
      %{"hotspot" => hotspot_params},
      %{assigns: %{hotspot: hotspot, current_user: current_user}} = socket
    ) do
  changeset = Hotspot.changeset(hotspot, hotspot_params)
  hotspot = Changeset.apply_action!(changeset, :update)

  socket = assign(socket, hotspot: hotspot, changeset: changeset)

  {:noreply, socket}
end

def handle_event("spot-click", %{"id" => spot_id}, socket) do
  socket = assign(socket, active_spot_id: spot_id)

  {:noreply, socket}
end

def handle_event("spot-moved", %{"id" => id, "x" => x, "y" => y} = params, socket) do
  changeset =
    socket.assigns.changeset
    |> Changeset.put_embed(:spots, id: id, x: x, y: y)

  socket = assign(socket, changeset: changeset)

  {:noreply, socket}
end

hook.js

import Hooks from "@assets/js/hooks";
import interact from "interactjs";
import tippy, { sticky } from "tippy.js";
import "tippy.js/dist/tippy.css";

Hooks.Spot = {
  mounted() {
    tippy(this.el, {
      appendTo: document.body,
      content: this.el.querySelector("tooltip"),
      arrow: true,
      interactive: true,
      sticky: true,
      plugins: [sticky]
    });

    interact(this.el).draggable({
      origin: "parent",
      onmove: event => {
        const x = (event.pageX / event.target.parentNode.offsetWidth) * 100;
        const y = (event.pageY / event.target.parentNode.offsetHeight) * 100;

        this.el.style.left = `${x}%`;
        this.el.style.top = `${y}%`;
      },
      onend: event => {
        this.pushEventTo(`#${this.el.id}`, "spot-moved", {
          id: this.el.getAttribute("phx-value-id"),
          x: parseFloat(this.el.style.left),
          y: parseFloat(this.el.style.top)
        });
      }
    });
  }
};
1 Like