Write your own extension
The extension hooks, commands, Markdown parsing, decorations, widgets, and HTML converters.
An extension is an object with a required name and optional hooks. Every hook receives
{ editor }, so it can read the editor instance and its options during setup.
The setup hooks run once while the editor is created. They do not run again when the document changes.
import type { Extension } from "@inksane/core";
export const MyExtension: Extension = {
name: "my-extension",
priority: 10,
addExtensions({ editor }) {
return [];
},
addMarkdownSyntax({ editor }) {
return [];
},
addMarkdownDecorations({ editor }) {
return [];
},
addStateQueries({ editor }) {
return [];
},
addHtmlConverters({ editor }) {
return [];
},
addCommands({ editor }) {
return {};
},
addKeybinds({ editor }) {
return [];
},
addCodeMirrorExtensions({ editor }) {
return [];
},
};Hooks
| Hook | Returns | Purpose |
|---|---|---|
addExtensions | Extension[] | Child extensions, resolved recursively. |
addCommands | command implementations | Register commands by name. |
addKeybinds | CodeMirror KeyBinding[] | Bind keys. |
addCodeMirrorExtensions | CodeMirror Extension[] | Add CodeMirror features that do not fit the other hooks. |
addMarkdownSyntax | Lezer MarkdownExtension[] | Teach the Markdown parser new syntax. |
addMarkdownDecorations | decoration configs | Style or replace parsed nodes. |
addStateQueries | state query configs | Name the nodes that make a feature active. |
addHtmlConverters | HtmlConverter[] | Turn pasted HTML into Markdown. |
Compose with child extensions
addExtensions returns further extensions. They are resolved recursively and contribute to the same
editor setup, so a feature can be assembled from smaller pieces.
export const ToolbarExtension: Extension = {
name: "toolbar",
addExtensions() {
return [BoldExtension, ItalicExtension];
},
};Commands
Declare the command on the Commands interface with module augmentation, then return a curried
implementation from addCommands. The outer function receives the command context, the inner
function receives the public arguments and returns a boolean.
import type { Extension } from "@inksane/core";
declare module "@inksane/core" {
interface Commands<ReturnType> {
timestamp: {
insertTimestamp: () => ReturnType;
};
}
}
export const TimestampExtension: Extension = {
name: "timestamp",
addCommands() {
return {
insertTimestamp:
({ state, dispatch }) =>
() => {
const { from } = state.selection.main;
dispatch({ changes: { from, insert: new Date().toISOString() } });
return true;
},
};
},
};The declared name and the key returned by addCommands must match. Once the extension is
configured, both forms are typed:
editor.commands.insertTimestamp(); // boolean
editor.chain().insertTimestamp().focus().run(); // booleanIf two extensions register the same command name, the higher-priority extension keeps its command and inksane logs a warning. The colliding command from the lower-priority extension is skipped.
The command context contains:
| Field | Description |
|---|---|
editor | The editor instance. |
state | The current editor state. |
view | The CodeMirror view. |
dispatch | Applies a transaction. Use this instead of view.dispatch so a chain stays one update. |
In a chain, state is projected to include the changes made by earlier commands in that chain.
Keybindings
addKeybinds returns CodeMirror KeyBinding objects, or undefined when there is nothing to bind.
Use CodeMirror’s key syntax, such as Mod-Shift-t. The run callback must return whether it
handled the key.
addKeybinds({ editor }) {
return [
{
key: "Mod-Shift-t",
run() {
return editor.commands.insertTimestamp();
},
},
];
}BaseKit registers its feature bindings before the default keymap.
To override a standard key with your own extension, give it a higher priority or register it
before the default keymap.
CodeMirror extensions
addCodeMirrorExtensions returns raw CodeMirror extensions. Use it for features that do not fit the
higher-level hooks.
import { drawSelection } from "@codemirror/view";
addCodeMirrorExtensions() {
return [drawSelection()];
}Markdown syntax
addMarkdownSyntax returns MarkdownExtension values from @lezer/markdown. inksane passes them to
CodeMirror’s Markdown parser, which lets an extension parse custom syntax.
import type { MarkdownExtension } from "@lezer/markdown";
addMarkdownSyntax(): MarkdownExtension[] {
return [myMarkdownSyntax];
}The node names your parser produces are what addMarkdownDecorations looks up.
Decorations
addMarkdownDecorations describes how to style or replace parsed nodes. It does not change parsing,
so pair it with addMarkdownSyntax when you decorate custom syntax.
addMarkdownDecorations() {
return [
{
nodeName: "StrongEmphasis",
className: "my-extension-strong",
hideSyntax: true,
},
];
}| Option | Description |
|---|---|
nodeName | Required. Must match a Lezer syntax-tree node name. |
className | Applied to the whole node range. |
lineClass | Applied to every line the node touches. |
markup | Which ranges count as syntax. Defaults to the node’s direct children whose names end in Mark. |
hideSyntax | Hides the markup while the selection is outside the node. |
widgets | Replacement or attached widgets. |
markup accepts child node names, or a function returning { from, to } ranges:
markup: ["LinkMark", "URL"];
markup: (node) => [{ from: node.from, to: node.to }];markRangesWithWhitespace(node, state, "ListMark") builds those ranges from a child mark and the
whitespace after it. It is exported from @inksane/core.
hideSyntax reveals a node again while the selection touches it. During a drag, the node stays
revealed until the pointer is released, so revealing cannot flicker.
Widgets
A replace widget replaces every markup range while the syntax is hidden. Its type is a
WidgetType, or a function that receives the syntax node and the editor state and may return null
to keep the source text visible.
{
kind: "replace",
type: new MyWidget(),
block: true,
}An attach widget sits before or after the node, or at a position returned by a function. It is
shown only while the syntax is hidden by default.
{
kind: "attach",
position: "after",
type: (node, state) => new MyWidget(state.doc.sliceString(node.from, node.to)),
onlyWhenHidden: false,
}State queries
addStateQueries gives a logical name to the syntax nodes that make a feature active, so
editor.isActive and editor.getMarkRange can report it. Each entry maps one name to the Lezer
nodes that make it active.
addStateQueries() {
return [
{ name: "highlight", nodeNames: ["Highlight"] },
];
}| Option | Description |
|---|---|
name | Required. The name passed to editor.isActive(name). |
nodeNames | Required. The Lezer syntax node names that make the query active. |
kind | "mark" or "node". Defaults to "mark". |
attrs | Reads attributes from a matched node, such as a heading level or a link href. editor.isActive(name, attrs) matches a subset. |
A mark requires a collapsed selection to sit inside the node, while kind: "node" also counts when
the selection sits on the node’s edge, which is what block nodes such as headings or lists need.
attrs is how a query gets attributes to match. The heading extension reads the level from
the matched node, so editor.isActive("heading", { level: 1 }) only matches first-level headings.
The link extension reads the href the same way.
See Editor state for the consumer side.
HTML converters
addHtmlConverters teaches the editor how pasted HTML becomes Markdown. Each converter has a match
predicate that claims an element and a toMarkdown that serializes it. Elements no converter claims
keep their text.
addHtmlConverters() {
return [
{
match: (element) => element.tagName.toLowerCase() === "mark",
toMarkdown: (element, { renderChildren }) => `==${renderChildren(element).trim()}==`,
},
];
}renderChildren(element) converts the children and applies the converters again. Set block: true
for block-level elements so they get blank lines around them.
The input is cleaned with sanitizeHtml before any converter runs.
Rendering HTML from an extension
htmlWidget renders an HTML string for you, so a widget only passes a string. The markup is trusted
because the extension writes it. Document text is not, so escape anything you interpolate.
isSafeUrl checks a URL, and htmlToMarkdown converts HTML to Markdown with the registered
converters. Both are exported from @inksane/core.
import { escapeHtml, htmlWidget, type Extension } from "@inksane/core";
export const CalloutExtension: Extension = {
name: "callout",
addMarkdownDecorations() {
return [
{
nodeName: "Callout",
hideSyntax: true,
markup: (node) => [{ from: node.from, to: node.to }],
widgets: [
{
kind: "replace",
block: true,
type: (node, state) =>
htmlWidget(
`<div class="callout">${escapeHtml(state.sliceDoc(node.from, node.to))}</div>`,
{ block: true, className: "my-callout" },
),
},
],
},
];
},
};htmlWidget(html, options) renders inline and wraps the markup in a span. Set block: true to wrap
it in a div. The wrapper always gets the inksane-html class, and className adds one more.
| Option | Description |
|---|---|
block | Renders as a block element. Defaults to false. |
className | Extra class next to inksane-html. |
estimatedHeight | Height hint in pixels for off-screen layout. |
onRender | Runs after the markup is mounted and receives the wrapper element. |
For block widgets, put spacing on the wrapper as padding, not as margin. CodeMirror measures the
widget’s border box, so margins are invisible to its height map and selection coordinates drift. The
wrapper already gets display: flow-root to keep child margins inside the measured box.
Factories with options
Give an extension options by writing it as a factory that closes over them, the way
LinkExtension({ onLinkRequest }) and CollaborationExtension({ ydoc }) do.
export function MyExtension(options: { color: string } = { color: "red" }): Extension {
return {
name: "my-extension",
addMarkdownDecorations() {
return [{ nodeName: "Emphasis", className: `text-${options.color}` }];
},
};
}Event handlers
Extensions can handle editor events with methods named after each event. Each handler receives
{ editor } as its first argument and the event payload as its second argument.
const MyExtension: Extension = {
name: "my-extension",
create({ editor }) {
console.log(editor.getMarkdown());
},
update({ editor }, { transaction }) {
console.log(editor.getMarkdown(), transaction.changes);
},
};The extension manager registers root and child extension handlers in priority order before
beforeCreate fires. The editor removes all listeners on destroy, after the destroy handlers run.
See editor events for event timing and payloads.