Plugins extend the editor's functionality. OpenBlock is built on ProseMirror, so you can use any ProseMirror plugin.
Plugins can:
- Add keyboard shortcuts
- Handle events (click, paste, drop)
- Maintain state (like tracking selections)
- Add decorations (highlights, widgets)
- Transform transactions
import { Plugin } from 'prosemirror-state';
const myPlugin = new Plugin({
// Plugin configuration
});
const editor = new OpenBlockEditor({
prosemirror: {
plugins: [myPlugin],
},
});OpenBlock includes these plugins by default:
| Plugin | Description |
|---|---|
history |
Undo/redo support |
keymap |
Keyboard shortcuts |
inputRules |
Markdown-style shortcuts |
dropCursor |
Visual cursor when dragging |
gapCursor |
Cursor in empty positions |
blockIdPlugin |
Manages block IDs |
slashMenuPlugin |
Slash command menu |
bubbleMenuPlugin |
Formatting toolbar |
dragDropPlugin |
Block drag and drop |
tablePlugin |
Table functionality |
import { Plugin, PluginKey } from 'prosemirror-state';
export const WORD_COUNT_KEY = new PluginKey<{ count: number }>('wordCount');
export function createWordCountPlugin(onChange?: (count: number) => void) {
return new Plugin({
key: WORD_COUNT_KEY,
state: {
// Initial state
init(_, state) {
return { count: countWords(state.doc.textContent) };
},
// Update state on each transaction
apply(tr, value, _, newState) {
if (tr.docChanged) {
const count = countWords(newState.doc.textContent);
onChange?.(count);
return { count };
}
return value;
},
},
});
}
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(Boolean).length;
}
// Usage
const editor = new OpenBlockEditor({
prosemirror: {
plugins: [
createWordCountPlugin((count) => {
console.log(`Word count: ${count}`);
}),
],
},
});
// Get current count
const state = WORD_COUNT_KEY.getState(editor.pm.state);
console.log(state?.count);import { Plugin } from 'prosemirror-state';
export function createCharacterLimitPlugin(maxChars: number) {
return new Plugin({
filterTransaction(tr, state) {
const newDoc = tr.doc;
const charCount = newDoc.textContent.length;
// Block transaction if it exceeds limit
if (charCount > maxChars) {
return false;
}
return true;
},
});
}import { Plugin } from 'prosemirror-state';
import debounce from 'lodash/debounce';
export function createAutoSavePlugin(
onSave: (content: string) => void,
delayMs: number = 1000
) {
const debouncedSave = debounce(onSave, delayMs);
return new Plugin({
view() {
return {
update(view, prevState) {
if (!view.state.doc.eq(prevState.doc)) {
debouncedSave(JSON.stringify(view.state.doc.toJSON()));
}
},
};
},
});
}Plugins that maintain state:
import { Plugin, PluginKey } from 'prosemirror-state';
interface MyPluginState {
// Your state shape
isActive: boolean;
data: string[];
}
export const MY_PLUGIN_KEY = new PluginKey<MyPluginState>('myPlugin');
export const myPlugin = new Plugin<MyPluginState>({
key: MY_PLUGIN_KEY,
state: {
// Initialize state
init() {
return { isActive: false, data: [] };
},
// Update state
apply(tr, value) {
// Check for metadata
const meta = tr.getMeta(MY_PLUGIN_KEY);
if (meta) {
return { ...value, ...meta };
}
return value;
},
},
});
// Update state via transaction
function setActive(view: EditorView, isActive: boolean) {
view.dispatch(
view.state.tr.setMeta(MY_PLUGIN_KEY, { isActive })
);
}Plugins that interact with the DOM:
import { Plugin } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
export const myViewPlugin = new Plugin({
view(editorView) {
// Called when plugin is added
console.log('Plugin mounted');
return {
// Called after each state update
update(view, prevState) {
if (view.state.selection !== prevState.selection) {
console.log('Selection changed');
}
},
// Called when editor is destroyed
destroy() {
console.log('Plugin destroyed');
},
};
},
});Plugins that modify editor behavior:
import { Plugin } from 'prosemirror-state';
export const myPropsPlugin = new Plugin({
props: {
// Handle click events
handleClick(view, pos, event) {
console.log('Clicked at position:', pos);
return false; // Let other handlers run
},
// Handle paste
handlePaste(view, event, slice) {
console.log('Pasting content');
return false;
},
// Handle keyboard events
handleKeyDown(view, event) {
if (event.key === 'Escape') {
// Handle escape
return true; // Prevent default
}
return false;
},
// Transform pasted content
transformPasted(slice) {
// Modify pasted content
return slice;
},
},
});Add visual elements to the editor:
import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
export const highlightEmptyPlugin = new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph' && node.content.size === 0) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: 'empty-paragraph',
})
);
}
});
return DecorationSet.create(state.doc, decorations);
},
},
});import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
export const lineNumbersPlugin = new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = [];
let lineNumber = 1;
state.doc.descendants((node, pos) => {
if (node.isBlock && node.type.name !== 'doc') {
const widget = document.createElement('span');
widget.className = 'line-number';
widget.textContent = String(lineNumber++);
decorations.push(
Decoration.widget(pos, widget, { side: -1 })
);
}
});
return DecorationSet.create(state.doc, decorations);
},
},
});import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
export const highlightSearchPlugin = (searchTerm: string) =>
new Plugin({
props: {
decorations(state) {
if (!searchTerm) return DecorationSet.empty;
const decorations: Decoration[] = [];
const regex = new RegExp(searchTerm, 'gi');
state.doc.descendants((node, pos) => {
if (node.isText) {
const text = node.text!;
let match;
while ((match = regex.exec(text)) !== null) {
decorations.push(
Decoration.inline(
pos + match.index,
pos + match.index + match[0].length,
{ class: 'search-highlight' }
)
);
}
}
});
return DecorationSet.create(state.doc, decorations);
},
},
});Create markdown-style shortcuts:
import { inputRules, InputRule } from 'prosemirror-inputrules';
// Convert "---" to horizontal rule
const hrRule = new InputRule(
/^---$/,
(state, match, start, end) => {
const hr = state.schema.nodes.divider.create();
return state.tr.replaceWith(start - 1, end, hr);
}
);
// Convert "```" to code block
const codeBlockRule = new InputRule(
/^```$/,
(state, match, start, end) => {
const codeBlock = state.schema.nodes.codeBlock.create();
return state.tr.replaceWith(start - 1, end, codeBlock);
}
);
// Add to editor
const editor = new OpenBlockEditor({
prosemirror: {
plugins: [inputRules({ rules: [hrRule, codeBlockRule] })],
},
});Extend the built-in plugin creator:
import { createPlugins } from '@labbs/openblock-core';
const plugins = createPlugins({
schema: mySchema,
inputRules: {
headings: true,
bulletLists: true,
orderedLists: false, // Disable ordered lists
},
additionalPlugins: [
myCustomPlugin,
createWordCountPlugin(updateWordCount),
createAutoSavePlugin(saveToServer),
],
});- Use PluginKey — Makes it easy to access plugin state
- Return false from handlers — Let other plugins handle events too
- Be efficient — Plugins run on every transaction
- Clean up — Use
destroy()to remove event listeners - Test with undo/redo — Ensure your plugin handles history correctly