Feature contract
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
*/
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:
| Member | Contributes |
|---|---|
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 |
overlays | framework 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 |
settingsTabs | factory (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, conversions | block pipeline stages, „Umwandeln in …“ providers |
anchorResolver, aiContext, formatAiResult | collaboration positions, what AI knows about the document, AI result formatting |
aiBlockData | material per Typed block for „Block füllen (KI)“ (additive; the host owns the fetch) |
requires | ids 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.
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
/**
* 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
requiresentry 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:
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_ABILITIESfrom/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 withdata-block-corner(BLOCK_CORNER_ATTRfrom/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 theschemaoption ofprovideEditorServer({ ports, schema }), so register the Vue-free node file there (schema: () => [...advancedSchemaExtensions(), YourNode], tutorial chapter, Backend and collaboration); a custom Hocuspocus server buildsgetSchema([...advancedSchemaExtensions(), YourNode])itself. Standalone editors (JSONv-model) need none of this.editorBlock: true(viaNodeFlags) gives an own node block IDs, also on the server. - A tab component can bind only to fields that exist in
EditorSettings:parseEditorSettingsdrops unknown keys, so new keys are neither stored locally nor sent to the server. The tab component runs inside the app, so it can calluseEditorSettings()(from/vue:{ settings, ready, synced }as refs; from/react: the same names as values). - Factories (
extensions,commands,blockActions, ...) receive theFeatureContext:app,settings,metadata,ui,notify,backend,ai,documentId,collaboration,getEditor().backendis theEditorBackendof the app (uploads, lookups, themes, AI:ctx.backend.assets?.upload(...)); features never callfetch.settingsis a framework-free store: readctx.settings.get(), change it withctx.settings.update(s => { ... })(orset), observe it withctx.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.appis the app context that app-level models key on. Menu commands and block-action menus return coreMenuItem[](type?: 'label' | 'separator',label,description,icon,disabled,onSelect).getEditor()isundefinedwhile extensions are constructed; use it in event handlers only. Components inoverlaysandcomponentcommands 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.