Chapter 4: Your own block, path A
Meeting notes need a block the library does not ship: a coloured box for a hint, a warning or a decision. In this chapter you write it as the „Hinweis“ block (callout, attribute tone = info | warning | success) and add it to the simple editor through two props. The chapter after this one turns the same node into a full feature.
The example exists twice on purpose, so each runnable app is self-contained; both copies are identical:
| Copy | Files | Used by |
|---|---|---|
| Simple playground | examples/vue-simple/src/callout/: node.ts, CalloutView.vue, index.ts, callout.css | Path A, wired in examples/vue-simple/src/App.vue |
| Advanced kitchen sink | app/editor/callout/: the same four files plus feature.ts and convert.ts | Path B, chapter 5 |
Start the simple playground with bun run --cwd examples/vue-simple dev (port 3040) and pick the sample „Hinweise“. The UI strings of the examples are German, the code and this guide are English.
Two ways to add a block
| Path A: props | Path B: a feature | |
|---|---|---|
| What you write | A TipTap node, plus a commands transformer | An EditorFeature (id, extensions, commands, toolbarGroups, blockActions, conversions, …) |
| Where it plugs in | The extensions and commands props of SimpleEditor, AdvancedEditor or EditorSurface | A preset: defineEditor({ features: [...] }) mounted with EditorSurface |
| You get | The node in the schema, its commands, input rules and shortcuts, its Markdown form, a / menu entry, block IDs when the node declares editorBlock | Everything of path A, plus a toolbar button, ability-gated / entries, hover actions, „Umwandeln in …“ targets and a requires check of the features it builds on |
| You do not get | A toolbar button, hover actions, conversion targets: the toolbar renders what features contribute | — |
Both paths share the same node. Start with path A; move to path B when the block needs a toolbar button, hover actions or conversion.
Anatomy of a block the editor understands
The node
node.ts is the complete, Vue-free definition: schema, commands, shortcut and Markdown form. It imports only @tiptap/core and the Vue-free @skaile/forge-editor/core/schema, so a server can import it without loading a component. Four things in order: the tones, the typed commands, the schema, the Markdown form.
The tones and their labels are plain data, shared with the node view:
import { mergeAttributes, Node } from '@tiptap/core'
import { ABILITY } from '@skaile/forge-editor/core/schema'
// Vue-free and server-importable: the callout's schema, commands and Markdown form. The Vue node view is attached in
// `index.ts`, so a server (`getSchema([...advancedSchemaExtensions(), Callout])`, or the simple schema) never loads a
// component.
export type Tone = 'info' | 'warning' | 'success'
export const TONES: readonly { value: Tone, label: string, icon: string }[] = [
{ value: 'info', label: 'Hinweis', icon: 'i-lucide-info' },
{ value: 'warning', label: 'Warnung', icon: 'i-lucide-triangle-alert' },
{ value: 'success', label: 'Erledigt', icon: 'i-lucide-circle-check' },
]
The commands are declared on TipTap's Commands interface so editor.commands.setCallout() is typed everywhere:
declare module '@tiptap/core' {
interface Commands<ReturnType> {
callout: {
/** Wrap the selected blocks in a callout of `tone` (default `info`). */
setCallout: (tone?: Tone) => ReturnType
/** Wrap the selection in a callout, or lift it out when it already is inside one. */
toggleCallout: () => ReturnType
/** Change the tone of the callout around the selection. */
setCalloutTone: (tone: Tone) => ReturnType
}
}
}
The schema part of the node. A callout holds one or more blocks (content: 'block+'), carries a tone attribute, and declares two fields the library reads: editorBlock and abilities:
export const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'block+',
defining: true,
// Carries a block ID like a paragraph: block flash/reveal, paste de-duplication, the server's ID backfill.
editorBlock: true,
// A callout holds plain text blocks: no headings, Typed blocks, media, records or speaker lines inside it.
abilities: {
revokes: [ABILITY.headings, ABILITY.composite, ABILITY.media, ABILITY.record, ABILITY.transcript],
},
addAttributes: () => ({
tone: {
default: 'info',
parseHTML: el => el.getAttribute('data-tone') ?? 'info',
renderHTML: attrs => ({ 'data-tone': attrs.tone }),
},
}),
parseHTML: () => [{ tag: 'div[data-type="callout"]' }],
renderHTML: ({ HTMLAttributes }) => ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'callout' }), 0],
The Markdown form, in both directions. @tiptap/markdown reads these four fields; the AI and the Markdown export both depend on them (chapter 7):
// Markdown: `!!! <tone>` followed by the body up to the next blank line. `:::` belongs to Typed blocks. The body blocks
// are joined by a single newline (without a separator `renderChildren` would glue them: „text- item“), so a body
// with several paragraphs reads back as one; a blank line would end the callout for the tokenizer below.
renderMarkdown: (node, h) => `!!! ${node.attrs?.tone ?? 'info'}\n${h.renderChildren(node.content ?? [], '\n')}`,
markdownTokenName: 'callout',
markdownTokenizer: {
name: 'callout',
level: 'block',
start: src => src.indexOf('!!! '),
tokenize(src, _tokens, lexer) {
const match = /^!!! (info|warning|success)\n([\s\S]+?)(?:\n{2,}|$)/.exec(src)
if (!match) return
return { type: 'callout', raw: match[0], tone: match[1], tokens: lexer.blockTokens(match[2]!) }
},
},
parseMarkdown: (token, h) => h.createNode('callout', { tone: typeof token.tone === 'string' ? token.tone : 'info' }, h.parseChildren(token.tokens ?? [])),
The commands and the keyboard shortcut close the node:
addCommands() {
return {
setCallout: tone => ({ commands }) => commands.wrapIn(this.name, { tone: tone ?? 'info' }),
toggleCallout: () => ({ editor, commands }) =>
editor.isActive(this.name) ? commands.lift(this.name) : commands.wrapIn(this.name, { tone: 'info' }),
setCalloutTone: tone => ({ commands }) => commands.updateAttributes(this.name, { tone }),
}
},
addKeyboardShortcuts() {
// Mod-Alt-c is the code block; Mod-Shift-s / -m / -k belong to the transcript, mention and tag features.
return { 'Mod-Shift-h': () => this.editor.commands.toggleCallout() }
},
})
Fields the library reads
Besides the plain TipTap fields (name, group, content, defining, addAttributes, parseHTML and renderHTML, commands, shortcuts) the package reads these NodeConfig fields:
| Field | Read by | Meaning |
|---|---|---|
editorBlock | NodeFlags (node-flags.ts) and BlockId (block-ids.ts) | The node is an addressable block: it gets the id attribute (rendered data-id) the built-in blocks have. Block IDs are what block flash and reveal look up (findBlockById), what paste de-duplication and the server's ID backfill (assignBlockIds) iterate, and what the AI chat addresses: a text block with an ID is a [<id>] … line of the chat's document view and can be updated, deleted, extended or converted by that ID. Built-in block nodes default to true (BLOCK_ID_TYPES); every other node defaults to false and then has no id attribute. isEditorBlock(node) (from @skaile/forge-editor/core/schema) reads the flag. |
editorBlockContainer, editorChrome | NodeFlags, block conversion and drag targeting | A Container block (the document, a Typed block) holds blocks that are individually convertible and draggable; chrome is fixed header rows of such a block. The callout declares neither: it is one block, its inner paragraphs are not offered separately. |
abilities | the Abilities extension (AbilityRule: grants, revokes, own) | What editing abilities hold inside the node. The callout revokes headings, Typed blocks, media, recording and speaker lines for its content. Ability ids are plain strings; host rules passed through the abilities prop replace a node's own rule (chapter 5 grants a custom ability). |
renderMarkdown, markdownTokenName, markdownTokenizer, parseMarkdown | @tiptap/markdown | The Markdown form of the block, in both directions. |
Why !!!. The Markdown form is the form the model reads and writes, and the one getMarkdown() exports. Pick syntax that no other stage claims: ::: fences belong to Typed blocks, and **Name:** lines are turned into speaker segments by the transcript feature's segment stage.
renderMarkdown joins the body blocks with '\n' and the tokenizer ends a callout at the first blank line. A callout with several paragraphs therefore exports without blank lines and re-imports as one paragraph. Keep callouts to one paragraph when the Markdown round trip matters, or extend the tokenizer.Node view and styles
The node view is attached in a second file, so node.ts stays free of Vue:
import type { NodeViewRenderer } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import CalloutView from './CalloutView.vue'
import { Callout } from './node'
export { Callout, TONES, type Tone } from './node'
// The schema node plus its Vue node view; node.ts stays Vue-free so a server can import it on its own.
export const CalloutBlock = Callout.extend({
// Explicit return type: keeps the declaration independent of the Vue view's inferred types.
addNodeView(): NodeViewRenderer {
return VueNodeViewRenderer(CalloutView)
},
})
On a React surface the same second file attaches ReactNodeViewRenderer(CalloutView) from @tiptap/react instead; node.ts stays unchanged and framework-free.
The view renders a non-editable header (tone icon and a USelect) above the editable body:
<script setup lang="ts">
import { NodeViewContent, nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
import { computed } from 'vue'
import { type Tone, TONES } from './node'
const props = defineProps(nodeViewProps)
const tone = computed(() => TONES.find(t => t.value === props.node.attrs.tone) ?? TONES[0])
</script>
<template>
<NodeViewWrapper as="div" class="callout" :data-tone="node.attrs.tone">
<div class="callout-header" contenteditable="false">
<UIcon :name="tone?.icon ?? 'i-lucide-info'" class="size-4 shrink-0" />
<USelect
:items="[...TONES]"
value-key="value"
label-key="label"
:model-value="node.attrs.tone"
size="xs"
variant="ghost"
:disabled="!editor.isEditable"
@update:model-value="(value: Tone) => updateAttributes({ tone: value })"
/>
</div>
<NodeViewContent as="div" class="callout-body" />
</NodeViewWrapper>
</template>
The styles are plain CSS with Nuxt UI's colour variables, one rule set per tone. Import them after the core stylesheet (@import "./callout/callout.css"; right below core.css, as in the example's style.css):
/* The „Hinweis“ block: a tinted box per tone; the tone colours come from Nuxt UI's colour variables. */
.callout {
--callout-color: var(--ui-info);
border-left: 3px solid var(--callout-color);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
margin: 0.5rem 0;
background: color-mix(in oklab, var(--callout-color) 8%, transparent);
}
.callout[data-tone='info'] {
--callout-color: var(--ui-info);
}
.callout[data-tone='warning'] {
--callout-color: var(--ui-warning);
}
.callout[data-tone='success'] {
--callout-color: var(--ui-success);
}
.callout-header {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--callout-color);
}
.callout-body > :first-child {
margin-top: 0;
}
.callout-body > :last-child {
margin-bottom: 0;
}
Path A: extensions and commands
The simple playground adds the node to a SimpleEditor with two props. The same props exist on AdvancedEditor and EditorSurface. extensions adds TipTap extensions after the built-in ones; commands transforms the whole command list, and a command with a slash placement joins the / menu. Both are read once at setup, so pass a constant and a module-level function:
// Added to the simple schema through the `extensions` prop (constant: read once at setup).
const extensions = [CalloutBlock]
// Host commands next to the simple editor's own (the `/h1` … `/rule` block entries and the toolbar): `/datum`, `/hinweis`.
function hostCommands(defaults: CommandDef[]): CommandDef[] {
return [
...defaults,
{
id: 'datum',
label: 'Datum',
icon: 'i-lucide-calendar',
slash: {},
run: editor => editor.chain().focus().insertContent(new Date().toLocaleDateString('de-DE')).run(),
},
{
id: 'hinweis',
label: 'Hinweis',
icon: 'i-lucide-info',
slash: {},
run: editor => editor.chain().focus().setCallout('info').run(),
},
]
}
slash.id (default: the command id) is what the user types after /; the query matches it or the label as a prefix, case-insensitively. run is called after the /query text is deleted. The two props reach the editor in the template:
<SimpleEditor
:model-value="content"
:editable="editable"
:extensions="extensions"
:commands="hostCommands"
@ready="onReady"
@update:model-value="onUpdate"
@content-error="onContentError"
/>
Try it. The demo loads the „Hinweise“ sample; type /hinweis and press Enter, or press Mod-Shift-h, and change the tone with the select in the callout's header:
What the user gets:
- Typing
/hinweisEnter wraps the paragraph at the caret in aninfocallout;Mod-Shift-htoggles it (wrap, or lift out of the callout). - The tone
USelectin the callout's header changesattrs.tone; the Markdown form then reads!!! successfor that block. - Nodes with
editorBlock: trueget anid. The JSON pane of the example showseditor.getJSON()(not thev-modelvalue) so you can see the IDs the editor adds. - The
/entry has noability, so it is listed wherever the/menu opens. A toolbar button needs path B: the toolbar renders what features contribute, and a prop cannot add to it.
The simple editor rejects documents with unknown nodes, so the „Hinweise“ sample (callouts in samples.ts) loads only because CalloutBlock is in the schema. Content with a typedBlock still ends up in contentError (chapter 2).
What you have now
A callout node with schema, commands, a shortcut, a Markdown form and a Vue node view, plugged into SimpleEditor through the extensions and commands props. The block shows up in / and the Markdown export, but not in the toolbar, in hover actions or in the conversion menu.
Next
Chapter 5: Your own feature, path B gives the block a toolbar button, a hover menu and „Umwandeln in …“.
Chapter 3: Compose features
Replace SimpleEditor with EditorSurface over defineEditor and add mentions, media, tables and block UI as features.
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.