Trix Editor

Trix text editor powered by ALpineJS and Laravel Blade View Components.

EasyMDE

Usage

Add the style and script file of Trix Editor.

@push('styles')
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/trix/1.2.1/trix.css" />
@endpush

@push('scripts')
<script src="https://cdnjs.cloudflare.com/ajax/libs/trix/1.2.1/trix.js" defer></script>
@endpush
// in blade view

<x-trix-editor 
    label="Body"
    name="body"
    placeholder="Write something..." />

Add this custom css for the component that uses svg icons instead of the default icons.

trix-toolbar {
    position: sticky;
    top: 0;
    z-index: 10;
    background-color: #fff;
    border: 0 !important;
    border-top-right-radius: 0.5em;
    border-top-left-radius: 0.5em;
    padding: 0.5em;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
    margin-left: 1px;
    margin-right: 1px;
    margin-top: 0.5em;
}
.trix-editor {
    margin-top: -2.8em;
    padding-top: 4em;
}
trix-toolbar .trix-button-group {
    /* border-color: #ddd;
    border-bottom-color: #ccc; */
    overflow: hidden;
    border-radius: 4px;
    margin-bottom: 0;
    border: 0;
}
trix-toolbar .trix-button:not(:first-child) {
    border-left: 0; 
}
trix-toolbar .trix-button {
    border: 0;
    background-color: #fff;
}
trix-toolbar .trix-button--icon::before {
    opacity: 0.75;
}
trix-toolbar .trix-button--icon-bold::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-bold' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M7 5h6a3.5 3.5 0 0 1 0 7h-6z' /%3E%3Cpath d='M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-italic::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-italic' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cline x1='11' y1='5' x2='17' y2='5' /%3E%3Cline x1='7' y1='19' x2='13' y2='19' /%3E%3Cline x1='14' y1='5' x2='10' y2='19' /%3E%3C/svg%3E");
}
trix-toolbar .trix-button--icon-strike::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-strikethrough' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cline x1='5' y1='12' x2='19' y2='12' /%3E%3Cpath d='M16 6.5a4 2 0 0 0 -4 -1.5h-1a3.5 3.5 0 0 0 0 7' /%3E%3Cpath d='M16.5 16a3.5 3.5 0 0 1 -3.5 3h-1.5a4 2 0 0 1 -4 -1.5' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-code::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-code' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpolyline points='7 8 3 12 7 16' /%3E%3Cpolyline points='17 8 21 12 17 16' /%3E%3Cline x1='14' y1='4' x2='10' y2='20' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-link::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-link' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M10 14a3.5 3.5 0 0 0 5 0l4 -4a3.5 3.5 0 0 0 -5 -5l-.5 .5' /%3E%3Cpath d='M14 10a3.5 3.5 0 0 0 -5 0l-4 4a3.5 3.5 0 0 0 5 5l.5 -.5' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-bullet-list::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-list' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cline x1='9' y1='6' x2='20' y2='6' /%3E%3Cline x1='9' y1='12' x2='20' y2='12' /%3E%3Cline x1='9' y1='18' x2='20' y2='18' /%3E%3Cline x1='5' y1='6' x2='5' y2='6.01' /%3E%3Cline x1='5' y1='12' x2='5' y2='12.01' /%3E%3Cline x1='5' y1='18' x2='5' y2='18.01' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-decrease-nesting-level::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-indent-decrease' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cline x1='20' y1='6' x2='13' y2='6' /%3E%3Cline x1='20' y1='12' x2='11' y2='12' /%3E%3Cline x1='20' y1='18' x2='13' y2='18' /%3E%3Cpath d='M8 8l-4 4l4 4' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-increase-nesting-level::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-indent-increase' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cline x1='20' y1='6' x2='9' y2='6' /%3E%3Cline x1='20' y1='12' x2='13' y2='12' /%3E%3Cline x1='20' y1='18' x2='9' y2='18' /%3E%3Cpath d='M4 8l4 4l-4 4' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-attach::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-photo' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cline x1='15' y1='8' x2='15.01' y2='8' /%3E%3Crect x='4' y='4' width='16' height='16' rx='3' /%3E%3Cpath d='M4 15l4 -4a3 5 0 0 1 3 0l 5 5' /%3E%3Cpath d='M14 14l1 -1a3 5 0 0 1 3 0l 2 2' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-quote::before {
    background-image: url("data:image/svg+xml,%3Csvg width='1em' height='1em' viewBox='0 0 16 16' class='bi bi-blockquote-left' fill='%232c3e50' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' d='M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm5 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm-5 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z'/%3E%3Cpath d='M3.734 6.352a6.586 6.586 0 0 0-.445.275 1.94 1.94 0 0 0-.346.299 1.38 1.38 0 0 0-.252.369c-.058.129-.1.295-.123.498h.282c.242 0 .431.06.568.182.14.117.21.29.21.521a.697.697 0 0 1-.187.463c-.12.14-.289.21-.503.21-.336 0-.577-.108-.721-.327C2.072 8.619 2 8.328 2 7.969c0-.254.055-.485.164-.692.11-.21.242-.398.398-.562.16-.168.33-.31.51-.428.18-.117.33-.213.451-.287l.211.352zm2.168 0a6.588 6.588 0 0 0-.445.275 1.94 1.94 0 0 0-.346.299c-.113.12-.199.246-.257.375a1.75 1.75 0 0 0-.118.492h.282c.242 0 .431.06.568.182.14.117.21.29.21.521a.697.697 0 0 1-.187.463c-.12.14-.289.21-.504.21-.335 0-.576-.108-.72-.327-.145-.223-.217-.514-.217-.873 0-.254.055-.485.164-.692.11-.21.242-.398.398-.562.16-.168.33-.31.51-.428.18-.117.33-.213.451-.287l.211.352z'/%3E%3C/svg%3E");
}
trix-toolbar .trix-button--icon-number-list::before {
    background-image: url("data:image/svg+xml,%3Csvg width='1em' height='1em' viewBox='0 0 16 16' class='bi bi-list-ol' fill='%232c3e50' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' d='M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5z'/%3E%3Cpath d='M1.713 11.865v-.474H2c.217 0 .363-.137.363-.317 0-.185-.158-.31-.361-.31-.223 0-.367.152-.373.31h-.59c.016-.467.373-.787.986-.787.588-.002.954.291.957.703a.595.595 0 0 1-.492.594v.033a.615.615 0 0 1 .569.631c.003.533-.502.8-1.051.8-.656 0-1-.37-1.008-.794h.582c.008.178.186.306.422.309.254 0 .424-.145.422-.35-.002-.195-.155-.348-.414-.348h-.3zm-.004-4.699h-.604v-.035c0-.408.295-.844.958-.844.583 0 .96.326.96.756 0 .389-.257.617-.476.848l-.537.572v.03h1.054V9H1.143v-.395l.957-.99c.138-.142.293-.304.293-.508 0-.18-.147-.32-.342-.32a.33.33 0 0 0-.342.338v.041zM2.564 5h-.635V2.924h-.031l-.598.42v-.567l.629-.443h.635V5z'/%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-undo::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-arrow-back-up' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M9 13l-4 -4l4 -4m-4 4h11a4 4 0 0 1 0 8h-1' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-redo::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-arrow-forward-up' width='44' height='44' viewBox='0 0 24 24' stroke-width='1.5' stroke='%232c3e50' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M15 13l4 -4l-4 -4m4 4h-11a4 4 0 0 0 0 8h1' /%3E%3C/svg%3E");
}

trix-toolbar .trix-button--icon-heading-1::before {
    background-image: url("data:image/svg+xml,%3Csvg width='1em' height='1em' viewBox='0 0 16 16' class='bi bi-type-h1' fill='%232c3e50' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8.637 13V3.669H7.379V7.62H2.758V3.67H1.5V13h1.258V8.728h4.62V13h1.259zm5.329 0V3.669h-1.244L10.5 5.316v1.265l2.16-1.565h.062V13h1.244z'/%3E%3C/svg%3E");
}

trix-editor:empty:not(:focus)::before {
    color: #a0aec0;
}

.trix-content h1 {
    font-size: 2rem;
    font-weight: 700;
    margin-bottom: 0.75rem;
}

.trix-content a {
    text-decoration: underline;
    cursor: pointer;
    color: #667eea;
}

.trix-content blockquote {
    border-left-color: #667eea;
}

.trix-content pre {
    border-radius: 0.5em;
}

.trix-content ol,
.trix-content ul {
    margin-bottom: 1rem;
}

.trix-content li {
    position: relative;
    padding-left: 1.5em;
    margin-bottom: 1rem;
}
.trix-content ul li:before {
    position: absolute;
    top: 10px;
    left: 0;
    content: "";
    width: 0.4em;
    height: 0.4em;
    background-color: #667eea;
    border-radius: 50%;
    display: inline-block;
}

.trix-content ol {
    counter-reset: custom-counter;
}
.trix-content ol li:before {
    counter-increment: custom-counter;
    position: absolute;
    top: 2px;
    left: 0;
    content: counter(custom-counter) ".";
    display: inline-block;
    font-size: 0.85em;
    font-weight: 500;
    color: #667eea;
    text-align: right;
}

Trix File Upload

By default file upload to server is disabled. To active file upload add a upload key with value true. Then create two endpoints in your route web.php

Route::post('/uploads', 'UploadController@upload');
Route::post('/uploads/remove', 'UploadController@remove');

In the controllers folder create a new controller named UploadController.php and add the following code:

<?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')) {
            $path = $request->image->store('editor-uploads');

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

        return;
    }

    public function remove(Request $request)
    {
        if ($request->has('image')) {
            $image = $request->image;
            $exists = Storage::disk('public')->exists($image);
            if ($exists) {
                Storage::delete($image);

                return response()->json('Deleted', 200);
            }
        }
    }
}
// in blade view
<x-trix-editor
    label="Body"
    name="body"
    placeholder="Write something..." 
    upload="true"
    endpoint="/uploads" 
    delete-endpoint="/uploads/remove"
/>

Content Styles

Add the following css styles for styling the content in your view

.markdown-content {
    max-width: 65ch; // change according to need...
}
.markdown-content pre code {
    padding: 1rem;
    font-size: 0.9rem;
}
.markdown-content div,
.markdown-content p,
.markdown-content pre,
.markdown-content blockquote,
.markdown-content ol,
.markdown-content ul {
    margin-bottom: 1.5rem;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
    color: #1a202c;
    font-size: 2rem;
    font-weight: bold;
    margin-top: 0;
    margin-bottom: 0.75rem;
    line-height: 1.2;
}
.markdown-content strong {
    font-weight: bold;
}
.markdown-content blockquote {
    display: block;
    border-left: 4px solid #667eea;
    padding-left: 0.8em;
    font-size: 1.25rem;
    font-style: italic;
    font-weight: 400;
}
.markdown-content a {
    color: #667eea;
    text-decoration: underline;
    text-decoration-color: hsla(229,76%,66%, 0.3);
    -moz-text-decoration-color: hsla(229,76%,66%, 0.3);
}
.markdown-content a:hover {
    text-decoration-color: hsla(229,76%,66%, 1);
    -moz-text-decoration-color: hsla(229,76%,66%,1);
}

.markdown-content ol,
.markdown-content ul {
    display: block;
}

.markdown-content li {
    position: relative;
    padding-left: 1.5em;
    margin-bottom: 1rem;
}
.markdown-content ul li:before {
    position: absolute;
    top: 10px;
    left: 0;
    content: "";
    width: 0.4em;
    height: 0.4em;
    background-color: #667eea;
    border-radius: 50%;
    display: inline-block;
}

.markdown-content ol {
    counter-reset: custom-counter;
}
.markdown-content ol li:before {
    counter-increment: custom-counter;
    position: absolute;
    top: 2px;
    left: 0;
    content: counter(custom-counter) ".";
    display: inline-block;
    font-size: 0.85em;
    font-weight: 500;
    color: #667eea;
    text-align: right;
}

Component

// components/trix-editor.blade.php

<div class="mb-5">
     @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

    @php $id = $name . Str::random(8); @endphp

    <input 
        id="{{ $id }}" 
        type="hidden" 
        name="{{ $name }}" 
        x-ref="{{ $name }}" 
        value="{{ old($name, $value ?? '') }}" />

    <div x-data="{ 
            csrf: '{{ csrf_token() }}',
            error: '', 
            showUploadButton: Boolean('{{ $upload ?? false }}'),
            endpoint: '{{ $endpoint ?? '' }}',
            deleteEndpoint: '{{ $deleteEndpoint ?? '' }}',
            uploadAttachment(event) {
                console.log(event.attachment);
                let attachment = event.attachment;
                let file = attachment.file
                if (file) {
                    let form = new FormData; 
                    form.append('Content-Type', file.type);
                    form.append('image', file);

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

                    xhr.upload.onprogress = function(event) {
                        var progress = event.loaded / event.total * 100;
                        return attachment.setUploadProgress(progress);
                    };

                    xhr.onload = function() {
                        if (this.status >= 200 && this.status < 300) {
                            var data = JSON.parse(this.responseText);
                            return attachment.setAttributes({
                                url: data.url,
                                href: data.url
                            });
                        }
                    };

                    return xhr.send(form);
                }
            },
            deleteAttachment(event) {
                // console.log(event.attachment.attachment);
                let attachment = event.attachment;

                let url = attachment.attachment.attributes.values.url.split('/');
                // console.log(`${url[1]}/${url[2]}`);
                let previewURL = `${url[1]}/${url[2]}`;

                if (previewURL && this.deleteEndpoint) {
                    let form = new FormData; 
                    form.append('image', previewURL);

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

                    xhr.upload.onprogress = function(event) {
                        var progress = event.loaded / event.total * 101;
                        return attachment.setUploadProgress(progress);
                    };

                    xhr.onload = function() {
                        if (this.status >= 201 && this.status < 300) {
                            var data = JSON.parse(this.responseText);
                            return '';
                        }
                    };

                    return xhr.send(form);
                }
            }
        }" 
        x-init="
            document.addEventListener('DOMContentLoaded', () => {
                Trix.config.attachments.preview.caption = {
                    name: false,
                    size: false
                };
            });
        "
        @js-errors.window="error = $event.detail.errors.{{ $name }} || ''" 
        class="relative"
        x-cloak>

        <trix-editor 
            placeholder="{{ $placeholder ?? '' }}"
            input="{{ $id }}" 
            class="trix-editor border-gray-300 trix-content"
            :class="{' border-red-500 bg-red-100' : error.length || '{{ $errors->has($name) }}'}"
            x-ref="trix-editor"
            x-on:trix-change="$dispatch('input', event.target.value)"
            x-on:keydown="error.length ? error = '' : ''"
            x-on:trix-initialize="$refs['trix-editor'].classList.add('rounded-lg', 'bg-white', 'shadow-sm', 'p-6')
                uploadBtn = document.querySelector('.trix-button-group--file-tools');
                if (showUploadButton == true) {
                    uploadBtn.setAttribute('style', 'display: block');
                } else  {
                    uploadBtn.setAttribute('style', 'display: none');
                }
            "
            x-on:trix-focus="$refs['trix-editor'].classList.add('focus:shadow-outline', 'focus:border-blue-300')"
            x-on:trix-attachment-add="showUploadButton == true ? uploadAttachment(event) : ''"
            x-on:trix-attachment-remove="deleteAttachment(event)"
            ></trix-editor>

        @isset($hint)
            <div class="text-sm text-gray-500 my-2 leading-tight help-text">{{ $hint }}</div>
        @endisset

        <div x-show="error.length > 0">
            <svg class="absolute text-red-600 fill-current w-5 h-5" style="top: 48px; 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 error-text" x-html="error"></div>
        </div>

        @error($name)
            <svg class="absolute text-red-600 fill-current w-5 h-5" style="top: 48px; 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 error-text">{{ $message }}</div>
        @enderror

    </div>
</div>