Skip to content

Instantly share code, notes, and snippets.

@ekrist1
Last active August 14, 2025 18:24
Show Gist options
  • Save ekrist1/7273536d6302e627c1ad59fcec290022 to your computer and use it in GitHub Desktop.
Save ekrist1/7273536d6302e627c1ad59fcec290022 to your computer and use it in GitHub Desktop.
TipTap editor for Laravel Livewire 3
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
// Register our Alpine component safely whether Alpine is already initialized or not.
function registerTiptapComponent(Alpine) {
if (!Alpine || typeof Alpine.data !== 'function') return;
Alpine.data('tiptapEditor', () => {
// Keep editor instance out of Alpine reactivity to avoid mismatched transactions
let editor = null;
let updateWireContent = null;
let initializing = false;
return ({
get editor() {
return editor;
},
init() {
this.setupEditor();
// Allow external callers (like modals) to safely focus/blur the editor without
// triggering Alpine transaction mismatches.
this.$el.addEventListener('tiptap:focus', () => this.focusEditor());
this.$el.addEventListener('tiptap:blur', () => this.blurEditor());
},
setupEditor() {
// Prevent multiple initializations and double scheduling
if (editor || initializing) return;
initializing = true;
// Wait for next tick to ensure DOM is ready
this.$nextTick(() => {
if (!this.$refs.editor) { initializing = false; return; }
// If an editor instance is already attached to this element, bail.
if (this.$refs.editor.__tiptapEditor) { initializing = false; return; }
// Read initial state from Livewire
const getProp = (name, fallback = '') => {
try {
if (this.$wire && typeof this.$wire.get === 'function') {
return this.$wire.get(name) ?? fallback;
}
return (this.$wire && name in this.$wire) ? this.$wire[name] ?? fallback : fallback;
} catch (_) {
return fallback;
}
};
const initialContent = getProp('content', '');
const placeholder = getProp('placeholder', 'Write something awesome…');
// Lightweight debounce helper
const debounce = (fn, wait = 200) => {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
};
updateWireContent = debounce((html) => {
this.$wire?.set?.('content', html);
}, 200);
// Create the editor
editor = new Editor({
element: this.$refs.editor,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
underline: false,
link: false,
}),
Underline,
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
}),
Placeholder.configure({ placeholder }),
],
content: initialContent,
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[200px] cursor-text',
style: 'outline: none !important; border: none !important; box-shadow: none !important;'
},
},
onUpdate: ({ editor }) => {
updateWireContent?.(editor.getHTML());
},
onCreate: () => { /* force a tick if needed */ },
onSelectionUpdate: () => { /* can be used to refresh toolbar */ },
});
// Mark the DOM node with our editor instance
this.$refs.editor.__tiptapEditor = editor;
initializing = false;
});
},
// Safely focus the editor after Alpine finishes DOM updates (great for modals)
focusEditor() {
requestAnimationFrame(() => queueMicrotask(() => editor?.chain().focus().run()));
},
// Optional: programmatically blur
blurEditor() {
requestAnimationFrame(() => queueMicrotask(() => editor?.commands.blur?.()));
},
// Command execution for toolbar
executeCommand(command, options = {}) {
if (!editor) return;
const commands = {
toggleBold: () => editor.chain().focus().toggleBold().run(),
toggleItalic: () => editor.chain().focus().toggleItalic().run(),
toggleUnderline: () => editor.chain().focus().toggleUnderline().run(),
setParagraph: () => editor.chain().focus().setParagraph().run(),
toggleHeading: () => editor.chain().focus().toggleHeading(options).run(),
toggleBulletList: () => editor.chain().focus().toggleBulletList().run(),
toggleOrderedList: () => editor.chain().focus().toggleOrderedList().run(),
toggleCodeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
undo: () => editor.chain().focus().undo().run(),
redo: () => editor.chain().focus().redo().run(),
};
if (commands[command]) {
// Let Alpine finish its transaction before TipTap mutates the DOM
queueMicrotask(() => commands[command]());
}
},
// Check if command is active for button styling
isActive(command, options = {}) {
if (!editor) return false;
switch (command) {
case 'bold': return editor.isActive('bold');
case 'italic': return editor.isActive('italic');
case 'underline': return editor.isActive('underline');
case 'paragraph': return editor.isActive('paragraph');
case 'heading': return editor.isActive('heading', options);
case 'bulletList': return editor.isActive('bulletList');
case 'orderedList': return editor.isActive('orderedList');
case 'codeBlock': return editor.isActive('codeBlock');
default: return false;
}
},
// Get button classes based on active state
getButtonClass(command, options = {}) {
const baseClasses = 'px-2 py-1 rounded';
const active = this.isActive(command, options);
return active ? `${baseClasses} bg-gray-900 text-white` : `${baseClasses} hover:bg-gray-100`;
},
// Link management
setLink() {
if (!editor) return;
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
},
unsetLink() {
editor?.chain().focus().unsetLink().run();
},
// Cleanup
destroy() {
if (editor) {
editor.destroy();
editor = null;
}
if (this.$refs?.editor && this.$refs.editor.__tiptapEditor) {
delete this.$refs.editor.__tiptapEditor;
}
},
});
});
}
// If Alpine is already on the page, register now; otherwise wait for it to init.
if (window.Alpine) {
registerTiptapComponent(window.Alpine);
} else {
document.addEventListener('alpine:init', () => registerTiptapComponent(window.Alpine));
}
<div
x-data="tiptapEditor"
x-ref="tiptapRoot"
x-on:destroy.window="destroy()"
class="bg-white rounded-2xl shadow border"
x-bind:key="$id('tiptap')"
wire:ignore
>
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-1 p-2 border-b">
<button type="button" @click="executeCommand('toggleBold')" :class="getButtonClass('bold')">B</button>
<button type="button" @click="executeCommand('toggleItalic')" :class="getButtonClass('italic')"><em>I</em></button>
<button type="button" @click="executeCommand('toggleUnderline')" :class="getButtonClass('underline')"><u>U</u></button>
<span class="w-px h-6 bg-gray-200 mx-1"></span>
<button type="button" @click="executeCommand('setParagraph')" :class="getButtonClass('paragraph')">P</button>
<button type="button" @click="executeCommand('toggleHeading', { level: 1 })" :class="getButtonClass('heading', {level:1})">H1</button>
<button type="button" @click="executeCommand('toggleHeading', { level: 2 })" :class="getButtonClass('heading', {level:2})">H2</button>
<button type="button" @click="executeCommand('toggleHeading', { level: 3 })" :class="getButtonClass('heading', {level:3})">H3</button>
<span class="w-px h-6 bg-gray-200 mx-1"></span>
<button type="button" @click="executeCommand('toggleBulletList')" :class="getButtonClass('bulletList')">• Liste</button>
<button type="button" @click="executeCommand('toggleOrderedList')" :class="getButtonClass('orderedList')">1. Liste</button>
<button type="button" @click="executeCommand('toggleCodeBlock')" :class="getButtonClass('codeBlock')">{ }</button>
<span class="w-px h-6 bg-gray-200 mx-1"></span>
<button type="button" @click="setLink()" class="px-2 py-1 rounded hover:bg-gray-100">Lenke</button>
<button type="button" @click="unsetLink()" class="px-2 py-1 rounded hover:bg-gray-100">Avlenke</button>
<span class="w-px h-6 bg-gray-200 mx-1"></span>
<button type="button" @click="executeCommand('undo')" class="px-2 py-1 rounded hover:bg-gray-100">Angre</button>
<button type="button" @click="executeCommand('redo')" class="px-2 py-1 rounded hover:bg-gray-100">Gjør om</button>
</div>
<!-- Editor surface -->
<div class="p-3">
<div class="border rounded-xl min-h-[240px] p-4 leading-7 cursor-text hover:bg-gray-50 transition-colors"
@click.prevent="focusEditor()"
style="outline: none !important;">
<div x-ref="editor" class="prose max-w-none min-h-[200px] cursor-text text-xl"
style="outline: none !important; border: none !important; box-shadow: none !important;"></div>
</div>
</div>
</div>
// app/Livewire/Editor.php
<?php
namespace App\Livewire;
use Livewire\Attributes\Modelable;
use Livewire\Component;
class Editor extends Component
{
#[Modelable]
public string $content = '';
public string $placeholder = 'Write something awesome…';
public function render()
{
return view('livewire.editor');
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment