Chapter 5: Your own feature, path B
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.
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],
extensionsadds the node with its view to the editor's schema. Feature extensions are added in feature order, before theextensionsprop.schemaExtensionsnames the Vue-free node file. A server that validates or seeds documents collects these withschemaExtensionsOf(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 inindex.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.
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.groupmust name a group some feature declares intoolbarGroups. The callout needs none of its own: it puts its button into theinsertsgroup that the transcript feature (speaker button,order: 10) and the media feature (pickers,order20 to 50) fill, soorder: 15places „Callout“ between „Sprecherblock“ and the media buttons. A commandidis unique per editor. - Bodies.
command: chain => chain.toggleX()builds aruncommand from a command chain: it focuses the editor, runs the chain and is disabled whileeditor.can().chain()says it is impossible.activemakes it a toggle. Writerun: editor => …yourself for anything else, ormenufor a dropdown.kbdsonly labels the tooltip; the shortcut itself is the node'saddKeyboardShortcuts. - Gating.
abilitydisables 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
abilityis neither inROOT_ABILITIESnor granted by theabilitiesprop 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.
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:
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
customtarget.ConvertTargetis a closed union of inert descriptors;{ type: 'custom', id, attrs? }is the member for host blocks.idis both the target id and the node type name. The core engine never executes acustomtarget. - Ownership. The provider whose
parseTarget(id)returns the target owns it (labels throughlabel, execution throughconvert). 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,parseCoreTargetandfinishConversion(carries block IDs and checks the result) come from@skaile/forge-editor/core. Returnnullfor "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:
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:
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:
// 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'.defineEditorfails withfeature "<id>" declares no dimensionotherwise. - Order is order.
defineEditorkeeps 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 + calloutneedsblockUiFeature()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
/calloutand the hover menu are missing. Root abilities come from theabilitiesprop; per-node rules go inrules, or in the node's ownabilities. - Build the preset once. Build it in
<script setup>(or a ReactuseState/useMemo): the preset is read once at mount, like every prop of the surface. CalladvancedEditor()during component setup: some of its features hook the Vue app's lifetime. requires. Name features your feature builds on withrequires: ['block-ui']:defineEditorthrows 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.
/** 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.
Chapter 4: Your own block, path A
Write the „Hinweis“ callout as a TipTap node and plug it into SimpleEditor through the extensions and commands props.
Chapter 6: Backend and collaboration
Register the Nuxt module, hand it EditorPorts for identity and storage, and open one document in two browsers with the collaboration session.