Quill Editor

Quill text editor powered by ALpineJS and Laravel Blade View Components. It supports file uploading also.

Quill Editor

Usage

Add the style and script file of Quill Editor.

@push('styles')
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
@endpush

@push('scripts')
<script src="https://cdn.quilljs.com/1.3.6/quill.js" defer></script>
<script src="https://unpkg.com/quill-paste-smart@latest/dist/quill-paste-smart.js" defer></script>
@endpush

Custom styles for Quill Editor

/* Toolbar Styles */
.ql-editor-haserror .ql-toolbar.ql-snow + .ql-container.ql-snow {
    border: 1px solid #f56565;
    border-radius: 0.5rem;
}
.ql-toolbar.ql-snow + .ql-container.ql-snow {
    border: 1px solid #e2e8f0; 
    border-radius: 0.5rem;
}
.ql-toolbar.ql-snow {
    font-family: inherit;
    border-top-left-radius: 0.5rem;
    border-top-right-radius: 0.5rem;
    background-color: #fff;
    border: none;
    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    position: sticky;
    top: 0;
    z-index: 1;
    margin-left: 1px;
    margin-right: 1px;
}
.ql-container {
    color: #2d3748;
    font-family: inherit;
    font-size: inherit;
}
.ql-container.ql-snow {
    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    border-color: #e2e8f0;
    margin-top: -44px;
}
.ql-editor {
    overflow-y: visible; 
    padding-top: 64px;
}
.ql-scrolling-container {
    height: 100%;
    min-height: 100%;
    overflow-y: auto;
}

.ql-editor.ql-blank::before {
    color: #a0aec0;
    font-style: normal;
}
.ql-editor:focus {
    border-radius: 0.5rem;
    box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}

.ql-editor h1,
.ql-editor h2,
.ql-editor h3 {
    font-size: 1.75rem !important;
    font-weight: 700;
    color: #2d3748;
    border-bottom: 0;
    margin-bottom: 0.75em;
    line-height: 1.2;
}
.ql-editor p,
.ql-editor ul,
.ql-editor ol,
.ql-snow .ql-editor pre {
    margin-bottom: 1em;
}
.ql-editor strong {
    font-weight: 700;
}
.ql-editor ol, 
.ql-editor ul {
    padding-left: 0;
}
.ql-editor li {
    margin-bottom: 0.25em;
}
.ql-editor a {
    color: #4299e1;
}
.ql-editor blockquote {
    position: relative;
    display: block;
    margin-top: 1.875em !important;
    margin-bottom: 1.875em !important;
    font-size: 1.875rem;
    line-height: 1.2;
    border-left: 3px solid #cbd5e0;
    font-weight: 600;
    color: #4a5568;
    font-style: normal;
    letter-spacing: -0.05em;
}
.ql-snow .ql-editor pre {
    display: block;
    border-radius: 0.5rem;
    padding: 1rem;
    font-size: 1rem;
}
.ql-snow .ql-editor img {
    border-radius: 0.5rem;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.ql-editor iframe {
    width: 100%;
    max-width: 100%;
    height: 400px;
}
// in blade view

<x-quill-editor 
    label="Body" 
    name="body" 
    value="" 
    endpoint="/uploads"
    placeholder="Content here..." />

Create an endpoint for image upload to work. See the sample Controller.

Route::post('/uploads', 'UploadController@upload')->name('upload');
// UploadController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class UploadController extends Controller
{
    public function upload(Request $request)
    {
        if ($request->file('image')) {

            if (is_array($request->image)) {
                $path = collect($request->image)->map->store('tmp-editor-uploads');
            } else {
                $path = $request->image->store('tmp-editor-uploads');                
            }

            return response()->json([
                'url' => $path
            ], 200);
        }

        return;
    }
}

Component

// components/quill-editor.blade.php

<div 
    class="mb-5" 
    x-data="{ 
        content: '',
        endpoint: '{{ $endpoint ?? '' }}',
        csrf: '{{ csrf_token() }}',
        selectLocalImage(quillInstance) {
            const input = document.createElement('input');
            input.setAttribute('type', 'file');
            input.click();

            // Listen upload local image and save to server
            input.onchange = () => {
                const file = input.files[0];

                // file type is only image.
                if (/^image\//.test(file.type)) {
                    this.saveToServer(file, quillInstance);
                } else {
                    console.warn('You could only upload images.');
                }
            };
        },
        saveToServer(file, quillInstance) {
            const fd = new FormData();
            fd.append('image', file);

            const xhr = new XMLHttpRequest();
            xhr.open('POST', this.endpoint, true);
            xhr.setRequestHeader('X-CSRF-Token', this.csrf);

            xhr.upload.onprogress = function(event) {
                var progress = Math.round(event.loaded / event.total * 100) + '%'; 
                var progressBar = document.getElementById('quillProgressBar');

                if (event.lengthComputable) {  
                    progressBar.style = `width: ${parseFloat(progress)}`; 

                    // Upload finished
                    if (event.loaded == event.total) {
                        progressBar.style = 'width: 0%'; 
                    }
                }
            };

            xhr.onload = function () {
                if (this.status >= 200 && this.status < 300) {
                    // this is callback data: url
                    const data = JSON.parse(this.responseText);
                    // console.log(data);

                    // push image url to rich editor.
                    const range = quillInstance.getSelection();
                    quillInstance.insertEmbed(range.index, 'image', `/${data.url}`);
                    // puts the cursor at the end of image
                    quillInstance.setSelection(range.index + 1, Quill.sources.SILENT);
                }
            };
            xhr.send(fd);
        } 
    }" 
    x-init="
        document.addEventListener('DOMContentLoaded', () => {
            quill = new Quill($refs.quillEditor, {
                scrollingContainer: '.ql-scrolling-container',
                modules: {
                    toolbar: {
                        container: [
                            [{'header': 2}, 'bold', 'italic', 'underline', 'strike'],
                            ['link', 'blockquote', 'code-block', 'image', 'video'],
                            [{ list: 'ordered' }, { list: 'bullet' }],
                            ['clean']
                        ]
                        // handlers: {
                        //  image: function () {
                        //      var range = quill.getSelection();
                        //      var value = prompt('Please enter your image URL');
                        //      if(value){
                        //          quill.insertEmbed(range.index, 'image', value, Quill.sources.USER);
                        //      }
                        //  }
                        // }
                    }
                },
                theme: 'snow',
                placeholder: '{{ $placeholder ?? 'Write something great!' }}'
            });
            quill.on('text-change', function () {
                let html = quill.root.innerHTML;
                if (html === '<p><br></p>') html = ''
                content = html;
            });
            quill.clipboard.addMatcher(Node.ELEMENT_NODE, function (node, delta) {
                var plaintext = node.innerText.replace(/\s+/g, ' ').trim();
                var Delta = Quill.import('delta');
                return new Delta().insert(plaintext);
            });
            // quill editor add image handler
            quill.getModule('toolbar').addHandler('image', () => {
                selectLocalImage(quill);
            });
            content = (quill.root.innerHTML === '<p><br></p>')
                    ? '' 
                    : quill.root.innerHTML;
        })
    "
    x-cloak>
    @if($label ?? null)
        <label for="{{ $name }}" class="form-label block mb-1 font-semibold text-gray-700">
            {{ $label }}
            @if($optional ?? null)
                <span class="text-sm text-gray-500 font-normal">(optional)</span>
            @endif
        </label>
    @endif

    <div class="relative {{ $errors->has($name) ? 'ql-editor-haserror' : '' }}">

        <div class="w-full pl-px pr-px bg-transparent z-20 absolute left-0 right-0" style="top: 38px;">
          <div id="quillProgressBar" class="bg-green-600 text-xs leading-none h-1" style="width: 0%"></div>
        </div>

        <textarea class="hidden" name="{{ $name }}" :value="content"></textarea>
        <div x-ref="quillEditor" x-model="content" class="bg-white min-h-full h-auto">
            {!! old($name, $value ?? '') !!}
        </div>

        @error($name)
            <svg class="absolute z-10 text-red-600 fill-current w-5 h-5" style="top: 12px; right: 12px"
                xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                <path
                    d="M11.953,2C6.465,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.493,2,11.953,2z M13,17h-2v-2h2V17z M13,13h-2V7h2V13z" />
            </svg>
            <div class="text-red-600 mt-2 text-sm block leading-tight">{{ $message }}</div>
        @enderror
    </div>
</div>