How to integrate TinyMCE Editor into a Phoenix Liveview project

Obute Moses
8 min readJul 18, 2023

--

Logo of Elixir rendering or rolling-out a component
Image by Erin Schaffer at educative.io

Introduction

TinyMCE is a rich-text editor that allows users to create formatted content within a user-friendly interface. TinyMCE's several advanced features, even on the free tier, have made it one of the most loved and used rich-text editors. When working on a Phoenix Liveview project that requires generating WYSIWYG text, TinyMCE is a straightforward editor to integrate with.

Prerequisites

In this tutorial, we will integrate TinyMCE into a Phoenix Liveview project. The project is a Todo App. The app will enable us to perform basic CRUD actions.

This tutorial assumes that you understand the fundamentals of:

Cloning the Project Setup

To get started, clone the GitHub repository for this project https://github.com/mosiac05/phoenix_todo_app.

On your terminal, change the directory into the project folder.

$ cd phoenix_todo_app

Checkout to the setup branch.

$ git checkout setup

Fetch and install the dependencies.

$ mix deps.get

Then launch the project to see if all things are working as expected.

$ mix phx.server
An image showing the screen for the Todo App
Todo App screen

When you go to localhost:4000 on your browser, you should see something like the image above.

We just have the Todo App screen that has a form with a normal input field for the title and a textarea for the description of a new todo. Below the form is a list of todos.

In the project, inside the live directory, we have created a TodoLive liveview and a TodoItemComponent component.

Integrating TinyMCE Rich-Text Editor

We are going to integrate TinyMCE into the description textarea field. To do that, we are going to add the Javascript script of TinyMCE to the root.html.heex file of the project using the script tag.

For this project, the root.html.heex file is located at
/lib/phoenix_todo_app_web/components/layouts/root.html.heex

Copy this script tag and paste it into the root.html.heex file before the script tag of the app.js file,

<script defer src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" 
referrerpolicy="origin"></script>

If you have the TinyMCE API Key, you can replace no-api-key in the script tag src with it.

Client Hook

Next, we will create the client hook for TinyMCE, to set things up and handle input events. Create a tinyMCEHook.js file in the assets/js directory of the project. Copy and paste the code below into the file. We will discuss it afterwards.

export default {
mounted() {
const elID = this.el.id
const fieldName = this.el.dataset.field;

tinymce.init({
selector: `#${elID}`,
height: 300,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount'
],
toolbar: 'undo redo | blocks | ' +
'bold italic backcolor image | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | help',
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }',
placeholder: 'Type something here...',
});

const editor = tinymce.get(elID)

const pushEventHandler = () => {
var content = editor.getContent();
//This sends the event of
// def handle_event("text-editor", %{"text_content" => content, "field_name" => field_name}, socket) do
this.pushEventTo(this.el, "text-editor", {
text_content: content, field_name: fieldName
});
}

const onChangeHandler = () => {
closeAllToolbars()
pushEventHandler()
}

this.handleEvent("tinymce_reset", () => {
editor.resetContent()
pushEventHandler()
});

editor.on('keyup', onChangeHandler);
editor.on('change', onChangeHandler);
},
destroyed() {
closeAllToolbars()
var editor = tinymce.get(this.el.id);
if (editor) {
editor.remove();
}
}
};

const closeAllToolbars = () => {
document.querySelectorAll(".tox-toolbar__overflow").forEach(el => el.remove())
}

According to the official documentation on how to create a hook, we have a mounted() method that sets things up.

  • The mounted() callback is triggered when “the element with the hook has been added to the DOM and its server LiveView has finished mounting.
  • In the mounted() callback you can see that we are initialising the tinymce and providing options as provided in the official documentation of TinyMCE editor.
  • The constant variableelID holds the ID of the element with the hook. It is required that every element with a hook has a unique ID. The second constant variable fieldName is a dataset value from the element with the hook.
  • The pushEventHandler() function gets the content typed in the editor and sends it to the Liveview through the pushEventTo method.
  • The pushEventTo(selectorOrTarget, event, payload)method “push targeted events from the client to LiveViews and LiveComponents”.
  • The onChangeHandler() function is triggered on every keyup or change events. It calls the closeAllToolbars()and pushEventHandler() functions.
  • The closeAllToolbars function closes all TinyMCE toolbars. We need to close the toolbar when there is a change or when its editor is “destroyed” because the toolbar always remains on the screen if not explicitly closed.
  • The handleEvent() method will enable the hook to listen for an event, “tinymce_reset” in this case. We will trigger that event from the Liveview on form submission to clear the content of the editor.
  • The destroyed() callback is triggered when “the element has been removed from the page, either by a parent update or by the parent being removed entirely”. The callback here ensures that the editor is properly removed from the DOM.

We are done with creating the hook, now we need to import the hook into our app.js file.

Import the tinyMCEHook.js file as TinyMCEHook in the app.js file, then create a Hooks object variable and add the TinyMCEHook hook as shown below.

import TinyMCEHook from "./tinyMCEHook";

let Hooks = { TinyMCEHook };

Next, update the liveSocket variable as below to include a new hook key that holds the Hooks variable as value.

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

Textarea With Hook

We are going to replace the input of type textarea in the todo_live.html.heex file with standard HTML textarea element as done below. Replace lines 10–14 with the code below.

<div id="todo-description--textarea-tinymce" phx-update="ignore">
<textarea id="todo-description--tinymce" data-field="description" phx-hook="TinyMCEHook">
</textarea>
</div>

<%= Form.hidden_input(f, :description, value: @todo_description) %>

As you can see in the code above, we have nested the textarea in a div that is not updated (phx-update=“ignore”) by Phoenix after mounting. It is necessary to avoid changes to the div so that we do not lose the TinyMCE editor when Phoenix tries to update the DOM. The textarea has been attached to the TinyMCEHook and has a data-field attribute to provide the name of the updated field.

Also, we have included a hidden input field, that is assigned the value (@todo_description) of the contents of the text editor.

Since we are in the todo_live.html.heex file, let us quickly include the submit and change events to the form. We will add phx-submit=“submit-todo” and phx-change= “validate” to the form’s open tag as shown below.

<.form
:let={f}
for={@todo_form}
class="flex flex-col gap-4 px-9 py-6 rounded shadow"
phx-submit="submit-todo"
phx-change="validate"
>

Event Handlers

Before we start creating events to handle changes from the text editor and for the submission of our form. Remember we have attached the hidden input to a variable that does not exist in the state. In the returned assigns of the mount callback function in todo_live.ex file, add todo_description: nil to the returned assigns as shown below.

{
:ok,
socket
|> assign(
todo_list: todo_list,
todo_form: to_form(%{"title" => nil, "description" => nil}),
todo_description: nil
)
}

In the todo_live.ex file, we will create the handle_event callback that will handle the event from the text editor. Remember that our text editor sends an event named “text-editor”. The event handler is given below.

def handle_event(
"text-editor",
%{"text_content" => content, "field_name" => _field_name},
socket
) do
{:noreply, socket |> assign(todo_description: content)}
end

The payload of the event includes the “text-content” which is the content of the text editor, and “field_name” which is the name of the field to be updated especially in cases where you have multiple textarea fields that have been hooked with the TinyMCEHook hook. The handler updates the value of todo_description in the state with the content of the editor.

The value of field_name is of no use to us here. You can omit it if you wish.

Next, we will create the event handler to keep track of inputs for the title field. The name of the form’s change event is “validate”.

def handle_event("validate", params, socket) do
{:noreply, socket |> assign(todo_form: to_form(params))}
end

Finally, we are going to create the handler for the form submission. Remember we attached the form to a submit handler named “submit-todo”. The handler is given below.

def handle_event("submit-todo", params, socket) do
{:noreply, socket |> add_new_todo(params)}
end

defp add_new_todo(
%{assigns: %{todo_list: todo_list}} = socket,
%{"description" => description, "title" => title} = _new_todo
) do
new_todo = %{
id: length(todo_list) + 1,
status: "active",
title: title,
description: validate_description(description)
}

todo_list = todo_list |> List.insert_at(-1, new_todo)

socket
|> assign(
todo_list: todo_list,
todo_form: get_todo_form()
)
|> push_event("tinymce_reset", %{})
end

defp validate_description(description) when description in [nil, "", "<p></p>"], do: nil
defp validate_description(description), do: description
defp get_todo_form(), do: %{"title" => nil, "description" => nil} |> to_form()

The handler for “submit-todo” event calls the private function add_new_todo which creates the new todo and adds it to the already existing list of todos.

For the new todo, it creates a new map and assigns it an id and an “activestatus, and also attaches the given title and description. We can get the description from the form’s params because of the hidden input in the form which holds the value of the text editor.

The new todo is appended at the back of the todo_list using List.insert_at/2 function. The todo_form is reset by calling the get_todo_form() function which returns a new todo_form. Reassigning the todo_form will clear the title input field.

Remember we used the handleEvent() method to allow the TinyMCEHook to listen for the “tinymce_reset” event. The push_event() from the Liveview will trigger that event, and clear the content of the text editor.

The private function validate_description() validates the given description and returns nil for empty values, else returns the description.

Lastly, since our description is a potential WYSIWYG text, let’s use the raw/1 method to render the description. Go to the todo_item_component.ex inside the live/components directory, and replace lines 55 — 61 with the code below.

<div class={[
"text-gray-500 text-xs font-light",
is_nil(@todo_item.description) && "italic",
!@show_description && "hidden"
]}>
<%= if @todo_item.description, do: raw(@todo_item.description), else: "No description." %>
</div>

You can run the app on the terminal to see the result.

$ mix phx.server

You should have the screen as shown above, with the TinyMCE editor for the description field.

Conclusion

We have just implemented the integration of the robust TinyMCE Rich Text editor into our Todo App, a Phoenix Liveview project. The focus is on how the hook connects and works. You can apply it to your project as you require. I am going to create a follow-up tutorial on how to integrate a plugin for creating formulas.

Please do not hesitate to ask your questions. Also, let me know if you found any bugs in my code :). You can access the code here on GitHub https://github.com/mosiac05/phoenix_todo_app/.

Edit: The follow-up tutorial has been published. Find it here. How to integrate Formula Plugin into a TinyMCE Editor in a Phoenix Liveview project.

--

--

Obute Moses

Developer at Refined Solutions Systems using PETAL stack and React.