Skip to content

Creating custom plugins

Plugins extend the editor with custom UI and behavior. Use a built-in plugin from the Plugins overview when one fits, or write your own with the InkwellPlugin API below.

A plugin is an object that implements InkwellPlugin:

interface InkwellPlugin {
name: string;
activation?:
| { type: "always" }
| { type: "trigger"; key: string }
| { type: "manual" };
render?: (props: PluginRenderProps) => ReactNode;
getPlaceholder?: (
editor: InkwellPluginEditor,
) => string | InkwellPluginPlaceholder | null;
onEditorChange?: (editor: InkwellPluginEditor) => void;
shouldTrigger?: (
event: React.KeyboardEvent,
ctx: PluginKeyDownContext,
) => boolean;
onKeyDown?: (event: React.KeyboardEvent, ctx: PluginKeyDownContext) => void;
onActiveKeyDown?: (
event: React.KeyboardEvent,
ctx: PluginKeyDownContext,
) => false | void;
onInsertData?: (
data: DataTransfer,
ctx: PluginInsertDataContext,
) => boolean | void;
setup?: (editor: InkwellPluginEditor) => void | (() => void);
}
interface PluginKeyDownContext {
editor: InkwellPluginEditor;
wrapSelection: (before: string, after: string) => void;
activate: (options?: { query?: string }) => void;
dismiss: () => void;
}
interface InkwellPluginPlaceholder {
text: string;
hint?: string;
}

Plugins also receive a subscribeForwardedKey callback through PluginRenderProps. While a plugin is the active one, the editor forwards navigation keys (ArrowUp/Down, Enter, Backspace) and typed printable characters to all subscribers. The channel is scoped per editor instance, so two editors on the same page do not cross-talk.

Here’s a command palette that opens with Ctrl+K:

import type { InkwellPlugin } from "@railway/inkwell";
const commandPalette: InkwellPlugin = {
name: "command-palette",
activation: { type: "manual" },
onKeyDown: (event, ctx) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
ctx.activate();
}
},
render: ({ active, query, onSelect, onDismiss, position }) => {
if (!active) return null;
const commands = [
{ label: "Heading", md: "## " },
{ label: "Bullet list", md: "- " },
{ label: "Code block", md: "```\n\n```" },
].filter((c) => c.label.toLowerCase().includes(query.toLowerCase()));
return (
<div
style={{
position: "absolute",
top: position.top + 24,
left: position.left,
}}
>
{commands.map((cmd) => (
<button key={cmd.label} onClick={() => onSelect(cmd.md)}>
{cmd.label}
</button>
))}
<button onClick={onDismiss}>Cancel</button>
</div>
);
},
};

The activation field determines how a plugin activates. Trigger keys allow single keys and explicit modifier combos such as Control+/, Meta+k, Alt+x, and Shift+Enter.

Modifier combos like "Control+/" or "Meta+k":

  • Prevents the default browser action
  • Best for command palettes, search overlays, and similar UI

Single characters like "[", ":", or "@":

  • The character is typed into the editor first
  • When the user selects via onSelect, the trigger character is automatically removed
  • Best for inline pickers (snippets, emoji, mentions)

Always active (activation: { type: "always" }, or omit activation):

  • The plugin is always rendered with active: true
  • Best for persistent UI like status bars or word counts

Manual activation (activation: { type: "manual" }):

  • The plugin renders only after it calls ctx.activate()
  • Best for context-sensitive flows that are not driven by one trigger key

Your render function receives these props:

PropTypeDescription
activebooleanWhether this plugin is active. Always-on plugins receive true every render.
querystringText typed since the trigger fired. Useful for filtering results.
position{ top, left }Wrapper-relative cursor coords (with a 4px gap below the caret). Use as the default top-left anchor for popups.
cursorRect{ top, bottom, left } (optional)Wrapper-relative caret bounding rect. Use to flip a popup above the caret when it would overflow the editor wrapper’s bottom or the viewport bottom.
onSelect(text: string) => voidInsert content at the cursor. For character triggers, removes the trigger character first.
onDismiss() => voidDeactivate the plugin and return focus to the editor.
wrapSelection(before, after) => voidToggle Markdown markers around the current selection.
editorRefRefObject<HTMLDivElement | null>Ref to the editor’s contenteditable element.
editorInkwellPluginEditorNarrow editor controller for plugin actions.
subscribeForwardedKeySubscribeForwardedKeySubscribe to editor-forwarded ArrowUp/Down, Enter, Backspace, and printable keys while this plugin is active; returns cleanup.

Plugins can add keyboard shortcuts via onKeyDown. The handler fires while the editor is focused and no other plugin is active.

const highlightShortcut: InkwellPlugin = {
name: "highlight-shortcut",
render: () => null,
onKeyDown: (event, { wrapSelection }) => {
if ((event.metaKey || event.ctrlKey) && event.key === "h") {
event.preventDefault();
wrapSelection("==", "==");
}
},
};

Call event.preventDefault() to stop the key from propagating further. The built-in bubble menu uses this same mechanism for its ⌘B / ⌘I / ⌘D shortcuts.

  • Plugins mount and unmount with the editor
  • Only one plugin can be active at a time
  • Pressing Escape or clicking outside the editor dismisses the active plugin

For production slash command flows, prefer the built-in Slash Commands plugin. This example shows how a custom single-character trigger can insert Markdown snippets.

const slashCommands: InkwellPlugin = {
name: "slash-commands",
activation: { type: "trigger", key: "/" },
render: ({ active, query, onSelect, onDismiss, position }) => {
if (!active) return null;
const commands = [
{ label: "Heading", md: "## " },
{ label: "Bullet list", md: "- " },
{ label: "Code block", md: "```\n\n```" },
].filter((c) => c.label.toLowerCase().includes(query.toLowerCase()));
return (
<div
style={{ position: "absolute", top: position.top, left: position.left }}
>
{commands.map((cmd) => (
<button key={cmd.label} onClick={() => onSelect(cmd.md)}>
{cmd.label}
</button>
))}
<button onClick={onDismiss}>Cancel</button>
</div>
);
},
};