Packages

phoenix_kit

2.60.2
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components image_editor.ex
Raw

lib/phoenix_kit_web/components/image_editor.ex

defmodule PhoenixKitWeb.Components.ImageEditor do
  @moduledoc """
  Edits a stored image after upload — crop, rotate, flip, straighten,
  redact, brightness and contrast — through
  `PhoenixKit.Modules.Storage.ImageEditing`.

  Everything is a server-rendered form, so the editor works with nothing
  but LiveView: numeric crop and region fields, buttons for the quarter
  turns, sliders for the rest. The `ImageEditor` hook adds drawing the crop
  and the redaction areas on the preview. The preview is CSS over the
  unedited original, so it is close to the result, not exact — tones in
  particular; the saved image is rendered by ImageMagick.

  ## Usage

      <.live_component
        module={PhoenixKitWeb.Components.ImageEditor}
        id={"image-editor-" <> @file.uuid}
        file={@file}
        scope={@phoenix_kit_current_scope}
        on_close={JS.push("close_image_editor")}
      />

  ## Assigns

    * `:file` (required) — the `PhoenixKit.Modules.Storage.File`.
    * `:scope` — who is editing. `ImageEditing` decides whether they may
      (the owner, an Owner/Admin, a `"media"` permission holder).
    * `:authorized` (default `false`) — `true` when the host has already
      decided this user may manage the file, and `scope` is only who they
      are. `MediaBrowser` passes it for files inside its folder scope, as
      it does for rotating and deleting them.
    * `:on_close` — a `Phoenix.LiveView.JS` run by the close button.
      Without it there is no close button.

  ## Keeping it current

  Saving queues the rendering; the editor shows the progress until the
  file is processed. The host forwards the storage file events (see
  `PhoenixKit.Modules.Storage.subscribe_to_file_events/0`):

      send_update(PhoenixKitWeb.Components.ImageEditor,
        id: editor_id,
        file_processed: file_uuid
      )
  """
  use PhoenixKitWeb, :live_component

  require Logger

  alias PhoenixKit.Modules.Storage
  alias PhoenixKit.Modules.Storage.ImageEdit
  alias PhoenixKit.Modules.Storage.ImageEditing
  alias PhoenixKit.Modules.Storage.Libraries
  alias PhoenixKit.Modules.Storage.URLSigner
  alias PhoenixKit.Users.Auth.Scope
  alias PhoenixKitWeb.FileController

  @styles ~w(blur pixelate fill)
  @aspects [
    {"free", nil},
    {"1:1", {1, 1}},
    {"4:3", {4, 3}},
    {"3:2", {3, 2}},
    {"16:9", {16, 9}},
    {"3:4", {3, 4}},
    {"9:16", {9, 16}}
  ]
  # Seconds a render may take before the editor offers to start it again.
  @stalled_after 120
  # As many areas as `ImageEdit.normalize/1` accepts.
  @max_regions 50
  # Where "Add area" puts a new region, in percent of the frame.
  @new_region %{"x" => 40.0, "y" => 40.0, "w" => 20.0, "h" => 20.0, "style" => "blur"}

  @impl true
  def mount(socket) do
    {:ok,
     socket
     |> assign(:authorized, false)
     |> assign(:scope, nil)
     |> assign(:on_close, nil)
     |> assign(:tool, "crop")
     |> assign(:aspect, "free")
     |> assign(:error, nil)
     |> assign(:notice, nil)
     |> assign(:confirm, nil)}
  end

  @impl true
  def update(%{file_processed: uuid}, %{assigns: %{file: %{uuid: uuid}}} = socket) do
    {:ok, reload(socket)}
  end

  def update(%{file_processed: _other}, socket), do: {:ok, socket}

  # See `watch_stall/1`.
  def update(%{stall_check: true}, socket), do: {:ok, assign(socket, :now, DateTime.utc_now())}

  def update(assigns, socket) do
    previous = socket.assigns[:file]
    socket = assign(socket, Map.take(assigns, [:id, :scope, :authorized, :on_close]))

    cond do
      is_nil(previous) -> {:ok, load(socket, assigns.file)}
      file_changed?(previous, assigns.file) -> {:ok, load(socket, assigns.file, keep_draft: true)}
      true -> {:ok, socket}
    end
  end

  defp file_changed?(a, b) do
    Map.take(a, [:uuid, :edit_revision, :edit_state, :file_checksum, :original_file_uuid]) !=
      Map.take(b, [:uuid, :edit_revision, :edit_state, :file_checksum, :original_file_uuid])
  end

  # ──────────────────────────────────────────────────────────────
  # State
  # ──────────────────────────────────────────────────────────────

  defp reload(socket) do
    case Storage.get_file(socket.assigns.file.uuid) do
      nil -> assign(socket, :file, nil)
      file -> load(socket, file, keep_draft: true)
    end
  end

  # `keep_draft`: a finished render must not throw away what the user has
  # been changing meanwhile — unless the draft is the edit that just
  # finished, which it then simply stays.
  defp load(socket, file, opts \\ []) do
    draft =
      if Keyword.get(opts, :keep_draft, false) and Map.has_key?(socket.assigns, :draft),
        do: socket.assigns.draft,
        else: file.edits || %{}

    edited? = ImageEditing.edited?(file)

    socket
    |> assign(:file, file)
    |> assign(:now, DateTime.utc_now())
    |> watch_stall()
    |> assign(:draft, draft)
    |> assign(:size, source_size(file))
    |> assign(:annotations, ImageEditing.annotation_count(file))
    |> assign(:preview_url, preview_url(socket, file))
    |> assign(:unedited_url, if(edited?, do: unedited_url(socket, file)))
  end

  # A pending edit offers "start again" once it has taken long; re-render
  # then even if nothing else happens.
  defp watch_stall(
         %{assigns: %{file: %{edit_state: "pending", updated_at: %DateTime{} = at}}} = socket
       ) do
    if connected?(socket) do
      due =
        max(
          DateTime.diff(DateTime.add(at, @stalled_after + 1), DateTime.utc_now(), :millisecond),
          0
        )

      send_update_after(__MODULE__, %{id: socket.assigns.id, stall_check: true}, due)
    end

    socket
  end

  defp watch_stall(socket), do: socket

  # The unedited original's dimensions (what the edit is applied to).
  defp source_size(file) do
    source = if ImageEditing.edited?(file), do: ImageEditing.backup(file), else: file

    case source do
      %{width: w, height: h} when is_integer(w) and is_integer(h) and w > 0 and h > 0 -> {w, h}
      _ -> nil
    end
  end

  defp preview_url(socket, file) do
    if ImageEditing.edited?(file) do
      unedited_url(socket, file, "large")
    else
      instance =
        Storage.get_file_instance_by_name(file.uuid, "large") ||
          Storage.get_file_instance_by_name(file.uuid, "original")

      instance &&
        URLSigner.signed_url(file.uuid, instance.variant_name,
          version: instance,
          private: Libraries.private_file?(file)
        )
    end
  end

  defp unedited_url(socket, file, variant \\ nil) do
    FileController.unedited_url(socket, file.uuid, user_uuid(socket), variant: variant)
  end

  defp user_uuid(socket), do: socket.assigns.scope && Scope.user_uuid(socket.assigns.scope)

  defp auth_opts(%{assigns: %{authorized: true}}), do: [system: true]
  defp auth_opts(socket), do: [scope: socket.assigns.scope]

  defp allowed?(socket), do: ImageEditing.can_edit?(socket.assigns.file, auth_opts(socket))

  # ──────────────────────────────────────────────────────────────
  # Events
  # ──────────────────────────────────────────────────────────────

  @impl true
  def handle_event("change", %{"edit" => params} = all, socket) do
    socket =
      socket
      |> assign(:draft, merge_draft(socket.assigns.draft, params))
      |> assign(:tool, pick(all["tool"], ~w(crop redact), socket.assigns.tool))
      |> assign(:error, nil)

    {:noreply, socket}
  end

  def handle_event("change", _params, socket), do: {:noreply, socket}

  # Turning and mirroring keep the crop and the areas on their pixels
  # (`ImageEdit.turn/2`, `ImageEdit.mirror/2`).
  def handle_event("turn", %{"to" => to}, socket) when to in ~w(left right) do
    draft = ImageEdit.turn(socket.assigns.draft, String.to_existing_atom(to))
    {:noreply, socket |> assign(:draft, draft) |> assign(:aspect, "free")}
  end

  def handle_event("mirror", %{"axis" => axis}, socket) when axis in ~w(horizontal vertical) do
    draft = ImageEdit.mirror(socket.assigns.draft, String.to_existing_atom(axis))
    {:noreply, assign(socket, :draft, draft)}
  end

  def handle_event("aspect", %{"aspect" => aspect}, socket) do
    case {List.keyfind(@aspects, aspect, 0), socket.assigns.size} do
      {{_, {aw, ah}}, {_, _} = size} ->
        crop = ImageEdit.centred_crop(socket.assigns.draft, {aw, ah}, size)

        {:noreply,
         socket
         |> assign(:aspect, aspect)
         |> assign(:draft, Map.put(socket.assigns.draft, "crop", crop))}

      {{_, nil}, _} ->
        {:noreply, assign(socket, :aspect, aspect)}

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event("clear_crop", _params, socket) do
    {:noreply,
     socket
     |> assign(:aspect, "free")
     |> assign(:draft, Map.delete(socket.assigns.draft, "crop"))}
  end

  # From the hook: the preview image is the recorded size turned a quarter
  # (an upload with an EXIF rotation, recorded before dimensions were read
  # oriented). The frame follows what is displayed — and rendered.
  def handle_event("turned_source", _params, socket) do
    case socket.assigns.size do
      {w, h} -> {:noreply, assign(socket, :size, {h, w})}
      nil -> {:noreply, socket}
    end
  end

  # From the hook: a rectangle drawn on the preview, in percent of the frame.
  def handle_event("drawn", %{"tool" => tool} = rect, socket) do
    case drawn_rect(rect) do
      {:ok, rect} -> {:noreply, assign(socket, :draft, add_drawn(socket, tool, rect))}
      :error -> {:noreply, socket}
    end
  end

  def handle_event("add_region", _params, socket) do
    regions = regions(socket.assigns.draft)

    if length(regions) < @max_regions do
      region = Map.put(@new_region, "style", last_style(socket))

      {:noreply,
       assign(socket, :draft, Map.put(socket.assigns.draft, "redact", regions ++ [region]))}
    else
      {:noreply, socket}
    end
  end

  def handle_event("remove_region", %{"index" => index}, socket) do
    case Integer.parse(to_string(index)) do
      {i, ""} when i >= 0 ->
        regions = List.delete_at(regions(socket.assigns.draft), i)
        {:noreply, assign(socket, :draft, Map.put(socket.assigns.draft, "redact", regions))}

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event("reset", _params, socket) do
    {:noreply,
     socket
     |> assign(:draft, socket.assigns.file.edits || %{})
     |> assign(:aspect, "free")
     |> assign(:error, nil)}
  end

  def handle_event("save", params, socket) do
    socket = assign(socket, :draft, merge_draft(socket.assigns.draft, params["edit"] || %{}))
    file = socket.assigns.file
    draft = socket.assigns.draft

    case params["intent"] do
      "copy" ->
        case ImageEditing.save_copy(file, draft, auth_opts(socket)) do
          {:ok, _job} ->
            {:noreply,
             assign(
               socket,
               :notice,
               gettext("A copy is being made. It will appear next to the original.")
             )}

          {:error, reason} ->
            {:noreply, assign(socket, :error, error_message(reason))}
        end

      _save ->
        case ImageEditing.edit(file, draft, auth_opts(socket)) do
          {:ok, file} ->
            {:noreply,
             socket
             |> load(file, keep_draft: true)
             |> assign(:notice, nil)
             |> assign(:error, nil)}

          {:error, reason} ->
            {:noreply, socket |> assign(:error, error_message(reason)) |> reload()}
        end
    end
  end

  def handle_event("retry", _params, socket) do
    result = ImageEditing.retry(socket.assigns.file, auth_opts(socket))
    {:noreply, after_action(socket, result)}
  end

  def handle_event("confirm", %{"action" => action}, socket)
      when action in ~w(revert delete_unedited) do
    {:noreply, assign(socket, :confirm, action)}
  end

  def handle_event("cancel_confirm", _params, socket),
    do: {:noreply, assign(socket, :confirm, nil)}

  def handle_event("revert", _params, socket) do
    result = ImageEditing.revert(socket.assigns.file, auth_opts(socket))

    socket
    |> assign(:confirm, nil)
    |> after_action(result)
    |> then(fn socket -> {:noreply, assign(socket, :draft, %{})} end)
  end

  def handle_event("delete_unedited", _params, socket) do
    result = ImageEditing.delete_unedited_original(socket.assigns.file, auth_opts(socket))

    socket
    |> assign(:confirm, nil)
    |> after_action(result)
    |> then(&{:noreply, &1})
  end

  defp after_action(socket, {:ok, file}),
    do: socket |> load(file) |> assign(:error, nil)

  defp after_action(socket, {:error, reason}),
    do: socket |> assign(:error, error_message(reason)) |> reload()

  # ──────────────────────────────────────────────────────────────
  # Draft helpers
  # ──────────────────────────────────────────────────────────────

  # The form carries the fields; the quarter turn and the mirrors are kept
  # from the draft (only their buttons change them, together with the
  # rectangles).
  defp merge_draft(draft, params) do
    params =
      params
      |> Map.merge(Map.take(draft, ~w(rotate flip_h flip_v)))
      |> Map.update("crop", nil, &blank_crop/1)
      |> keep_unreadable_rects(draft)

    case ImageEdit.normalize(params) do
      {:ok, nil} -> %{}
      {:ok, edit} -> edit
      {:error, _} -> draft
    end
  end

  # A number field is EMPTY for a moment while it is being retyped, and the
  # form reports every keystroke. `ImageEdit.normalize/1` drops a rectangle it
  # cannot read — right for a saved edit, wrong here: backspacing one area's
  # width deleted the area, the rows below moved up under the cursor, and the
  # next keystrokes resized a different area of a destructive redaction. An
  # area is removed by its remove button only; a rectangle that does not read
  # right now keeps the one the draft already has.
  defp keep_unreadable_rects(params, draft) do
    params
    |> Map.update("redact", nil, &keep_regions(&1, regions(draft)))
    |> Map.update("crop", nil, fn crop ->
      if is_map(crop) and not readable_rect?(crop), do: Map.get(draft, "crop"), else: crop
    end)
  end

  # Forms send the list as a map keyed by position.
  defp keep_regions(%{} = incoming, previous) do
    incoming
    |> Enum.sort_by(fn {key, _} -> position(key) end)
    |> Enum.map(&elem(&1, 1))
    |> keep_regions(previous)
  end

  defp keep_regions(incoming, previous) when is_list(incoming) do
    incoming
    |> Enum.with_index()
    |> Enum.map(fn {region, index} ->
      with false <- readable_rect?(region),
           %{} = kept <- Enum.at(previous, index) do
        style = if is_map(region), do: region["style"]
        if style, do: Map.put(kept, "style", style), else: kept
      else
        _ -> region
      end
    end)
  end

  defp keep_regions(other, _previous), do: other

  defp position(key) do
    case Integer.parse(to_string(key)) do
      {n, ""} -> n
      _ -> 0
    end
  end

  defp readable_rect?(%{} = rect), do: match?({:ok, _}, drawn_rect(rect))
  defp readable_rect?(_), do: false

  # An untouched crop (the whole frame) is no crop.
  defp blank_crop(%{} = crop) do
    if Enum.all?(~w(x y w h), &(to_string(crop[&1] || "") == "")), do: nil, else: crop
  end

  defp blank_crop(other), do: other

  defp regions(draft), do: Map.get(draft, "redact", [])

  defp last_style(socket) do
    case List.last(regions(socket.assigns.draft)) do
      %{"style" => style} -> style
      _ -> "blur"
    end
  end

  defp add_drawn(socket, "redact", rect) do
    regions = regions(socket.assigns.draft) ++ [Map.put(rect, "style", last_style(socket))]
    Map.put(socket.assigns.draft, "redact", Enum.take(regions, @max_regions))
  end

  defp add_drawn(socket, _crop, rect), do: Map.put(socket.assigns.draft, "crop", rect)

  defp drawn_rect(params) do
    case ImageEdit.normalize(%{"redact" => [Map.put(params, "style", "fill")]}) do
      {:ok, %{"redact" => [rect]}} -> {:ok, Map.delete(rect, "style")}
      _ -> :error
    end
  end

  defp frame(socket) do
    case socket.assigns.size do
      nil -> nil
      size -> ImageEdit.frame_size(socket.assigns.draft, size)
    end
  end

  defp pick(value, allowed, default), do: if(value in allowed, do: value, else: default)

  defp error_message(:forbidden), do: gettext("You can't edit this image.")
  defp error_message(:not_editable), do: gettext("This file can't be edited.")
  defp error_message(:not_found), do: gettext("This file no longer exists.")

  defp error_message(:not_queued),
    do: gettext("Saved, but the image could not be queued for rendering. Try again.")

  defp error_message(:no_edit),
    do: gettext("Nothing to save: the copy would be the same as the original.")

  defp error_message(:not_edited), do: gettext("This image has no unedited original.")
  defp error_message(:nothing_to_retry), do: gettext("There is nothing to retry.")

  defp error_message(:edit_changed),
    do: gettext("The image changed meanwhile. Check it and try again.")

  defp error_message(:edit_in_progress),
    do: gettext("Wait until the image has finished rendering.")

  defp error_message({:annotated, count}),
    do:
      ngettext(
        "This image has an annotation. Cropping, turning, flipping or straightening it would move the annotation off its place. Save a copy instead.",
        "This image has %{count} annotations. Cropping, turning, flipping or straightening it would move them off their places. Save a copy instead.",
        count
      )

  defp error_message({:invalid_edit, _}), do: gettext("Some of the values are not valid.")

  defp error_message(reason) do
    Logger.warning("ImageEditor: #{inspect(reason)}")
    gettext("Something went wrong. Try again.")
  end

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

  @impl true
  def render(%{file: nil} = assigns) do
    ~H"""
    <div id={@id} class="p-6 text-center text-base-content/70">
      {gettext("This file no longer exists.")}
    </div>
    """
  end

  def render(assigns) do
    assigns =
      assigns
      |> assign(:allowed, allowed?(%{assigns: assigns}))
      |> assign(:editable, ImageEditing.editable?(assigns.file))
      |> assign(:busy, ImageEditing.edit_in_progress?(assigns.file))
      |> assign(:failed, assigns.file.edit_state == "failed")
      |> assign(:edited, ImageEditing.edited?(assigns.file))
      |> assign(:frame, frame(%{assigns: assigns}))
      |> assign(:geometry_locked, assigns.annotations > 0)
      |> assign(:replace_mode, ImageEditing.mode() == "replace_original")
      |> assign(:dirty, assigns.draft != (assigns.file.edits || %{}))
      |> assign(:styles, @styles)
      |> assign(:aspects, Enum.map(@aspects, &elem(&1, 0)))

    ~H"""
    <div id={@id} class="flex flex-col lg:flex-row gap-4 min-h-0 h-full">
      <%!-- Preview --%>
      <div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
        <div
          id={"#{@id}-stage"}
          class="relative flex-1 min-h-[16rem] bg-base-200 rounded-lg grid place-items-center overflow-hidden p-4"
        >
          <%= if @preview_url && @frame do %>
            <% {fw, fh} = @frame %>
            <div
              id={"#{@id}-frame"}
              phx-hook="ImageEditor"
              phx-target={@myself}
              data-tool={@tool}
              data-aspect={aspect_ratio(@aspect)}
              data-frame-width={fw}
              data-frame-height={fh}
              data-source-width={elem(@size, 0)}
              data-source-height={elem(@size, 1)}
              data-locked={to_string(@geometry_locked and @tool == "crop")}
              class={[
                "relative max-w-full max-h-full overflow-hidden touch-none select-none",
                @tool == "crop" && !@geometry_locked && "cursor-crosshair",
                @tool == "redact" && "cursor-cell"
              ]}
              style={"aspect-ratio: #{fw} / #{fh}; width: min(100%, calc(65vh * #{fw} / #{fh}));"}
            >
              <img
                src={@preview_url}
                alt=""
                draggable="false"
                class="absolute left-1/2 top-1/2 max-w-none pointer-events-none"
                style={image_style(@draft, @size, @frame)}
              />
              <%!-- Redaction areas --%>
              <div
                :for={{region, index} <- Enum.with_index(Map.get(@draft, "redact", []))}
                class={["absolute", region_class(region["style"])]}
                style={rect_style(region)}
                data-region={index}
              >
                <span class="absolute -top-5 left-0 badge badge-xs badge-neutral">{index + 1}</span>
              </div>
              <%!-- Crop: the parts that go are shaded --%>
              <%= if crop = @draft["crop"] do %>
                <div class="absolute inset-0 pointer-events-none" style={shade_style(crop)}></div>
                <div
                  class="absolute border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.6)] pointer-events-none"
                  style={rect_style(crop)}
                >
                  <div class="absolute inset-0 grid grid-cols-3 grid-rows-3">
                    <div :for={_ <- 1..9} class="border border-white/30"></div>
                  </div>
                </div>
              <% end %>
              <div
                id={"#{@id}-draft"}
                phx-update="ignore"
                data-draft
                class="absolute hidden border-2 border-dashed border-primary bg-primary/10 pointer-events-none"
              >
              </div>
            </div>
          <% else %>
            <p :if={is_nil(@frame)} class="text-sm text-base-content/60">
              {gettext("The image has no dimensions yet. Try again once it has been processed.")}
            </p>
            <p :if={@frame} class="text-sm text-base-content/60">
              {gettext("No preview is available. The fields still work.")}
            </p>
          <% end %>

          <%= if @busy do %>
            <div class="absolute inset-0 bg-base-100/70 grid place-items-center">
              <div class="text-center space-y-2">
                <%= if @failed do %>
                  <.icon name="hero-exclamation-triangle" class="w-8 h-8 text-error mx-auto" />
                  <p class="font-semibold">{gettext("The edit could not be applied.")}</p>
                  <p class="text-sm text-base-content/70 max-w-xs">
                    {gettext("Until it is, the image shows a placeholder everywhere.")}
                  </p>
                  <button
                    :if={@allowed}
                    type="button"
                    phx-click="retry"
                    phx-target={@myself}
                    class="btn btn-sm btn-primary"
                  >
                    <.icon name="hero-arrow-path" class="w-4 h-4" /> {gettext("Try again")}
                  </button>
                <% else %>
                  <span class="loading loading-spinner loading-md"></span>
                  <p class="font-semibold">{gettext("Applying the edit…")}</p>
                  <p class="text-sm text-base-content/70 max-w-xs">
                    {gettext("The image shows a placeholder until it is done.")}
                  </p>
                  <%!-- A run that died without a trace (a timeout, a node
                       going away) leaves nothing to finish the edit. --%>
                  <button
                    :if={@allowed and stalled?(@file, @now)}
                    type="button"
                    phx-click="retry"
                    phx-target={@myself}
                    class="btn btn-sm btn-ghost"
                  >
                    <.icon name="hero-arrow-path" class="w-4 h-4" /> {gettext(
                      "Taking long? Start again"
                    )}
                  </button>
                <% end %>
              </div>
            </div>
          <% end %>
        </div>
        <p class="text-xs text-base-content/60">
          {gettext("The preview is approximate; colours and blur are exact once saved.")}
        </p>
      </div>

      <%!-- Controls --%>
      <div class="w-full lg:w-80 lg:flex-none min-w-0 flex flex-col gap-3 overflow-y-auto">
        <div class="flex items-center gap-2">
          <h3 class="font-semibold flex-1 truncate">{gettext("Edit image")}</h3>
          <button
            :if={@on_close}
            type="button"
            phx-click={@on_close}
            class="btn btn-ghost btn-sm btn-circle"
            aria-label={gettext("Close")}
          >
            <.icon name="hero-x-mark" class="w-5 h-5" />
          </button>
        </div>

        <div :if={@error} role="alert" class="alert alert-error text-sm py-2">{@error}</div>
        <div :if={@notice} role="status" class="alert alert-info text-sm py-2">{@notice}</div>

        <%= cond do %>
          <% not @editable -> %>
            <p class="text-sm text-base-content/70">
              {gettext("Only still JPEG, PNG, WebP, AVIF, TIFF and BMP images can be edited.")}
            </p>
          <% not @allowed -> %>
            <p class="text-sm text-base-content/70">{gettext("You can't edit this image.")}</p>
          <% true -> %>
            <.form
              for={%{}}
              as={:edit}
              id={"#{@id}-form"}
              phx-change="change"
              phx-submit="save"
              phx-target={@myself}
              class="flex flex-col gap-4"
            >
              <fieldset disabled={@busy and not @failed} class="flex flex-col gap-4">
                <div role="tablist" class="tabs tabs-box tabs-sm">
                  <label
                    :for={
                      {value, label} <- [{"crop", gettext("Crop")}, {"redact", gettext("Hide areas")}]
                    }
                    class={["tab", @tool == value && "tab-active"]}
                  >
                    <input
                      type="radio"
                      name="tool"
                      value={value}
                      checked={@tool == value}
                      class="sr-only"
                    />
                    {label}
                  </label>
                </div>

                <div :if={@geometry_locked} class="alert alert-warning text-xs py-2">
                  {ngettext(
                    "This image has an annotation, so it can't be cropped, turned, flipped or straightened (the annotation would end up in the wrong place). Save a copy for that.",
                    "This image has %{count} annotations, so it can't be cropped, turned, flipped or straightened (they would end up in the wrong place). Save a copy for that.",
                    @annotations
                  )}
                </div>

                <%!-- Geometry --%>
                <section :if={@tool == "crop" and not @geometry_locked} class="flex flex-col gap-3">
                  <div class="flex flex-wrap gap-1">
                    <button
                      type="button"
                      phx-click="turn"
                      phx-value-to="left"
                      phx-target={@myself}
                      class="btn btn-sm btn-square"
                      title={gettext("Turn left")}
                      aria-label={gettext("Turn left")}
                    >
                      <.icon name="hero-arrow-uturn-left" class="w-4 h-4" />
                    </button>
                    <button
                      type="button"
                      phx-click="turn"
                      phx-value-to="right"
                      phx-target={@myself}
                      class="btn btn-sm btn-square"
                      title={gettext("Turn right")}
                      aria-label={gettext("Turn right")}
                    >
                      <.icon name="hero-arrow-uturn-right" class="w-4 h-4" />
                    </button>
                    <button
                      type="button"
                      phx-click="mirror"
                      phx-value-axis="horizontal"
                      phx-target={@myself}
                      aria-pressed={to_string(@draft["flip_h"] == true)}
                      class={["btn btn-sm", @draft["flip_h"] && "btn-active"]}
                    >
                      <.icon name="hero-arrows-right-left" class="w-4 h-4" /> {gettext("Mirror")}
                    </button>
                    <button
                      type="button"
                      phx-click="mirror"
                      phx-value-axis="vertical"
                      phx-target={@myself}
                      aria-pressed={to_string(@draft["flip_v"] == true)}
                      class={["btn btn-sm", @draft["flip_v"] && "btn-active"]}
                    >
                      <.icon name="hero-arrows-up-down" class="w-4 h-4" /> {gettext("Flip")}
                    </button>
                  </div>

                  <label class="flex flex-col gap-1 text-sm">
                    <span class="flex justify-between">
                      {gettext("Straighten")}
                      <span class="tabular-nums text-base-content/70">{format_degrees(
                        @draft["straighten"]
                      )}</span>
                    </span>
                    <input
                      type="range"
                      name="edit[straighten]"
                      min="-45"
                      max="45"
                      step="0.5"
                      value={@draft["straighten"] || 0}
                      class="range range-xs"
                    />
                  </label>

                  <div class="flex flex-col gap-1 text-sm">
                    <span>{gettext("Crop")}</span>
                    <div class="flex flex-wrap gap-1">
                      <button
                        :for={aspect <- @aspects}
                        type="button"
                        phx-click="aspect"
                        phx-value-aspect={aspect}
                        phx-target={@myself}
                        class={["btn btn-xs", @aspect == aspect && "btn-primary"]}
                      >
                        {if aspect == "free", do: gettext("Free"), else: aspect}
                      </button>
                      <button
                        :if={@draft["crop"]}
                        type="button"
                        phx-click="clear_crop"
                        phx-target={@myself}
                        class="btn btn-xs btn-ghost"
                      >
                        {gettext("No crop")}
                      </button>
                    </div>
                    <div class="grid grid-cols-4 gap-1">
                      <label
                        :for={
                          {key, label} <- [
                            {"x", gettext("Left %")},
                            {"y", gettext("Top %")},
                            {"w", gettext("Width %")},
                            {"h", gettext("Height %")}
                          ]
                        }
                        class="flex flex-col text-xs"
                      >
                        <span class="text-base-content/70">{label}</span>
                        <input
                          type="number"
                          step="any"
                          min="0"
                          max="100"
                          name={"edit[crop][#{key}]"}
                          value={crop_value(@draft["crop"], key)}
                          class="input input-xs w-full"
                        />
                      </label>
                    </div>
                  </div>
                </section>

                <%!-- The geometric fields still travel with the form while
                     another tool is open, so they are not reset. --%>
                <div :if={@tool != "crop" or @geometry_locked} class="hidden">
                  <input type="hidden" name="edit[straighten]" value={@draft["straighten"] || 0} />
                  <input
                    :for={key <- ~w(x y w h)}
                    type="hidden"
                    name={"edit[crop][#{key}]"}
                    value={crop_value(@draft["crop"], key)}
                  />
                </div>

                <%!-- Redaction --%>
                <section :if={@tool == "redact"} class="flex flex-col gap-2">
                  <p class="text-xs text-base-content/70">
                    {gettext(
                      "Drag on the image to cover an area, or add one and set it below. Covered areas are permanently removed from the saved image."
                    )}
                  </p>
                  <div
                    :for={{region, index} <- Enum.with_index(Map.get(@draft, "redact", []))}
                    class="flex flex-col gap-1 rounded-box bg-base-200 p-2"
                  >
                    <div class="flex items-center gap-2">
                      <span class="badge badge-sm badge-neutral">{index + 1}</span>
                      <select name={"edit[redact][#{index}][style]"} class="select select-xs flex-1">
                        <option
                          :for={style <- @styles}
                          value={style}
                          selected={region["style"] == style}
                        >
                          {style_label(style)}
                        </option>
                      </select>
                      <button
                        type="button"
                        phx-click="remove_region"
                        phx-value-index={index}
                        phx-target={@myself}
                        class="btn btn-ghost btn-xs btn-square"
                        aria-label={gettext("Remove area")}
                      >
                        <.icon name="hero-trash" class="w-4 h-4" />
                      </button>
                    </div>
                    <div class="grid grid-cols-4 gap-1">
                      <input
                        :for={key <- ~w(x y w h)}
                        type="number"
                        step="any"
                        min="0"
                        max="100"
                        name={"edit[redact][#{index}][#{key}]"}
                        value={region[key]}
                        aria-label={rect_label(key)}
                        class="input input-xs w-full"
                      />
                    </div>
                  </div>
                  <button
                    type="button"
                    phx-click="add_region"
                    phx-target={@myself}
                    class="btn btn-sm btn-outline"
                  >
                    <.icon name="hero-plus" class="w-4 h-4" /> {gettext("Add area")}
                  </button>
                </section>

                <div :if={@tool != "redact"} class="hidden">
                  <%= for {region, index} <- Enum.with_index(Map.get(@draft, "redact", [])) do %>
                    <input
                      :for={key <- ~w(x y w h style)}
                      type="hidden"
                      name={"edit[redact][#{index}][#{key}]"}
                      value={region[key]}
                    />
                  <% end %>
                </div>

                <%!-- Light --%>
                <section class="flex flex-col gap-2">
                  <label
                    :for={
                      {key, label} <- [
                        {"brightness", gettext("Brightness")},
                        {"contrast", gettext("Contrast")}
                      ]
                    }
                    class="flex flex-col gap-1 text-sm"
                  >
                    <span class="flex justify-between">
                      {label}
                      <span class="tabular-nums text-base-content/70">{@draft[key] || 0}</span>
                    </span>
                    <input
                      type="range"
                      name={"edit[#{key}]"}
                      min="-100"
                      max="100"
                      step="1"
                      value={@draft[key] || 0}
                      class="range range-xs"
                    />
                  </label>
                </section>

                <div :if={@frame} class="text-xs text-base-content/60">
                  <% {ow, oh} = ImageEdit.output_size(@draft, @size) %>
                  {gettext("Result: %{width} × %{height} px", width: ow, height: oh)}
                </div>

                <div :if={@replace_mode} class="alert alert-warning text-xs py-2">
                  {gettext("Saving replaces the original image. This can't be undone.")}
                </div>

                <div class="flex flex-wrap gap-2">
                  <button
                    type="submit"
                    name="intent"
                    value="save"
                    class="btn btn-primary btn-sm"
                    disabled={not @dirty}
                  >
                    {gettext("Save")}
                  </button>
                  <button
                    type="submit"
                    name="intent"
                    value="copy"
                    class="btn btn-sm"
                    disabled={@draft == %{}}
                  >
                    {gettext("Save as copy")}
                  </button>
                  <button
                    type="button"
                    phx-click="reset"
                    phx-target={@myself}
                    class="btn btn-ghost btn-sm"
                    disabled={not @dirty}
                  >
                    {gettext("Undo changes")}
                  </button>
                </div>
              </fieldset>
            </.form>

            <%!-- The unedited original --%>
            <div :if={@edited} class="card bg-base-200">
              <div class="card-body p-3 gap-2 text-sm">
                <p class="font-semibold">{gettext("Unedited original")}</p>
                <p class="text-xs text-base-content/70">
                  {gettext(
                    "Kept privately so the edit can be changed or undone. Only people who can edit this image can get it."
                  )}
                </p>
                <div class="flex flex-wrap gap-2">
                  <a :if={@unedited_url} href={@unedited_url} class="btn btn-xs" download>
                    <.icon name="hero-arrow-down-tray" class="w-3 h-3" /> {gettext("Download")}
                  </a>
                  <button
                    type="button"
                    phx-click="confirm"
                    phx-value-action="revert"
                    phx-target={@myself}
                    class="btn btn-xs"
                    disabled={
                      (@busy and not @failed) or
                        (@geometry_locked and ImageEdit.geometric?(@file.edits))
                    }
                  >
                    <.icon name="hero-arrow-uturn-left" class="w-3 h-3" /> {gettext("Restore")}
                  </button>
                  <button
                    type="button"
                    phx-click="confirm"
                    phx-value-action="delete_unedited"
                    phx-target={@myself}
                    class="btn btn-xs btn-error btn-outline"
                    disabled={@busy}
                  >
                    <.icon name="hero-trash" class="w-3 h-3" /> {gettext("Delete")}
                  </button>
                </div>

                <div
                  :if={@confirm == "revert"}
                  role="alertdialog"
                  class="alert alert-warning flex-col items-start gap-2 text-xs"
                >
                  <span>{gettext(
                    "Undo the edit and show the unedited original again everywhere this image is used?"
                  )}</span>
                  <div class="flex gap-2">
                    <button
                      type="button"
                      phx-click="revert"
                      phx-target={@myself}
                      class="btn btn-xs btn-warning"
                    >{gettext("Restore original")}</button>
                    <button
                      type="button"
                      phx-click="cancel_confirm"
                      phx-target={@myself}
                      class="btn btn-xs btn-ghost"
                    >{gettext("Cancel")}</button>
                  </div>
                </div>

                <div
                  :if={@confirm == "delete_unedited"}
                  role="alertdialog"
                  class="alert alert-error flex-col items-start gap-2 text-xs"
                >
                  <span>{gettext(
                    "Delete the unedited original for good? The edit becomes permanent and can no longer be changed or undone."
                  )}</span>
                  <div class="flex gap-2">
                    <button
                      type="button"
                      phx-click="delete_unedited"
                      phx-target={@myself}
                      class="btn btn-xs btn-error"
                    >{gettext("Delete for good")}</button>
                    <button
                      type="button"
                      phx-click="cancel_confirm"
                      phx-target={@myself}
                      class="btn btn-xs btn-ghost"
                    >{gettext("Cancel")}</button>
                  </div>
                </div>
              </div>
            </div>
        <% end %>
      </div>
    </div>
    """
  end

  # ──────────────────────────────────────────────────────────────
  # Render helpers
  # ──────────────────────────────────────────────────────────────

  # The unedited image inside the frame: turned and mirrored as the edit
  # says, straightened (and scaled so no corner shows), toned.
  defp image_style(draft, {w, h}, {fw, fh}) do
    rotate = draft["rotate"] || 0
    straighten = draft["straighten"] || 0
    scale = ImageEdit.straighten_scale(fw, fh, straighten)
    {sx, sy} = {if(draft["flip_h"], do: -1, else: 1), if(draft["flip_v"], do: -1, else: 1)}

    # Width and height in percent of the frame, before the quarter turn.
    {pw, ph} =
      if rotate in [90, 270],
        do: {w / fw * 100, h / fh * 100},
        else: {100, 100}

    brightness = 1 + (draft["brightness"] || 0) / 100
    contrast = 1 + (draft["contrast"] || 0) / 100

    [
      "width: #{num(pw)}%; height: #{num(ph)}%;",
      "transform: translate(-50%, -50%) rotate(#{num(straighten)}deg) scale(#{num(scale)}) ",
      "scale(#{sx}, #{sy}) rotate(#{rotate}deg);",
      "filter: brightness(#{num(brightness)}) contrast(#{num(contrast)});"
    ]
    |> Enum.join()
  end

  # Saved (or retried) a while ago and still rendering.
  defp stalled?(%{edit_state: "pending", updated_at: %DateTime{} = at}, now),
    do: DateTime.diff(now, at) > @stalled_after

  defp stalled?(_file, _now), do: false

  defp rect_style(%{"x" => x, "y" => y, "w" => w, "h" => h}),
    do: "left: #{num(x)}%; top: #{num(y)}%; width: #{num(w)}%; height: #{num(h)}%;"

  # Everything outside the crop, darkened: one element, four inset shadows
  # would not do; a clip-path polygon with a hole does.
  defp shade_style(%{"x" => x, "y" => y, "w" => w, "h" => h}) do
    {l, t, r, b} = {num(x), num(y), num(x + w), num(y + h)}

    "background: rgb(0 0 0 / 0.55); clip-path: polygon(evenodd, " <>
      "0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, " <>
      "#{l}% #{t}%, #{r}% #{t}%, #{r}% #{b}%, #{l}% #{b}%, #{l}% #{t}%);"
  end

  defp region_class("fill"), do: "bg-black"

  defp region_class("pixelate"),
    do:
      "backdrop-blur-md bg-base-content/20 [image-rendering:pixelated] outline outline-2 outline-white/70"

  defp region_class(_blur),
    do: "backdrop-blur-xl bg-base-100/10 outline outline-2 outline-white/70"

  defp style_label("blur"), do: gettext("Blur")
  defp style_label("pixelate"), do: gettext("Pixelate")
  defp style_label("fill"), do: gettext("Black box")

  defp rect_label("x"), do: gettext("Left %")
  defp rect_label("y"), do: gettext("Top %")
  defp rect_label("w"), do: gettext("Width %")
  defp rect_label("h"), do: gettext("Height %")

  defp crop_value(nil, key), do: if(key in ~w(w h), do: 100, else: 0)
  defp crop_value(crop, key), do: crop[key]

  defp format_degrees(nil), do: "0°"
  defp format_degrees(value), do: "#{num(value)}°"

  defp aspect_ratio(aspect) do
    case List.keyfind(@aspects, aspect, 0) do
      {_, {w, h}} -> "#{w}/#{h}"
      _ -> ""
    end
  end

  defp num(value) when is_integer(value), do: Integer.to_string(value)

  defp num(value) when is_float(value) do
    rounded = Float.round(value, 3)

    if rounded == trunc(rounded),
      do: Integer.to_string(trunc(rounded)),
      else: :erlang.float_to_binary(rounded, [:compact, decimals: 3])
  end
end