The editor has a plugin system that lets you add UI panels, document overlays, ProseMirror plugins, and custom styles — without modifying editor internals.
import { DocxEditor, PluginHost, templatePlugin } from '@eigenpal/docx-js-editor';
import '@eigenpal/docx-js-editor/styles.css';
function Editor({ file }: { file: ArrayBuffer }) {
return (
<PluginHost plugins={[templatePlugin]}>
<DocxEditor documentBuffer={file} />
</PluginHost>
);
}Wrap DocxEditor in PluginHost and pass an array of plugins. Each plugin can contribute any combination of:
- ProseMirror plugins — decorations, keymaps, transaction listeners
- Panels — React components rendered alongside the editor (left, right, or bottom)
- Overlays — React elements positioned over the rendered pages
- CSS styles — injected automatically on mount, cleaned up on unmount
interface EditorPlugin<TState = any> {
/** Unique plugin identifier */
id: string;
/** Display name (shown in panel toggle buttons) */
name: string;
/** ProseMirror plugins merged with the editor's internal plugins */
proseMirrorPlugins?: ProseMirrorPlugin[];
/** React component rendered in a side/bottom panel */
Panel?: React.ComponentType<PluginPanelProps<TState>>;
/** Panel position and size configuration */
panelConfig?: PanelConfig;
/** Called on every editor state change. Return new state or undefined to keep existing. */
onStateChange?: (view: EditorView) => TState | undefined;
/** Called once when the plugin is first loaded */
initialize?: (view: EditorView | null) => TState;
/** Called when the plugin is destroyed (cleanup timers, subscriptions) */
destroy?: () => void;
/** CSS string injected into the document head */
styles?: string;
/** Render overlay elements positioned over the document pages */
renderOverlay?: (
context: RenderedDomContext,
state: TState,
editorView: EditorView | null
) => ReactNode;
}A plugin that logs every document change:
import type { EditorPlugin } from '@eigenpal/docx-js-editor';
const loggerPlugin: EditorPlugin = {
id: 'logger',
name: 'Logger',
onStateChange(view) {
console.log('Doc size:', view.state.doc.content.size);
return undefined; // no state to track
},
};import type { EditorPlugin, PluginPanelProps } from '@eigenpal/docx-js-editor';
interface WordCountState {
words: number;
characters: number;
}
function WordCountPanel({ pluginState }: PluginPanelProps<WordCountState>) {
return (
<div style={{ padding: 12 }}>
<p>Words: {pluginState?.words ?? 0}</p>
<p>Characters: {pluginState?.characters ?? 0}</p>
</div>
);
}
const wordCountPlugin: EditorPlugin<WordCountState> = {
id: 'word-count',
name: 'Word Count',
Panel: WordCountPanel,
panelConfig: {
position: 'right',
defaultSize: 200,
collapsible: true,
defaultCollapsed: false,
},
initialize: () => ({ words: 0, characters: 0 }),
onStateChange(view) {
const text = view.state.doc.textContent;
return {
words: text.split(/\s+/).filter(Boolean).length,
characters: text.length,
};
},
};Overlays render on top of the document pages. Use the RenderedDomContext to map ProseMirror positions to pixel coordinates.
import type { EditorPlugin, RenderedDomContext } from '@eigenpal/docx-js-editor';
const highlightPlugin: EditorPlugin<number[]> = {
id: 'highlight',
name: 'Highlight',
initialize: () => [],
renderOverlay(context: RenderedDomContext, positions: number[]) {
// Get pixel rects for each position range
const rects = positions.flatMap((pos) =>
context.getRectsForRange(pos, pos + 1)
);
return (
<>
{rects.map((rect, i) => (
<div
key={i}
style={{
position: 'absolute',
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height,
background: 'rgba(255, 255, 0, 0.3)',
pointerEvents: 'none',
}}
/>
))}
</>
);
},
};import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import type { EditorPlugin } from '@eigenpal/docx-js-editor';
const pluginKey = new PluginKey('my-decorations');
const decorationPlugin: EditorPlugin = {
id: 'my-decorations',
name: 'Decorations',
proseMirrorPlugins: [
new Plugin({
key: pluginKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
// Update decorations based on transactions
return set.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return pluginKey.getState(state);
},
},
}),
],
};interface PanelConfig {
position: 'left' | 'right' | 'bottom'; // default: 'right'
defaultSize: number; // pixels, default: 280
minSize?: number; // default: 200
maxSize?: number; // default: 500
resizable?: boolean; // default: true
collapsible?: boolean; // default: true
defaultCollapsed?: boolean; // default: false
}Right-positioned panels render inside the editor viewport and scroll with the document. Left and bottom panels render outside the viewport as fixed sidebars.
Panel components receive these props:
interface PluginPanelProps<TState> {
editorView: EditorView | null;
doc: ProseMirrorNode | null;
scrollToPosition: (pos: number) => void;
selectRange: (from: number, to: number) => void;
pluginState: TState;
panelWidth: number;
renderedDomContext: RenderedDomContext | null;
}scrollToPosition— scrolls the editor to show a ProseMirror positionselectRange— sets a text selection in the editorrenderedDomContext— for mapping ProseMirror positions to DOM coordinates (may be null during initial render)
The rendered DOM context bridges ProseMirror document positions and the visual page layout:
interface RenderedDomContext {
pagesContainer: HTMLElement;
zoom: number;
getCoordinatesForPosition(pmPos: number): { x: number; y: number; height: number } | null;
findElementsForRange(from: number, to: number): Element[];
getRectsForRange(
from: number,
to: number
): Array<{ x: y; y: number; width: number; height: number }>;
getContainerOffset(): { x: number; y: number };
}This is necessary because the editor uses a dual-DOM architecture: a hidden ProseMirror instance handles editing while a separate visual renderer (LayoutPainter) draws the paginated output. The context translates between the two.
For programmatic access, use a ref on PluginHost:
const hostRef = useRef<PluginHostRef>(null);
// Get/set plugin state
const state = hostRef.current?.getPluginState<MyState>('my-plugin');
hostRef.current?.setPluginState('my-plugin', newState);
// Access the editor view
const view = hostRef.current?.getEditorView();
// Force refresh all plugin states
hostRef.current?.refreshPluginStates();
<PluginHost ref={hostRef} plugins={plugins}>
<DocxEditor documentBuffer={file} />
</PluginHost>;Syntax highlighting and annotation panel for docxtemplater template tags.
import { DocxEditor, PluginHost, templatePlugin } from '@eigenpal/docx-js-editor';
// Default configuration
<PluginHost plugins={[templatePlugin]}>
<DocxEditor documentBuffer={file} />
</PluginHost>;
// Custom configuration
import { createTemplatePlugin } from '@eigenpal/docx-js-editor';
const myPlugin = createTemplatePlugin({
panelPosition: 'left',
panelWidth: 320,
defaultCollapsed: true,
});
<PluginHost plugins={[myPlugin]}>
<DocxEditor documentBuffer={file} />
</PluginHost>;Features:
- Detects variables (
{name}), loops ({#items}...{/items}), and conditionals - Color-coded highlighting by tag type
- Side panel showing template structure
- Click-to-navigate from panel to tag in the document
See src/plugins/template/README.md for full details.
Under the hood, the editor uses a separate extension system (src/prosemirror/extensions/) for its core ProseMirror schema, commands, and keyboard shortcuts. This is a Tiptap-style architecture with three extension types:
- Extension — contributes plugins and commands (e.g., history, base keymap)
- NodeExtension — adds a node to the ProseMirror schema (e.g., paragraph, table, image)
- MarkExtension — adds a mark to the schema (e.g., bold, italic, text color)
All 26+ built-in extensions are bundled via createStarterKit(). This system is internal and not part of the public API — use the Plugin API described above for extending the editor.
For details on the extension architecture, see docs/EXTENSIONS.md.