Using Tiptap Rich Text Editor with Livewire

d3adr1nger

Thanos Stantzouris

Posted on September 24, 2024

Using Tiptap Rich Text Editor with Livewire

I’ve always had a tough time with rich text editors, not because they couldn’t do the job, but because getting them to work smoothly with Livewire has always been a challenge for me. I never really figured out how to make the integration feel clean, especially with my limited IQ 😅 and... JavaScript knowledge.

But yesterday I discovered the Tiptap rich text editor and I’m thrilled because, believe it or not, it actually supports Livewire integration out of the box! No more duct-tape solutions or spending hours untangling JavaScript spaghetti code. For the first time, a rich text editor didn’t make me want to pull my hair out. In fact, I got it working on the first try. Yeah, I know, I almost didn’t believe it myself. I didn’t even have to question my entire career as a developer this time!
And as always, I must share my newfound knowledge with you! 🤓 Let's kick things off!

don't mess with hasbullah

Table of contents

What we are building

For this example we are going to create a minimal rich text editor blade component that should "live" under a livewire component for, let's say, saving a post. And it's going to look like this

my image saved on sudorealm.com

We will mainly focus on a Laravel 11 - Livewire v3 TALL project so if you are outdated, well... laravel shift is your friend!

Introduction to Tiptap

I won't go into much detail here cause you can find anything you want about the Tiptap Rich Text Editor by browsing their website. What I want to highlight about this rich text editor though is the following:

Create exactly the rich text editor you want out of customizable building blocks. Tiptap comes with sensible defaults, a lot of extensions, and a friendly API to customize every aspect. Tiptap offers both open-source extensions and Pro extensions available through a Tiptap account.

How cool is that guys? This little paragraph is what ignited the fire of curiosity in me and got me to explore further.

Install Tiptap

Let's begin with the setup!

npm install alpinejs @tiptap/core @tiptap/pm @tiptap/starter-kit
Enter fullscreen mode Exit fullscreen mode

The starter-kit package includes the most common extensions to get started quickly. No need to install each one of them individually. Later in the example though, we will install some extra standalone extensions and you'll see what I am talking about.

Next up navigate to your app.js file and include the following lines:

import './bootstrap'; // should already exist do not paste that :P 

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'

// resources/app.js
window.setupEditor = function (content) {
    let editor

    return {
      content: content,

      init(element) {
        editor = new Editor({
          element: element,
          },
          extensions: [StarterKit],
          content: this.content,
          onUpdate: ({ editor }) => {
            this.content = editor.getHTML();
          },
        })

        this.editor = editor;

        this.$watch('content', (content) => {
          // If the new content matches TipTap's then we just skip.
          if (content === editor.getHTML()) return

          editor.commands.setContent(content, false)
        });
        editor.commands.setContent(this.content, false);
      },
    }
  }
Enter fullscreen mode Exit fullscreen mode

A few words about $watch. Well, you can "watch" a component property using the $watch magic method. So in our case, whenever the content property changes we fire a callback.

Now this code is ready to listen to and connect with Livewire. Does that make sense? 😅
Jump ahead and see how we use it from inside a blade component. Setup the blade component

Livewire integration

Let's break the Livewire implementation into steps—because who doesn’t love making complex things a bit more painful, right? But seriously, taking it step by step will help us actually understand what’s going on. We'll go from setting up the components to wrangling the data flow between the frontend and backend, all while making sure the rich text editor behaves. This way, we can avoid that 'what is even happening?' moment later on.

Step 1: Create the Livewire Component

I’m sure you’re familiar with how everything in Livewire is built as a component, so I won’t dive into the details of that. Instead, let's get straight into creating a Livewire component named PostForm:

#or sail artisan
php artisan make:livewire Post/PostForm
Enter fullscreen mode Exit fullscreen mode

This will create two different files:

  1. A class: app/Livewire/Post/PostForm.php
  2. A livewire frontend component: resources/views/livewire/post/post-form

I like grouping my Livewire components under the model they serve because I didn’t do that in the past, and I deeply regretted it.

Step 2: Setup the Livewire Component

Let's start with setting down the validation of our model!

class PostForm extends Component
{
    #[Validate(
        [
            'post.name' => ['required', 'min:3', 'max:100'],
            'post.body' => ['required'],
        ],
        message: [
            'post.name.required' => 'A post without a name... That works!',
            'post.name.min' => 'Too small... That\'s what she said!',
            'post.name.max' => 'Too large!!! That\'s what she didn\'t say',
        ]
    )]

    public $post; # The value that's going to be entangled
Enter fullscreen mode Exit fullscreen mode

I've also included some messages, to spice up the app a bit 🌶️ !

Now let's create a simple showcase of how to save our model!

public function save()
{
    $validated = $this->validate();

    # Security tip. Always add a policy to authenticate the create/update functionality. 
    $this->authorize('updateOrCreate', $post); 

    $post = Post::updateOrCreate(
        ['id' => $this->post['id'] ?? null],
        [
            'name' => $validated['post']['name'],
            'user_id' => Auth::id(),
            'instructions' => $validated['post']['body'] ?? null,
        ]
    );

    # Redirect to the same page, but on the edit
    $this->redirect(route('posts.form', $post), navigate: true);
Enter fullscreen mode Exit fullscreen mode

Here I’m handling both the create and update functions for the model in a single file. You don’t have to do this—I’m just being lazy and avoiding the hassle of managing two files. Efficiency, right? 🫠

For the Frontend setup now we'll add a minimal form that will fire our save method when submitted:

<form wire:submit='save'>
    <div>
        <x-form.input.editor wire:model="post.body"></x-form.input.editor>
    </div>
    <textarea class="w-full bg-slate-900" wire:model='post.body' cols="30" rows="10"></textarea>
</form>
Enter fullscreen mode Exit fullscreen mode

The textarea should be deleted after we have finalized the example. I am adding it at this point to see if the editor works as expected!

Setup the Blade Component

This Blade component handles all the logic for the Tiptap Rich Text Editor and will be named tiptap.blade.php. I like to keep things organized, so it’ll go under resources/views/components/form/input/.

It may seem a bit over the top, but this simple system has saved me from the headache of revisiting old projects. Having cleared that out, now paste the following code:

<div x-data="setupEditor(
    $wire.entangle('{{ $attributes->wire('model')->value() }}')
)" x-init="() => init($refs.editor)" wire:ignore {{ $attributes->whereDoesntStartWith('wire:model') }}>
    <div x-ref="editor"></div>
</div>
Enter fullscreen mode Exit fullscreen mode

This snippet needs some explanation:

  • x-data="setupEditor(...)": Initializes an Alpine.js component using the setupEditor function. This function is passed a two-way data binding ($wire.entangle) to synchronize the Tiptap editor's state with the Livewire model. Remember what setupEditor is doing. Install Tiptap.
  • $wire.entangle('{{ $attributes->wire('model')->value() }}'): Binds the editor's data with the Livewire model, ensuring changes made in the editor are automatically synced with the server.
  • x-init="() => init($refs.editor)": On initialization, the init function is called, passing the reference to the editor DOM element ($refs.editor), which initializes the Tiptap editor.
  • wire:ignore: Instructs Livewire to ignore this particular DOM element, preventing Livewire from re-rendering the Tiptap editor’s DOM when it updates other parts of the page.
  • $attributes->whereDoesntStartWith('wire:model'): Ensures that any attribute starting with wire:model is excluded, so it doesn't interfere with the custom bindings and functionality of the component.
  • <div x-ref="editor"></div>: Creates a reference for the editor (x-ref="editor") to allow Alpine.js to target this specific element for initializing the Tiptap editor.

Having done all that we should have this result on our page.
my image saved on sudorealm.com

You see, this blew my mind! The result is incredibly raw and flexible, giving us full control to customize and add functionalities as we need. You’ll see exactly what I mean as we continue with the example, where we’ll dive into customizations and feature enhancements.

Markdown support

As I’m sure you’ve noticed, Tiptap by default transforms our text into HTML, which I’m personally not a big fan of. Markdown output, though—that I like! Thanks to aguingand though we can easily include markdown support to our tiptap editor. Let's follow the instructions of tiptap-markdown repo.

Install the extension package

npm install tiptap-markdown
Enter fullscreen mode Exit fullscreen mode

Update our app.js file like so

import './bootstrap'; // should already exist do not paste that :P 

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from 'tiptap-markdown'; //new line

...
      extensions: [StarterKit, Markdown],
      content: this.content,
      onUpdate: ({ editor }) => {
        this.content = editor.storage.markdown.getMarkdown(); // new function
    ...
      if (content === editor.storage.markdown.getMarkdown()) return // new function
    ...
Enter fullscreen mode Exit fullscreen mode

That's all really. Now instead of HTML the editor will produce markdown. Clean! 🧼

This clearly demonstrates the power of open-source software and how leveraging it correctly can give your product a competitive edge in the market!

Toolbar Work and Customization

This is where the fun begins! We’re going to add the essential buttons for our minimal text editor and customize it with Tailwind CSS to make it look sleek. Most importantly, we’ll ensure it feels like a natural part of our app.

...
    init(element) {
    editor = new Editor({
      element: element,
      editorProps: {
        attributes: {
          class: 'p-2 prose-sm min-h-60 prose-h1:mb-0 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 focus:outline-none',
        },
      },
      extensions: [
        StarterKit.configure({
            heading: {
                levels: [2, 3, 4],
            },
        }),
        Markdown,
    ],
...
    ],
      content: this.content,
      onUpdate: ({ editor }) => {
        this.content = editor.storage.markdown.getMarkdown();
      },
    })
    this.editor = editor;
    this.toggleBold = () => editor.chain().focus().toggleBold().run();
    this.toggleItalic = () => editor.chain().focus().toggleItalic().run();
    this.toggleH2 = () =>  editor.chain().focus().toggleHeading({ level: 2 }).run();
    this.toggleH3 = () =>  editor.chain().focus().toggleHeading({ level: 3 }).run();
    this.toggleH4 = () =>  editor.chain().focus().toggleHeading({ level: 4 }).run();
    this.toggleOrderedList = () =>  editor.chain().focus().toggleOrderedList().run();
    this.toggleBulletList = () =>  editor.chain().focus().toggleBulletList().run();
...
Enter fullscreen mode Exit fullscreen mode

There are two key points that we should take from that code snippet.

Styling the Editor with Tailwind CSS

The editorProps are being used to apply Tailwind CSS (which I assume you are familiar with) classes to the editor's root DOM element. These classes handle the visual styling and interaction feedback. Learn More

editorProps: {
    attributes: {
        class: 'p-2 prose-sm min-h-60 prose-h1:mb-0 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 focus:outline-none',
    },
},
Enter fullscreen mode Exit fullscreen mode

Editor Commands for Text Formatting

The editor instance provides several custom commands for text formatting. These commands make it easy to apply specific styles or content structures, such as bold, italic, headings, and lists.

You may notice that we don't include the Level:1 because we don't want the user to be able to add multiple <h1> on the page, which is bad for SEO. But you'll see how we will create the illusion to the user that they are adding a H1 🤓.

StarterKit.configure({
    heading: {
        levels: [2, 3, 4], // here we leave the level 1 out. 
    },
}),
Enter fullscreen mode Exit fullscreen mode

Next step though, is to make them visible on our component. So back we go to our tiptap.blade.php component to sprinkle some more last touches of code.

<div x-data="setupEditor(
    $wire.entangle('{{ $attributes->wire('model')->value() }}')
)" x-init="() => init($refs.editor)" wire:ignore {{ $attributes->whereDoesntStartWith('wire:model') }}>
    <div class="flex w-full border-b divide-x  dark:bg-slate-900 divide-slate-700 border-slate-700 rounded-t-md">
        <button type="button" class="flex justify-center p-2 transition dark:hover:bg-slate-700 w-14 rounded-tl-md"
            @click="toggleBold();">
            <x-svg class="w-5 h-auto" svg="bold" />
        </button>
        <button type="button" class="flex justify-center p-2 transition dark:hover:bg-slate-700 w-14 dark:bg-slate-900"
            @click="toggleItalic()">
            <x-svg class="w-5 h-auto" svg="italic" />
        </button>
        <button type="button" class="flex justify-center p-2 transition dark:hover:bg-slate-700 w-14 dark:bg-slate-900"
            @click="toggleH2()">
            <x-svg class="w-5 h-auto" svg="h-1" />
        </button>
        <button type="button" class="flex justify-center p-2 transition dark:hover:bg-slate-700 w-14 dark:bg-slate-900"
            @click="toggleH3()">
            <x-svg class="w-5 h-auto" svg="h-2" />
        </button>
        <button type="button" class="flex justify-center p-2 transition dark:hover:bg-slate-700 w-14 dark:bg-slate-900"
            @click="toggleH4()">
            <x-svg class="w-5 h-auto" svg="h-3" />
        </button>
        <button type="button" class="flex justify-center p-2 transition dark:hover:bg-slate-700 w-14 dark:bg-slate-900"
            @click="toggleOrderedList()">
            <x-svg class="w-5 h-auto" svg="list-numbers" />
        </button>
        <button type="button" class="flex justify-center p-2 transition dark:hover:bg-slate-700 w-14 dark:bg-slate-900"
            @click="toggleBulletList()">
            <x-svg class="w-5 h-auto" svg="list" />
        </button>
    </div>
    <div class="border-gray-300 shadow-sm rounded-b-md dark:border-gray-700 dark:bg-slate-900 dark:text-gray-300"
        x-ref="editor">
    </div>
</div>

Enter fullscreen mode Exit fullscreen mode

For the SVGs in this snippet, I used a cool blade component that I've created in a previous article Laravel Blade SVG Component. Feel free to add it to your toolbelt, it's very useful! Maybe my most reused blade component.

By now, you should be looking at the final result I teased at the start—just like we planned all along! What we built. 😀

But... oops... we are missing link support!

Link Support

To get link support up and running, we’ll start by installing the necessary package from the Tiptap extensions.

Install:

npm install @tiptap/extension-link
Enter fullscreen mode Exit fullscreen mode

Include:

...
import { Markdown } from 'tiptap-markdown'
import Link from '@tiptap/extension-link' // new line

...
extensions: [
    StarterKit.configure({
        heading: {
            levels: [2, 3, 4],
        },
    }),
    Markdown,
    Link.configure({
        autolink: true,
        defaultProtocol: 'https',
    }),
]
Enter fullscreen mode Exit fullscreen mode

Now we’ve officially added link support to our Tiptap editor, thanks to Tiptap’s ridiculously easy extension system. Just like that, you can insert links with ease, with the bonus of automatic linking and a default protocol of HTTPS.

🚧 If you hardcode http:// into the link, it will remain as-is, and won't be automatically converted to https.

Conclusion

And there you have it! We’ve barely scratched the surface of what Tiptap Rich Text Editor can do, and already we’ve added powerful link support, Markdown integration, and customized the editor’s behavior and looks—all without breaking a sweat. The beauty of Tiptap lies in its endless extensibility. Want tables, embeds, or custom plugins? No problem. There’s always something more to explore. Plus, with Livewire in the mix, you can enjoy real-time updates without needing a PhD in JavaScript. The only limit is your imagination...

I hope this walkthrough sparked your creativity and gave you the urge to dive deeper into Tiptap. There’s so much more you can do with this amazing editor—so go ahead, experiment, and build something awesome! I know I'll do 😉.

And if you want to stay updated on new articles, tips, and tricks (because trust me, we’re far from done with Tiptap), be sure to follow me here on Sudorealm or Github @athanstan. I’ll be covering more advanced extensions and clever hacks to make our rich text editor even more powerful. Stay tuned, and let’s keep pushing the boundaries together!

💖 💪 🙅 🚩
d3adr1nger
Thanos Stantzouris

Posted on September 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related