Chapter 3: Compose 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:
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:
<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):
| Feature | Entry | What it adds |
|---|---|---|
mentionsFeature() | @skaile/forge-editor/mentions | @username and #tag chips with suggestion popups |
mediaFeature() | @skaile/forge-editor/media/vue | Image, audio, video and file blocks, drop and paste of files, toolbar buttons, the audio recorder |
tablesFeature() | @skaile/forge-editor/tables | Tables with a toolbar group |
blockUiFeature() | @skaile/forge-editor/block-ui/vue | Drag handle, hover actions per block, block-range selection |
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, remountEditorSurfacewith a newkey. EditorSurfacetakesv-model,:preset,:editable,:metadata,:abilities,:extensionsand:commands. The last three are the props chapter 4 uses;metadatasupplies 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:
// 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:
<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. 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:
@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 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:
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 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 adds a block the library does not have.
Chapter 2: A simple editor
Mount SimpleEditor with a JSON v-model, add the toolbar and the outline beside it, switch it read-only and handle rejected content.
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.