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(/ /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>