Guides

Feature composition and entries

Compose an editor from features and presets, pick the right entries, and choose the integration depth from no data layer to a Nuxt server.

Dimensions and integration depth

An editor is composed from three dimensions of features (composeEditor of @skaile/forge-editor/vue/advanced or /react/advanced; framework-free core in @skaile/forge-editor/presets/compose). The backend (EditorBackend; capabilities documents, collaboration, assets, users, tags, settings, themes, ai, comments) is a separate decision: it only prunes features whose capability it lacks, reported in omitted.

DimensionLevels (or a list of feature ids)Features
content: nodes, marks, structure; the server schema derives from it'basic' = core-content; 'rich' = core-content, media, transcript, composites, mentions, authors, tables, composite-mediacore-content is always installed
interaction: editing UX without schema'minimal' = core-ui; 'full' = core-ui, collab, comments, block-ui, themes; [] = none (a viewer)core-ui is prepended to every non-empty list
aifalse, or true = ai plus composite-ai (iff composites) and transcript-ai (iff transcript)an integration whose requires are missing lands in omitted

add appends host features after the catalog; remove drops catalog features and everything that requires them. Examples: viewer composeEditor({ content: 'rich', interaction: [], ai: false }); advanced without AI composeEditor({ content: 'rich', interaction: 'full', ai: false }); simple plus tables composeEditor({ content: ['tables'], interaction: 'minimal', ai: false }).

transfer (drag a Typed block from one editor or tree into another, with the copy transform and, with ai: true, the summary and bullet transforms of integrations/composite-ai) is in no level list, so a host opts in with interaction: [...INTERACTION.full, 'transfer'] and configures it with transfer: { transforms?, defaultTransform? } (Content tree mechanics). compositeBaskets replaces the built-in colour Baskets like blockTypes replaces the Block types (Baskets).

core-content is configurable: composeEditor({ content: 'basic', interaction: 'minimal', ai: false, coreContent: { headings: { levels: [1, 2] }, rule: false } }) (or coreContentFeature(options) in a hand-built preset) drops optional StarterKit structures from the editor. Options (CoreContentOptions): headings: false | { levels }, lists: false (bullet and ordered lists with their items), blockquote: false, codeBlock: false, rule: false; everything is on by default. A dropped structure is not in the schema, so the same options object also shapes the server schema (schemaExtensionsOf(preset.features), or coreContentSchemaExtensions(options) from @skaile/forge-editor/core/schema for a schema built by hand); pass the same options on both sides. Commands that create a dropped node (toolbar button, / entry, ⌘K row) are hidden, not greyed out, and its shortcuts do not exist. A heading level outside levels hides just that level's command. Abilities are untouched: they stay a permission, not a schema switch. Content that holds a dropped structure (Markdown with # Titel and headings: false, stored JSON with a heading node) has no node type to land in: do not pass such documents to an editor with that option.

block-ui chooses where block actions appear: composeEditor({ content: 'rich', interaction: 'full', ai: false, blockUi: { actions: 'both' } }) (or blockUiFeature({ actions }) in a hand-built preset). actions (BlockUiOptions) is 'hover' (default: the bar of the block under the pointer), 'toolbar' (the bar of the block holding the selection, shown at its corner as soon as the caret is in it), 'both' (one bar per block: where the pointer hovers the selected block only the toolbar shows) or 'none' (drag handle and block targeting only, no action bar). Each action can narrow itself with placement: 'hover' | 'toolbar' | 'both' (default 'both'). Abilities are not touched: an ability stays a permission ("can"), blockUi.actions only chooses where the allowed actions are offered. The toolbar bar carries the class block-actions-toolbar for host styling.

The catalog behind these dimensions is one table of rows in presets/catalog: one row per feature with its dimension, the level lists that hold it and whether a UI layer binds it (ui), and presets/compose adds how each is built from the composeEditor options. Its key order is the canonical order (content, interaction, AI), which the composed feature list and the toolbar order follow (the schema order is the explicit SCHEMA_IDS list of presets/schema-ids, a leaf the server's schema/advanced reads without the catalog); CONTENT, INTERACTION and AI are derived from it. Adding a catalog feature takes one row there, plus its id in presets/schema-ids when it has a schema; every entry that holds a part of it is keyed by the rows or that list, so a missing part (the builder, a schema source, the Vue, React or test binding of a ui row) fails to compile, and UiBindings<C> follows the ui rows. A framework-free feature without schema needs nothing else.

Integration depthWhat the host runsClient callServer
No data layernothing; the browser keeps the dataapp.use(createEditor({ backend: createMemoryBackend({ documents, users, tags }) })) (@skaile/forge-editor/adapters/memory), then composeEditor({ content: 'rich', interaction: 'full', ai: true, backend }): collab, comments, themes, ai and its integrations are omitted automaticallynone
Nuxt, built-inthe Nuxt module with an EditorPorts bundlemodule @skaile/forge-editor/nuxt (config key tiptapEditor); the plugin installs createHttpBackend({ probe: true }); <AdvancedEditor /> or composeEditor({ …, backend })provideEditorServer({ ports: createJsonAdapter({ … }) }) (or createDrizzleAdapter, createForgeAdapter) in a Nitro plugin, EditorPorts
Nuxt, external data layerthe Nuxt module with ports of your own over the external systemsame as the previous rowprovideEditorServer({ ports: { identity, documents, assets, … } }): implement only the ports the external system serves; the probe omits the rest on the client

No Directus (or other CMS) adapter ships with the package: the seam for an external data layer is EditorPorts, unchanged.

Bundles

A bundle is a named choice of the three dimensions, for hosts that want a starting point instead of picking levels. composeBundle(bundle, options?) (from @skaile/forge-editor/vue/advanced or /react/advanced; composeBundle(ui, bundle, options?) in the framework-free @skaile/forge-editor/presets/compose) expands the bundle from BUNDLES and calls composeEditor:

Bundlecontentinteractionai
simple'basic''minimal'false
writingmedia, tables, mentionsblock-ui, themestrue
collaborativemedia, tables, mentions, authorscollab, comments, block-ui, themesfalse
advanced'rich''full'true

BundleOptions are the options of composeEditor without the three dimensions (backend, add, remove, blockTypes, blockUi, …), plus two adjustments by catalog id: with adds features on top of the bundle (each lands in the dimension its catalog row names; an AI feature switches ai on) and without takes features out, exactly like remove (features that require them land in omitted). A with id outside the catalog throws. Any other combination is a composeEditor call with explicit id lists.

// The writing bundle plus the transfer menu, without tables.
const preset = composeBundle('writing', { with: ['transfer'], without: ['tables'], backend })

simpleEditor() is composeBundle(…, 'simple') and advancedEditor(options) is composeBundle('advanced', options); the advancedEditor options are BundleOptions, so the three dimensions are no longer overridable there (use composeEditor or a bundle for that).

Entries

EntrySideContents
@skaile/forge-editor/nuxtbuildThe Nuxt module (defineNuxtModule, config key tiptapEditor). Registers the Nitro scan dir, the client context plugin, AI runtime config, build.transpile, the ui.css Tailwind @source, Nitro externals.inline and experimental.websocket.
@skaile/forge-editor/configanywhereFramework-free defineEditorConfig, serverConfig, publicConfig and the types EditorConfig, EditorConfigInput, EditorServerConfig, EditorPublicConfig, ModelPresetConfig, TranscriptionConfig: the module options, the server options and the client options from one definition (see AI configuration).
@skaile/forge-editor/serverserverFramework-free (no Nuxt, h3, Nitro, Vue or React): createCollabServer({ ports, schema, hooks }) → { hocuspocus, handleConnection, forgetDocument, liveContent, contentChanged, stats, close }, createAiModels({ presets, apiKey, slots, transcription }), createRateLimiter({ max, windowMs }), EditorPorts with AccessControl, DocumentContentStore, CollabStateStore, Directory, AssetStore, SettingsStore, ThemeStore, ModelProvider, PORT_CAPABILITY, serverCapabilities, HttpError. The Nitro installers (installEditorServer, provideEditorServer, useEditorServer of @skaile/forge-editor/server/nitro) return the EditorServer handle with forgetDocument, liveContent, enforceRateLimit and resolveModel.
@skaile/forge-editor/server/handlersserverOne handler per editor/AI route, (ctx, request) => Promise<HandlerResponse> (usersGet, tagsGet, settingsGet/Put, assetGet/Post, themesGet, themeGet/Post/Delete, modelsGet, chatPost, generatePost, completePost, extractPost, transcriptCleanupPost) and the zod schemas of their bodies (chatBodySchema, …). Map any HTTP server's request onto HandlerRequest and send the HandlerResponse; HttpError carries the status.
@skaile/forge-editor/server/nodeserver (Node)listenCollab(collab, { port, host }): a Node HTTP server that serves a createCollabServer over websockets, for a plain script (needs the crossws dependency; Node only).
@skaile/forge-editor/collab, /collab/vue, /collab/reactbrowserframework-free collabFeatureCore(ui?), yjsAnchorResolver; collabFeature() per surface
@skaile/forge-editor/block-ui, /block-ui/vue, /block-ui/reactbrowserframework-free blockUiFeatureCore(ui, options?), blockActionsFor, selectedBlockOf, effectiveTarget, BLOCK_CORNER_ATTR, blockCornerOf; blockUiFeature(options?), BlockUi, BlockActionsOverlay per surface
@skaile/forge-editor/authors, /authors/vue, /authors/reactbrowserframework-free authorsFeatureCore(ui), currentAuthor, documentAuthors; authorsFeature() plus AuthorColorsButton, AuthorColorsPanel, SettingsAuthorsTab per surface (see Authors and comments)
@skaile/forge-editor/comments, /comments/vue, /comments/reactbrowserframework-free commentsFeatureCore(ui, { model? }), createCommentsModel; commentsFeature({ model? }) (requires collab unless a host model backs it), useComments, CommentsMargin, CommentsPopover, CommentsListMenu, … per surface
@skaile/forge-editor/themes, /themes/vue, /themes/reactbrowserframework-free themesFeatureCore(ui), startObsidianTheme, pinObsidianTheme, scopeThemeCss, THEME_SCOPE_CLASS; themesFeature() (site-wide Obsidian themes; see Themes) and SettingsAppearanceTab per surface; Vue-free in /themes/schema: ThemeRecord, THEME_ID, MAX_THEME_BYTES, themeManifestSchema
@skaile/forge-editor/schema/advancedanywhereVue-free advancedSchemaExtensions(); imports no Vue, UI or browser code
@skaile/forge-editor/styles/collab.css, /block-ui.css, /authors.css, /comments.css, /themes.cssbrowserRemote carets, block-range selection, author colouring, comment highlights and cards, the Obsidian variable bridge. The Nuxt module adds every feature stylesheet to css (themes.css last).

The server logic (src/server/) runs in any Node process: createCollabServer, createAiModels, createRateLimiter and the route handlers take their ports and configuration explicitly. The HTTP routes are one table, API (src/core/api-routes.ts: method, path, Port and request and response shape of every route), which the HTTP backend reads too; serverRoutesOf(ports, { ai }) (src/server/routes.ts) pairs each row with its handler and serves a route only when its port is present. One installer, installEditorServer (@skaile/forge-editor/server/nitro), mounts that table and the collab websocket on a Nitro app and returns the EditorServer handle (forgetDocument, liveContent, enforceRateLimit, resolveModel); the Nuxt module calls it from its server plugin and a plain Nitro app from a plugin of its own. A plain script needs no Nitro at all:

import { createCollabServer } from '@skaile/forge-editor/server'
import { listenCollab } from '@skaile/forge-editor/server/node'

const collab = createCollabServer({ ports }) // ports.identity.authenticate({ headers, token }) decides who may connect; needs ports.collabState
const { url, close } = await listenCollab(collab, { port: 4000 })
// browser: new HocuspocusProvider({ url, name: documentId, document, token })

Per-feature entries: framework-free, Vue, React

Every feature that has a UI is split in the same four entries, so a host takes exactly the layers it uses:

EntryContentsMay import
./<x>The feature's logic: <x>FeatureCore(ui), the models (create*, *Model: framework-free stores such as createCommentsModel, createCompositeRows, createAiAssistant, createDocumentChat, modelProfiles(app)), Tiptap extensions and plugins, helpersTiptap/ProseMirror, /core, /settings; no vue, react, @nuxt/ui or sonner
./<x>/vue<x>Feature(): VueFeature, the feature's Vue components and node views, thin Vue hooks (useComments, useCompositeRows, useAiAssistant, useModelProfiles, useFileImport, …) over the modelsvue, @tiptap/vue-3, Nuxt UI; never react
./<x>/react<x>Feature(): ReactFeature, the React components (shadcn/ui) and node views, the React hooks over the same modelsreact, @tiptap/react, Radix, sonner; never vue
./<x>/schemaNode/mark specs, protocol schemas and limits for servers and validatorsno UI, no DOM

Entries that exist per feature: media, transcript, composites, authors, comments, ai, collab, block-ui, themes (with /schema where the feature owns document nodes or a protocol), outline and tree (/vue, /react, no Core(ui)). mentions is framework-free alone (mentionsFeature(): EditorFeature<never>, usable in both layers). The three integrations (integrations/composite-media, composite-ai, transcript-ai) are framework-free alone too: they only wire other features' models, so compositeMediaFeature(), compositeAiFeature() and transcriptAiFeature() fit a Vue and a React preset unchanged.

  • The UI binding contract. features/<x>/feature.ts exports <x>FeatureCore<C>(ui: <X>Ui<C>): EditorFeature<C>: all feature behaviour (extensions, commands, toolbar items, slash commands, settings tabs, conversions) with the UI left open as the typed interface <X>Ui<C> (for example CommentsUi: toolbarButton, overlay, settingsTab; MediaUi: recorderModal, nodeViews). C is the framework's component type. ./<x>/vue fills it with Vue components, ./<x>/react with React components; a host writing its own chrome (another UI kit, a test) calls <x>FeatureCore(ui) with its own components. EditorFeature<never> means “no components at all”: such a feature (coreContentFeature(), coreUiFeature(), mentionsFeature(), the integrations) is accepted by both presets.
  • Notices. Features never import a toast library. A user-facing message is a Notice ({ id?, title, description?, kind?, icon?, duration? }) sent to a Notifier ((notice) => void, optional dismiss(id); silentNotifier drops everything), from /core. A feature reads it as ctx.notify in its factories (FeatureContext.notify), app-level models (model profiles, the rate-limit notice) as EditorContext.notify, set with createEditorContext({ notify }). Vue: vueNotifier(useToast()) shows Nuxt UI toasts (the page needs <UApp>); createEditor() installs a queued notifier (createQueuedNotifier()) that holds notices raised before the first EditorSurface exists and shows them through that surface's useToast(). React: reactNotifier shows sonner toasts; pass it as createEditorContext({ notify: reactNotifier }) and mount sonner's Toaster (re-exported from @skaile/forge-editor/react) once per page, otherwise no notice is visible. id replaces a visible toast, duration: 0 keeps it until replaced or dismissed.
  • setup and teardown. A feature's setup(ctx) runs once per mounted surface and may return a teardown function that the surface runs when it unmounts. Per-app runtimes dedupe inside and hand back a release: startObsidianTheme(ctx) is reference-counted per app, so the React themesFeature() removes the theme when the last surface that started it unmounts, while the Vue themesFeature() (call it during component setup) keeps it for the life of the Vue app through the lifetime hook of ThemesUi, because the theme styles the whole site. Run own per-app side effects there instead of in a component.
  • Models versus hooks. State and logic are framework-free createStore stores (from /core: createStore, derived, ReadonlyStore) inside the models; the hooks only subscribe: React useStore(store) (from /react), Vue useStoreRef(store) (a readonly ref, from /vue; useWritableStoreRef(store) for two-way refs, trackedStore(store) for a store whose get() Vue tracks, storeFromRef(ref) the other way round), all from the one vue/store-ref module, used inside the thin use* hooks of the feature. Notices follow one policy on both surfaces: notifierOf(editor, app) (from /core) picks the editor's surface notifier, else the app context's, when a notice is shown; useNotifier(editor?) (from /vue and /react) is its hook form, so a host that passes createEditor({ notify }) / createEditorContext({ notify }) receives every notice of the hooks, including the AI rate-limit notice (useAiAssistant(editor?), useDocumentChat(editor?), useFileImport(editor?)). A host that wants a different framework (or a script) uses the model directly.

Feature composition

An editor is a list of features. Every feature is imported from its own entry, so a feature you do not import is not in your bundle:

import { compositeFeature } from '@skaile/forge-editor/composites/vue'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { compositeMediaFeature } from '@skaile/forge-editor/integrations/composite-media'
import { mediaFeature } from '@skaile/forge-editor/media/vue'

// Order = order of toolbar groups, menu entries and the schema.
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), mediaFeature(), compositeFeature(), compositeMediaFeature()] })
  • defineEditor checks requires: an integration listed without the features it connects (commentsFeature() needs collab; compositeMediaFeature() needs composites and media; compositeAiFeature() needs composites, ai, media; transcriptAiFeature() needs transcript, ai) throws at composition time. bun run check:editor-composition keeps that behavior.
  • Own blocks and features: see the tutorial chapters Your own block (path A) and Your own feature (path B).
  • Prebuilt compositions: composeEditor(options) per surface (@skaile/forge-editor/vue/advanced, @skaile/forge-editor/react/advanced) builds an EditorPreset from the three dimensions of the table at the top plus an optional backend: composeEditor({ content: 'rich', interaction: 'full', ai: true, backend }) returns the preset with omitted ({ id, reason }[]: a feature dropped because it requires "<id>" or because backend "<name>" lacks "<capability>"). advancedEditor({ blockTypes?, …overrides }) ({ content: 'rich', interaction: 'full', ai: true }) is a one-line wrapper; the framework-free simpleEditor() (@skaile/forge-editor/presets/compose: { content: 'basic', interaction: 'minimal', ai: false }, no ⌘K palette) is the component-free simple preset; SimpleEditor (/vue/simple, /react/simple) and AdvancedEditor (/vue/advanced, /react/advanced) are the matching components. @skaile/forge-editor/presets/compose is the framework-free assembly (presets/compose.ts: composeEditor(ui, options), CONTENT, INTERACTION, AI, UiBindings<C>, ComposeOptions<C>, ComposedEditor<C>, simpleEditor()): a UI layer binds only the UI-bound features (core UI, media, transcript, composites, authors, comments, AI and its integrations, collab, block UI, themes), the framework-free ones (core content, mentions, tables, composite-media) are built there; it contains no component and is not a preset to mount. The order is canonical (content, then interaction, then AI, then add in the given order), so schema and toolbar order do not depend on how the options were written.
  • Both surfaces take the same features: a React preset is defineEditor({ features: [coreContentFeature(), coreUiFeature(), mediaFeature(), commentsFeature(), …] }) with the factories from ./<x>/react, a Vue one with those from ./<x>/vue. Mixing is a type error (VueFeature is EditorFeature<Component>, ReactFeature is EditorFeature<ComponentType<{ editor: Editor }>>); only EditorFeature<never> features (coreContentFeature(), coreUiFeature(), mentionsFeature() and the integrations) fit either (quick start for React).
  • The preset, and with it the schema, collaboration mode and slash commands, is read once when the surface mounts. Change it by remounting with a new key. A simple editor cannot safely edit advanced JSON: unknown nodes are rejected through contentError (simple editor tutorial).
  • Components that belong beside an editor are separate exports and take the editor as a prop (the toolbar and the settings components find its preset through it). Vue: EditorToolbar, EditorSettingsPanel, EditorSettingsModal (/vue), EditorOutline (/outline/vue), CompositeTable, CompositeInspector (/composites/vue), AiSidebar (/ai/vue/chat, optional peer @skaile/forge-chat), CommentsMargin (/comments/vue), DocumentTree (/tree/vue).
  • The React equivalents have the same names and props style: EditorToolbar, EditorSettingsPanel, EditorSettingsModal from @skaile/forge-editor/react; EditorOutline from /outline/react; CompositeTable, CompositeInspector from /composites/react; AiSidebar from /ai/react; CommentsMargin from /comments/react; DocumentTree from /tree/react. The host mounts sonner's Toaster once.
  • EditorToolbar (Vue and React) takes overflow: 'wrap' (default) breaks its groups onto further lines; 'collapse' keeps one line and folds the groups that do not fit, from the end, into one dropdown each (the first command's icon and a chevron; component commands, such as the comments and author-colour toggles, stay as buttons). Give the toolbar a bounded width, e.g. class="w-full" in a header row.
  • A feature can run per-app side effects from every surface's setup without touching the surface markup: its setup: (ctx) => void | (() => void) contribution is called by EditorSurface for each mounted surface and its returned teardown when the surface unmounts (themesFeature() starts its theme runtime this way; the runtime dedupes per app and puts obsidian-theme theme-dark|theme-light on <html> so the whole app follows the theme; see Per-feature entries for its lifetime on each surface).

Browser entries versus schema and server entries

Entry groupEntriesMay import
Browser, with Vue and Nuxt UI/vue, /vue/simple, /vue/advanced, /<feature>/vue (/media, /transcript, /authors, /comments, /composites, /ai, /collab, /block-ui, /themes, /outline, /tree)everything above them in the rules of Dependency rules; never React
Browser, with React and shadcn/ui/react, /react/simple, /react/advanced, /<feature>/react (the same features)the same rules; never Vue, @tiptap/vue-3 or Nuxt UI
Browser, framework-free/core (defineEditor, createEditorContext, the settings store, createStore, the notice seam, the suggestion renderer seam, descriptors such as EditorFeature<C> and MenuItem), /<feature> (<x>FeatureCore(ui), the models), /mentions, /integrations/*, /presets/*Tiptap/ProseMirror, /core and /settings only (plus Yjs/AI SDK where the feature needs them); no vue, @tiptap/vue-3, @nuxt/ui, react or sonner module
Server-safe schema and protocol, usable in Node/core/schema, /schema/simple, /schema/advanced, /media/schema, /mentions/schema, /transcript/schema, /authors/schema, /comments/schema, /composites/schema, /themes/schema, /tree/types, /settings, /ai/protocol, /integrations/transcript-ai/protocolno UI framework, no DOM globals or feature factories
Server/server, /nuxtNitro/Nuxt build code; never imported by browser code

Servers and validators build their ProseMirror schema from the framework-free schema entries (getSchema(advancedSchemaExtensions())), never from a browser entry.

Choosing a preset or features

GoalUse
Rich text only, no backend, smallest bundleSimpleEditor (with the ⌘K palette) / simpleEditor() from /presets/compose (without it); features core-content, core-ui
Everything, Nuxt module as the backendAdvancedEditor / advancedEditor({ blockTypes? })
A level in between: advanced minus a feature, or a small editor with AI<AdvancedEditor :remove="['tables']" />, <AdvancedEditor content="basic" interaction="minimal" :ai="true" />: AdvancedEditor takes content, interaction, ai, add, remove, backend, blockTypes, blockUi (which surfaces show block actions: 'hover', 'toolbar', 'both', 'none') and coreContent (headings, lists, blockquote, code block or rule left out of the schema), all read once at setup, builds the preset with composeEditor and exposes it (preset, with omitted). SimpleEditor stays lean (core features only; its bundle holds no catalog feature) and takes just add: <SimpleEditor :add="[aiFeature()]" />
Something else, or the advanced set plus an own featuredefineEditor({ features: [...] }) mounted with EditorSurface, or advancedEditor({ add: [ownFeature()] })

Advanced list in schema order: core-content, core-ui, media, transcript, composites, mentions, authors, comments, ai, collab, block-ui, composite-media, composite-ai, transcript-ai, themes. transfer is not part of it; add it through the interaction list as above.

Feature (factory, entries)Needs (requires)Backend
coreContentFeature() (/core)nonenone
coreUiFeature(ui?) (/core; ui = { palette } adds the ⌘K palette: the SimpleEditor components and the composeEditor()/advancedEditor() of /vue/advanced and /react/advanced pass the framework's EditorCommandPalette; the framework-free simpleEditor() and a hand-built defineEditor list have none unless they pass it themselves)nonenone
mediaFeature() (/media/vue, /media/react; mediaFeatureCore in /media)noneuploads (backend.assets; HTTP: /api/editor/assets/:id)
mentionsFeature() (/mentions, no components: both surfaces)none/api/editor/users, /api/editor/tags or host metadata lists
transcriptFeature() (/transcript/vue, /transcript/react)nonenone
authorsFeature() (/authors/vue, /authors/react)nonenone (author comes from the collaboration session user)
collabFeature() (/collab/vue, /collab/react)none/collab/:id websocket; active only when collaboration is passed to the surface
commentsFeature({ model? }) (/comments/vue, /comments/react)collab, none with modelcomments live in the Y.Doc, or in the host's CommentsModel (model: getEditor => CommentsModel)
compositeFeature({ blockTypes? }) (/composites/vue, /composites/react)nonenone (also mounts the Typed block overlay: status/tag chips on each title line and, on the Block type icon, a popover with the Block type dropdown (warns before changeBlockType, naming the Block property values that would be lost), the Block type's properties and the summary; also read-only: Block type as text, properties disabled; enforces the Block types' Accepted children, BlockType.accepts: an edit that nests a Typed block into one that does not accept its Block type is refused with the notice composites.nesting.title („Verschachtelung nicht möglich“))
aiFeature() (/ai/vue, /ai/react; framework-free aiFeatureCore in /ai; the Vue sidebar is the separate /ai/vue/chat)none/api/ai/{models,generate,chat,complete}
blockUiFeature() (/block-ui/vue, /block-ui/react)nonenone (drag handle, hover actions)
themesFeature() (/themes/vue, /themes/react)noneadapter listThemes/loadTheme
transferFeature({ transforms?, defaultTransform? }) (/transfer/vue, /transfer/react; framework-free transferFeatureCore in /transfer)composites (requires); none else (summaryTransform and bulletsTransform of integrations/composite-ai are offered only with an AI backend and the typedBlock node in the schema)none for copy; summary and bullets use POST /api/ai/generate
compositeMediaFeature() (/integrations/composite-media, both surfaces)composites, mediauploads
compositeAiFeature() (/integrations/composite-ai, both surfaces)composites, ai, mediaAI plus /api/ai/extract
transcriptAiFeature() (/integrations/transcript-ai, both surfaces)transcript, aiAI plus /api/ai/transcript-cleanup
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { mediaFeature } from '@skaile/forge-editor/media/vue' // or '/media/react' with a React surface
import { mentionsFeature } from '@skaile/forge-editor/mentions'

// The models come from the app context, so no component setup is needed for the AI feature (themesFeature() of the Vue binding is the exception: call it in setup).
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), mediaFeature(), mentionsFeature()] })
<EditorSurface v-model="content" :preset="preset" @ready="editor = $event" />

defineEditor keeps the given order (toolbar groups, menu entries, schema) and throws on a duplicate id, on a requires entry that is not in the list and on a second contributor of a singleton hook (parseMarkdown, segment, structure, migrateContent, anchorResolver, formatAiResult). It installs nothing for you. A Vue list takes the factories from /<x>/vue, a React list those from /<x>/react; mixing them is a type error (VueFeature is EditorFeature<Component>, ReactFeature is EditorFeature<ComponentType<{ editor }>>), while features without components (coreContentFeature(), coreUiFeature(), mentionsFeature(), the integrations) fit both. A host that brings its own UI kit calls <x>FeatureCore(ui) with its own components (per-feature entries). When you use feature entries directly in a plain app, also import that feature's stylesheet. Per-feature usage: the other guides of this section; composition and entries: this page.

Copyright © 2026