Last active
August 14, 2025 18:24
-
-
Save ekrist1/7273536d6302e627c1ad59fcec290022 to your computer and use it in GitHub Desktop.
TipTap editor for Laravel Livewire 3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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