Tutorial

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:

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.

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.

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.

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 ConversionProviders. The callout contributes one, in convert.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:

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:

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:

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

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.

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.

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 puts the editor behind a server and lets two browsers edit one document.

Copyright © 2026