---
title: "Chapter 5: Your own feature, path B"
description: "Turn the callout into an EditorFeature with a toolbar button, a hover menu for the tone, „Umwandeln in …“ and its own ability."
canonical_url: "https://skaile-ai.github.io/forge-editor/tutorial/own-feature-path-b"
---
# Chapter 5: Your own feature, path B

> Turn the callout into an EditorFeature with a toolbar button, a hover menu for the tone, „Umwandeln in …“ and its own ability.

Path A gave the callout a slash entry. Path B makes it a feature like the built-in ones: a toolbar button next to the media buttons, a hover menu on the block that switches the tone, „Umwandeln in Callout“ for paragraphs and „Umwandeln in …“ out of a callout. It reuses the node of chapter 4 unchanged.

A feature is a bundle of contributions behind one `id`. Features differ only by which contribution slots they fill; nothing is registered globally. The callout feature fills six slots, shown piece by piece below. Try the result first: the toolbar has a „Callout“ button in the `inserts` group, hovering a callout shows a bar with the tone menu, and the block's „Umwandeln in …“ menu lists the conversions:

<demo-callout-path-b>



</demo-callout-path-b>

## The feature

`feature.ts` in the kitchen sink (`app/editor/callout/`) starts with the ability that gates the callout and the factory that returns the feature. Three fields identify it: `id`, the `dimension` (`'content'`, because it adds a node to the document) and the node it contributes.

```ts [app/editor/callout/feature.ts]
import { command, type EditorFeature } from '@skaile/forge-editor/core'
import { calloutConversions } from './convert'
import { CalloutBlock } from './index'
import { Callout, TONES } from './node'

/** The ability that gates the callout's toolbar button, `/` command and hover menu; grant it at root through the `abilities` prop. */
export const CALLOUT_ABILITY = 'insert.callout'

/**
 * The callout as a full editor feature: node, one command (toolbar button merged into the `inserts` group beside
 * "Speaker block", and `/callout`), a hover menu for the tone, the two-way "Convert to …" conversion and its description for the AI
 * (`aiContext.blocks`), so chat and generate tasks can create, convert to and edit callouts.
 */
export function calloutFeature(): EditorFeature<never> {
  return {
    id: 'callout',
    dimension: 'content',
    schemaExtensions: () => [Callout],
    extensions: () => [CalloutBlock],
```

- **extensions** adds the node with its view to the editor's schema. Feature extensions are added in feature order, before the `extensions` prop.
- **schemaExtensions** names the Vue-free node file. A server that validates or seeds documents collects these with `schemaExtensionsOf(features)` from `@skaile/forge-editor/core/schema` (chapter 6).
- **EditorFeature<never>** says the feature holds no framework components. It fits a Vue and a React list alike; the callout node view stays in `index.ts`.

### `commands`: toolbar, `/` menu and palette from one definition

A command is declared once, with the placements that show it. `toolbar: { group, order }` puts it on the toolbar, `slash: { id? }` in the `/` menu, and the ⌘K palette lists every command that runs or opens a menu. The toolbar, the `/` menu and the palette all derive their entries and their enabled and visible state from the same definition, so a command cannot be gated differently in two places.

```ts [app/editor/callout/feature.ts]
commands: () => [
      command({
        id: 'callout',
        toolbar: { group: 'inserts', order: 15 },
        slash: { id: 'callout' },
        creates: { type: 'callout' },
        icon: 'i-lucide-info',
        label: 'Callout',
        kbds: ['meta', 'shift', 'h'],
        ability: CALLOUT_ABILITY,
        command: c => c.toggleCallout(),
        active: e => e.isActive('callout'),
      }),
    ],
```

- **Groups.** `toolbar.group` must name a group some feature declares in `toolbarGroups`. The callout needs none of its own: it puts its button into the `inserts` group that the transcript feature (speaker button, `order: 10`) and the media feature (pickers, `order` 20 to 50) fill, so `order: 15` places „Callout“ between „Sprecherblock“ and the media buttons. A command `id` is unique per editor.
- **Bodies.** `command: chain => chain.toggleX()` builds a `run` command from a command chain: it focuses the editor, runs the chain and is disabled while `editor.can().chain()` says it is impossible. `active` makes it a toggle. Write `run: editor => …` yourself for anything else, or `menu` for a dropdown. `kbds` only labels the tooltip; the shortcut itself is the node's `addKeyboardShortcuts`.
- **Gating.** `ability` disables the command where the caret does not hold that ability. `creates: { type }` names the node the command inserts: it is disabled where the schema does not allow that node at the caret. `can(editor)` adds a structural check of your own, `visible(editor)` hides the command. The `/` menu lists enabled commands only; the toolbar and the palette grey them.
- **Abilities at root.** A command whose `ability` is neither in `ROOT_ABILITIES` nor granted by the `abilities` prop never becomes enabled at document root. The surface logs a warning once per command when it mounts. The mount below grants it.

### `blockActions`: the hover menu

Hover actions sit on a block's top line at the editor's right edge, shown by an installed `block-ui` feature. `ability` is checked on the block itself, `isVisible` narrows it further, and `order` sorts feature actions; actions with a larger `order`, like the callout's 500, follow the built-in ones. With `menu` instead of `run` the action opens a dropdown. The callout's lists the tones and applies one with `setCalloutTone` after selecting the block.

```ts [app/editor/callout/feature.ts]
blockActions: () => [{
      id: 'callout-tone',
      label: 'Callout tone',
      icon: 'i-lucide-palette',
      ability: CALLOUT_ABILITY,
      order: 500,
      isVisible: ({ node }) => node.type.name === 'callout',
      menu: ({ editor, pos }) => TONES.map(tone => ({
        label: tone.label,
        icon: tone.icon,
        onSelect: () => editor.chain().focus().setNodeSelection(pos).setCalloutTone(tone.value).run(),
      })),
    }],
    conversions: [calloutConversions],
```

The bar is hidden while the editor is read-only.

Where the bar appears is the host's choice: `blockUiFeature({ actions })` (or `composeEditor({ blockUi: { actions } })`) selects the hover bar (default), a toolbar on the block holding the selection, both, or none. An action can restrict itself with `placement: 'hover' | 'toolbar' | 'both'` (default `'both'`), e.g. a rarely needed action on the toolbar only. `ability` stays a gate on the block's place in the document, not a presentation switch: removing an ability hides the action everywhere, `placement` and `actions` only decide on which surface it is offered.

### `conversions`: „Umwandeln in …“

„Umwandeln in …“ (hover menu, toolbar groups, the AI `convert` operation) runs through `ConversionProvider`s. The callout contributes one, in `convert.ts`:

```ts [app/editor/callout/convert.ts]
import type { Node as PMNode } from '@tiptap/pm/model'
import {
  childrenOf,
  type ConversionProvider,
  CORE_TARGET_IDS,
  convertCoreBlocks,
  type ConvertTarget,
  createConverter,
  finishConversion,
  isCoreSource,
  parseCoreTarget,
} from '@skaile/forge-editor/core'

const CALLOUT = 'callout'
// A host block names itself with the inert `custom` target: `id` is the target id and the node type name.
const TARGET: ConvertTarget = { type: 'custom', id: CALLOUT }
/** The core targets a callout unwraps into. */
const UNWRAP_TARGETS: readonly ConvertTarget[] = CORE_TARGET_IDS.flatMap(id => parseCoreTarget(id) ?? [])

const isCallout = (node: PMNode) => node.type.name === CALLOUT

/**
 * Paragraphs, headings, code blocks, lists and quotes become a callout (the quote rule: textblocks turn into
 * paragraphs, lists and quotes keep their blocks); a callout unwraps into every core target through the core engine.
 * `handlesSource` makes this provider execute every conversion of a callout, the core provider has none for it.
 */
export const calloutConversions: ConversionProvider = {
  handlesSource: isCallout,
  targets: (_editor, node) => {
    const { nodes } = node.type.schema
    if (isCallout(node)) return UNWRAP_TARGETS.filter(target => nodes[target.type])
    return isCoreSource(node) && nodes[CALLOUT] ? [TARGET] : []
  },
  parseTarget: id => id === CALLOUT ? TARGET : null,
  label: target => target.type === 'custom' && target.id === CALLOUT ? { label: 'Callout', icon: 'i-lucide-info' } : null,
  convert: (state, $start, target) => {
    const node = $start.nodeAfter
    const { schema } = state
    if (!node) return null
    let output: PMNode[] | null
    if (isCallout(node)) {
      const children = childrenOf(node)
      output = convertCoreBlocks(schema, children, target) ?? children
    }
    else if (target.type === 'custom' && target.id === CALLOUT && schema.nodes[CALLOUT] && isCoreSource(node)) {
      const quote = createConverter(schema).toQuote([node])[0]
      output = quote ? [schema.nodes[CALLOUT].create({ tone: 'info' }, quote.content)] : null
    }
    else return null
    const blocks = output && finishConversion([node], output)
    return blocks ? { from: $start.pos, to: $start.pos + node.nodeSize, blocks } : null
  },
}
```

The rules a provider follows:

- **The custom target.** `ConvertTarget` is a closed union of inert descriptors; `{ type: 'custom', id, attrs? }` is the member for host blocks. `id` is both the target id and the node type name. The core engine never executes a `custom` target.
- **Ownership.** The provider whose `parseTarget(id)` returns the target owns it (labels through `label`, execution through `convert`). A target id offered by two providers throws.
- **handlesSource.** The first provider that claims a source block executes *every* conversion of it. The callout claims callouts, so unwrapping into a paragraph or a list runs here and reuses the core engine (`convertCoreBlocks`). Without a claim, the target's owner executes.
- **Helpers.** `createConverter`, `convertCoreBlocks`, `childrenOf`, `isCoreSource`, `CORE_TARGET_IDS`, `parseCoreTarget` and `finishConversion` (carries block IDs and checks the result) come from `@skaile/forge-editor/core`. Return `null` for "not possible"; the result must fit its parent, so a callout is only offered where one may stand.
- **One transaction.** A conversion is one undo step.

### `aiContext`

The last slot, `aiContext`, tells the model that the block exists and how to write it. It is chapter 7's topic.

## Composing and mounting

The chapter-3 pattern again: a preset from `defineEditor`, mounted with `EditorSurface`. The preset lists the callout feature next to the core features and `blockUiFeature()`, which draws the hover bar. The `abilities` prop grants the custom ability at document root:

```vue [docs/app/components/content/DemoCalloutPathB.client.vue]
const preset = defineEditor({
  features: [coreContentFeature(), coreUiFeature(), blockUiFeature(), calloutFeature()],
})
```

The custom ability is not part of `ROOT_ABILITIES`, so the host grants it at document root, or the toolbar button, `/callout` and the hover menu stay hidden:

```vue [docs/app/components/content/DemoCalloutPathB.client.vue]
const abilities: Partial<AbilitiesOptions> = { root: [...ROOT_ABILITIES, CALLOUT_ABILITY] }
```

`ROOT_ABILITIES` is the built-in set from `@skaile/forge-editor/core/schema`; spreading it keeps every standard ability and adds yours. The surface then takes both:

```vue
<EditorSurface v-model="content" :preset="preset" :abilities="abilities" @ready="editor = $event" />
```

In the repository's kitchen sink (`app/pages/kitchen-sink/surface.vue`, route `/kitchen-sink/surface`) the callout joins the full advanced composition instead of a short list; `add` appends host features after the catalog. Another host feature, `epikriseFeature()`, has its own page, `/kitchen-sink/epikrise`:

```vue [app/pages/kitchen-sink/surface.vue]
// The advanced composition plus the host's own feature, handed to the surface as one preset.
const preset = advancedEditor({ blockTypes: defaultBlockTypes(useEditorI18n().t), add: [calloutFeature()] })
```

Rules for the composition:

- **Declare the dimension.** Every feature names the axis it belongs to in `dimension`: `'content'` (nodes, marks or structure; the callout), `'interaction'` (editing UX, no schema) or `'ai'`. `defineEditor` fails with `feature "<id>" declares no dimension` otherwise.
- **Order is order.** `defineEditor` keeps the given order: toolbar groups, menu entries and the schema follow it. A feature that is not in the list contributes nothing, and a smaller list works too: `core + ui + callout` needs `blockUiFeature()` only for the hover actions.
- **Grant a custom ability.** Items gated by your own ability id are inert until the ability exists at the caret. Without the grant the toolbar button is disabled, and `/callout` and the hover menu are missing. Root abilities come from the `abilities` prop; per-node rules go in `rules`, or in the node's own `abilities`.
- **Build the preset once.** Build it in `<script setup>` (or a React `useState`/`useMemo`): the preset is read once at mount, like every prop of the surface. Call `advancedEditor()` during component setup: some of its features hook the Vue app's lifetime.
- **requires.** Name features your feature builds on with `requires: ['block-ui']`: `defineEditor` throws at composition time when one is missing, instead of a button silently not appearing. The callout does not declare it because it works without hover actions.

The full contract of `EditorFeature`, including the slots this chapter did not use, is on [Feature contract](https://skaile-ai.github.io/forge-editor/reference/feature-contract).

### Markdown import

The kitchen sink's Surface page has an „Insert Markdown“ button that shows the tokenizer of chapter 4 working through the block pipeline: `parseBlocks(editor, markdown).blocks` returns TipTap JSON blocks, here with a `callout`.

```vue [app/pages/kitchen-sink/surface.vue]
/** Imports Markdown with the callout syntax through the editor's block pipeline (the tokenizer of `callout/node.ts`). */
function insertCalloutMarkdown() {
  const editor = pane.editor
  if (!editor) return
  editor.chain().focus().insertContent(parseBlocks(editor, CALLOUT_MARKDOWN).blocks).run()
}
```

## What you have now

The callout is a feature: a toolbar button in the `inserts` group, a `/callout` entry and a shortcut from one command definition, a hover menu that switches the tone, „Umwandeln in …“ in both directions, all gated by `CALLOUT_ABILITY`, which the host grants at the document root.

## Next

[Chapter 6: Backend and collaboration](https://skaile-ai.github.io/forge-editor/tutorial/backend-and-collaboration) puts the editor behind a server and lets two browsers edit one document.


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
