---
title: "Chapter 4: Your own block, path A"
description: "Write the „Hinweis“ callout as a TipTap node and plug it into SimpleEditor through the extensions and commands props."
canonical_url: "https://skaile-ai.github.io/forge-editor/tutorial/own-block-path-a"
---
# 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:

<table>
<thead>
  <tr>
    <th>
      Copy
    </th>
    
    <th>
      Files
    </th>
    
    <th>
      Used by
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Simple playground
    </td>
    
    <td>
      <a href="https://github.com/skaile-ai/forge-editor/tree/main/examples/vue-simple/src/callout" rel="nofollow">
        <code>
          examples/vue-simple/src/callout/
        </code>
      </a>
      
      : <code>
        node.ts
      </code>
      
      , <code>
        CalloutView.vue
      </code>
      
      , <code>
        index.ts
      </code>
      
      , <code>
        callout.css
      </code>
    </td>
    
    <td>
      Path A, wired in <a href="https://github.com/skaile-ai/forge-editor/blob/main/examples/vue-simple/src/App.vue" rel="nofollow">
        <code>
          examples/vue-simple/src/App.vue
        </code>
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Advanced kitchen sink
    </td>
    
    <td>
      <a href="https://github.com/skaile-ai/forge-editor/tree/main/app/editor/callout" rel="nofollow">
        <code>
          app/editor/callout/
        </code>
      </a>
      
      : the same four files plus <code>
        feature.ts
      </code>
      
       and <code>
        convert.ts
      </code>
    </td>
    
    <td>
      Path B, chapter 5
    </td>
  </tr>
</tbody>
</table>

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

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      Path A: props
    </th>
    
    <th>
      Path B: a feature
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      What you write
    </td>
    
    <td>
      A TipTap node, plus a <code>
        commands
      </code>
      
       transformer
    </td>
    
    <td>
      An <code>
        EditorFeature
      </code>
      
       (<code>
        id
      </code>
      
      , <code>
        extensions
      </code>
      
      , <code>
        commands
      </code>
      
      , <code>
        toolbarGroups
      </code>
      
      , <code>
        blockActions
      </code>
      
      , <code>
        conversions
      </code>
      
      , …)
    </td>
  </tr>
  
  <tr>
    <td>
      Where it plugs in
    </td>
    
    <td>
      The <code>
        extensions
      </code>
      
       and <code>
        commands
      </code>
      
       props of <code>
        SimpleEditor
      </code>
      
      , <code>
        AdvancedEditor
      </code>
      
       or <code>
        EditorSurface
      </code>
    </td>
    
    <td>
      A preset: <code>
        defineEditor({ features: [...] })
      </code>
      
       mounted with <code>
        EditorSurface
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      You get
    </td>
    
    <td>
      The node in the schema, its commands, input rules and shortcuts, its Markdown form, a <code>
        /
      </code>
      
       menu entry, block IDs when the node declares <code>
        editorBlock
      </code>
    </td>
    
    <td>
      Everything of path A, plus a toolbar button, ability-gated <code>
        /
      </code>
      
       entries, hover actions, „Umwandeln in …“ targets and a <code>
        requires
      </code>
      
       check of the features it builds on
    </td>
  </tr>
  
  <tr>
    <td>
      You do not get
    </td>
    
    <td>
      A toolbar button, hover actions, conversion targets: the toolbar renders what features contribute
    </td>
    
    <td>
      —
    </td>
  </tr>
</tbody>
</table>

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:

```ts [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:

```ts [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`:

```ts [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):

```ts [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:

```ts [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:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Read by
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        editorBlock
      </code>
    </td>
    
    <td>
      <code>
        NodeFlags
      </code>
      
       (<a href="https://github.com/skaile-ai/forge-editor/blob/main/packages/editor/src/core/schema/node-flags.ts" rel="nofollow">
        <code>
          node-flags.ts
        </code>
      </a>
      
      ) and <code>
        BlockId
      </code>
      
       (<a href="https://github.com/skaile-ai/forge-editor/blob/main/packages/editor/src/core/schema/block-ids.ts" rel="nofollow">
        <code>
          block-ids.ts
        </code>
      </a>
      
      )
    </td>
    
    <td>
      The node is an addressable block: it gets the <code>
        id
      </code>
      
       attribute (rendered <code>
        data-id
      </code>
      
      ) the built-in blocks have. Block IDs are what block flash and reveal look up (<code>
        findBlockById
      </code>
      
      ), what paste de-duplication and the server's ID backfill (<code>
        assignBlockIds
      </code>
      
      ) iterate, and what the AI chat addresses: a text block with an ID is a <code>
        [<id>] …
      </code>
      
       line of the chat's document view and can be updated, deleted, extended or converted by that ID. <strong>
        Built-in block nodes default to <code>
          true
        </code>
      </strong>
      
       (<code>
        BLOCK_ID_TYPES
      </code>
      
      ); every other node defaults to <code>
        false
      </code>
      
       and then has no <code>
        id
      </code>
      
       attribute. <code>
        isEditorBlock(node)
      </code>
      
       (from <code>
        @skaile/forge-editor/core/schema
      </code>
      
      ) reads the flag.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        editorBlockContainer
      </code>
      
      , <code>
        editorChrome
      </code>
    </td>
    
    <td>
      <code>
        NodeFlags
      </code>
      
      , block conversion and drag targeting
    </td>
    
    <td>
      A <em>
        Container block
      </em>
      
       (the document, a Typed block) holds blocks that are individually convertible and draggable; <em>
        chrome
      </em>
      
       is fixed header rows of such a block. The callout declares neither: it is one block, its inner paragraphs are not offered separately.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        abilities
      </code>
    </td>
    
    <td>
      the <code>
        Abilities
      </code>
      
       extension (<code>
        AbilityRule
      </code>
      
      : <code>
        grants
      </code>
      
      , <code>
        revokes
      </code>
      
      , <code>
        own
      </code>
      
      )
    </td>
    
    <td>
      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 <code>
        abilities
      </code>
      
       prop replace a node's own rule (chapter 5 grants a custom ability).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        renderMarkdown
      </code>
      
      , <code>
        markdownTokenName
      </code>
      
      , <code>
        markdownTokenizer
      </code>
      
      , <code>
        parseMarkdown
      </code>
    </td>
    
    <td>
      <code>
        @tiptap/markdown
      </code>
    </td>
    
    <td>
      The Markdown form of the block, in both directions.
    </td>
  </tr>
</tbody>
</table>

**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.

<note>

**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.

</note>

### Node view and styles

The node view is attached in a second file, so `node.ts` stays free of Vue:

```ts [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:

```vue [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`):

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

```vue [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:

```vue [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:

<demo-callout-path-a>



</demo-callout-path-a>

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`](https://github.com/skaile-ai/forge-editor/blob/main/examples/vue-simple/src/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](https://skaile-ai.github.io/forge-editor/tutorial/own-feature-path-b) gives the block a toolbar button, a hover menu and „Umwandeln in …“.


## Sitemap

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