Livewire/Alpine and TipTap editor

Hi,

I am struggling with the implementation of data binding using Livewire 3 with Alpine and the TipTap Editor. Tried to implement something similar to this Livewire 2 solution: https://github.com/mattlake/tiptap-livewire-demo

  • Editor is initialized / shown
  • My initial content is in the editor
  • After editing the content in the editor, my wire:model attached property does NOT update

This is the line in the blade component for the editor:

<div x-data="editor($wire.entangle('{{ $attributes->wire('model')->value() }}') )" {{ $attributes->whereDoesntStartWith('wire:model') }}>

Binding in a form looks like this

<x-editor wire:model="content"></x-editor>

Does anybody have a working solution for TipTap and Livewire 3? Or Alex, is it worth a video?

Thanks! Philipp

philipp71206 Member
philipp71206
1
6
581
alex Member
alex
Moderator

Hey Philipp, I don't have much experience at all with using TipTap with Livewire (only Vue).

I'll try and get a working demo together today for you, and will link you up to the GitHub here. And of course, it's always worth turning into a course — so I'll take care of that after we've found a solution for you.

philipp71206 Member
philipp71206
Solution

Hi Alex,

thank you very much - I really appreciate it!

I also tried my best and finally it's working. Not sure if it's elegant ;-) Happy to hear your feedback.

In the form inside a livewire component I use the editor blade component:

<x-editor wire:model="addOutcomeForm.note"></x-editor>

The editor.blade.php component is as described in samples from tiptap but adding the live mode to entangle:

<div>
    <div x-data="setupEditor(@entangle($attributes['wire:model']).live)" x-init="() => init($refs.editor)" wire:ignore
        {{ $attributes->whereDoesntStartWith('wire:model') }}>
        {{-- <template x-if="isLoaded()"> --}}
        <div class="menu flex space-x-2">
            <button type="button" @click="toggleBold()" :class="{ 'is-active': isActive('bold', updatedAt) }"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25">
                <flux:icon.bold class="size-5" />
            </button>
            <button type="button" @click="toggleItalic()" :class="{ 'is-active': isActive('italic', updatedAt) }"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25">
                <flux:icon.italic class="size-5" />
            </button>
            <button type="button" @click="toggleUnderline()" :class="{ 'is-active': isActive('italic', updatedAt) }"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25">
                <flux:icon.underline class="size-5" />
            </button>
            <button type="button"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25"
                @click="setParagraph()">
                P
            </button>
            <flux:separator vertical class="my-1" variant="subtle" />
            <button type="button"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25"
                @click="toggleHighlight()">
                <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000"
                    viewBox="0 0 256 256" class="size-5">
                    <path
                        d="M253.66,106.34a8,8,0,0,0-11.32,0L192,156.69,107.31,72l50.35-50.34a8,8,0,1,0-11.32-11.32L96,60.69A16,16,0,0,0,93.18,79.5L72,100.69a16,16,0,0,0,0,22.62L76.69,128,18.34,186.34a8,8,0,0,0,3.13,13.25l72,24A7.88,7.88,0,0,0,96,224a8,8,0,0,0,5.66-2.34L136,187.31l4.69,4.69a16,16,0,0,0,22.62,0l21.19-21.18A16,16,0,0,0,203.31,168l50.35-50.34A8,8,0,0,0,253.66,106.34ZM93.84,206.85l-55-18.35L88,139.31,124.69,176ZM152,180.69,83.31,112,104,91.31,172.69,160Z">
                    </path>
                </svg>
            </button>
            <flux:separator vertical class="my-1" variant="subtle" />
            <button type="button" @click="toggleHeading({ level: 1 })"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25"
                :class="{ 'is-active': isActive('heading', { level: 1 }, updatedAt) }">
                <flux:icon.h1 class="size-5" />
            </button>
            <button type="button" @click="toggleHeading({ level: 2 })"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25"
                :class="{ 'is-active': isActive('heading', { level: 2 }, updatedAt) }">
                <flux:icon.h2 class="size-5" />
            </button>
            <button type="button" @click="toggleHeading({ level: 3 })"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25"
                :class="{ 'is-active': isActive('heading', { level: 3 }, updatedAt) }">
                <flux:icon.h3 class="size-5" />
            </button>
            <flux:separator vertical class="my-1" variant="subtle" />
            <button type="button" @click="toggleBulletList()"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25"
                :class="{ 'is-active': isActive('bullet-list', updatedAt) }">
                <flux:icon.list-bullet class="size-5" />
            </button>
            <button type="button" @click="toggleOrderedList()"
                class="relative inline-flex items-center font-medium justify-center gap-2 whitespace-nowrap disabled:opacity-75 dark:disabled:opacity-75 disabled:cursor-default disabled:pointer-events-none h-8 text-sm rounded-md w-8 bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-white/10 dark:hover:bg-white/20 text-zinc-800 dark:text-white group-[]/button:border-r group-[]/button:last:border-r-0 group-[]/button:border-black group-[]/button:dark:border-zinc-900/25"
                :class="{ 'is-active': isActive('bullet-list', updatedAt) }">
                <flux:icon.numbered-list class="size-5" />
            </button>
        </div>
        {{-- </template> --}}

        <div x-ref="editor"
            class="border rounded-lg prose prose-zinc prose-sm dark:prose-invert prose-p:m-0 leading-relaxed py-2 px-3 rounded-lg border border-zinc-300 mt-1 focus:!outline-none focus:ring-2 focus:!ring-zinc-600 focus:border-transparent">
        </div>
    </div>
</div>

And finally the editor.js with some extensions for tiptap installed

import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Highlight from "@tiptap/extension-highlight";
import Underline from "@tiptap/extension-underline";

document.addEventListener("alpine:init", () => {
  window.setupEditor = function(content) {
    let editor;

    return {
      content: content,
      updatedAt: Date.now(), // force Alpine to rerender on selection change
      init(element) {
        const _this = this;
        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, Highlight, Underline],
          content: this.content,
          onCreate({ editor }) {
            _this.updatedAt = Date.now();
          },
          onUpdate: ({ editor }) => {
            _this.updatedAt = Date.now();
            _this.content = editor.getHTML();
            this.$dispatch("input", _this.content);
          },
          onSelectionUpdate({ editor }) {
            _this.updatedAt = Date.now();
          }
        });

        this.editor = editor;

        this.toggleBold = () => editor.chain().toggleBold().focus().run();
        this.toggleItalic = () => editor.chain().toggleItalic().focus().run();
        this.toggleUnderline = () =>
          editor.chain().toggleUnderline().focus().run();
        this.setParagraph = () => editor.chain().setParagraph().focus().run();
        this.toggleBulletList = () =>
          editor.chain().toggleBulletList().focus().run();
        this.toggleOrderedList = () =>
          editor.chain().toggleOrderedList().focus().run();
        this.toggleHighlight = () =>
          editor.chain().toggleHighlight().focus().run();
        this.toggleHeading = opts =>
          editor.chain().toggleHeading(opts).focus().run();
        this.undo = () => editor.chain().undo().focus().run();

        this.$watch("content", content => {
          // If the new content matches TipTap's then we just skip.
          this.$dispatch("input", content);
          if (content === editor.getHTML()) return;
          console.log("updated:" + content);
          /*
            Otherwise, it means that a force external to TipTap
            is modifying the data on this Alpine component,
            which could be Livewire itself.
            In this case, we just need to update TipTap's
            content and we're good to do.
            For more information on the `setContent()` method, see:
              https://www.tiptap.dev/api/commands/set-content
          */
          editor.commands.setContent(content, false);

          // Update the entangled value in the master blade component
        });
      },
      isLoaded() {
        return editor;
      },
      isActive(type, opts = {}) {
        return editor.isActive(type, opts);
      }
    };
  };
});

Best, Philipp

alex Member
alex
Moderator

Ha, this looks exactly how I worked around grabbing the content for a Vue version of the course I did right here — and I've seen this approach in the wild used before.

So, I'd say as long as it worked... looks good to me!

mothannadoubaa
mothannadoubaa

@philipp71206 hi , thanks for your efforts. when i try to apply your code in my project i faced this error :

Alpine Expression Error: init is not defined
Expression: "() => init($refs.editor)"
 <div x-data="setupEditor(window.Livewire.find('V3VS5bMDzmuMFKipJFzl')​.entangle('tiptapContent')​.live)​" x-init="()​ => init($refs.editor)​" wire:ignore>​…​</div>

any help please ?

philipp71206 Member
philipp71206

Hi @mothannadoubaa,

seems like your alpine lib is not properly embedded or your order in app.js is wrong?

My app.js looks like this: import './bootstrap'; import '../../vendor/masmerise/livewire-toaster/resources/js'; import'./tiptap-editor';

mothannadoubaa
mothannadoubaa

Ya thanks alot, it's works fine