102.6 KiB · text History eb57556
defmodule DiogramosWeb.CanvasLive.Edit do
  use DiogramosWeb, :live_view

  alias Diogramos.Diagrams
  alias Diogramos.Diagrams.{Authority, ConnectorGeometry, Document, MermaidExport}
  alias DiogramosWeb.CanvasPresence

  @tools ~w(select text rect rounded circle connector)

  @impl true
  def mount(%{"slug" => slug}, _session, socket) do
    scope = socket.assigns.current_scope

    case Diagrams.get_canvas_by_slug(scope, slug) do
      nil ->
        {:ok,
         socket
         |> put_flash(:error, "Canvas not found.")
         |> push_navigate(to: ~p"/canvases")}

      canvas ->
        role = Diagrams.effective_role(scope, canvas)

        {document, version} =
          case Authority.snapshot(canvas.id) do
            {:ok, %{document: d, version: v}} -> {d, v}
            _ -> {ensure_document_shape(canvas.document), canvas.version}
          end

        topic = Authority.topic(canvas.id)
        identity = presence_identity(scope)

        if connected?(socket) do
          Phoenix.PubSub.subscribe(Diogramos.PubSub, topic)
          CanvasPresence.track(self(), topic, identity)
        end

        actor_ref = "lv-#{System.unique_integer([:positive])}"

        {:ok,
         socket
         |> assign(:page_title, canvas.title)
         |> assign(:canvas, %{canvas | document: document, version: version})
         |> assign(:role, role)
         |> assign(:read_only, role == "viewer")
         |> assign(:document, document)
         |> assign(:version, version)
         |> assign(:tool, "select")
         |> assign(:selection, [])
         |> assign(:selection_clipboard, nil)
         |> assign(:actor_ref, actor_ref)
         |> assign(:show_mermaid, false)
         |> assign(:mermaid_source, "")
         |> assign(:topic, topic)
         |> assign(:presence_key, identity.key)
         |> assign(:peers, [])
         |> assign(:locked_elements, MapSet.new())
         |> assign(:style_clipboard, nil)
         |> assign(:snap_grid, false)
         |> assign(:grid_size, 20)
         |> assign(:show_embed_cursors, true)}
    end
  end

  defp presence_identity(%{user: %{id: id, email: email, display_name: display_name}}) do
    %{
      key: "user-#{id}",
      name: display_name || (email && hd(String.split(email, "@"))) || "Guest #{id}"
    }
  end

  defp presence_identity(_) do
    %{key: "anon-#{System.unique_integer([:positive])}", name: "Guest"}
  end

  @impl true
  def handle_info({:canvas_op, %{op: op, version: version, actor_ref: ref}}, socket) do
    cond do
      ref == socket.assigns.actor_ref ->
        {:noreply, socket}

      version <= socket.assigns.version ->
        {:noreply, socket}

      true ->
        case Document.apply_op(socket.assigns.document, op) do
          {:ok, doc} -> {:noreply, assign(socket, document: doc, version: version)}
          {:error, _} -> {:noreply, socket}
        end
    end
  end

  def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
    {:noreply, refresh_peers(socket)}
  end

  @impl true
  def handle_event("select_tool", %{"tool" => tool}, socket) when tool in @tools do
    {:noreply, assign(socket, tool: tool, selection: [])}
  end

  def handle_event("select_element", params, socket) do
    {:noreply, do_select(socket, {:element, params["id"]}, params["multi"] == true)}
  end

  def handle_event("select_connector", params, socket) do
    {:noreply, do_select(socket, {:connector, params["id"]}, params["multi"] == true)}
  end

  def handle_event("select_set", params, socket) do
    elements = for id <- params["elements"] || [], is_binary(id), do: {:element, id}
    connectors = for id <- params["connectors"] || [], is_binary(id), do: {:connector, id}
    {:noreply, assign(socket, :selection, elements ++ connectors)}
  end

  def handle_event("clear_selection", _, socket) do
    {:noreply, assign(socket, :selection, [])}
  end

  def handle_event("apply_op", _params, %{assigns: %{read_only: true}} = socket) do
    {:noreply, put_flash(socket, :error, "You only have view access to this canvas.")}
  end

  def handle_event("apply_op", %{"op" => op}, socket) do
    apply_op_and_persist(socket, apply_clipboard_defaults(op, socket.assigns.style_clipboard))
  end

  def handle_event("update_selected", %{"props" => props}, socket) do
    case selection_focus(socket.assigns.selection) do
      {:element, id} ->
        patch = coerce_element_patch(props, socket.assigns.document["elements"][id])
        apply_op_and_persist(socket, %{"type" => "update_element", "id" => id, "patch" => patch})

      {:connector, id} ->
        patch = coerce_connector_patch(props)

        apply_op_and_persist(socket, %{"type" => "update_connector", "id" => id, "patch" => patch})

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event("show_mermaid", _, socket) do
    source = MermaidExport.to_mermaid(socket.assigns.document)
    {:noreply, assign(socket, show_mermaid: true, mermaid_source: source)}
  end

  def handle_event("close_mermaid", _, socket) do
    {:noreply, assign(socket, :show_mermaid, false)}
  end

  def handle_event("toggle_grid", _, socket) do
    socket = update(socket, :snap_grid, &(!&1))
    {:noreply, push_event(socket, "grid_changed", %{on: socket.assigns.snap_grid})}
  end

  def handle_event("toggle_embed_cursors", _, socket) do
    {:noreply, socket |> update(:show_embed_cursors, &(!&1)) |> refresh_peers()}
  end

  def handle_event("set_grid", %{"on" => on}, socket) when is_boolean(on) do
    {:noreply, assign(socket, :snap_grid, on)}
  end

  def handle_event("z_order", %{"action" => action}, socket)
      when action in ~w(to_back backward forward to_front) do
    case selection_focus(socket.assigns.selection) do
      {:element, id} ->
        new_order = reorder(socket.assigns.document["order"], id, action)

        if new_order == socket.assigns.document["order"] do
          {:noreply, socket}
        else
          apply_op_and_persist(socket, %{"type" => "set_z_order", "order" => new_order})
        end

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event("cursor", %{"x" => x, "y" => y}, socket) when is_number(x) and is_number(y) do
    CanvasPresence.update(self(), socket.assigns.topic, socket.assigns.presence_key, fn meta ->
      Map.put(meta, :cursor, %{x: x, y: y})
    end)

    {:noreply, socket}
  end

  def handle_event("cursor", _, socket), do: {:noreply, socket}

  def handle_event("set_lock", %{"id" => id}, socket) when is_binary(id) do
    CanvasPresence.update(self(), socket.assigns.topic, socket.assigns.presence_key, fn meta ->
      Map.put(meta, :lock, id)
    end)

    {:noreply, socket}
  end

  def handle_event("clear_lock", _, socket) do
    CanvasPresence.update(self(), socket.assigns.topic, socket.assigns.presence_key, fn meta ->
      Map.put(meta, :lock, nil)
    end)

    {:noreply, socket}
  end

  def handle_event("copy_style", _, socket) do
    clipboard =
      case selection_focus(socket.assigns.selection) do
        {:element, id} ->
          case socket.assigns.document["elements"][id] do
            nil -> nil
            element -> {:element, Map.get(element, "style", %{})}
          end

        {:connector, id} ->
          case socket.assigns.document["connectors"][id] do
            nil ->
              nil

            connector ->
              {:connector,
               Map.take(
                 connector,
                 ~w(routing dash marker_start marker_end start_gap end_gap stroke_width color)
               )}
          end

        _ ->
          nil
      end

    {:noreply, assign(socket, :style_clipboard, clipboard)}
  end

  def handle_event("paste_style", _, socket) do
    case {selection_focus(socket.assigns.selection), socket.assigns.style_clipboard} do
      {{:element, id}, {:element, style}} when map_size(style) > 0 ->
        apply_op_and_persist(socket, %{
          "type" => "update_element",
          "id" => id,
          "patch" => %{"style" => style}
        })

      {{:connector, id}, {:connector, style}} when map_size(style) > 0 ->
        apply_op_and_persist(socket, %{
          "type" => "update_connector",
          "id" => id,
          "patch" => style
        })

      _ ->
        {:noreply, put_flash(socket, :info, "Nothing matching to paste.")}
    end
  end

  def handle_event("copy_selection", _, socket) do
    el_ids = selection_element_ids(socket.assigns.selection)
    con_ids = selection_connector_ids(socket.assigns.selection)
    document = socket.assigns.document

    elements = el_ids |> Enum.map(&document["elements"][&1]) |> Enum.reject(&is_nil/1)
    el_set = MapSet.new(el_ids)

    connectors =
      con_ids
      |> Enum.map(&document["connectors"][&1])
      |> Enum.reject(&is_nil/1)
      # Also include any connector both of whose endpoints were selected,
      # even if the lasso didn't hit the connector itself.
      |> Kernel.++(
        Enum.filter(Map.values(document["connectors"]), fn c ->
          c["from"]["element"] in el_set and c["to"]["element"] in el_set and
            c["id"] not in con_ids
        end)
      )
      |> Enum.uniq_by(& &1["id"])

    if elements == [] and connectors == [] do
      {:noreply, put_flash(socket, :info, "Nothing selected to copy.")}
    else
      {:noreply,
       assign(socket, :selection_clipboard, %{elements: elements, connectors: connectors})}
    end
  end

  def handle_event("paste_selection", _, %{assigns: %{read_only: true}} = socket) do
    {:noreply, put_flash(socket, :error, "You only have view access to this canvas.")}
  end

  def handle_event("paste_selection", _, socket) do
    case socket.assigns.selection_clipboard do
      nil ->
        {:noreply, put_flash(socket, :info, "Selection clipboard is empty.")}

      %{elements: [], connectors: []} ->
        {:noreply, put_flash(socket, :info, "Selection clipboard is empty.")}

      %{elements: elements, connectors: connectors} ->
        paste_offset = 24
        id_map = Map.new(elements, fn e -> {e["id"], Diogramos.ULID.generate()} end)

        new_elements = Enum.map(elements, &offset_and_remint(&1, id_map, paste_offset))
        new_connectors = Enum.map(connectors, &remap_connector(&1, id_map))

        socket =
          new_elements
          |> Enum.reduce(socket, fn element, socket ->
            {_, s} =
              apply_op_and_persist(socket, %{
                "type" => "insert_element",
                "element" => element
              })

            s
          end)

        socket =
          new_connectors
          |> Enum.reduce(socket, fn connector, socket ->
            {_, s} =
              apply_op_and_persist(socket, %{
                "type" => "insert_connector",
                "connector" => connector
              })

            s
          end)

        # Re-select the freshly pasted shapes so the user can keep moving them.
        new_selection =
          Enum.map(new_elements, &{:element, &1["id"]}) ++
            Enum.map(new_connectors, &{:connector, &1["id"]})

        {:noreply, assign(socket, :selection, new_selection)}
    end
  end

  def handle_event("toggle_embed", _, socket) do
    canvas = socket.assigns.canvas

    result =
      if canvas.embed_token do
        Diagrams.set_canvas_embed_token(socket.assigns.current_scope, canvas, nil)
      else
        Diagrams.generate_canvas_embed_token(socket.assigns.current_scope, canvas)
      end

    case result do
      {:ok, updated} ->
        {:noreply, assign(socket, :canvas, updated)}

      {:error, :forbidden} ->
        {:noreply, put_flash(socket, :error, "Only owners can manage the embed token.")}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, "Could not update embed.")}
    end
  end

  def handle_event("add_link", %{"element_id" => id}, socket) do
    case socket.assigns.document["elements"][id] do
      nil ->
        {:noreply, socket}

      element ->
        existing = element["links"] || []

        new_link = %{
          "enabled" => true,
          "kind" => "external",
          "target" => "",
          "icon" => "link"
        }

        apply_op_and_persist(socket, %{
          "type" => "update_element",
          "id" => id,
          "patch" => %{"links" => existing ++ [new_link]}
        })
    end
  end

  def handle_event("remove_link", %{"element_id" => id, "index" => idx}, socket) do
    case socket.assigns.document["elements"][id] do
      nil ->
        {:noreply, socket}

      element ->
        existing = element["links"] || []
        idx = String.to_integer(idx)
        updated = List.delete_at(existing, idx)

        apply_op_and_persist(socket, %{
          "type" => "update_element",
          "id" => id,
          "patch" => %{"links" => updated}
        })
    end
  end

  def handle_event("delete_selected", _, socket) do
    selection = socket.assigns.selection

    if selection == [] do
      {:noreply, socket}
    else
      socket = assign(socket, :selection, [])

      socket =
        Enum.reduce(selection, socket, fn
          {:element, id}, socket ->
            {_, s} = apply_op_and_persist(socket, %{"type" => "delete_element", "id" => id})
            s

          {:connector, id}, socket ->
            {_, s} = apply_op_and_persist(socket, %{"type" => "delete_connector", "id" => id})
            s
        end)

      {:noreply, socket}
    end
  end

  defp apply_op_and_persist(%{assigns: %{read_only: true}} = socket, _op) do
    {:noreply, put_flash(socket, :error, "You only have view access to this canvas.")}
  end

  defp apply_op_and_persist(socket, op) do
    case Authority.apply_op(
           socket.assigns.canvas.id,
           socket.assigns.current_scope,
           op,
           socket.assigns.actor_ref
         ) do
      {:ok, %{document: doc, version: version}} ->
        {:noreply, assign(socket, document: doc, version: version)}

      {:error, :forbidden} ->
        {:noreply, put_flash(socket, :error, "You don't have permission to edit this canvas.")}

      {:error, reason} when is_atom(reason) ->
        {:noreply, put_flash(socket, :error, "Op rejected: #{reason}")}
    end
  end

  defp apply_clipboard_defaults(
         %{"type" => "insert_element", "element" => element} = op,
         {:element, %{} = style}
       )
       when map_size(style) > 0 do
    existing = Map.get(element, "style", %{})
    %{op | "element" => Map.put(element, "style", Map.merge(style, existing))}
  end

  defp apply_clipboard_defaults(
         %{"type" => "insert_connector", "connector" => connector} = op,
         {:connector, %{} = style}
       )
       when map_size(style) > 0 do
    %{op | "connector" => Map.merge(connector, style)}
  end

  defp apply_clipboard_defaults(op, _), do: op

  defp do_select(socket, item, true) do
    selection = socket.assigns.selection

    selection =
      if item in selection,
        do: List.delete(selection, item),
        else: selection ++ [item]

    assign(socket, :selection, selection)
  end

  defp do_select(socket, item, _multi), do: assign(socket, :selection, [item])

  defp reorder(order, id, action) do
    case Enum.find_index(order, &(&1 == id)) do
      nil -> order
      idx -> do_reorder(order, idx, action)
    end
  end

  defp do_reorder(order, idx, "to_back") do
    {value, rest} = List.pop_at(order, idx)
    [value | rest]
  end

  defp do_reorder(order, idx, "to_front") do
    {value, rest} = List.pop_at(order, idx)
    rest ++ [value]
  end

  defp do_reorder(order, idx, "backward") when idx > 0 do
    a = Enum.at(order, idx)
    b = Enum.at(order, idx - 1)
    order |> List.replace_at(idx, b) |> List.replace_at(idx - 1, a)
  end

  defp do_reorder(order, idx, "forward") when idx < length(order) - 1 do
    a = Enum.at(order, idx)
    b = Enum.at(order, idx + 1)
    order |> List.replace_at(idx, b) |> List.replace_at(idx + 1, a)
  end

  defp do_reorder(order, _, _), do: order

  defp refresh_peers(socket) do
    exclude = if socket.assigns.show_embed_cursors, do: [], else: ["embed"]

    peers =
      CanvasPresence.list_peers(socket.assigns.topic, socket.assigns.presence_key,
        exclude_sources: exclude
      )

    locked = CanvasPresence.locked_elements(socket.assigns.topic, socket.assigns.presence_key)
    assign(socket, peers: peers, locked_elements: locked)
  end

  ## Form coercion ----------------------------------------------------------

  @element_numeric_keys ~w(x y w h cx cy r)
  @connector_numeric_keys ~w(start_gap end_gap stroke_width)
  @style_color_keys ~w(fill stroke label_color)
  @style_select_keys ~w(font_family dash label_position label_size label_font_family)
  @style_number_keys ~w(font_size stroke_width)
  @style_bool_keys ~w(shadow font_bold font_italic label_bold label_italic)

  defp coerce_element_patch(props, _existing) when is_map(props) do
    base =
      props
      |> Map.take(["label" | @element_numeric_keys])
      |> Enum.reduce(%{}, fn {k, v}, acc ->
        cond do
          k == "label" -> Map.put(acc, k, v || "")
          v in [nil, ""] -> acc
          true -> Map.put(acc, k, parse_number(v))
        end
      end)

    style =
      %{}
      |> merge_string_styles(props, @style_color_keys ++ @style_select_keys)
      |> merge_number_styles(props, @style_number_keys)
      |> merge_bool_styles(props, @style_bool_keys)

    base = if map_size(style) > 0, do: Map.put(base, "style", style), else: base

    case Map.get(props, "links") do
      %{} = links_params ->
        Map.put(base, "links", coerce_links_patch(links_params))

      [_ | _] = list when is_list(list) ->
        Map.put(base, "links", Enum.map(list, &coerce_link_patch/1))

      _ ->
        base
    end
  end

  defp coerce_links_patch(map) when is_map(map) do
    map
    |> Enum.sort_by(fn {k, _} -> integer_or_zero(k) end)
    |> Enum.map(fn {_, link_params} -> coerce_link_patch(link_params) end)
  end

  defp integer_or_zero(k) when is_binary(k) do
    case Integer.parse(k) do
      {n, _} -> n
      :error -> 0
    end
  end

  defp integer_or_zero(k) when is_integer(k), do: k
  defp integer_or_zero(_), do: 0

  defp coerce_link_patch(link_params) when is_map(link_params) do
    Map.take(link_params, ~w(enabled kind target icon))
    |> Enum.reduce(%{}, fn
      {"enabled", v}, acc -> Map.put(acc, "enabled", parse_bool(v))
      {k, v}, acc -> Map.put(acc, k, v)
    end)
    |> Map.put_new("enabled", false)
    |> Map.put_new("kind", "external")
    |> Map.put_new("target", "")
    |> Map.put_new("icon", "link")
  end

  defp merge_string_styles(acc, props, keys) do
    Enum.reduce(keys, acc, fn key, acc ->
      case Map.get(props, key) do
        v when v in [nil, ""] -> acc
        v -> Map.put(acc, key, v)
      end
    end)
  end

  defp merge_number_styles(acc, props, keys) do
    Enum.reduce(keys, acc, fn key, acc ->
      case Map.get(props, key) do
        v when v in [nil, ""] -> acc
        v -> Map.put(acc, key, parse_number(v))
      end
    end)
  end

  defp merge_bool_styles(acc, props, keys) do
    Enum.reduce(keys, acc, fn key, acc ->
      case Map.fetch(props, key) do
        :error -> acc
        {:ok, v} -> Map.put(acc, key, parse_bool(v))
      end
    end)
  end

  defp parse_bool(true), do: true
  defp parse_bool("true"), do: true
  defp parse_bool("on"), do: true
  defp parse_bool(_), do: false

  defp coerce_connector_patch(props) when is_map(props) do
    base = Map.take(props, ["label", "routing", "dash", "marker_start", "marker_end", "color"])

    numeric =
      props
      |> Map.take(@connector_numeric_keys)
      |> Enum.reject(fn {_, v} -> v in [nil, ""] end)
      |> Map.new(fn {k, v} -> {k, parse_number(v)} end)

    Map.merge(base, numeric)
  end

  defp parse_number(n) when is_number(n), do: n

  defp parse_number(s) when is_binary(s) do
    case Float.parse(s) do
      {f, ""} -> if f == trunc(f), do: trunc(f), else: f
      _ -> 0
    end
  end

  ## ── Helpers ──────────────────────────────────────────────────────────────

  defp ensure_document_shape(nil), do: Document.new()

  defp ensure_document_shape(%{"elements" => _, "order" => _, "connectors" => _} = doc), do: doc

  defp ensure_document_shape(other) when is_map(other) do
    %{
      "elements" => other["elements"] || %{},
      "order" => other["order"] || [],
      "connectors" => other["connectors"] || %{}
    }
  end

  defp connector_render_data(connectors, document) do
    connectors
    |> Enum.map(fn {id, connector} ->
      {id, connector, ConnectorGeometry.render(connector, document)}
    end)
    |> Enum.reject(fn {_id, _c, rendered} -> is_nil(rendered) end)
  end

  defp marker_id("none"), do: ""
  defp marker_id(name), do: "url(#dx-marker-#{name})"

  defp element_data_attrs(%{"type" => "circle"} = e),
    do: %{"data-cx" => e["cx"], "data-cy" => e["cy"], "data-r" => e["r"]}

  defp element_data_attrs(%{"x" => x, "y" => y, "w" => w, "h" => h}),
    do: %{"data-x" => x, "data-y" => y, "data-w" => w, "data-h" => h}

  defp selection_focus([{:element, id}]), do: {:element, id}
  defp selection_focus([{:connector, id}]), do: {:connector, id}
  defp selection_focus(_), do: nil

  defp selection_element_ids(selection),
    do: for({:element, id} <- selection, do: id)

  defp selection_connector_ids(selection),
    do: for({:connector, id} <- selection, do: id)

  defp selection_includes_element?(selection, id), do: {:element, id} in selection
  defp selection_includes_connector?(selection, id), do: {:connector, id} in selection

  # Compatibility helpers used in the SVG render loop and the resize handle.
  defp selected_element_id(selection) do
    case selection_focus(selection) do
      {:element, id} -> id
      _ -> nil
    end
  end

  defp selected_element(%{"elements" => elements}, selection) do
    case selection_focus(selection) do
      {:element, id} -> elements[id]
      _ -> nil
    end
  end

  defp selected_element(_, _), do: nil

  defp selected_connector(%{"connectors" => connectors}, selection) do
    case selection_focus(selection) do
      {:connector, id} -> connectors[id]
      _ -> nil
    end
  end

  defp selected_connector(_, _), do: nil

  defp offset_and_remint(element, id_map, offset) do
    new_id = Map.fetch!(id_map, element["id"])

    element
    |> Map.put("id", new_id)
    |> shift_geometry(offset)
  end

  defp shift_geometry(%{"type" => "circle"} = e, offset),
    do: %{e | "cx" => e["cx"] + offset, "cy" => e["cy"] + offset}

  defp shift_geometry(e, offset),
    do: %{e | "x" => e["x"] + offset, "y" => e["y"] + offset}

  defp remap_connector(connector, id_map) do
    new_id = Diogramos.ULID.generate()
    from_id = id_map[connector["from"]["element"]] || connector["from"]["element"]
    to_id = id_map[connector["to"]["element"]] || connector["to"]["element"]

    connector
    |> Map.put("id", new_id)
    |> put_in(["from", "element"], from_id)
    |> put_in(["to", "element"], to_id)
  end

  ## ── Render ───────────────────────────────────────────────────────────────

  @impl true
  def render(assigns) do
    assigns =
      assign(
        assigns,
        :rendered_connectors,
        connector_render_data(assigns.document["connectors"], assigns.document)
      )

    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope} wide>
      <div
        id="canvas-edit"
        data-theme={@canvas.theme}
        data-canvas-id={@canvas.id}
        data-read-only={to_string(@read_only)}
        class="grid grid-cols-[4rem_1fr_18rem] grid-rows-[3rem_1fr] h-[calc(100vh-4rem)] gap-0 w-full"
      >
        <header class="col-span-3 flex items-center justify-between border-b border-base-300 px-3">
          <div class="flex items-center gap-3">
            <.link navigate={~p"/canvases"} class="btn btn-ghost btn-xs">
              <.icon name="hero-arrow-left-micro" class="size-4" />
            </.link>
            <span class="font-semibold truncate">{@canvas.title}</span>
            <span class="text-xs font-mono text-accent">{@canvas.slug}</span>
            <%= if @read_only do %>
              <span class="badge badge-warning badge-sm">view only</span>
            <% end %>
          </div>
          <div class="flex items-center gap-2 text-xs">
            <span class="opacity-70">role: {@role || "—"}</span>
            <span class="opacity-70">v{@version}</span>
            <button
              type="button"
              id="btn-toggle-grid"
              phx-click="toggle_grid"
              class={["btn btn-ghost btn-xs", @snap_grid && "text-accent"]}
              title="Snap to grid"
            >
              <.icon name="hero-squares-2x2-micro" class="size-4" />
              {if @snap_grid, do: "Grid on", else: "Grid"}
            </button>
            <button
              type="button"
              id="btn-toggle-embed-cursors"
              phx-click="toggle_embed_cursors"
              class={["btn btn-ghost btn-xs", @show_embed_cursors && "text-accent"]}
              title={
                if @show_embed_cursors,
                  do: "Hide cursors of embed viewers",
                  else: "Show cursors of embed viewers"
              }
            >
              <.icon
                name={if @show_embed_cursors, do: "hero-eye-micro", else: "hero-eye-slash-micro"}
                class="size-4"
              /> Viewers
            </button>
            <a
              id="btn-export-json"
              href={~p"/c/#{@canvas.slug}/export.json"}
              download={"#{@canvas.slug}.json"}
              class="btn btn-ghost btn-xs"
              title="Download canvas as JSON"
            >
              <.icon name="hero-arrow-down-tray-micro" class="size-4" /> JSON
            </a>
            <button
              type="button"
              id="btn-export-mermaid"
              phx-click="show_mermaid"
              class="btn btn-ghost btn-xs"
            >
              <.icon name="hero-arrow-down-tray-micro" class="size-4" /> Mermaid
            </button>
            <%= if @role == "owner" do %>
              <%= if @canvas.embed_token do %>
                <div class="flex items-center gap-1">
                  <a
                    id="embed-link"
                    href={"/embed/" <> @canvas.embed_token}
                    target="_blank"
                    rel="noopener"
                    class="btn btn-ghost btn-xs text-accent gap-1 font-mono"
                    title="Open embed in a new tab"
                  >
                    <.icon name="hero-link-micro" class="size-4" />
                    /embed/{String.slice(@canvas.embed_token, 0, 6)}…
                  </a>
                  <button
                    type="button"
                    id="btn-toggle-embed"
                    phx-click="toggle_embed"
                    class="btn btn-ghost btn-xs"
                    title="Disable embed"
                  >
                    <.icon name="hero-x-mark-micro" class="size-4" />
                  </button>
                </div>
              <% else %>
                <button
                  type="button"
                  id="btn-toggle-embed"
                  phx-click="toggle_embed"
                  class="btn btn-ghost btn-xs"
                  title="Generate embed link"
                >
                  <.icon name="hero-link-micro" class="size-4" /> Embed
                </button>
              <% end %>
            <% end %>
          </div>
        </header>

        <nav
          class="flex flex-col gap-2 p-2 border-r border-base-300 bg-base-200"
          aria-label="Tools"
        >
          <div
            :for={tool <- ~w(select text rect rounded circle connector)}
            class="tooltip tooltip-right"
            data-tip={tool_label(tool) <> " · " <> tool_shortcut(tool)}
          >
            <button
              type="button"
              id={"tool-#{tool}"}
              class={[
                "btn btn-ghost btn-square btn-md w-full",
                @tool == tool && "btn-active"
              ]}
              phx-click="select_tool"
              phx-value-tool={tool}
              disabled={@read_only}
              aria-label={tool_label(tool)}
            >
              <.icon name={tool_icon(tool)} class="size-6" />
            </button>
          </div>
          <div class="tooltip tooltip-right mt-auto" data-tip="Fit camera to canvas">
            <button
              type="button"
              id="tool-fit-camera"
              phx-click={JS.dispatch("phx:fit-canvas", to: "#canvas-svg")}
              class="btn btn-ghost btn-square btn-md w-full"
              aria-label="Fit camera to canvas"
            >
              <.icon name="hero-arrows-pointing-in" class="size-6" />
            </button>
          </div>
        </nav>

        <main class="relative overflow-hidden bg-base-100">
          <svg
            id="canvas-svg"
            phx-hook=".CanvasViewport"
            data-tool={@tool}
            data-canvas-id={@canvas.id}
            data-selected-id={selected_element_id(@selection) || ""}
            data-has-selection={to_string(@selection != [])}
            data-read-only={to_string(@read_only)}
            data-locked-ids={Enum.join(MapSet.to_list(@locked_elements), ",")}
            data-snap-grid={to_string(@snap_grid)}
            data-grid-size={@grid_size}
            class="absolute inset-0 w-full h-full select-none"
            xmlns="http://www.w3.org/2000/svg"
          >
            <defs>
              <symbol id="dx-link-icon-link" viewBox="0 0 24 24">
                <path
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
                />
              </symbol>
              <symbol id="dx-link-icon-arrow-top-right-on-square" viewBox="0 0 24 24">
                <path
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
                />
              </symbol>
              <symbol id="dx-link-icon-document-text" viewBox="0 0 24 24">
                <path
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
                />
              </symbol>
              <symbol id="dx-link-icon-bookmark" viewBox="0 0 24 24">
                <path
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
                />
              </symbol>
              <symbol id="dx-link-icon-globe-alt" viewBox="0 0 24 24">
                <path
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"
                />
              </symbol>
              <symbol id="dx-link-icon-information-circle" viewBox="0 0 24 24">
                <path
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  d="M11.25 11.25l.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
                />
              </symbol>
              <pattern
                id="canvas-grid-pattern"
                x="0"
                y="0"
                width={@grid_size}
                height={@grid_size}
                patternUnits="userSpaceOnUse"
              >
                <circle cx="0.5" cy="0.5" r="0.9" class="fill-base-content opacity-30" />
              </pattern>
              <marker
                id="dx-marker-arrow"
                viewBox="0 0 10 10"
                refX="10"
                refY="5"
                markerWidth="8"
                markerHeight="8"
                orient="auto-start-reverse"
              >
                <path d="M 0 0 L 10 5 L 0 10" fill="none" stroke="currentColor" stroke-width="1.5" />
              </marker>
              <marker
                id="dx-marker-filled-arrow"
                viewBox="0 0 10 10"
                refX="10"
                refY="5"
                markerWidth="8"
                markerHeight="8"
                orient="auto-start-reverse"
              >
                <path d="M 0 0 L 10 5 L 0 10 Z" fill="currentColor" />
              </marker>
              <marker
                id="dx-marker-diamond"
                viewBox="0 0 10 10"
                refX="10"
                refY="5"
                markerWidth="10"
                markerHeight="10"
                orient="auto-start-reverse"
              >
                <path d="M 0 5 L 5 0 L 10 5 L 5 10 Z" fill="currentColor" />
              </marker>
              <marker
                id="dx-marker-circle"
                viewBox="0 0 10 10"
                refX="10"
                refY="5"
                markerWidth="6"
                markerHeight="6"
                orient="auto"
              >
                <circle cx="5" cy="5" r="3" fill="currentColor" />
              </marker>
            </defs>

            <g id="viewport" class="text-base-content">
              <%= if @snap_grid do %>
                <rect
                  x="-10000"
                  y="-10000"
                  width="20000"
                  height="20000"
                  fill="url(#canvas-grid-pattern)"
                  class="pointer-events-none"
                />
              <% end %>
              <g
                :for={id <- @document["order"]}
                id={"el-" <> id}
                data-element-id={id}
                data-element-type={@document["elements"][id]["type"]}
                {element_data_attrs(@document["elements"][id])}
                class={[
                  "cursor-move",
                  selection_includes_element?(@selection, id) && "outline outline-2 outline-accent",
                  MapSet.member?(@locked_elements, id) && "opacity-40 pointer-events-none"
                ]}
              >
                <.element_node element={@document["elements"][id]} />
                <%= if @tool == "select" and selected_element_id(@selection) == id and not @read_only do %>
                  <.resize_handle element={@document["elements"][id]} element_id={id} />
                <% end %>
              </g>

              <g
                :for={{cid, connector, render} <- @rendered_connectors}
                id={"con-" <> cid}
                data-connector-id={cid}
                class={[
                  "text-base-content cursor-pointer",
                  selection_includes_connector?(@selection, cid) && "text-accent"
                ]}
              >
                <path
                  d={render.d}
                  fill="none"
                  stroke="transparent"
                  stroke-width="14"
                  class="pointer-events-auto"
                />
                <path
                  d={render.d}
                  fill="none"
                  style={"stroke: #{token_var(connector["color"] || "base-content")}; color: #{token_var(connector["color"] || "base-content")};"}
                  stroke-width={render.stroke_width}
                  stroke-dasharray={render.dash_array}
                  marker-start={marker_id(render.marker_start)}
                  marker-end={marker_id(render.marker_end)}
                  class="pointer-events-none"
                />
                <%= if connector["label"] != "" do %>
                  <text class="text-xs fill-base-content opacity-80 pointer-events-none">
                    {connector["label"]}
                  </text>
                <% end %>
              </g>

              <g id="canvas-preview" class="pointer-events-none text-accent" />

              <g id="canvas-cursors" class="pointer-events-none">
                <g
                  :for={peer <- @peers}
                  :if={peer.cursor}
                  id={"cur-" <> peer.key}
                  transform={"translate(#{peer.cursor.x} #{peer.cursor.y})"}
                  style={"color: #{peer.color}"}
                >
                  <path
                    d="M 0 0 L 0 14 L 4 11 L 7 17 L 9 16 L 6 10 L 11 10 Z"
                    fill="currentColor"
                    stroke="black"
                    stroke-width="0.5"
                  />
                  <text
                    x="14"
                    y="14"
                    class="text-xs fill-current"
                    style="paint-order: stroke; stroke: black; stroke-width: 2px;"
                  >
                    {peer.name}
                  </text>
                </g>
              </g>
            </g>

            <script :type={Phoenix.LiveView.ColocatedHook} name=".CanvasViewport">
              const SVG_NS = "http://www.w3.org/2000/svg"
              const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"

              function ulid() {
                const ts = BigInt(Date.now())
                const rand = new Uint8Array(10)
                crypto.getRandomValues(rand)
                let bi = ts << 80n
                for (let i = 0; i < 10; i++) {
                  bi |= BigInt(rand[i]) << BigInt((9 - i) * 8)
                }
                bi &= (1n << 130n) - 1n
                let out = ""
                for (let i = 25; i >= 0; i--) {
                  out += ALPHABET[Number((bi >> BigInt(i * 5)) & 31n)]
                }
                return out
              }

              const DRAG_THRESHOLD = 3 // px
              const CURSOR_THROTTLE_MS = 33 // ~30 Hz

              export default {
                mounted() {
                  this.scale = 1
                  this.tx = 0
                  this.ty = 0
                  this.viewport = this.el.querySelector("#viewport")
                  this.preview = this.el.querySelector("#canvas-preview")
                  this.lastCursorAt = 0
                  this.applyTransform()

                  // Per-canvas grid preference, stored in localStorage.
                  this.gridStorageKey = `dx-grid:${this.el.dataset.canvasId || "default"}`
                  this.handleEvent("grid_changed", ({on}) => {
                    try { localStorage.setItem(this.gridStorageKey, on ? "true" : "false") } catch (_) {}
                  })
                  let stored = null
                  try { stored = localStorage.getItem(this.gridStorageKey) } catch (_) {}
                  if (stored !== null) {
                    const want = stored === "true"
                    if ((this.el.dataset.snapGrid === "true") !== want) {
                      this.pushEvent("set_grid", { on: want })
                    }
                  }

                  this.onWheelBound = (e) => this.onWheel(e)
                  this.onPointerDownBound = (e) => this.onPointerDown(e)
                  this.onMove = (e) => this.onPointerMove(e)
                  this.onUp = (e) => this.onPointerUp(e)
                  this.onSvgMove = (e) => this.onSvgPointerMove(e)
                  this.onKey = (e) => this.onKeyDown(e)

                  this.onFitCanvas = () => this.fitCamera()

                  this.el.addEventListener("wheel", this.onWheelBound, { passive: false })
                  this.el.addEventListener("pointerdown", this.onPointerDownBound)
                  this.el.addEventListener("pointermove", this.onSvgMove)
                  this.el.addEventListener("phx:fit-canvas", this.onFitCanvas)
                  window.addEventListener("pointermove", this.onMove)
                  window.addEventListener("pointerup", this.onUp)
                  window.addEventListener("keydown", this.onKey)
                },
                destroyed() {
                  this.el.removeEventListener("wheel", this.onWheelBound)
                  this.el.removeEventListener("pointerdown", this.onPointerDownBound)
                  this.el.removeEventListener("pointermove", this.onSvgMove)
                  this.el.removeEventListener("phx:fit-canvas", this.onFitCanvas)
                  window.removeEventListener("pointermove", this.onMove)
                  window.removeEventListener("pointerup", this.onUp)
                  window.removeEventListener("keydown", this.onKey)
                },
                fitCamera() {
                  const elements = this.viewport.querySelectorAll("[data-element-id]")
                  if (elements.length === 0) {
                    this.tx = 0; this.ty = 0; this.scale = 1
                    this.applyTransform()
                    return
                  }
                  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
                  elements.forEach(g => {
                    let bx, by, bw, bh
                    if (g.dataset.elementType === "circle") {
                      const cx = parseFloat(g.dataset.cx)
                      const cy = parseFloat(g.dataset.cy)
                      const r = parseFloat(g.dataset.r)
                      bx = cx - r; by = cy - r; bw = 2 * r; bh = 2 * r
                    } else {
                      bx = parseFloat(g.dataset.x); by = parseFloat(g.dataset.y)
                      bw = parseFloat(g.dataset.w); bh = parseFloat(g.dataset.h)
                    }
                    if (!isFinite(bx) || !isFinite(by) || !isFinite(bw) || !isFinite(bh)) return
                    minX = Math.min(minX, bx)
                    minY = Math.min(minY, by)
                    maxX = Math.max(maxX, bx + bw)
                    maxY = Math.max(maxY, by + bh)
                  })
                  if (!isFinite(minX) || !isFinite(maxX)) return
                  const bw = maxX - minX
                  const bh = maxY - minY
                  if (bw <= 0 || bh <= 0) return
                  const rect = this.el.getBoundingClientRect()
                  const margin = 32
                  const sx = (rect.width - margin * 2) / bw
                  const sy = (rect.height - margin * 2) / bh
                  this.scale = Math.max(0.05, Math.min(2, Math.min(sx, sy)))
                  this.tx = -minX * this.scale + (rect.width - bw * this.scale) / 2
                  this.ty = -minY * this.scale + (rect.height - bh * this.scale) / 2
                  this.applyTransform()
                },
                onKeyDown(e) {
                  // Ignore shortcuts while typing in any input/textarea.
                  const t = e.target
                  const tag = t && t.tagName
                  if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (t && t.isContentEditable)) return

                  if (e.key === "Escape") {
                    this.pushEvent("select_tool", { tool: "select" })
                    return
                  }

                  if (e.key === "Delete" || e.key === "Backspace") {
                    if (this.el.dataset.hasSelection === "true" && this.el.dataset.readOnly !== "true") {
                      e.preventDefault()
                      this.pushEvent("delete_selected", {})
                    }
                    return
                  }

                  if (e.key === "PageUp" || e.key === "PageDown") {
                    e.preventDefault()
                    const action = e.key === "PageUp"
                      ? (e.shiftKey ? "to_front" : "forward")
                      : (e.shiftKey ? "to_back" : "backward")
                    this.pushEvent("z_order", { action })
                    return
                  }

                  if (!(e.ctrlKey || e.metaKey)) return
                  if (e.altKey) return

                  if (!e.shiftKey) {
                    if (e.key === "c" || e.key === "C") {
                      if (this.el.dataset.hasSelection === "true") {
                        e.preventDefault()
                        this.pushEvent("copy_selection", {})
                      }
                      return
                    }
                    if (e.key === "v" || e.key === "V") {
                      if (this.el.dataset.readOnly !== "true") {
                        e.preventDefault()
                        this.pushEvent("paste_selection", {})
                      }
                      return
                    }
                  }

                  if (e.shiftKey) return

                  const map = { "0": "select", "1": "text", "2": "rect", "3": "rounded", "4": "circle", "5": "connector" }
                  const tool = map[e.key]
                  if (!tool) return
                  e.preventDefault()
                  this.pushEvent("select_tool", { tool })
                },
                onSvgPointerMove(e) {
                  const now = performance.now()
                  if (now - this.lastCursorAt < CURSOR_THROTTLE_MS) return
                  this.lastCursorAt = now
                  const pt = this.worldPoint(e)
                  this.pushEvent("cursor", { x: pt.x, y: pt.y })
                },
                applyTransform() {
                  this.viewport.setAttribute(
                    "transform",
                    `translate(${this.tx} ${this.ty}) scale(${this.scale})`
                  )
                },
                worldPoint(e) {
                  const rect = this.el.getBoundingClientRect()
                  return {
                    x: (e.clientX - rect.left - this.tx) / this.scale,
                    y: (e.clientY - rect.top - this.ty) / this.scale
                  }
                },
                tool() { return this.el.dataset.tool || "select" },
                readOnly() { return this.el.dataset.readOnly === "true" },
                snapEnabled() { return this.el.dataset.snapGrid === "true" },
                gridSize() { return parseFloat(this.el.dataset.gridSize) || 20 },
                snap(v) {
                  if (!this.snapEnabled()) return v
                  const g = this.gridSize()
                  return Math.round(v / g) * g
                },
                snapPoint(p) {
                  return { x: this.snap(p.x), y: this.snap(p.y) }
                },
                onWheel(e) {
                  e.preventDefault()
                  const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
                  const rect = this.el.getBoundingClientRect()
                  const cx = e.clientX - rect.left
                  const cy = e.clientY - rect.top
                  const wx = (cx - this.tx) / this.scale
                  const wy = (cy - this.ty) / this.scale
                  this.scale = Math.max(0.1, Math.min(8, this.scale * factor))
                  this.tx = cx - wx * this.scale
                  this.ty = cy - wy * this.scale
                  this.applyTransform()
                },
                findElementGroup(target) {
                  let node = target
                  while (node && node !== this.el) {
                    if (node.dataset && node.dataset.elementId) return node
                    node = node.parentNode
                  }
                  return null
                },
                findResizeHandle(target) {
                  let node = target
                  while (node && node !== this.el) {
                    if (node.dataset && node.dataset.resizeHandle) return node
                    node = node.parentNode
                  }
                  return null
                },
                findConnectorGroup(target) {
                  let node = target
                  while (node && node !== this.el) {
                    if (node.dataset && node.dataset.connectorId) return node
                    node = node.parentNode
                  }
                  return null
                },
                elementCenter(group) {
                  if (group.dataset.elementType === "circle") {
                    return { x: parseFloat(group.dataset.cx), y: parseFloat(group.dataset.cy) }
                  }
                  return {
                    x: parseFloat(group.dataset.x) + parseFloat(group.dataset.w) / 2,
                    y: parseFloat(group.dataset.y) + parseFloat(group.dataset.h) / 2
                  }
                },
                onPointerDown(e) {
                  if (e.button !== 0 && e.button !== 1) return

                  // Middle-click or space-modified → pan
                  if (e.button === 1 || e.shiftKey) {
                    this.action = { kind: "pan", lastX: e.clientX, lastY: e.clientY }
                    return
                  }

                  const tool = this.tool()
                  const group = this.findElementGroup(e.target)

                  if (tool === "select") {
                    const handle = this.findResizeHandle(e.target)
                    if (handle && group) {
                      this.action = {
                        kind: "resize",
                        group,
                        elementId: group.dataset.elementId,
                        type: group.dataset.elementType,
                        startWorld: this.worldPoint(e),
                        startClient: { x: e.clientX, y: e.clientY }
                      }
                      return
                    }

                    if (group) {
                      this.pushEvent("select_element", {
                        id: group.dataset.elementId,
                        multi: e.ctrlKey || e.metaKey
                      })
                      this.pushEvent("set_lock", { id: group.dataset.elementId })
                      this.action = {
                        kind: "move",
                        group,
                        elementId: group.dataset.elementId,
                        type: group.dataset.elementType,
                        startWorld: this.worldPoint(e),
                        startClient: { x: e.clientX, y: e.clientY },
                        moved: false
                      }
                      return
                    }

                    const conGroup = this.findConnectorGroup(e.target)
                    if (conGroup) {
                      this.pushEvent("select_connector", {
                        id: conGroup.dataset.connectorId,
                        multi: e.ctrlKey || e.metaKey
                      })
                      return
                    }

                    // Empty area drag → lasso. Empty click without movement → clear selection.
                    const start = this.worldPoint(e)
                    this.action = {
                      kind: "lasso",
                      start,
                      current: start,
                      startClient: { x: e.clientX, y: e.clientY }
                    }
                    this.renderLassoPreview()
                    return
                  }

                  if (this.readOnly()) return

                  if (tool === "connector") {
                    if (!group) return
                    const center = this.elementCenter(group)
                    this.action = {
                      kind: "connect",
                      fromId: group.dataset.elementId,
                      fromCenter: center,
                      current: this.worldPoint(e)
                    }
                    this.renderConnectorPreview()
                    return
                  }

                  // Drawing tools
                  if (["rect", "rounded", "circle", "text"].includes(tool)) {
                    const start = this.worldPoint(e)
                    this.action = { kind: "draw", tool, start, current: start }
                    this.renderPreview()
                  }
                },
                onPointerMove(e) {
                  if (!this.action) return
                  const a = this.action

                  if (a.kind === "pan") {
                    this.tx += e.clientX - a.lastX
                    this.ty += e.clientY - a.lastY
                    a.lastX = e.clientX
                    a.lastY = e.clientY
                    this.applyTransform()
                    return
                  }

                  if (a.kind === "draw") {
                    a.current = this.worldPoint(e)
                    this.renderPreview()
                    return
                  }

                  if (a.kind === "connect") {
                    a.current = this.worldPoint(e)
                    this.renderConnectorPreview()
                    return
                  }

                  if (a.kind === "lasso") {
                    a.current = this.worldPoint(e)
                    this.renderLassoPreview()
                    return
                  }

                  if (a.kind === "move") {
                    const dx = e.clientX - a.startClient.x
                    const dy = e.clientY - a.startClient.y
                    if (!a.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
                      a.moved = true
                    }
                    if (a.moved) {
                      a.group.setAttribute(
                        "transform",
                        `translate(${dx / this.scale} ${dy / this.scale})`
                      )
                    }
                    return
                  }

                  if (a.kind === "resize") {
                    // Live preview: stretch the group via SVG transform from
                    // the top-left (or center for circles), then reset on drop.
                    const ds = this.el.dataset
                    const w0 = parseFloat(a.group.dataset.w)
                    const h0 = parseFloat(a.group.dataset.h)
                    const dx = (e.clientX - a.startClient.x) / this.scale
                    const dy = (e.clientY - a.startClient.y) / this.scale
                    if (a.type === "circle") {
                      const r0 = parseFloat(a.group.dataset.r)
                      const newR = Math.max(4, r0 + Math.max(dx, dy))
                      const sf = newR / r0
                      const cx = parseFloat(a.group.dataset.cx)
                      const cy = parseFloat(a.group.dataset.cy)
                      a.group.setAttribute(
                        "transform",
                        `translate(${cx} ${cy}) scale(${sf}) translate(${-cx} ${-cy})`
                      )
                    } else {
                      const newW = Math.max(8, w0 + dx)
                      const newH = Math.max(8, h0 + dy)
                      const sx = newW / w0
                      const sy = newH / h0
                      const x0 = parseFloat(a.group.dataset.x)
                      const y0 = parseFloat(a.group.dataset.y)
                      a.group.setAttribute(
                        "transform",
                        `translate(${x0} ${y0}) scale(${sx} ${sy}) translate(${-x0} ${-y0})`
                      )
                    }
                  }
                },
                onPointerUp(e) {
                  const a = this.action
                  this.action = null
                  if (!a) return

                  if (a.kind === "pan") return

                  if (a.kind === "lasso") {
                    this.preview.replaceChildren()
                    const dx = e.clientX - a.startClient.x
                    const dy = e.clientY - a.startClient.y
                    if (Math.hypot(dx, dy) < DRAG_THRESHOLD) {
                      this.pushEvent("clear_selection", {})
                      return
                    }
                    this.commitLasso(a)
                    return
                  }

                  if (a.kind === "draw") {
                    this.preview.replaceChildren()
                    this.commitDraw(a)
                    return
                  }

                  if (a.kind === "connect") {
                    this.preview.replaceChildren()
                    const targetGroup = this.findElementGroup(e.target)
                    if (targetGroup && targetGroup.dataset.elementId !== a.fromId) {
                      const id = ulid()
                      this.pushEvent("apply_op", {
                        op: {
                          type: "insert_connector",
                          connector: {
                            id,
                            from: { element: a.fromId, anchor: "auto", _other: targetGroup.dataset.elementId },
                            to: { element: targetGroup.dataset.elementId, anchor: "auto", _other: a.fromId },
                            routing: "orthogonal",
                            dash: "solid",
                            marker_start: "none",
                            marker_end: "arrow",
                            start_gap: 6,
                            end_gap: 6,
                            stroke_width: 2
                          }
                        }
                      })
                    }
                    return
                  }

                  if (a.kind === "resize") {
                    a.group.removeAttribute("transform")
                    const dx = (e.clientX - a.startClient.x) / this.scale
                    const dy = (e.clientY - a.startClient.y) / this.scale
                    let patch
                    if (a.type === "circle") {
                      const r0 = parseFloat(a.group.dataset.r)
                      const newR = Math.max(4, this.snap(r0 + Math.max(dx, dy)))
                      patch = { r: newR }
                    } else {
                      const w0 = parseFloat(a.group.dataset.w)
                      const h0 = parseFloat(a.group.dataset.h)
                      patch = {
                        w: Math.max(8, this.snap(w0 + dx)),
                        h: Math.max(8, this.snap(h0 + dy))
                      }
                    }
                    this.pushEvent("apply_op", {
                      op: { type: "update_element", id: a.elementId, patch }
                    })
                    return
                  }

                  if (a.kind === "move") {
                    this.pushEvent("clear_lock", {})
                    if (!a.moved) {
                      this.pushEvent("select_element", { id: a.elementId })
                      return
                    }
                    a.group.removeAttribute("transform")
                    const dx = e.clientX - a.startClient.x
                    const dy = e.clientY - a.startClient.y
                    const wx = dx / this.scale
                    const wy = dy / this.scale
                    let patch
                    if (a.type === "circle") {
                      patch = {
                        cx: this.snap(parseFloat(a.group.dataset.cx) + wx),
                        cy: this.snap(parseFloat(a.group.dataset.cy) + wy)
                      }
                    } else {
                      patch = {
                        x: this.snap(parseFloat(a.group.dataset.x) + wx),
                        y: this.snap(parseFloat(a.group.dataset.y) + wy)
                      }
                    }
                    this.pushEvent("apply_op", {
                      op: { type: "update_element", id: a.elementId, patch }
                    })
                  }
                },
                renderPreview() {
                  const a = this.action
                  if (!a || a.kind !== "draw") return
                  this.preview.replaceChildren()
                  const el = this.shapePreviewElement(a)
                  if (el) this.preview.appendChild(el)
                },
                renderLassoPreview() {
                  const a = this.action
                  if (!a || a.kind !== "lasso") return
                  this.preview.replaceChildren()
                  const x1 = Math.min(a.start.x, a.current.x)
                  const y1 = Math.min(a.start.y, a.current.y)
                  const w = Math.abs(a.current.x - a.start.x)
                  const h = Math.abs(a.current.y - a.start.y)
                  if (w < 1 && h < 1) return
                  const rect = document.createElementNS(SVG_NS, "rect")
                  rect.setAttribute("x", x1); rect.setAttribute("y", y1)
                  rect.setAttribute("width", w); rect.setAttribute("height", h)
                  rect.setAttribute("fill", "currentColor")
                  rect.setAttribute("fill-opacity", "0.08")
                  rect.setAttribute("stroke", "currentColor")
                  rect.setAttribute("stroke-dasharray", "4 3")
                  this.preview.appendChild(rect)
                },
                commitLasso(a) {
                  const x1 = Math.min(a.start.x, a.current.x)
                  const y1 = Math.min(a.start.y, a.current.y)
                  const x2 = Math.max(a.start.x, a.current.x)
                  const y2 = Math.max(a.start.y, a.current.y)

                  const elementIds = []
                  this.viewport.querySelectorAll("[data-element-id]").forEach(g => {
                    const id = g.dataset.elementId
                    if (!id) return
                    let bx, by, bw, bh
                    if (g.dataset.elementType === "circle") {
                      const cx = parseFloat(g.dataset.cx)
                      const cy = parseFloat(g.dataset.cy)
                      const r = parseFloat(g.dataset.r)
                      bx = cx - r; by = cy - r; bw = 2 * r; bh = 2 * r
                    } else {
                      bx = parseFloat(g.dataset.x); by = parseFloat(g.dataset.y)
                      bw = parseFloat(g.dataset.w); bh = parseFloat(g.dataset.h)
                    }
                    if (bx < x2 && bx + bw > x1 && by < y2 && by + bh > y1) {
                      elementIds.push(id)
                    }
                  })

                  // Auto-include connectors whose endpoints are both inside the
                  // lasso so copy/paste gets the wires too.
                  const elSet = new Set(elementIds)
                  const connectorIds = []
                  this.viewport.querySelectorAll("[data-connector-id]").forEach(g => {
                    const id = g.dataset.connectorId
                    const path = g.querySelector("path")
                    if (path) {
                      try {
                        const bb = path.getBBox()
                        if (bb.x < x2 && bb.x + bb.width > x1 && bb.y < y2 && bb.y + bb.height > y1) {
                          connectorIds.push(id)
                        }
                      } catch (_) {}
                    }
                  })

                  this.pushEvent("select_set", { elements: elementIds, connectors: connectorIds })
                },
                renderConnectorPreview() {
                  const a = this.action
                  if (!a || a.kind !== "connect") return
                  this.preview.replaceChildren()
                  const line = document.createElementNS(SVG_NS, "line")
                  line.setAttribute("x1", a.fromCenter.x)
                  line.setAttribute("y1", a.fromCenter.y)
                  line.setAttribute("x2", a.current.x)
                  line.setAttribute("y2", a.current.y)
                  line.setAttribute("stroke", "currentColor")
                  line.setAttribute("stroke-dasharray", "4 3")
                  this.preview.appendChild(line)
                },
                shapePreviewElement(a) {
                  const x1 = Math.min(a.start.x, a.current.x)
                  const y1 = Math.min(a.start.y, a.current.y)
                  const w = Math.abs(a.current.x - a.start.x)
                  const h = Math.abs(a.current.y - a.start.y)
                  if (w < 1 && h < 1) return null

                  if (a.tool === "circle") {
                    const cx = (a.start.x + a.current.x) / 2
                    const cy = (a.start.y + a.current.y) / 2
                    const r = Math.max(w, h) / 2
                    const c = document.createElementNS(SVG_NS, "circle")
                    c.setAttribute("cx", cx); c.setAttribute("cy", cy); c.setAttribute("r", r)
                    c.setAttribute("fill", "none")
                    c.setAttribute("stroke", "currentColor")
                    c.setAttribute("stroke-dasharray", "4 3")
                    return c
                  }

                  const rect = document.createElementNS(SVG_NS, "rect")
                  rect.setAttribute("x", x1); rect.setAttribute("y", y1)
                  rect.setAttribute("width", w); rect.setAttribute("height", h)
                  if (a.tool === "rounded") { rect.setAttribute("rx", 8); rect.setAttribute("ry", 8) }
                  rect.setAttribute("fill", "none")
                  rect.setAttribute("stroke", "currentColor")
                  rect.setAttribute("stroke-dasharray", "4 3")
                  return rect
                },
                commitDraw(a) {
                  const start = this.snapPoint(a.start)
                  const current = this.snapPoint(a.current)
                  const x1 = Math.min(start.x, current.x)
                  const y1 = Math.min(start.y, current.y)
                  const w = Math.max(Math.abs(current.x - start.x), 8)
                  const h = Math.max(Math.abs(current.y - start.y), 8)
                  const id = ulid()
                  let element

                  if (a.tool === "circle") {
                    element = {
                      id, type: "circle",
                      cx: (start.x + current.x) / 2,
                      cy: (start.y + current.y) / 2,
                      r: Math.max(w, h) / 2,
                      label: ""
                    }
                  } else {
                    element = { id, type: a.tool, x: x1, y: y1, w, h, label: a.tool === "text" ? "Text" : "" }
                  }

                  this.pushEvent("apply_op", {
                    op: { type: "insert_element", element }
                  })
                }
              }
            </script>
          </svg>
        </main>

        <%= if @show_mermaid do %>
          <div
            class="modal modal-open"
            id="mermaid-export-modal"
            phx-window-keydown="close_mermaid"
            phx-key="escape"
          >
            <div class="modal-box max-w-3xl">
              <h3 class="font-bold text-lg mb-2">Mermaid export</h3>
              <p class="text-sm opacity-70 mb-3">
                Paste this into any Mermaid renderer (mermaid.live, GitHub markdown, etc.).
              </p>
              <pre
                phx-no-curly-interpolation
                class="bg-base-300 p-3 rounded-box text-sm overflow-auto whitespace-pre"
              ><code id="mermaid-source"><%= @mermaid_source %></code></pre>
              <div class="modal-action">
                <button type="button" phx-click="close_mermaid" class="btn btn-ghost">Close</button>
              </div>
            </div>
          </div>
        <% end %>

        <aside
          id="property-panel"
          class="border-l border-base-300 bg-base-200 p-3 overflow-y-auto"
        >
          <div class="flex items-center justify-between mb-2 gap-2">
            <h2 class="font-mono text-xs uppercase tracking-wide text-accent">Properties</h2>
            <div class="flex items-center gap-1">
              <%= if selection_focus(@selection) do %>
                <button
                  type="button"
                  id="btn-copy-style"
                  phx-click="copy_style"
                  class="btn btn-ghost btn-xs"
                  title="Copy style"
                >
                  <.icon name="hero-clipboard-micro" class="size-4" />
                </button>
                <button
                  type="button"
                  id="btn-paste-style"
                  phx-click="paste_style"
                  class="btn btn-ghost btn-xs"
                  disabled={
                    @read_only or
                      !style_clipboard_compatible?(@style_clipboard, selection_focus(@selection))
                  }
                  title="Paste style"
                >
                  <.icon name="hero-clipboard-document-check-micro" class="size-4" />
                </button>
              <% end %>
              <%= if @selection != [] do %>
                <button
                  type="button"
                  id="btn-copy-selection"
                  phx-click="copy_selection"
                  class="btn btn-ghost btn-xs"
                  title="Copy selection (Ctrl-C)"
                >
                  <.icon name="hero-document-duplicate-micro" class="size-4" />
                </button>
              <% end %>
              <button
                type="button"
                id="btn-paste-selection"
                phx-click="paste_selection"
                class="btn btn-ghost btn-xs"
                disabled={@read_only or @selection_clipboard == nil}
                title="Paste selection (Ctrl-V)"
              >
                <.icon name="hero-clipboard-document-list-micro" class="size-4" />
              </button>
            </div>
          </div>

          <.style_clipboard_preview clipboard={@style_clipboard} />
          <.property_panel
            selection={@selection}
            element={selected_element(@document, @selection)}
            connector={selected_connector(@document, @selection)}
            read_only={@read_only}
          />
        </aside>
      </div>
    </Layouts.app>
    """
  end

  attr :element, :map, required: true

  defp shape_label(%{element: element} = assigns) do
    if (element["label"] || "") == "" do
      ~H""
    else
      assigns =
        assigns
        |> assign(:geom, label_geom(element))
        |> assign(:text_style, shape_label_style(element))

      ~H"""
      <text
        x={@geom.x}
        y={@geom.y}
        text-anchor={@geom.text_anchor}
        dominant-baseline={@geom.baseline}
        style={@text_style}
      >{@element["label"]}</text>
      """
    end
  end

  @doc false
  def shape_label_style(element) do
    color = get_in(element, ["style", "label_color"]) || "base-content"
    family = font_family_css(get_in(element, ["style", "label_font_family"]) || "sans")
    size = label_size_px(get_in(element, ["style", "label_size"]) || "md")
    weight = if get_in(element, ["style", "label_bold"]) == true, do: "700", else: "400"
    style_part = if get_in(element, ["style", "label_italic"]) == true, do: "italic", else: "normal"

    "fill: #{token_var(color)}; font-family: #{family}; font-size: #{size}px; font-weight: #{weight}; font-style: #{style_part};"
  end

  attr :element, :map, required: true

  defp link_overlay(%{element: element} = assigns) do
    enabled = enabled_links(element)

    if enabled == [] do
      ~H""
    else
      assigns = assign(assigns, :placements, link_placements(element, enabled, :editor))

      ~H"""
      <%= for %{link: link, x: x, y: y, size: size, href: href, icon: icon} <- @placements do %>
        <a
          href={href}
          target={if link["kind"] == "external", do: "_blank", else: nil}
          rel={if link["kind"] == "external", do: "noopener noreferrer", else: nil}
          class="text-accent"
        >
          <rect
            x={x - 1}
            y={y - 1}
            width={size + 2}
            height={size + 2}
            rx="3"
            ry="3"
            class="fill-base-100 stroke-accent"
            stroke-width="1"
          />
          <use href={"#" <> icon} x={x} y={y} width={size} height={size} />
        </a>
      <% end %>
      """
    end
  end

  @doc false
  def enabled_links(%{"links" => links}) when is_list(links),
    do: Enum.filter(links, &(&1["enabled"] == true))

  def enabled_links(_), do: []

  @doc """
  Returns one placement per enabled link, stacked horizontally from the
  bottom-right corner of the element's bbox toward the left so the
  "primary" (first) link is closest to the corner.
  """
  def link_placements(element, enabled_links, context \\ :embed) do
    {origin_x, origin_y, size} = link_overlay_geometry(element)
    gap = 2

    enabled_links
    |> Enum.with_index()
    |> Enum.map(fn {link, i} ->
      %{
        link: link,
        href: link_href(link, context),
        icon: link_icon_id(link["icon"]),
        x: origin_x - i * (size + gap),
        y: origin_y,
        size: size
      }
    end)
  end

  @doc false
  def link_overlay_geometry(%{"type" => "circle"} = e) do
    smallest = 2 * (e["r"] || 0)
    size = max(8, smallest * 0.08)
    bbox_x = (e["cx"] || 0) - (e["r"] || 0)
    bbox_y = (e["cy"] || 0) - (e["r"] || 0)
    pad = 2
    {bbox_x + smallest - size - pad, bbox_y + smallest - size - pad, size}
  end

  def link_overlay_geometry(%{"w" => w, "h" => h, "x" => x, "y" => y}) do
    smallest = min(w, h)
    size = max(8, smallest * 0.08)
    pad = 2
    {x + w - size - pad, y + h - size - pad, size}
  end

  def link_overlay_geometry(_), do: {0, 0, 0}

  @doc false
  def link_icon_id(name), do: "dx-link-icon-#{name || "link"}"

  @doc false
  def link_href(link, context \\ :embed)

  def link_href(%{"kind" => "canvas", "target" => slug}, :editor)
      when is_binary(slug) and slug != "",
      do: "/c/#{slug}"

  def link_href(%{"kind" => "canvas", "target" => slug}, _context)
      when is_binary(slug) and slug != "",
      do: "/c-embed/#{slug}"

  def link_href(%{"target" => target}, _context) when is_binary(target), do: target
  def link_href(_, _context), do: "#"

  attr :element, :map, required: true
  attr :element_id, :string, required: true

  defp resize_handle(%{element: %{"type" => "circle"} = el} = assigns) do
    handle_size = 10
    cx = el["cx"] + el["r"]
    cy = el["cy"] + el["r"]
    assigns = assign(assigns, x: cx - handle_size / 2, y: cy - handle_size / 2, size: handle_size)

    ~H"""
    <rect
      x={@x}
      y={@y}
      width={@size}
      height={@size}
      class="fill-accent stroke-base-100 cursor-nwse-resize"
      stroke-width="1"
      data-resize-handle={@element_id}
      pointer-events="all"
    />
    """
  end

  defp resize_handle(%{element: el} = assigns) do
    handle_size = 10

    assigns =
      assign(assigns,
        x: el["x"] + el["w"] - handle_size / 2,
        y: el["y"] + el["h"] - handle_size / 2,
        size: handle_size
      )

    ~H"""
    <rect
      x={@x}
      y={@y}
      width={@size}
      height={@size}
      class="fill-accent stroke-base-100 cursor-nwse-resize"
      stroke-width="1"
      data-resize-handle={@element_id}
      pointer-events="all"
    />
    """
  end

  attr :element, :map, required: true

  defp element_node(%{element: %{"type" => "rect"}} = assigns) do
    ~H"""
    <%= if shadow?(@element) do %>
      <rect
        x={@element["x"] + shadow_offset()}
        y={@element["y"] + shadow_offset()}
        width={@element["w"]}
        height={@element["h"]}
        style={shadow_style(@element)}
      />
    <% end %>
    <rect
      x={@element["x"]}
      y={@element["y"]}
      width={@element["w"]}
      height={@element["h"]}
      style={shape_style(@element)}
      pointer-events="visiblePainted"
    />
    <.shape_label element={@element} />
    <.link_overlay element={@element} />
    """
  end

  defp element_node(%{element: %{"type" => "rounded"}} = assigns) do
    ~H"""
    <%= if shadow?(@element) do %>
      <rect
        x={@element["x"] + shadow_offset()}
        y={@element["y"] + shadow_offset()}
        width={@element["w"]}
        height={@element["h"]}
        rx="8"
        ry="8"
        style={shadow_style(@element)}
      />
    <% end %>
    <rect
      x={@element["x"]}
      y={@element["y"]}
      width={@element["w"]}
      height={@element["h"]}
      rx="8"
      ry="8"
      style={shape_style(@element)}
      pointer-events="visiblePainted"
    />
    <.shape_label element={@element} />
    <.link_overlay element={@element} />
    """
  end

  defp element_node(%{element: %{"type" => "circle"}} = assigns) do
    ~H"""
    <%= if shadow?(@element) do %>
      <circle
        cx={@element["cx"] + shadow_offset()}
        cy={@element["cy"] + shadow_offset()}
        r={@element["r"]}
        style={shadow_style(@element)}
      />
    <% end %>
    <circle
      cx={@element["cx"]}
      cy={@element["cy"]}
      r={@element["r"]}
      style={shape_style(@element)}
      pointer-events="visiblePainted"
    />
    <.shape_label element={@element} />
    <.link_overlay element={@element} />
    """
  end

  defp element_node(%{element: %{"type" => "text"}} = assigns) do
    ~H"""
    <text
      x={@element["x"]}
      y={@element["y"] + text_baseline(@element)}
      style={text_element_style(@element)}
      pointer-events="visiblePainted"
    >
      {@element["label"]}
    </text>
    <.link_overlay element={@element} />
    """
  end

  @doc false
  def text_element_style(element) do
    fill = get_in(element, ["style", "fill"]) || "base-content"
    family = font_family_css(get_in(element, ["style", "font_family"]) || "sans")
    size = get_in(element, ["style", "font_size"]) || 14
    weight = if get_in(element, ["style", "font_bold"]) == true, do: "700", else: "400"
    style = if get_in(element, ["style", "font_italic"]) == true, do: "italic", else: "normal"

    "fill: #{token_var(fill)}; font-family: #{family}; font-size: #{size}px; font-weight: #{weight}; font-style: #{style};"
  end

  @doc false
  def font_family_css("serif"), do: "ui-serif, Georgia, serif"
  def font_family_css("mono"), do: "ui-monospace, SFMono-Regular, Menlo, monospace"
  def font_family_css("b612"), do: "'B612', system-ui, sans-serif"

  def font_family_css("b612-mono"),
    do: "'B612 Mono', ui-monospace, SFMono-Regular, Menlo, monospace"

  def font_family_css(_), do: "ui-sans-serif, system-ui, sans-serif"

  @doc false
  def text_baseline(element) do
    size = get_in(element, ["style", "font_size"]) || 14
    # Approximate ascender height so the top of the glyph aligns to y.
    round(size * 0.85)
  end

  @doc """
  Computes label placement attrs for a shape (rect/rounded/circle).
  Returns `%{x, y, text_anchor, baseline, font_size}` driven by
  `style.label_position` and `style.label_size`.
  """
  def label_geom(element) do
    {x, y, w, h} = bbox_of(element)
    pos = get_in(element, ["style", "label_position"]) || default_label_position(element)
    size = label_size_px(get_in(element, ["style", "label_size"]) || "md")
    pad = 8

    {ax, anchor} =
      case pos do
        p when p in ["tl", "ml", "bl"] -> {x + pad, "start"}
        p when p in ["tc", "center", "bc"] -> {x + w / 2, "middle"}
        p when p in ["tr", "mr", "br"] -> {x + w - pad, "end"}
        _ -> {x + w / 2, "middle"}
      end

    {ay, baseline} =
      case pos do
        p when p in ["tl", "tc", "tr"] -> {y + pad, "hanging"}
        p when p in ["ml", "center", "mr"] -> {y + h / 2, "middle"}
        p when p in ["bl", "bc", "br"] -> {y + h - pad, "auto"}
        _ -> {y + h / 2, "middle"}
      end

    %{x: ax, y: ay, text_anchor: anchor, baseline: baseline, font_size: size}
  end

  defp bbox_of(%{"type" => "circle", "cx" => cx, "cy" => cy, "r" => r}),
    do: {cx - r, cy - r, 2 * r, 2 * r}

  defp bbox_of(%{"x" => x, "y" => y, "w" => w, "h" => h}), do: {x, y, w, h}

  defp default_label_position(%{"type" => "circle"}), do: "center"
  defp default_label_position(_), do: "tl"

  defp label_size_px("sm"), do: 11
  defp label_size_px("md"), do: 14
  defp label_size_px("lg"), do: 18
  defp label_size_px("xl"), do: 24
  defp label_size_px(_), do: 14

  @doc false
  def shape_style(element) do
    fill = get_in(element, ["style", "fill"]) || "base-200"
    stroke = get_in(element, ["style", "stroke"]) || "base-content"
    width = get_in(element, ["style", "stroke_width"]) || 1.5
    dash = shape_dash_array(get_in(element, ["style", "dash"]) || "solid")

    base = "fill: #{token_var(fill)}; stroke: #{token_var(stroke)}; stroke-width: #{width};"
    if dash, do: base <> " stroke-dasharray: #{dash};", else: base
  end

  defp shape_dash_array("solid"), do: nil
  defp shape_dash_array("dotted"), do: "2 4"
  defp shape_dash_array("dashed"), do: "8 6"
  defp shape_dash_array("dash-dot"), do: "8 4 2 4"
  defp shape_dash_array(_), do: nil

  @doc false
  def shadow_style(element) do
    color =
      get_in(element, ["style", "fill"]) || get_in(element, ["style", "stroke"]) || "base-content"

    "fill: #{token_var(color)}; stroke: #{token_var(color)}; stroke-width: 1; opacity: 0.55;"
  end

  @doc false
  def shadow?(element), do: get_in(element, ["style", "shadow"]) == true

  @doc false
  def shadow_offset, do: 12

  @doc false
  def token_var("transparent"), do: "transparent"
  def token_var(token), do: "var(--color-#{token})"

  defp tool_icon("select"), do: "hero-cursor-arrow-rays"
  defp tool_icon("text"), do: "hero-language"
  defp tool_icon("rect"), do: "hero-square-2-stack"
  defp tool_icon("rounded"), do: "hero-stop"
  defp tool_icon("circle"), do: "hero-circle-stack"
  defp tool_icon("connector"), do: "hero-arrow-long-right"

  defp tool_label("select"), do: "Select / move"
  defp tool_label("text"), do: "Text"
  defp tool_label("rect"), do: "Rectangle"
  defp tool_label("rounded"), do: "Rounded rectangle"
  defp tool_label("circle"), do: "Circle"
  defp tool_label("connector"), do: "Connector"

  defp tool_shortcut("select"), do: "Esc / Ctrl-0"
  defp tool_shortcut("text"), do: "Ctrl-1"
  defp tool_shortcut("rect"), do: "Ctrl-2"
  defp tool_shortcut("rounded"), do: "Ctrl-3"
  defp tool_shortcut("circle"), do: "Ctrl-4"
  defp tool_shortcut("connector"), do: "Ctrl-5"

  ## ── Property panel ───────────────────────────────────────────────────────

  attr :selection, :any, required: true
  attr :element, :map, default: nil
  attr :connector, :map, default: nil
  attr :read_only, :boolean, default: false

  defp property_panel(%{selection: []} = assigns) do
    ~H"""
    <p class="text-sm opacity-70">
      Click an element to edit it. Drag from empty canvas in select mode to lasso a group.
    </p>
    """
  end

  defp property_panel(%{selection: selection} = assigns)
       when length(selection) > 1 do
    assigns =
      assigns
      |> assign(:n_elements, length(selection_element_ids(selection)))
      |> assign(:n_connectors, length(selection_connector_ids(selection)))

    ~H"""
    <div id="multi-selection-panel" class="flex flex-col gap-2">
      <p class="text-sm">
        Selected: <span class="font-mono">{@n_elements}</span>
        elements, <span class="font-mono">{@n_connectors}</span>
        connectors.
      </p>
      <button
        type="button"
        phx-click="delete_selected"
        class="btn btn-error btn-sm"
        disabled={@read_only}
      >
        <.icon name="hero-trash-micro" class="size-4" /> Delete all
      </button>
    </div>
    """
  end

  defp property_panel(%{selection: [{:element, _id}], element: nil} = assigns) do
    ~H"""
    <p class="text-sm opacity-70">Selected element no longer exists.</p>
    """
  end

  defp property_panel(%{selection: [{:element, id}], element: element} = assigns) do
    assigns =
      assign(assigns, :id, id) |> assign(:form, to_form(element_form_params(element), as: :props))

    ~H"""
    <div class="flex items-center justify-between mb-2">
      <span class="font-mono text-xs">{@element["type"]}</span>
      <button
        type="button"
        phx-click="delete_selected"
        class="btn btn-ghost btn-xs text-error"
        disabled={@read_only}
      >
        <.icon name="hero-trash-micro" class="size-4" />
      </button>
    </div>
    <div class="flex items-center justify-between mb-2 text-xs">
      <span class="opacity-60">Order</span>
      <div class="join">
        <div class="tooltip tooltip-bottom" data-tip="Send to back">
          <button
            type="button"
            phx-click="z_order"
            phx-value-action="to_back"
            class="btn btn-ghost btn-xs join-item"
            disabled={@read_only}
            aria-label="Send to back"
          >
            <.icon name="hero-bars-arrow-down-micro" class="size-4" />
          </button>
        </div>
        <div class="tooltip tooltip-bottom" data-tip="Send backward">
          <button
            type="button"
            phx-click="z_order"
            phx-value-action="backward"
            class="btn btn-ghost btn-xs join-item"
            disabled={@read_only}
            aria-label="Send backward"
          >
            <.icon name="hero-arrow-down-micro" class="size-4" />
          </button>
        </div>
        <div class="tooltip tooltip-bottom" data-tip="Bring forward">
          <button
            type="button"
            phx-click="z_order"
            phx-value-action="forward"
            class="btn btn-ghost btn-xs join-item"
            disabled={@read_only}
            aria-label="Bring forward"
          >
            <.icon name="hero-arrow-up-micro" class="size-4" />
          </button>
        </div>
        <div class="tooltip tooltip-bottom" data-tip="Bring to front">
          <button
            type="button"
            phx-click="z_order"
            phx-value-action="to_front"
            class="btn btn-ghost btn-xs join-item"
            disabled={@read_only}
            aria-label="Bring to front"
          >
            <.icon name="hero-bars-arrow-up-micro" class="size-4" />
          </button>
        </div>
      </div>
    </div>
    <.form
      for={@form}
      id={"el-form-" <> @id}
      phx-change="update_selected"
      phx-submit="update_selected"
      class="flex flex-col gap-2"
    >
      <.input field={@form[:label]} type="text" label="Label" disabled={@read_only} />
      <%= if @element["type"] == "circle" do %>
        <div class="grid grid-cols-3 gap-2">
          <.input field={@form[:cx]} type="number" label="cx" step="0.1" disabled={@read_only} />
          <.input field={@form[:cy]} type="number" label="cy" step="0.1" disabled={@read_only} />
          <.input field={@form[:r]} type="number" label="r" step="0.1" disabled={@read_only} />
        </div>
      <% else %>
        <div class="grid grid-cols-2 gap-2">
          <.input field={@form[:x]} type="number" label="x" step="0.1" disabled={@read_only} />
          <.input field={@form[:y]} type="number" label="y" step="0.1" disabled={@read_only} />
          <.input field={@form[:w]} type="number" label="w" step="0.1" disabled={@read_only} />
          <.input field={@form[:h]} type="number" label="h" step="0.1" disabled={@read_only} />
        </div>
      <% end %>
      <%= if @element["type"] != "text" do %>
        <div class="grid grid-cols-2 gap-2">
          <.input
            field={@form[:fill]}
            type="select"
            label="Background"
            options={Diogramos.Themes.color_tokens()}
            disabled={@read_only}
          />
          <.input
            field={@form[:stroke]}
            type="select"
            label="Border"
            options={Diogramos.Themes.color_tokens()}
            disabled={@read_only}
          />
        </div>
        <div class="grid grid-cols-2 gap-2">
          <.input
            field={@form[:dash]}
            type="select"
            label="Border style"
            options={["solid", "dotted", "dashed", "dash-dot"]}
            disabled={@read_only}
          />
          <.input
            field={@form[:stroke_width]}
            type="number"
            label="Border width"
            step="0.5"
            min="0.5"
            disabled={@read_only}
          />
        </div>
        <.input field={@form[:shadow]} type="checkbox" label="Shadow" disabled={@read_only} />
        <div class="grid grid-cols-2 gap-2">
          <.input
            field={@form[:label_position]}
            type="select"
            label="Label position"
            options={[
              {"Top left", "tl"},
              {"Top center", "tc"},
              {"Top right", "tr"},
              {"Middle left", "ml"},
              {"Center", "center"},
              {"Middle right", "mr"},
              {"Bottom left", "bl"},
              {"Bottom center", "bc"},
              {"Bottom right", "br"}
            ]}
            disabled={@read_only}
          />
          <.input
            field={@form[:label_size]}
            type="select"
            label="Label size"
            options={[{"Small", "sm"}, {"Medium", "md"}, {"Large", "lg"}, {"X-large", "xl"}]}
            disabled={@read_only}
          />
        </div>
        <div class="grid grid-cols-2 gap-2">
          <.input
            field={@form[:label_color]}
            type="select"
            label="Label color"
            options={Diogramos.Themes.color_tokens()}
            disabled={@read_only}
          />
          <.input
            field={@form[:label_font_family]}
            type="select"
            label="Label font"
            options={[
              {"Sans-serif", "sans"},
              {"Serif", "serif"},
              {"Monospace", "mono"},
              {"B612", "b612"},
              {"B612 Mono", "b612-mono"}
            ]}
            disabled={@read_only}
          />
        </div>
        <div class="grid grid-cols-2 gap-2">
          <.input
            field={@form[:label_bold]}
            type="checkbox"
            label="Bold label"
            disabled={@read_only}
          />
          <.input
            field={@form[:label_italic]}
            type="checkbox"
            label="Italic label"
            disabled={@read_only}
          />
        </div>
      <% else %>
        <.input
          field={@form[:fill]}
          type="select"
          label="Color"
          options={Diogramos.Themes.color_tokens()}
          disabled={@read_only}
        />
        <.input
          field={@form[:font_family]}
          type="select"
          label="Font"
          options={[
            {"Sans-serif", "sans"},
            {"Serif", "serif"},
            {"Monospace", "mono"},
            {"B612", "b612"},
            {"B612 Mono", "b612-mono"}
          ]}
          disabled={@read_only}
        />
        <.input
          field={@form[:font_size]}
          type="number"
          label="Size (px)"
          min="4"
          max="256"
          step="1"
          disabled={@read_only}
        />
        <div class="grid grid-cols-2 gap-2">
          <.input field={@form[:font_bold]} type="checkbox" label="Bold" disabled={@read_only} />
          <.input field={@form[:font_italic]} type="checkbox" label="Italic" disabled={@read_only} />
        </div>
      <% end %>

      <fieldset class="border-t border-base-300 pt-2 mt-2 flex flex-col gap-2">
        <legend class="font-mono text-[10px] uppercase tracking-wider text-accent px-1 flex items-center gap-2">
          <span>Links</span>
          <button
            type="button"
            phx-click="add_link"
            phx-value-element_id={@id}
            class="btn btn-ghost btn-xs"
            disabled={@read_only}
            title="Add another link"
          >
            <.icon name="hero-plus-micro" class="size-3" /> Add
          </button>
        </legend>
        <%= for {link, idx} <- Enum.with_index(Map.get(@form.params, "links", [])) do %>
          <.link_form_row
            link={link}
            index={idx}
            element_id={@id}
            read_only={@read_only}
          />
        <% end %>
      </fieldset>
    </.form>
    """
  end

  defp property_panel(%{selection: [{:connector, _id}], connector: nil} = assigns) do
    ~H"""
    <p class="text-sm opacity-70">Selected connector no longer exists.</p>
    """
  end

  defp property_panel(%{selection: [{:connector, id}], connector: connector} = assigns) do
    assigns =
      assigns
      |> assign(:id, id)
      |> assign(:form, to_form(connector_form_params(connector), as: :props))

    ~H"""
    <div class="flex items-center justify-between mb-2">
      <span class="font-mono text-xs">connector</span>
      <button
        type="button"
        phx-click="delete_selected"
        class="btn btn-ghost btn-xs text-error"
        disabled={@read_only}
      >
        <.icon name="hero-trash-micro" class="size-4" />
      </button>
    </div>
    <.form
      for={@form}
      id={"con-form-" <> @id}
      phx-change="update_selected"
      phx-submit="update_selected"
      class="flex flex-col gap-2"
    >
      <.input field={@form[:label]} type="text" label="Label" disabled={@read_only} />
      <.input
        field={@form[:routing]}
        type="select"
        label="Routing"
        options={["straight", "orthogonal", "curve"]}
        disabled={@read_only}
      />
      <.input
        field={@form[:dash]}
        type="select"
        label="Tick pattern"
        options={["solid", "dotted", "dashed", "dash-dot"]}
        disabled={@read_only}
      />
      <.input
        field={@form[:color]}
        type="select"
        label="Color"
        options={Diogramos.Themes.color_tokens()}
        disabled={@read_only}
      />
      <div class="grid grid-cols-2 gap-2">
        <.input
          field={@form[:marker_start]}
          type="select"
          label="Start"
          options={["none", "arrow", "filled-arrow", "diamond", "circle"]}
          disabled={@read_only}
        />
        <.input
          field={@form[:marker_end]}
          type="select"
          label="End"
          options={["none", "arrow", "filled-arrow", "diamond", "circle"]}
          disabled={@read_only}
        />
      </div>
      <div class="grid grid-cols-3 gap-2">
        <.input
          field={@form[:start_gap]}
          type="number"
          label="Start gap"
          step="0.5"
          min="0"
          disabled={@read_only}
        />
        <.input
          field={@form[:end_gap]}
          type="number"
          label="End gap"
          step="0.5"
          min="0"
          disabled={@read_only}
        />
        <.input
          field={@form[:stroke_width]}
          type="number"
          label="Width"
          step="0.5"
          min="0.5"
          disabled={@read_only}
        />
      </div>
    </.form>
    """
  end

  defp element_form_params(%{"type" => "circle"} = e) do
    %{
      "label" => e["label"] || "",
      "cx" => e["cx"],
      "cy" => e["cy"],
      "r" => e["r"]
    }
    |> with_style_fields(e)
  end

  defp element_form_params(e) do
    %{
      "label" => e["label"] || "",
      "x" => e["x"],
      "y" => e["y"],
      "w" => e["w"],
      "h" => e["h"]
    }
    |> with_style_fields(e)
  end

  defp with_style_fields(params, %{"type" => "text"} = e) do
    params
    |> Map.put("fill", get_in(e, ["style", "fill"]) || "base-content")
    |> Map.put("font_family", get_in(e, ["style", "font_family"]) || "sans")
    |> Map.put("font_size", get_in(e, ["style", "font_size"]) || 14)
    |> Map.put("font_bold", get_in(e, ["style", "font_bold"]) == true)
    |> Map.put("font_italic", get_in(e, ["style", "font_italic"]) == true)
    |> with_link_fields(e)
  end

  defp with_style_fields(params, e) do
    params
    |> Map.put("fill", get_in(e, ["style", "fill"]) || "base-200")
    |> Map.put("stroke", get_in(e, ["style", "stroke"]) || "base-content")
    |> Map.put("dash", get_in(e, ["style", "dash"]) || "solid")
    |> Map.put("stroke_width", get_in(e, ["style", "stroke_width"]) || 1.5)
    |> Map.put("shadow", get_in(e, ["style", "shadow"]) == true)
    |> Map.put(
      "label_position",
      get_in(e, ["style", "label_position"]) ||
        if(e["type"] == "circle", do: "center", else: "tl")
    )
    |> Map.put("label_size", get_in(e, ["style", "label_size"]) || "md")
    |> Map.put("label_color", get_in(e, ["style", "label_color"]) || "base-content")
    |> Map.put("label_font_family", get_in(e, ["style", "label_font_family"]) || "sans")
    |> Map.put("label_bold", get_in(e, ["style", "label_bold"]) == true)
    |> Map.put("label_italic", get_in(e, ["style", "label_italic"]) == true)
    |> with_link_fields(e)
  end

  defp with_link_fields(params, e) do
    links =
      case e["links"] do
        list when is_list(list) -> list
        _ -> []
      end

    Map.put(
      params,
      "links",
      Enum.map(links, fn link ->
        %{
          "enabled" => link["enabled"] == true,
          "kind" => link["kind"] || "external",
          "target" => link["target"] || "",
          "icon" => link["icon"] || "link"
        }
      end)
    )
  end

  defp connector_form_params(c) do
    %{
      "label" => c["label"] || "",
      "routing" => c["routing"] || "orthogonal",
      "dash" => c["dash"] || "solid",
      "marker_start" => c["marker_start"] || "none",
      "marker_end" => c["marker_end"] || "arrow",
      "start_gap" => c["start_gap"] || 6,
      "end_gap" => c["end_gap"] || 6,
      "stroke_width" => c["stroke_width"] || 2,
      "color" => c["color"] || "base-content"
    }
  end

  defp style_clipboard_compatible?(nil, _), do: false
  defp style_clipboard_compatible?(_, nil), do: false
  defp style_clipboard_compatible?({:element, _}, {:element, _}), do: true
  defp style_clipboard_compatible?({:connector, _}, {:connector, _}), do: true
  defp style_clipboard_compatible?(_, _), do: false

  attr :link, :map, required: true
  attr :index, :integer, required: true
  attr :element_id, :string, required: true
  attr :read_only, :boolean, default: false

  defp link_form_row(assigns) do
    base = "props[links][#{assigns.index}]"

    assigns =
      assigns
      |> assign(:base, base)
      |> assign(:icon_options, [
        {"Link", "link"},
        {"Open in new tab", "arrow-top-right-on-square"},
        {"Document", "document-text"},
        {"Bookmark", "bookmark"},
        {"Globe", "globe-alt"},
        {"Info", "information-circle"}
      ])
      |> assign(:kind_options, [{"External URL", "external"}, {"Canvas slug", "canvas"}])

    ~H"""
    <div class="border border-base-300 rounded-box p-2 flex flex-col gap-2">
      <div class="flex items-center justify-between">
        <span class="font-mono text-[10px] opacity-60">link {@index + 1}</span>
        <button
          type="button"
          phx-click="remove_link"
          phx-value-element_id={@element_id}
          phx-value-index={@index}
          class="btn btn-ghost btn-xs text-error"
          disabled={@read_only}
          title="Remove link"
        >
          <.icon name="hero-x-mark-micro" class="size-3" />
        </button>
      </div>

      <label class="flex items-center gap-2 text-sm">
        <input type="hidden" name={@base <> "[enabled]"} value="false" />
        <input
          type="checkbox"
          name={@base <> "[enabled]"}
          value="true"
          checked={@link["enabled"] == true}
          disabled={@read_only}
          class="checkbox checkbox-xs"
        /> Show icon
      </label>

      <label class="flex flex-col gap-1 text-xs">
        <span class="opacity-70">Kind</span>
        <select
          name={@base <> "[kind]"}
          disabled={@read_only}
          class="select select-bordered select-sm"
        >
          <option
            :for={{label, value} <- @kind_options}
            value={value}
            selected={@link["kind"] == value}
          >{label}</option>
        </select>
      </label>

      <label class="flex flex-col gap-1 text-xs">
        <span class="opacity-70">Target</span>
        <input
          type="text"
          name={@base <> "[target]"}
          value={@link["target"] || ""}
          placeholder="https://… or canvas-slug"
          disabled={@read_only}
          class="input input-bordered input-sm"
        />
      </label>

      <label class="flex flex-col gap-1 text-xs">
        <span class="opacity-70">Icon</span>
        <select
          name={@base <> "[icon]"}
          disabled={@read_only}
          class="select select-bordered select-sm"
        >
          <option
            :for={{label, value} <- @icon_options}
            value={value}
            selected={@link["icon"] == value}
          >{label}</option>
        </select>
      </label>
    </div>
    """
  end

  attr :clipboard, :any, default: nil

  defp style_clipboard_preview(%{clipboard: nil} = assigns) do
    ~H"""
    <div
      id="clipboard-preview"
      class="text-xs opacity-50 mb-3 italic"
    >
      Clipboard empty. Copy a style to apply it to new shapes.
    </div>
    """
  end

  defp style_clipboard_preview(%{clipboard: {:element, style}} = assigns) do
    assigns = assign(assigns, :style, style)

    ~H"""
    <div
      id="clipboard-preview"
      class="flex items-center gap-2 text-xs mb-3 px-2 py-1 rounded border border-base-300 bg-base-100"
    >
      <span class="opacity-60">clipboard</span>
      <span class="flex items-center gap-1">
        <span
          class="size-3 rounded border border-base-content/20 bg-transparent bg-[repeating-linear-gradient(45deg,_var(--color-base-content)_0_2px,_transparent_2px_4px)]"
          style={"background: " <> token_var(Map.get(@style, "fill", "base-200"))}
          title={"fill: " <> Map.get(@style, "fill", "base-200")}
        />
        <span
          class="size-3 rounded border-2"
          style={"border-color: " <> token_var(Map.get(@style, "stroke", "base-content"))}
          title={"border: " <> Map.get(@style, "stroke", "base-content")}
        />
      </span>
      <%= if @style["shadow"] do %>
        <.icon name="hero-cube-transparent-micro" class="size-3 text-accent" />
      <% end %>
      <span class="ml-auto opacity-50 font-mono">element</span>
    </div>
    """
  end

  defp style_clipboard_preview(%{clipboard: {:connector, style}} = assigns) do
    assigns = assign(assigns, :style, style)

    ~H"""
    <div
      id="clipboard-preview"
      class="flex items-center gap-2 text-xs mb-3 px-2 py-1 rounded border border-base-300 bg-base-100"
    >
      <span class="opacity-60">clipboard</span>
      <svg width="44" height="10" viewBox="0 0 44 10" class="overflow-visible">
        <line
          x1="0"
          y1="5"
          x2="44"
          y2="5"
          stroke-width={Map.get(@style, "stroke_width", 2)}
          stroke-dasharray={dash_preview(Map.get(@style, "dash", "solid"))}
          style={"stroke: " <> token_var(Map.get(@style, "color", "base-content"))}
        />
      </svg>
      <span class="ml-auto opacity-50 font-mono">{@style["routing"] || "orthogonal"}</span>
    </div>
    """
  end

  defp dash_preview("solid"), do: nil
  defp dash_preview("dotted"), do: "2 4"
  defp dash_preview("dashed"), do: "8 6"
  defp dash_preview("dash-dot"), do: "8 4 2 4"
  defp dash_preview(_), do: nil
end