---
title: "Chapter 3: Compose features"
description: "Replace SimpleEditor with EditorSurface over defineEditor and add mentions, media, tables and block UI as features."
canonical_url: "https://skaile-ai.github.io/forge-editor/tutorial/compose-features"
---
# Chapter 3: Compose features

> Replace SimpleEditor with EditorSurface over defineEditor and add mentions, media, tables and block UI as features.

`SimpleEditor` is a fixed composition. An editor is really a list of **features**, and `EditorSurface` mounts any list you build with `defineEditor`. In this chapter the meeting-notes editor gets people and tag mentions, image drop, tables and a drag handle by adding four entries to that list.

Try it. Type `@a` for people, `#i` for tags, `/` for the block menu, drop an image on the page, or insert a table from the toolbar:

<demo-composed-editor>



</demo-composed-editor>

## The preset

Each feature is one factory you import from its own entry, so a feature you do not import is not in your bundle. `defineEditor({ features })` keeps the given order: toolbar groups, menu entries and the schema follow it. The component of the demo above is the whole code:

```vue [docs/app/components/content/DemoComposedEditor.client.vue]
<script setup lang="ts">
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { blockUiFeature } from '@skaile/forge-editor/block-ui/vue'
import { mediaFeature } from '@skaile/forge-editor/media/vue'
import { mentionsFeature } from '@skaile/forge-editor/mentions'
import { tablesFeature } from '@skaile/forge-editor/tables'
import { EditorSurface, EditorToolbar } from '@skaile/forge-editor/vue'
import type { Editor, JSONContent } from '@tiptap/vue-3'
import { ref, shallowRef } from 'vue'

// An editor is the sum of its features. Mentions and tags come from the `backend` the app context carries.
const preset = defineEditor({
  features: [coreContentFeature(), coreUiFeature(), mentionsFeature(), mediaFeature(), tablesFeature(), blockUiFeature()],
})

const content = ref<JSONContent>({
  type: 'doc',
  content: [
    { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Meeting notes' }] },
    {
      type: 'paragraph',
      content: [{ type: 'text', text: 'Type @ to mention a person, # to add a tag, / for the block menu, or drop an image.' }],
    },
  ],
})
const editor = shallowRef<Editor>()
</script>

<template>
  <!-- Global MDC components skip Nuxt's `.client` handling, so the page's server render would reach the editor: no UI kit there. -->
  <ClientOnly>
    <div class="not-prose my-6 flex flex-col gap-3">
      <EditorToolbar :editor="editor" />
      <div class="min-h-64 rounded-lg border border-default p-4">
        <EditorSurface v-model="content" :preset="preset" @ready="editor = $event" />
      </div>
    </div>
  </ClientOnly>
</template>
```

What each feature adds on top of `coreContentFeature()` (headings, lists, marks and so on) and `coreUiFeature()` (toolbar, `/` menu, command palette):

<table>
<thead>
  <tr>
    <th>
      Feature
    </th>
    
    <th>
      Entry
    </th>
    
    <th>
      What it adds
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        mentionsFeature()
      </code>
    </td>
    
    <td>
      <code>
        @skaile/forge-editor/mentions
      </code>
    </td>
    
    <td>
      <code>
        @username
      </code>
      
       and <code>
        #tag
      </code>
      
       chips with suggestion popups
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mediaFeature()
      </code>
    </td>
    
    <td>
      <code>
        @skaile/forge-editor/media/vue
      </code>
    </td>
    
    <td>
      Image, audio, video and file blocks, drop and paste of files, toolbar buttons, the audio recorder
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        tablesFeature()
      </code>
    </td>
    
    <td>
      <code>
        @skaile/forge-editor/tables
      </code>
    </td>
    
    <td>
      Tables with a toolbar group
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        blockUiFeature()
      </code>
    </td>
    
    <td>
      <code>
        @skaile/forge-editor/block-ui/vue
      </code>
    </td>
    
    <td>
      Drag handle, hover actions per block, block-range selection
    </td>
  </tr>
</tbody>
</table>

None of the four needs Typed blocks, AI or Yjs, and none imports the others: `bun run check:editor-entries` proves that per feature. The framework-free entries (`/mentions`, `/tables`, `/core`) work on both surfaces; the entries with a `/vue` suffix carry Vue components, and their React twins end in `/react` (chapter 8).

Two rules to remember:

- **The preset is read once.** Build it in `<script setup>` or at module level. To change the list, remount `EditorSurface` with a new `key`.
- **EditorSurface takes v-model, :preset, :editable, :metadata, :abilities, :extensions and :commands.** The last three are the props chapter 4 uses; `metadata` supplies the lists behind `@` and `#` when you do not want the context's backend to.

## Where the suggestions come from

The `@` and `#` popups search two named lists, `mentions` and `hashtags`. A list your app does not supply falls back to the feature's built-in source, which asks the context's backend: `users` for mentions, `tags` for hashtags. The memory backend of the example's `main.ts` (chapter 2) answers both from the data you gave it:

```ts [examples/vue-simple/src/main.ts]
// No data layer: documents, dropped files (as blob: URLs), mention users and tags, and settings live in the page.
const backend = createMemoryBackend({
  users: [
    { id: 'u1', username: 'Ada Lovelace', color: '#7c3aed' },
    { id: 'u2', username: 'Alan Turing', color: '#0ea5e9' },
    { id: 'u3', username: 'Grace Hopper', color: '#16a34a' },
  ],
  tags: ['idee', 'entwurf', 'wichtig'],
})

createApp(App)
  .use(ui)
  .use(createEditor({ kit: nuxtUiKit(), backend }))
```

An app without a backend can pass its own lists on the surface instead, and never calls those endpoints:

```vue
<EditorSurface
  :preset="preset"
  :metadata="{
    mentions: [{ id: 'u1', label: 'anna', color: '#e11d48' }],
    hashtags: q => searchTags(q.query),
  }"
  @metadata-create="(list, item) => persist(list, item)"
/>
```

Mentions are `@username` chips tinted with the item's `color` (a `#rgb` or `#rrggbb` value; anything else is dropped). Tags are `#tag` chips; the typed name is normalised (lower case, spaces become `-`, letters, digits, `-` and `_` only, at most 64 characters), and the popup offers „#name erstellen“ when it is not in the list. Picking that entry emits `metadataCreate('hashtags', { id, label })`.

## Images and files

`mediaFeature()` adds image, audio, video and file blocks, drop and paste of files, the toolbar buttons (Bild, Audio-Datei, Video, Aufnahme), the matching `/` commands and the audio recorder. Every path ends in one function, `editor.storage.media.upload(file)`, which calls the backend's `assets` capability. The memory backend answers with a `blob:` URL, so a dropped image shows up at once and disappears when the page reloads. To store files elsewhere, implement `assets.upload` in your own backend; the returned `url` becomes the block's `src`. Accepted types and sizes are in [Media, mentions and transcript](https://skaile-ai.github.io/forge-editor/guides/media-mentions-transcript). The transcript feature (speaker blocks) is described there as well; the meeting notes do not need it.

## The feature stylesheets

Features that draw their own UI ship a stylesheet. Import each one after Tailwind, Nuxt UI and `core.css`:

```css
@import "@skaile/forge-editor/styles/mentions.css";
@import "@skaile/forge-editor/styles/media.css";
@import "@skaile/forge-editor/styles/tables.css";
@import "@skaile/forge-editor/styles/block-ui.css";
```

[CSS and UI-provider requirements](https://skaile-ai.github.io/forge-editor/reference/css-and-ui) has the full list and the reason for the order.

## The shortcut: `composeEditor`

`defineEditor` is the explicit form. `composeEditor` from `@skaile/forge-editor/vue/advanced` builds the same kind of preset from three **dimensions**, the document model (`content`), the editing UX (`interaction`) and `ai`, each a named level or a list of feature ids:

```ts
import { composeEditor } from '@skaile/forge-editor/vue/advanced'

// core-content and core-ui plus mentions, media, tables and block UI, without AI.
const preset = composeEditor({ content: ['mentions', 'media', 'tables'], interaction: ['block-ui'], ai: false })
```

The result is a preset like the one from `defineEditor`, plus `omitted`, the features that were selected but dropped because a feature they `require` is missing or because the `backend` lacks a capability. `advancedEditor()` is the one-line wrapper for the full catalog. Prefer `defineEditor` while you learn what each feature does, and `composeEditor` when you want a level (`'rich'`, `'full'`) instead of a list. See [Feature composition](https://skaile-ai.github.io/forge-editor/guides/composition) for the levels.

## What you have now

An editor assembled from features: mentions and tags backed by a memory backend, image drop with `blob:` URLs, tables and a drag handle, mounted with `EditorSurface`. You also know the rule that makes this safe: a feature that is not in the list contributes nothing, neither code nor schema.

## Next

[Chapter 4: Your own block, path A](https://skaile-ai.github.io/forge-editor/tutorial/own-block-path-a) adds a block the library does not have.


## Sitemap

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