Tutorial

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.

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:

CopyFilesUsed by
Simple playgroundexamples/vue-simple/src/callout/: node.ts, CalloutView.vue, index.ts, callout.cssPath A, wired in examples/vue-simple/src/App.vue
Advanced kitchen sinkapp/editor/callout/: the same four files plus feature.ts and convert.tsPath 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: propsPath B: a feature
What you writeA TipTap node, plus a commands transformerAn EditorFeature (id, extensions, commands, toolbarGroups, blockActions, conversions, …)
Where it plugs inThe extensions and commands props of SimpleEditor, AdvancedEditor or EditorSurfaceA preset: defineEditor({ features: [...] }) mounted with EditorSurface
You getThe node in the schema, its commands, input rules and shortcuts, its Markdown form, a / menu entry, block IDs when the node declares editorBlockEverything 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 getA 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:

examples/vue-simple/src/callout/node.ts
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:

examples/vue-simple/src/callout/node.ts
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:

examples/vue-simple/src/callout/node.ts
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):

examples/vue-simple/src/callout/node.ts
  // 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:

examples/vue-simple/src/callout/node.ts
  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:

FieldRead byMeaning
editorBlockNodeFlags (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, editorChromeNodeFlags, block conversion and drag targetingA 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.
abilitiesthe 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/markdownThe 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.

Limitation of this example.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:

examples/vue-simple/src/callout/index.ts
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:

examples/vue-simple/src/callout/CalloutView.vue
<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):

examples/vue-simple/src/callout/callout.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:

examples/vue-simple/src/App.vue
// 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:

examples/vue-simple/src/App.vue
            <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 /hinweis Enter wraps the paragraph at the caret in an info callout; Mod-Shift-h toggles it (wrap, or lift out of the callout).
  • The tone USelect in the callout's header changes attrs.tone; the Markdown form then reads !!! success for that block.
  • Nodes with editorBlock: true get an id. The JSON pane of the example shows editor.getJSON() (not the v-model value) so you can see the IDs the editor adds.
  • The / entry has no ability, 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 …“.

Copyright © 2026