Published on

QuillJS & Alpine

Author

I'm working on an app that requires some specific features in a rich text editor widget. I picked Quill, and I'll have more to say on that (probably), but I wanted to make some notes on a specific Quill & AlpineJS problem I ran into.

To keep things simple, it would be best if the editor would accept and produce HTML with style= attributes for colours and things. Good ol' [HTMLPurifier](https://htmlpurifier.org] can keep user-supplied markup under control.

The app will use AlpineJS and Livewire, so I wanted it to play nicely with Alpine, which is a normal thing that should Just Work1 out of the box.

I started with TinyMCE, but I ran into a pretty serious bug that makes it unsuitable. I'm not really sure when to even start debugging that in its codebase ... so I moved on to something else I was familiar with: Quill.

AlpineJS Gotcha

I started out initializing it like I would anything else, and it appeared to work just fine:

<div
    x-data="{ rte: null, getHTML: () => $refs.editor.querySelector('.ql-editor').innerHTML }"
    x-init="
				rte = new Quill($refs.editor, {/* config */}); 
				$el.closest('form').addEventListener('formdata', (e) => e.formData.append($refs.editor.getAttribute('name'), getHTML()))
		"
>
	<div x-ref="editor" name="profile">
			<!-- Editable user content here -->
		</div>
</div>

This will initialize the editor. The second line in the x-init finds the parent <form> and does the plumbing to add the editor's contents to the form submission; this doesn't happen automatically because Quill's editor is a <div> instead of a form input.

It'll work great right up until you want to customize the toolbar and call the format() method, when you will start seeing blot is null (or perhaps i is null if it's minified) errors. I hit this when I was working on my own toolbar:

<div
    x-data="{ rte: null, getHTML: () => $refs.editor.querySelector('.ql-editor').innerHTML }"
    x-init="
				rte = new Quill($refs.editor, { modules: { toolbar: $refs.toolbar } }); 
				$el.closest('form').addEventListener('formdata', (e) => e.formData.append($refs.editor.getAttribute('name'), getHTML()))
		"
>
	<div x-ref="toolbar">
			<!-- A lot of buttons, but here's the interesting one: -->
			<div class="ql-formats">
        <button class="ql-details" x-on:click="rte.format('bold', true, 'user')">
            <i class="fa-regular fa-square-caret-down fa-fw d-block"></i>
        </button>
    </div>
	</div>
	
	<div x-ref="editor" name="profile">
			<!-- ... -->
		</div>
</div>

Clicking on that should make the text bold2, but would instead give me the blot is null error. Here's an example on CodePen.

Solution

I got a hint from Quill issue 4132, where Daniel Pinto Salazar was having a similar problem with Vue properties:

It seems that using a reactive variable with the Quill editor can cause conflicts when events are generated within the editor. Using a non-reactive variable for the Quill instance resolved the issue for me.

In AlpineJS, all the variables under x-data are being wrapped with a proxy object. That's how it monitors them for changes so all the reactive stuff works.

To solve it, I shuffled things around a little bit on my Alpine component <div>:

<div
    x-data="{
        getHTML: () => $refs.editor.querySelector('.ql-editor').innerHTML,
        init: () => {
            rte = RichEditor($refs.editor, $refs.toolbar);
        }
    }"
    x-init="$el.closest('form').addEventListener('formdata', (e) => e.formData.append($refs.editor.getAttribute('name'), getHTML()));">

The rte variable is no longer an x-data property, but remains available to the x-on:click handlers in my toolbar. Here's a working CodePen for reference.


  1. Spoiler: no. 

  2. Which isn't what it's ultimately for, but I was figuring out how to add working buttons before I started implementing a blot for <details><summary>...</summary> ...</details>