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)
});
}
});
}
};