How to handle file upload using Trix editor in a Phoenix application

In my quest of searching for a WYSIWYG editor, I came across Trix. An open-source rich text editor, easy to setup. Thanks to multiple ressources, I was able to set it up pretty quickly, however I didn't find anything regarding file uploads. In this article, I will focus on how to upload file locally or on a cloud storage.

Setup

Just in case you are starting from scratch reading this article, I will quickly go over how to setup Trix.

First, download the package using your favorite package manager :

$ yarn add trix

Install Trix using CLI

Edit your app.css :

@import '../node_modules/trix/dist/trix.css';

/* Chances are that you are using Tailwind CSS, you might want to add more lines to handle options from the editor since the library resets most HTML tags' default style */

/* Trix editor specifics */
@layer base {

    /* Adds style to unordered list */
    trix-editor > ul {
        @apply list-disc
    }

    /* Add style to ordered list */
    trix-editor > ol {
        @apply list-decimal
    }

    trix-editor > ol, trix-editor > ul {
        @apply ml-4
    }

    /* Add style to links added within the editor */
    trix-editor > div > a {
        @apply text-blue-700 underline decoration-blue-700 italic
    }
}

CSS code block

If you don't want to install it, you can also use CDN by including the following code in your root.html.heex file :

<head>
  ...
  <link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css">
  <script type="text/javascript" src="https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js"></script>
</head>

CDN Block to add Trix

After that, create the Trix.js hook.

$ touch assets/js/Trix.js

Then write the following in it :

import Trix from "trix";

export default {
    mounted() {
        const element = document.querySelector("trix-editor");
      
        element.editor.element.addEventListener("trix-change", (e) => {
            this.el.dispatchEvent(new Event("change", { bubbles: true }));
        });

       // Handles behavior when inserting a file
        element.editor.element.addEventListener("trix-attachment-add", function (event) {
            if (event.attachment.file) uploadFileAttachment(event.attachment)
        })

      // Handle behavior when deleting a file
        element.editor.element.addEventListener("trix-attachment-remove", function (event) {
            removeFileAttachment(event.attachment.attachment.previewURL)
        })
    },
};

Trix hook code

In your app.js :

...

import Trix from "./Trix";

const Hooks = { Trix }

let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken},
  hooks: Hooks
})

...

Register the Trix Hook to your Phoenix application

Finally, in your form_component.ex (or wherever you need to use the rich text editor)

<.simple_form
        for={@form}
        id="article-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
      ...
        
        <.input
          field={@form[:content]}
          id="article-content"
          type="hidden"
          label="Content"
          phx-hook="Trix"
          phx-debounce="blur"
        />
        <div id="trix-editor-container" phx-update="ignore">
          <trix-editor input="article-content"></trix-editor>
        </div>
        ...
        
      </.simple_form>

Add Trix editor to your HTML markup

Make sure that the input's id matches the input attribute of the <trix-editor /> tag.

At this point, you should see the editor displayed. Make sure to check your logs on the validate event whenever you type something to see if it works as intended.

Trix editor appearance

File Upload

Now that you are set up, let's get into the file upload part. If you take a closer look at the Trix's Github repository, they provide a sample code to handle file attachment. We will take inspiration from it and make adjustments to suit our needs.

First, let's create a dedicated route to handle file uploads. Add the following to your router.ex under your "/" scope :

post "/trix-uploads", TrixUploadsController, :create

Then, create a trix_uploads_controller.ex with the following :

defmodule MyAppWeb.TrixUploadsController do
  use MyAppWeb, :controller

  def create(conn, params) do
    case impl().upload(params) do
      {:ok, file_url} -> send_resp(conn, 201, file_url)
      {:error, _reason} -> send_resp(conn, 400, "Unable to upload file, please try again later.")
    end
  end

  defp impl, do: Application.get_env(:my_app, :uploader)[:adapter]
end

Trix uploads controller sample code

I will go over the impl/0 in a bit. We'll start with the local upload.

Local upload

As you can see in the previous controller sample code, we are using an uploader adapter that will change depending on the current environment. Let's set up our local upload adapter.

In the dev.exs, add the following line :

config :my_app, :uploader, adapter: Clients.Storage.Local

Then, create the lib/clients/storage/local.ex with the following lines :

defmodule Clients.Storage.Local do
  @moduledoc """
  Storage module to store files locally
  """

  def upload(%{"Content-Type" => content_type, "file" => %Plug.Upload{path: tmp_path}}) do
    # Create uploads dir if not exist
    create_uploads_dir()

    # Generate unique filename
    file_name = "#{Ecto.UUID.generate()}.#{ext(content_type)}"

    # Copy file to file system
    case File.cp(tmp_path, Path.join(uploads_dir(), file_name)) do
      :ok -> {:ok, Path.join("/uploads", file_name)}
      error -> error
    end
  end

  defp ext(content_type) do
    [ext | _] = MIME.extensions(content_type)
    ext
  end

  defp uploads_dir, do: Path.join(["priv", "static", "uploads"])
  defp create_uploads_dir, do: File.mkdir_p!(uploads_dir())
end

Local Storage module sample code

This module directly answers to the client-side XHR that we are going to setup right now.

Let's go back to the Trix.js hook file and add the request :

We keep the initial implementation of the uploadFileAttachment from the provided sample code.

function uploadFileAttachment(attachment) {
    uploadFile(attachment.file, setProgress, setAttributes)

    function setProgress(progress) {
        attachment.setUploadProgress(progress)
    }

    function setAttributes(attributes) {
        attachment.setAttributes(attributes)
    }
}

Our uploadFile function will be a little different :

function uploadFile(file, progressCallback, successCallback) {
    const formData = createFormData(file)
    const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
    const xhr = new XMLHttpRequest()

    // Send a POST request to the route previously defined in `router.ex`
    xhr.open("POST", "/trix-uploads", true)
    xhr.setRequestHeader("X-CSRF-Token", csrfToken)

    xhr.upload.addEventListener("progress", function (event) {
        if (event.lengthComputable) {
            const progress = Math.round((event.loaded / event.total) * 100)
            progressCallback(progress)
        }
    })

    xhr.addEventListener("load", function (_event) {
        // The sample code provides a check against a 204 HTTP status
        // However, responseText is empty for this response code, so I switched to a 201 instead.
        // It also makes sense since we are "creating" a new resource inside our content.
        if (xhr.status === 201) {
            // Retrieve the full path of the uploaded file from the server
            const url = xhr.responseText;
            const attributes = { url, href: `${url}?content-disposition=attachment` }
            successCallback(attributes)
        }
    })

    xhr.send(formData)
}

Client-side upload function block

Voilà. With this, you should be able to add attachments to your content and see them uploaded to the /uploads folder.

Now, what if you had a change of heart and wanted to delete the newly added attachment ? Let's see how we can remove an attachment from our content.

If you take a look at our Trix.js, we already covered the event (trix-attachment-remove) and are invoking the function, we just need to implement it :

function removeFileAttachment(url) {
    const xhr = new XMLHttpRequest()
    const formData = new FormData()
    formData.append("key", url)
    const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")

    xhr.open("DELETE", "/trix-uploads", true)
    xhr.setRequestHeader("X-CSRF-Token", csrfToken)

    xhr.send(formData)
}

Remove file attachment code block

This piece of code simply sends another XHR to our back-end with the file url under the key param. Let's go back to our trix_uploads_controller.ex and add a function to respond to that request.

First, let's add another line to our router.ex :

delete "/trix-uploads", TrixUploadsController, :delete

Then add the delete function :

# trix_uploads_controller.ex

...

  def delete(conn, %{"key" => key}) do
    case impl().delete_file(key) do
      :ok -> send_resp(conn, 204, "File successfully deleted")
      {:error, _reason} -> send_resp(conn, 400, "Unable to delete file, please try again later.")
    end
  end

As you can see, it ressembles our previous create function. Let's add the delete_file/1 function to our adapter.

# local.ex

...

  def delete_file(file_url) do
    "priv/static"
    |> Path.join(file_url)
    |> File.rm()
    |> case do
      :ok -> :ok
      error -> error
    end
  end

Our keys will always be prefixed with /uploads, so we are joining priv/static to recreate the path to our file and delete it from the file system. Now, when clicking on the ❌ logo above your attachment, you should see it deleted from the /uploads folder.

Now that we covered local upload, let's see how we can do the same thing for an external provider.


External upload

To give a little bit of context, I am using Backblaze B2 as a cloud storage. It comes with an S3 compatible API so the following code should work whether you are using AWS or a provider with a compatible API. At a particular point, I will add a line that is specific to Backblaze (I will stress it furthermore once we reach the relevant line of code), so make sure to delete it if you are using something else.

I will use the aws package for this example. Add the following lines to your mix.exs

defp deps do
  ...
  {:aws, "~> 0.13.0"},
  {:hackney, "~> 1.18"},
  ...
end

Let's create the S3 upload adapter.

defmodule Clients.Storage.S3 do
  @moduledoc false

  @b2_region Application.compile_env!(:my_app, :b2_region)
  @b2_access_key_id Application.compile_env!(:my_app, :b2_access_key_id)
  @b2_secret_key_access Application.compile_env!(:my_app, :b2_secret_key_access)
  @b2_bucket_name Application.compile_env!(:my_app, :b2_bucket_name)

  def upload(%{"Content-Type" => content_type, "file" => %Plug.Upload{path: tmp_path}}) do
    file_path = "public/#{Ecto.UUID.generate()}.#{ext(content_type)}"

    file = File.read!(tmp_path)
    md5 = :md5 |> :crypto.hash(file) |> Base.encode64()

    get_client()
    |> AWS.S3.put_object(@b2_bucket_name, file_path, %{
      "Body" => file,
      "ContentMD5" => md5,
      "Content-Type" => content_type
    })
    |> case do
      {:ok, _, %{status_code: 200}} ->
        {:ok, "#{endpoint()}/#{@b2_bucket_name}/#{file_path}"}

      _ = response ->
        {:error, "Unable to upload file, please try again later."}
    end
  end

  defp get_client do
    @b2_application_key_id
    |> AWS.Client.create(@b2_application_key, @b2_region)
    # This line might be irrelevant for you if you are not using backblaze
    |> AWS.Client.put_endpoint("s3.#{@b2_region}.backblazeb2.com")
  end

  defp endpoint do
    "https://s3.#{@b2_region}.backblazeb2.com"
  end
end

For testing purposes, you can modify your adapter in the dev.exs from Clients.Storage.Local to Clients.Storage.S3.

Now let's have a look at the delete_file/1 function :

If you remember correctly from the Local implementation, we send the full url. In order to get the key, we need to split the url after the bucket name.

e.g : https://s3.eu-central-003.backblazeb2.com/my-bucket/public/3c528724-7b44-4d94-a3ef-06aa2e3ee5989.png will become my-bucket/public/3c528724-7b44-4d94-a3ef-06aa2e3ee5989.png

  def delete_file(file_url) do
    key = file_url |> String.split("#{@b2_bucket_name}/") |> List.last()
    
    case AWS.S3.delete_object(get_client(), @b2_bucket_name, key, %{}) do
      {:ok, _body, %{status_code: 204}} -> :ok
      {:error, _reason} = error -> error
    end
  end

That's it - Attachments are now handled regardless of the environment you are firing the request from.

Happy coding !