Reference

Feature contract

The EditorFeature, FeatureContext and EditorPreset types, what defineEditor validates and how to contribute a custom feature.

A feature is an object whose optional members are the contribution points. Fill only what you need. The types below come straight from packages/editor/src/core/feature.ts.

EditorFeature

packages/editor/src/core/feature.ts
 */
export type Dimension = 'content' | 'interaction' | 'ai'

/** One named contribution to an editor (blocks, toolbar entries, menus, settings, AI knowledge). Every member is optional but `dimension`; identity and ordering come from the feature list. */
export interface EditorFeature<C = unknown> {
  id: string
  /** The feature's primary dimension. */
  dimension: Dimension
  /**
   * The Vue-free schema-bearing extensions of this feature (no node views, no UI), collected by `schemaExtensionsOf` for
   * the server schema. Present on every feature whose nodes or marks the stored document can contain.
   */
  schemaExtensions?: () => AnyExtension[]
  /** Ids of features that must also be installed (`defineEditor` does not install them). */
  requires?: readonly string[]
  /** Backend capabilities the feature cannot work without; a surface session over a backend lacking one throws. Optional capabilities (users and tags for mentions) are not listed. */
  backend?: readonly BackendCapability[]
  extensions?: (ctx: FeatureContext) => AnyExtension[]
  blockActions?: (ctx: FeatureContext) => BlockAction[]
  /**
   * Block capabilities the feature declares (`container` and `title` are built in): descriptors of what a block is, which a
   * `BlockAction.requires` names and `capabilitiesAt` answers. Ids are unique across features. A capability installs nothing:
   * schema, extensions and node views stay with the feature that owns the mechanism.
   */
  blockCapabilities?: readonly BlockCapability[]
  /**
   * Ways to make a new block or text from a block (transcription, text extraction, summary, AI fill, AI derivation, bibliography fetch):
   * one registry across all features, each method declaring the capabilities the original must have (`requires`) and its backend
   * flags (`available`). The block actions of the methods are generated from it. Ids are unique across features and every
   * `requires` id must be a declared capability; `defineEditor` throws otherwise.
   */
  deriveMethods?: readonly DeriveMethod[]
  /**
   * The Block types the feature's editor offers: data (an id and the capabilities each type lists with their config) the capability
   * registry answers from. Additive: the contributions of all features concatenate in feature order. An editor created from the preset
   * checks them against the declared capabilities (`validateBlockTypes`): an unknown capability id or an invalid config throws. Block
   * types are per editor (the composites feature builds its defaults in the surface's language).
   */
  blockTypes?: (editor: Editor) => readonly BlockTypeData[]
  /**
   * Options of the block UI (`BlockUiOptions`: which surfaces render block actions). Set by the block-ui feature; the
   * overlay components are mounted by the surface without props, so they read it from the editor entry (`EditorEntry.blockUi`).
   */
  blockUi?: Readonly<BlockUiOptions>
  /** The feature's commands, each declared once with its toolbar and `/` placements (see `CommandDef`). */
  commands?: (ctx: FeatureContext) => CommandDef<C>[]
  /** Toolbar groups the feature declares; every command's `toolbar.group` must be declared by some feature. Ids are unique across features. */
  toolbarGroups?: ToolbarGroupDef[]
  /** Rules a command's `creates` must pass at the caret besides the schema (e.g. a Typed block's accepted children). */

The members at a glance:

MemberContributes
extensions(ctx)TipTap extensions (nodes, marks, plugins)
commands(ctx), toolbarGroups, insertGuards(ctx)CommandDef[] declared once with toolbar and slash placements (toolbar, / menu, ⌘K palette, enabled/visible state via commandState), the groups they name, and guards a creates command must pass (e.g. a Typed block's Accepted children)
transactionGuards(ctx)TransactionGuard[]: (tr, state) => true | string, run in feature order for every transaction that changes the document (not for selection-only ones). The first string refuses the whole transaction and is shown as the notice. For rules no schema expresses (a locked paragraph, a required document shape); insertGuards only gate commands
blockActions(ctx)block actions (shown by block-ui: hover bar, selected-block toolbar or both, per BlockUiOptions.actions); ability unset = every block; placement: 'hover' | 'toolbar' | 'both' (default 'both') narrows the surface; target: 'block' | 'typedBlock' (default 'block'): with 'typedBlock' the toolbar evaluates the action against the Typed block around the selected block (ctx.node, ctx.pos are the Typed block's), absent when none encloses it
overlaysframework components (EditorFeature<C>; Vue: VueFeature) mounted beside the content, receiving { editor }
rowBadges(ctx)RowBadge[]: chips after the title of a Typed block in the composites chrome, additive in feature order (of(editor, row) returns { label, tone } | null; optional subscribe for changes outside the editor's transactions). The Prüfung feature draws „Geprüft“ / „Prüfung veraltet“ this way
documentBadges(ctx)DocumentBadge[]: chips of the whole document, drawn after the „Dokument“ heading of the inspector and on the open document's row of the Block outline in the Document tree (of(editor), optional subscribe). The Prüfung feature draws the Freigabestatus this way
inspectorSections(ctx)InspectorSection<C>[]: sections of the Typed block inspector and details card below the block's fields, additive in feature order ({ id, title, component, appliesTo? }; ids unique). The Prüfung feature adds „Prüfstand“
setup(ctx)per-surface side effect when the surface mounts (runtime, listener, <head> element); returns an optional teardown the surface runs when it unmounts
settingsTabsfactory (ctx: EditorContext) => SettingsTab<C>[] for the tabs of EditorSettingsPanel/EditorSettingsModal (SettingsTab<C>: label is already translated, the component slot is the framework's component type)
metadataDefaults(ctx)built-in @/#-style lists
blocks, conversionsblock pipeline stages, „Umwandeln in …“ providers
anchorResolver, aiContext, formatAiResultcollaboration positions, what AI knows about the document, AI result formatting
aiBlockDatamaterial per Typed block for „Block füllen (KI)“ (additive; the host owns the fetch)
requiresids of features that must also be listed

FeatureContext

Every factory (extensions, commands, blockActions, …) receives the FeatureContext of the surface it runs on. It is created when a surface is set up and never shared between editors.

packages/editor/src/core/feature.ts
  stopUndoCapture: (state: EditorState) => boolean
}

/** Per-surface dependencies of a feature's factories. Created when a surface is set up; never shared between editors. */
export interface FeatureContext {
  /** The app context the surface belongs to (shared by all editors of the app): app-level models key on it. */
  readonly app: EditorContext
  /** The app's shared settings (see `createEditorContext`): read with `get()`, write with `update()`, observe with `subscribe()`. */
  readonly settings: Store<EditorSettings>
  readonly metadata: MetadataRegistry
  /** Chrome of the editor this context belongs to (settings, sidebar). */
  readonly ui: EditorUi
  /** Shows a notice through the surface's framework (Nuxt UI toast, sonner). */
  readonly notify: Notifier
  /** The app's UI language (`createEditorContext({ locale })`). */
  readonly locale: Locale
  /** Renders a catalog message in the app's language; every label a feature contributes goes through it. */
  readonly t: Translate
  /** The data layer of the app (`createEditorContext({ backend })`): uploads, mention lookups, themes, AI. */
  readonly backend: EditorBackend
  /** Inert public AI configuration of the app context; absent when none was supplied. */
  readonly ai: EditorAiConfig | undefined
  /** Document whose package stores uploads (media feature). */
  readonly documentId: string | undefined
  readonly collaboration: CollaborationSession | undefined
  /** The collaboration-aware position mapping of the preset's `anchorResolver` feature; null means local mapping only. */
  readonly anchorResolver: AnchorResolver | null
  /** Host options for the ability extension (`abilities` prop). */
  readonly abilities: Partial<AbilitiesOptions>
  /** The final command list: the features' commands after the host's `commands` transformer. The `/` menu lists the ones with a `slash` placement. */
  readonly commands: () => CommandDef[]

EditorPreset

packages/editor/src/core/feature.ts
  /**
   * Refusals of whole transactions: every transaction that changes the document runs through the guards of all
   * features, in feature order; the first one answering a string refuses it and shows that string as a notice. For rules
   * about the document as a whole that no schema expresses (a locked paragraph, a required document shape).
   */
  transactionGuards?: (ctx: FeatureContext) => TransactionGuard[]
  /** Components mounted beside the editor content; they receive `{ editor }`. `C` is the component type of the framework layer that renders them. */
  overlays?: readonly C[]
  /**
   * Chips after the title of a Typed block in the composites chrome, additive in feature order (one per `RowBadge`).
   * The factory runs once per surface; the chrome reads the result from the editor entry (`EditorEntry.rowBadges`).
   */

What defineEditor validates

defineEditor({ features }) keeps the given order, neither deduplicates nor installs missing dependencies, and throws for:

  • a feature without a dimension;
  • a duplicate feature id;
  • a requires entry that is not installed;
  • a toolbar group declared by two features (toolbarGroups);
  • a singleton hook contributed twice (parseMarkdown, segment, structure, migrateContent, anchorResolver, formatAiResult);
  • an importer id declared twice (importers).

A command naming a toolbar group no feature declares needs the feature context to build the commands: toolbarOf throws it. normalize hooks chain left to right. aiContext contributions merge (blockTypes and blocks concatenated in feature order, structure from one feature only); aiBlockData contributions are additive too. The doc comment of the function:

packages/editor/src/core/define-editor.ts

const fail = (message: string): never => failWith(message, 'defineEditor')

/**
 * Validates `features` and resolves their pipeline and conversion providers. Keeps the given order; neither
 * deduplicates nor installs missing dependencies. Throws on duplicate ids, a `requires` entry that is not installed,
 * a toolbar group declared by two features (`toolbarGroups`), a block capability declared twice or with a built-in id (`blockCapabilities`), a derive method declared twice, with neither `run` nor `menu` or requiring an undeclared capability (`deriveMethods`), an importer id declared twice (`importers`) and a singleton hook (`parseMarkdown`, `segment`, `structure`, `migrateContent`, `anchorResolver`,
 * `formatAiResult`) contributed twice. A command naming a toolbar group no feature declares needs the feature context
 * to build the commands: `toolbarOf` throws it. `normalize` hooks chain left to right. `aiContext` contributions merge (`blockTypes` and `blocks`
 * concatenated in feature order, `structure` from one feature only); their conflicts surface when the merged `aiContext` reads the editor.

Example: a notes feature

// notes/feature.ts
import { command } from '@skaile/forge-editor/core'
import type { VueFeature } from '@skaile/forge-editor/vue'
import SettingsNotesTab from './SettingsNotesTab.vue'

export function notesFeature(): VueFeature {
  return {
    id: 'notes',
    dimension: 'interaction', // 'content' when it adds nodes or marks, 'ai' for AI features
    // requires: ['block-ui'], // fail at composition time when a dependency is missing
    toolbarGroups: [{ id: 'notes', order: 100 }],
    // One declaration per command: the toolbar button and the `/strich` entry share label, icon and gating.
    commands: () => [
      command({
        id: 'dash',
        toolbar: { group: 'notes' },
        slash: { id: 'strich' },
        icon: 'i-lucide-minus',
        label: 'Gedankenstrich',
        command: chain => chain.insertContent('\u2013'),
      }),
    ],
    // Runs in every mounted surface's setup and returns its teardown. Several surfaces of one app share state: dedupe per app
    // inside and reference-count the release (the theme runtime keys a WeakSet by `ctx.settings`). Report problems with `ctx.notify({ title, kind })`.
    setup: (ctx) => {
      const onKey = (event: KeyboardEvent) => {
        if (event.key === 'F2') ctx.ui.openSettings('notes')
      }
      document.addEventListener('keydown', onKey)
      return () => document.removeEventListener('keydown', onKey)
    },
    // Tab ids must be unique across the preset; the panel renders nothing when no feature contributes a tab.
    settingsTabs: ctx => [{ id: 'notes', label: ctx.t('notes.tab'), icon: 'i-lucide-notebook', component: SettingsNotesTab }],
  }
}
<!-- notes/SettingsNotesTab.vue -->
<script setup lang="ts">
import USwitch from '@nuxt/ui/components/Switch.vue'
import { SettingsSection } from '@skaile/forge-editor/vue'
import { ref } from 'vue'

const shout = ref(false) // local state; see the settings note below for persisted values
</script>

<template>
  <SettingsSection title="Notizen">
    <div class="py-3">
      <USwitch v-model="shout" label="Hervorheben" />
    </div>
  </SettingsSection>
</template>

Compose it with the advanced list (or any list) and mount it with the same preset the toolbar and settings UI receive:

import { defineEditor } from '@skaile/forge-editor/core'
import { advancedEditor } from '@skaile/forge-editor/vue/advanced'
import { notesFeature } from './notes/feature'

const preset = defineEditor({ features: [...advancedEditor().features, notesFeature()] })

Things that bite

  • Items gated by an own ability (CommandDef.ability, BlockAction.ability) stay inert until the ability exists at the caret. Grant custom abilities at root through the surface prop: :abilities="{ root: [...ROOT_ABILITIES, MY_ABILITY] }" (ROOT_ABILITIES from /core/schema).
  • The hover bar of BlockActions is right-aligned 8px inside the editor's right edge for every block and is hidden while the editor is read-only. A block that draws its own icons in its top-right corner marks them with data-block-corner (BLOCK_CORNER_ATTR from /block-ui): the bar then docks immediately left of them, centred on their row, instead of covering them.
  • A feature that adds a node must be known to every schema that touches the document. The Nuxt module's collab route builds its schema from advancedSchemaExtensions() plus the schema option of provideEditorServer({ ports, schema }), so register the Vue-free node file there (schema: () => [...advancedSchemaExtensions(), YourNode], tutorial chapter, Backend and collaboration); a custom Hocuspocus server builds getSchema([...advancedSchemaExtensions(), YourNode]) itself. Standalone editors (JSON v-model) need none of this. editorBlock: true (via NodeFlags) gives an own node block IDs, also on the server.
  • A tab component can bind only to fields that exist in EditorSettings: parseEditorSettings drops unknown keys, so new keys are neither stored locally nor sent to the server. The tab component runs inside the app, so it can call useEditorSettings() (from /vue: { settings, ready, synced } as refs; from /react: the same names as values).
  • Factories (extensions, commands, blockActions, ...) receive the FeatureContext: app, settings, metadata, ui, notify, backend, ai, documentId, collaboration, getEditor(). backend is the EditorBackend of the app (uploads, lookups, themes, AI: ctx.backend.assets?.upload(...)); features never call fetch. settings is a framework-free store: read ctx.settings.get(), change it with ctx.settings.update(s => { ... }) (or set), observe it with ctx.settings.subscribe(listener). ctx.notify({ id?, title, description?, kind?, icon?, duration? }) shows a user-facing notice (Nuxt UI toast or sonner toast, per surface; features never import a toast library). ctx.app is the app context that app-level models key on. Menu commands and block-action menus return core MenuItem[] (type?: 'label' | 'separator', label, description, icon, disabled, onSelect). getEditor() is undefined while extensions are constructed; use it in event handlers only. Components in overlays and component commands receive { editor } as props.

Complete worked example with node, node view, conversions and AI description: the tutorial chapter The callout as a feature and the runnable app/editor/callout/. Lighter path without a feature (extensions prop and commands transformer): The callout through props.

Copyright © 2026