Table of Contents
Svelte TipTap component
A basic example on how to use TipTap with svelte components
Work In Progress
Example
Editor.svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import { StarterKit } from '@tiptap/starter-kit';
import BubbleMenu from '@tiptap/extension-bubble-menu';
import CustomNode from './node';
let bubbleMenu: HTMLElement | null = $state(null);
let element: HTMLElement | null = $state(null);
let editorState: { editor: Editor | null } = $state({ editor: null });
onMount(() => {
editorState.editor = new Editor({
element: element,
extensions: [
StarterKit,
BubbleMenu.configure({
element: bubbleMenu,
}),
CustomNode,
],
content: `
<h1>Hello Svelte! 🌍️ </h1>
<p>This editor is running in Svelte.</p>
<node-view data-count="0">Edit me.</node-view>
<p>Select some text to see the bubble menu popping up.</p>
`,
onTransaction: ({ editor }) => {
// Update the state signal to force a re-render
editorState = { editor };
},
});
});
onDestroy(() => {
editorState.editor?.destroy();
});
</script>
<div>
<div>
<button
onclick={() => {
editorState.editor
?.chain()
.focus()
.toggleHeading({ level: 1 })
.run();
}}
class:active={editorState.editor?.isActive('heading', {
level: 1,
})}
>
H1
</button>
<button
onclick={() => {
editorState.editor
?.chain()
.focus()
.toggleHeading({ level: 2 })
.run();
}}
class:active={editorState.editor?.isActive('heading', {
level: 2,
})}
>
H2
</button>
<button
onclick={() => {
editorState.editor?.chain().focus().setParagraph().run();
}}
class:active={editorState.editor?.isActive('paragraph')}
>
P
</button>
</div>
<div bind:this={element}></div>
<div bind:this={bubbleMenu} style="visibility: hidden;">
<button
onclick={() => {
editorState.editor?.chain().focus().toggleBold().run();
}}
class:active={editorState.editor?.isActive('bold')}
>
Bold
</button>
<button
onclick={() => {
editorState.editor?.chain().focus().toggleItalic().run();
}}
class:active={editorState.editor?.isActive('italic')}
>
Italic
</button>
<button
onclick={() => {
editorState.editor?.chain().focus().toggleStrike().run();
}}
class:active={editorState.editor?.isActive('strike')}
>
Strike
</button>
</div>
</div>
<style>
.active {
background: green;
}
</style>
node.js
import { mergeAttributes, Node } from '@tiptap/core'
import CustomNode from './CustomNode.svelte'
import { mount, unmount } from 'svelte'
export default Node.create({
name: 'nodeView',
group: 'block',
content: 'inline*',
addAttributes() {
return {
count: {
default: 0,
parseHTML: (el) => Number(el.getAttribute('data-count')) || 0,
renderHTML: (attrs) =>
attrs.count != null ? { 'data-count': String(attrs.count) } : {},
},
}
},
parseHTML() {
return [{ tag: 'node-view' }]
},
renderHTML({ HTMLAttributes }) {
return ['node-view', mergeAttributes(HTMLAttributes), 0]
},
addNodeView() {
return ({ editor, node, getPos }) => {
const { view } = editor
const holder = document.createElement('div')
let currentNode = node
const instance = mount(CustomNode, {
target: holder,
props: {
count: currentNode.attrs.count,
onclick: (val) => {
const pos = getPos()
if (typeof pos !== 'number') return
const n = view.state.doc.nodeAt(pos)
if (!n) return
view.dispatch(
view.state.tr.setNodeMarkup(pos, undefined, {
...n.attrs,
count: val,
}),
)
editor.commands.focus()
},
},
})
const dom = holder.firstElementChild
const contentDOM = dom.querySelector(
'[data-node-view-content]',
)
return {
dom,
contentDOM,
destroy() {
unmount(instance)
},
update(updatedNode) {
if (updatedNode.type !== currentNode.type) return false
currentNode = updatedNode
instance.syncCount(updatedNode.attrs.count)
return true
},
stopEvent(event) {
const t = event.target
if (!(t instanceof globalThis.Node) || !dom.contains(t)) return false
return !contentDOM.contains(t)
},
ignoreMutation(mutation) {
const t = mutation.target
if (!(t instanceof globalThis.Node) || !dom.contains(t)) return false
return !contentDOM.contains(t)
},
}
}
},
})
CustomNode.svelte
<script lang="ts">
import { onMount } from 'svelte';
let { count, onclick } = $props();
let displayCount = $state(0);
export function syncCount(c: number) {
displayCount = c;
}
onMount(() => {
displayCount = count;
});
</script>
<div class="wrapper">
<div data-node-view-content class="editable"></div>
<div contenteditable="false" class="chrome">
<button type="button" onclick={() => onclick(displayCount + 1)}>
This button has been clicked {displayCount} times.
</button>
</div>
</div>
<style>
.wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid;
padding: 0.5rem;
}
.editable {
border: 1px solid;
padding: 0.5rem;
min-width: 8rem;
flex: 1;
}
.chrome {
flex-shrink: 0;
}
</style>