Skip to main content

Forms

Rich Text Editor code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/rich-text-editor.blade.php

@props([
    'name' => null,
    'value' => '',
    'placeholder' => 'Write something…',
    'id' => null,
    'minHeight' => '12rem',
    'toolbar' => null,
    'disabled' => false,
    'invalid' => false,
])

@php
    /*
    | Dependency-free rich-text (WYSIWYG) editor. A contenteditable surface with a
    | formatting toolbar (bold, italic, headings, lists, quote, link) whose
    | buttons reflect the active selection. The HTML is mirrored into a hidden
    | textarea so it submits as a normal form field under `name`. Block buttons
    | toggle back to a paragraph when pressed on an active block. Theme-aware
    | prose styling adapts to light/dark and the active colour theme.
    |
    | Override the toolbar with `:toolbar` — an array of button groups, each a
    | list of ['command' => ?, 'value' => ?, 'icon' => ?, 'label' => ?,
    | 'block' => false, 'prompt' => false, 'promptText' => ?].
    |
    | Usage:
    |   <x-ui.rich-text-editor name="body" :value="old('body')" placeholder="Tell your story…" />
    */
    $defaultToolbar = [
        [
            ['command' => 'bold', 'icon' => 'text-b', 'label' => 'Bold'],
            ['command' => 'italic', 'icon' => 'text-italic', 'label' => 'Italic'],
            ['command' => 'underline', 'icon' => 'text-underline', 'label' => 'Underline'],
            ['command' => 'strikeThrough', 'icon' => 'text-strikethrough', 'label' => 'Strikethrough'],
        ],
        [
            ['command' => 'formatBlock', 'value' => 'H2', 'icon' => 'text-h-two', 'label' => 'Heading', 'block' => true],
            ['command' => 'formatBlock', 'value' => 'H3', 'icon' => 'text-h-three', 'label' => 'Subheading', 'block' => true],
            ['command' => 'formatBlock', 'value' => 'blockquote', 'icon' => 'quotes', 'label' => 'Quote', 'block' => true],
        ],
        [
            ['command' => 'insertUnorderedList', 'icon' => 'list-bullets', 'label' => 'Bulleted list'],
            ['command' => 'insertOrderedList', 'icon' => 'list-numbers', 'label' => 'Numbered list'],
        ],
        [
            ['command' => 'createLink', 'icon' => 'link-simple', 'label' => 'Insert link', 'prompt' => true, 'promptText' => 'Enter a URL'],
            ['command' => 'removeFormat', 'icon' => 'eraser', 'label' => 'Clear formatting'],
        ],
    ];

    $groups = $toolbar ?? $defaultToolbar;

    $editorClasses = implode(' ', [
        'max-w-none px-4 py-3 text-sm leading-relaxed text-gray-900 dark:text-gray-100 focus:outline-none',
        '[&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:text-xl [&_h2]:font-semibold',
        '[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-lg [&_h3]:font-semibold',
        '[&_p]:my-1.5',
        '[&_a]:text-brand-600 dark:[&_a]:text-brand-400 [&_a]:underline [&_a]:underline-offset-2',
        '[&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-1.5 [&_ol]:list-decimal [&_ol]:ps-6',
        '[&_blockquote]:my-2 [&_blockquote]:border-s-4 [&_blockquote]:border-gray-200 dark:[&_blockquote]:border-gray-700 [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-gray-600 dark:[&_blockquote]:text-gray-400',
        '[&_strong]:font-semibold',
    ]);

    $ring = $invalid
        ? 'border-red-400 dark:border-red-500 focus-within:border-red-500 focus-within:ring-red-500/30'
        : 'border-gray-300 dark:border-gray-700 focus-within:border-brand-500 focus-within:ring-brand-500/30';
@endphp

<div
    x-data="{
        html: @js((string) $value),
        disabled: @js((bool) $disabled),
        state: { bold: false, italic: false, underline: false, strikeThrough: false, insertUnorderedList: false, insertOrderedList: false, block: '' },
        init() {
            this.sync();
            this.refreshState();
        },
        sync() {
            this.html = this.$refs.editor.innerHTML;
            if (this.$refs.input) {
                this.$refs.input.value = this.html;
            }
        },
        get isEmpty() {
            return this.html
                .replace(/<br\s*\/?>/gi, '')
                .replace(/<[^>]*>/g, '')
                .replace(/&nbsp;/gi, ' ')
                .trim() === '';
        },
        refreshState() {
            try {
                this.state = {
                    bold: document.queryCommandState('bold'),
                    italic: document.queryCommandState('italic'),
                    underline: document.queryCommandState('underline'),
                    strikeThrough: document.queryCommandState('strikeThrough'),
                    insertUnorderedList: document.queryCommandState('insertUnorderedList'),
                    insertOrderedList: document.queryCommandState('insertOrderedList'),
                    block: (document.queryCommandValue('formatBlock') || '').toLowerCase(),
                };
            } catch (error) {
                // queryCommandState can throw when the selection is detached.
            }
        },
        btnActive(command, value, isBlock) {
            if (isBlock) {
                return this.state.block === (value || '').toLowerCase();
            }

            return !! this.state[command];
        },
        run(command, value = null, isBlock = false, usePrompt = false, promptText = '') {
            if (this.disabled) {
                return;
            }

            this.$refs.editor.focus();

            if (usePrompt) {
                const input = window.prompt(promptText || 'Enter a value');

                if (! input) {
                    return;
                }

                value = input;
            }

            if (isBlock && this.state.block === (value || '').toLowerCase()) {
                document.execCommand('formatBlock', false, 'P');
            } else {
                document.execCommand(command, false, value);
            }

            this.sync();
            this.refreshState();
        },
        onInput() {
            this.sync();
            this.refreshState();
        },
    }"
    {{ $attributes->merge(['class' => 'overflow-hidden rounded-xl border bg-white shadow-sm transition focus-within:ring-2 dark:bg-gray-900 '.$ring.($disabled ? ' opacity-60' : '')]) }}
>
    {{-- Toolbar --}}
    <div class="flex flex-wrap items-center gap-0.5 border-b border-gray-200 bg-gray-50/80 px-2 py-1.5 dark:border-gray-800 dark:bg-gray-900/60" role="toolbar" aria-label="Text formatting">
        @foreach ($groups as $groupIndex => $buttons)
            @if ($groupIndex > 0)
                <span class="mx-1 h-5 w-px bg-gray-200 dark:bg-gray-700" aria-hidden="true"></span>
            @endif
            @foreach ($buttons as $button)
                @php
                    $command = $button['command'] ?? '';
                    $btnValue = $button['value'] ?? null;
                    $isBlock = (bool) ($button['block'] ?? false);
                    $usePrompt = (bool) ($button['prompt'] ?? false);
                    $promptText = $button['promptText'] ?? '';
                @endphp
                <button
                    type="button"
                    @mousedown.prevent
                    @click="run(@js($command), @js($btnValue), @js($isBlock), @js($usePrompt), @js($promptText))"
                    :aria-pressed="btnActive(@js($command), @js($btnValue), @js($isBlock)).toString()"
                    :class="btnActive(@js($command), @js($btnValue), @js($isBlock))
                        ? 'bg-brand-50 text-brand-700 dark:bg-brand-950/60 dark:text-brand-300'
                        : 'text-gray-500 hover:bg-gray-200/70 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'"
                    class="inline-flex h-8 w-8 items-center justify-center rounded-md transition cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 disabled:cursor-not-allowed"
                    :disabled="disabled"
                    aria-label="{{ $button['label'] ?? $command }}"
                    title="{{ $button['label'] ?? $command }}"
                >
                    <x-ui.icon :name="$button['icon'] ?? 'textbox'" size="base" />
                </button>
            @endforeach
        @endforeach
    </div>

    {{-- Editor surface --}}
    <div class="relative">
        <div
            x-ref="editor"
            @if ($id) id="{{ $id }}" @endif
            @input="onInput()"
            @keyup="refreshState()"
            @mouseup="refreshState()"
            @blur="sync()"
            contenteditable="{{ $disabled ? 'false' : 'true' }}"
            role="textbox"
            aria-multiline="true"
            aria-label="{{ $attributes->get('aria-label') ?: 'Rich text editor' }}"
            style="min-height: {{ $minHeight }}"
            class="{{ $editorClasses }}"
        >{!! $value !!}</div>

        <div
            x-show="isEmpty"
            x-cloak
            class="pointer-events-none absolute left-4 top-3 text-sm text-gray-400 dark:text-gray-500"
            aria-hidden="true"
        >{{ $placeholder }}</div>
    </div>

    @if ($name)
        <textarea x-ref="input" name="{{ $name }}" class="hidden" aria-hidden="true" tabindex="-1">{{ $value }}</textarea>
    @endif
</div>