Editor Plugins
Create custom editor extensions for Lokus’s TipTap-based rich text editor. Add custom nodes, marks, extensions, and editor behaviors.
Editor Architecture
Lokus uses TipTap 3 (built on ProseMirror) for its editor. Plugins can extend the editor with:
- Nodes - Block-level content (paragraphs, headings, code blocks)
- Marks - Inline formatting (bold, italic, links)
- Extensions - Editor behaviors (keyboard shortcuts, paste handlers)
- Decorations - Visual overlays (syntax highlighting, spell check)
Creating Custom Nodes
Basic Node Example
import { Node } from '@tiptap/core'
const CustomBlock = Node.create({
name: 'customBlock',
group: 'block',
content: 'inline*',
parseHTML() {
return [
{
tag: 'div[data-type="custom-block"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-type': 'custom-block', ...HTMLAttributes }, 0]
},
addCommands() {
return {
insertCustomBlock: () => ({ commands }) => {
return commands.insertContent({
type: this.name,
content: [{ type: 'text', text: 'Custom block content' }],
})
},
}
},
})
Register Node in Plugin
export default class EditorPlugin implements Plugin {
async activate(context: PluginContext) {
// Register the custom node
context.api.editor.registerExtension(CustomBlock)
// Register command to insert it
context.subscriptions.push(
context.api.commands.register({
id: 'myPlugin.insertCustomBlock',
title: 'Insert Custom Block',
handler: async () => {
const editor = await context.api.editor.getActiveEditor()
editor?.commands.insertCustomBlock()
}
})
)
}
}
Interactive Node Example
Create a node with custom React component:
import { Node, ReactNodeViewRenderer } from '@tiptap/react'
const InteractiveNode = Node.create({
name: 'interactiveNode',
group: 'block',
atom: true,
addAttributes() {
return {
count: {
default: 0,
},
}
},
parseHTML() {
return [{ tag: 'interactive-node' }]
},
renderHTML({ HTMLAttributes }) {
return ['interactive-node', HTMLAttributes]
},
addNodeView() {
return ReactNodeViewRenderer(InteractiveNodeComponent)
},
})
// React component
function InteractiveNodeComponent({ node, updateAttributes }) {
return (
<div className="interactive-node">
<p>Count: {node.attrs.count}</p>
<button onClick={() => updateAttributes({ count: node.attrs.count + 1 })}>
Increment
</button>
</div>
)
}
Creating Custom Marks
Basic Mark Example
import { Mark } from '@tiptap/core'
const CustomHighlight = Mark.create({
name: 'customHighlight',
addAttributes() {
return {
color: {
default: 'yellow',
parseHTML: element => element.getAttribute('data-color'),
renderHTML: attributes => {
return { 'data-color': attributes.color }
},
},
}
},
parseHTML() {
return [
{
tag: 'mark[data-type="custom-highlight"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['mark', { 'data-type': 'custom-highlight', ...HTMLAttributes }, 0]
},
addCommands() {
return {
setCustomHighlight: attributes => ({ commands }) => {
return commands.setMark(this.name, attributes)
},
toggleCustomHighlight: attributes => ({ commands }) => {
return commands.toggleMark(this.name, attributes)
},
}
},
})
Creating Extensions
Keyboard Shortcut Extension
import { Extension } from '@tiptap/core'
const CustomShortcuts = Extension.create({
name: 'customShortcuts',
addKeyboardShortcuts() {
return {
// Ctrl/Cmd + Shift + H
'Mod-Shift-h': () => this.editor.commands.setHeading({ level: 1 }),
// Ctrl/Cmd + D - Duplicate line
'Mod-d': () => {
const { state } = this.editor
const { from, to } = state.selection
const text = state.doc.textBetween(from, to, '\n')
return this.editor.commands.insertContentAt(to, `\n${text}`)
},
// Tab - Indent
'Tab': () => this.editor.commands.sinkListItem('listItem'),
// Shift+Tab - Outdent
'Shift-Tab': () => this.editor.commands.liftListItem('listItem'),
}
},
})
Paste Handler Extension
const SmartPaste = Extension.create({
name: 'smartPaste',
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handlePaste: (view, event, slice) => {
const text = event.clipboardData?.getData('text/plain')
// Handle URL paste
if (text && /^https?:\/\//.test(text)) {
const { state, dispatch } = view
const { tr } = state
tr.insertText(`[${text}](${text})`)
dispatch(tr)
return true
}
// Handle code paste
if (event.clipboardData?.types.includes('text/html')) {
const html = event.clipboardData.getData('text/html')
if (html.includes('<pre') || html.includes('<code')) {
// Insert as code block
this.editor.commands.setCodeBlock()
return false
}
}
return false
},
},
}),
]
},
})
Syntax Highlighting
Adding Language Support
import { lowlight } from 'lowlight'
import rust from 'highlight.js/lib/languages/rust'
export default class RustSyntaxPlugin implements Plugin {
async activate(context: PluginContext) {
// Register Rust language
lowlight.registerLanguage('rust', rust)
// Add to manifest
context.api.languages.registerLanguage({
id: 'rust',
extensions: ['.rs'],
aliases: ['Rust', 'rust-lang'],
})
}
}
Completion Providers
Custom Autocomplete
context.api.editor.registerCompletionProvider(
'markdown',
{
provideCompletionItems(document, position, token, context) {
// Trigger on @mention
if (context.triggerCharacter === '@') {
return [
{
label: '@john',
kind: CompletionItemKind.User,
insertText: '@john',
detail: 'John Doe',
documentation: 'Mention John Doe'
},
{
label: '@jane',
kind: CompletionItemKind.User,
insertText: '@jane',
detail: 'Jane Smith',
}
]
}
// Trigger on #hashtag
if (context.triggerCharacter === '#') {
return [
{
label: '#todo',
kind: CompletionItemKind.Keyword,
insertText: '#todo',
},
{
label: '#important',
kind: CompletionItemKind.Keyword,
insertText: '#important',
}
]
}
return []
}
},
'@', '#' // Trigger characters
)
Editor Commands
Custom Editor Commands
// Register custom editor command
context.api.commands.register({
id: 'myPlugin.formatMarkdown',
title: 'Format Markdown',
handler: async () => {
const content = await context.api.editor.getContent()
// Format the markdown
const formatted = formatMarkdown(content)
// Update editor
await context.api.editor.setContent(formatted)
context.api.ui.showNotification('Markdown formatted', 'success')
}
})
// Register selection-based command
context.api.commands.register({
id: 'myPlugin.uppercaseSelection',
title: 'Uppercase Selection',
handler: async () => {
const selection = await context.api.editor.getSelection()
if (!selection) return
const text = await context.api.editor.getTextInRange({
start: selection.start,
end: selection.end
})
await context.api.editor.replaceText(
{ start: selection.start, end: selection.end },
text.toUpperCase()
)
}
})
Decorations
Adding Visual Decorations
import { Decoration, DecorationSet } from '@tiptap/pm/view'
const TodoHighlight = Extension.create({
name: 'todoHighlight',
addProseMirrorPlugins() {
return [
new Plugin({
state: {
init(_, { doc }) {
return findTodos(doc)
},
apply(tr, oldState) {
return tr.docChanged ? findTodos(tr.doc) : oldState
},
},
props: {
decorations(state) {
return this.getState(state)
},
},
}),
]
},
})
function findTodos(doc): DecorationSet {
const decorations = []
doc.descendants((node, pos) => {
if (node.isText && node.text) {
const regex = /TODO:([^\n]+)/g
let match
while ((match = regex.exec(node.text))) {
const from = pos + match.index
const to = from + match[0].length
decorations.push(
Decoration.inline(from, to, {
class: 'todo-highlight',
style: 'background-color: yellow; font-weight: bold;'
})
)
}
}
})
return DecorationSet.create(doc, decorations)
}
Best Practices
Performance
// Bad: Recreate extension on every render
function MyComponent() {
const CustomNode = Node.create({ /* ... */ })
return <Editor extensions={[CustomNode]} />
}
// Good: Create once
const CustomNode = Node.create({ /* ... */ })
function MyComponent() {
return <Editor extensions={[CustomNode]} />
}
Error Handling
const SafeNode = Node.create({
name: 'safeNode',
addCommands() {
return {
insertSafeNode: () => ({ commands, editor }) => {
try {
return commands.insertContent({ type: this.name })
} catch (error) {
console.error('Failed to insert node:', error)
return false
}
},
}
},
})
Testing
import { createTestEditor } from '@lokus/plugin-sdk/testing'
describe('CustomNode', () => {
it('should insert custom node', () => {
const editor = createTestEditor({
extensions: [CustomNode]
})
editor.commands.insertCustomNode()
expect(editor.getJSON()).toMatchObject({
type: 'doc',
content: [
{
type: 'customNode',
},
],
})
})
})
Examples
Check out complete examples: