Write your own extension – inksane
inksane
Extensions

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

HookReturnsPurpose
addExtensionsExtension[]Child extensions, resolved recursively.
addCommandscommand implementationsRegister commands by name.
addKeybindsCodeMirror KeyBinding[]Bind keys.
addCodeMirrorExtensionsCodeMirror Extension[]Add CodeMirror features that do not fit the other hooks.
addMarkdownSyntaxLezer MarkdownExtension[]Teach the Markdown parser new syntax.
addMarkdownDecorationsdecoration configsStyle or replace parsed nodes.
addStateQueriesstate query configsName the nodes that make a feature active.
addHtmlConvertersHtmlConverter[]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(); // boolean

If 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:

FieldDescription
editorThe editor instance.
stateThe current editor state.
viewThe CodeMirror view.
dispatchApplies 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,
    },
  ];
}
OptionDescription
nodeNameRequired. Must match a Lezer syntax-tree node name.
classNameApplied to the whole node range.
lineClassApplied to every line the node touches.
markupWhich ranges count as syntax. Defaults to the node’s direct children whose names end in Mark.
hideSyntaxHides the markup while the selection is outside the node.
widgetsReplacement 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"] },
  ];
}
OptionDescription
nameRequired. The name passed to editor.isActive(name).
nodeNamesRequired. The Lezer syntax node names that make the query active.
kind"mark" or "node". Defaults to "mark".
attrsReads 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.

OptionDescription
blockRenders as a block element. Defaults to false.
classNameExtra class next to inksane-html.
estimatedHeightHeight hint in pixels for off-screen layout.
onRenderRuns 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.