Feature composition and entries
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.
| Dimension | Levels (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-media | core-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 |
ai | false, 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 depth | What the host runs | Client call | Server |
|---|---|---|---|
| No data layer | nothing; the browser keeps the data | app.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 automatically | none |
| Nuxt, built-in | the Nuxt module with an EditorPorts bundle | module @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 layer | the Nuxt module with ports of your own over the external system | same as the previous row | provideEditorServer({ 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:
| Bundle | content | interaction | ai |
|---|---|---|---|
simple | 'basic' | 'minimal' | false |
writing | media, tables, mentions | block-ui, themes | true |
collaborative | media, tables, mentions, authors | collab, comments, block-ui, themes | false |
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
| Entry | Side | Contents |
|---|---|---|
@skaile/forge-editor/nuxt | build | The 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/config | anywhere | Framework-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/server | server | Framework-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/handlers | server | One 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/node | server (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/react | browser | framework-free collabFeatureCore(ui?), yjsAnchorResolver; collabFeature() per surface |
@skaile/forge-editor/block-ui, /block-ui/vue, /block-ui/react | browser | framework-free blockUiFeatureCore(ui, options?), blockActionsFor, selectedBlockOf, effectiveTarget, BLOCK_CORNER_ATTR, blockCornerOf; blockUiFeature(options?), BlockUi, BlockActionsOverlay per surface |
@skaile/forge-editor/authors, /authors/vue, /authors/react | browser | framework-free authorsFeatureCore(ui), currentAuthor, documentAuthors; authorsFeature() plus AuthorColorsButton, AuthorColorsPanel, SettingsAuthorsTab per surface (see Authors and comments) |
@skaile/forge-editor/comments, /comments/vue, /comments/react | browser | framework-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/react | browser | framework-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/advanced | anywhere | Vue-free advancedSchemaExtensions(); imports no Vue, UI or browser code |
@skaile/forge-editor/styles/collab.css, /block-ui.css, /authors.css, /comments.css, /themes.css | browser | Remote 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:
| Entry | Contents | May 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, helpers | Tiptap/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 models | vue, @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 models | react, @tiptap/react, Radix, sonner; never vue |
./<x>/schema | Node/mark specs, protocol schemas and limits for servers and validators | no 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.tsexports<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 exampleCommentsUi:toolbarButton,overlay,settingsTab;MediaUi:recorderModal,nodeViews).Cis the framework's component type../<x>/vuefills it with Vue components,./<x>/reactwith 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 aNotifier((notice) => void, optionaldismiss(id);silentNotifierdrops everything), from/core. A feature reads it asctx.notifyin its factories (FeatureContext.notify), app-level models (model profiles, the rate-limit notice) asEditorContext.notify, set withcreateEditorContext({ notify }). Vue:vueNotifier(useToast())shows Nuxt UI toasts (the page needs<UApp>);createEditor()installs a queued notifier (createQueuedNotifier()) that holds notices raised before the firstEditorSurfaceexists and shows them through that surface'suseToast(). React:reactNotifiershows sonner toasts; pass it ascreateEditorContext({ notify: reactNotifier })and mount sonner'sToaster(re-exported from@skaile/forge-editor/react) once per page, otherwise no notice is visible.idreplaces a visible toast,duration: 0keeps it until replaced or dismissed. setupand teardown. A feature'ssetup(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 ReactthemesFeature()removes the theme when the last surface that started it unmounts, while the VuethemesFeature()(call it during component setup) keeps it for the life of the Vue app through thelifetimehook ofThemesUi, 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
createStorestores (from/core:createStore,derived,ReadonlyStore) inside the models; the hooks only subscribe: ReactuseStore(store)(from/react), VueuseStoreRef(store)(a readonly ref, from/vue;useWritableStoreRef(store)for two-way refs,trackedStore(store)for a store whoseget()Vue tracks,storeFromRef(ref)the other way round), all from the onevue/store-refmodule, used inside the thinuse*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/vueand/react) is its hook form, so a host that passescreateEditor({ 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()] })
defineEditorchecksrequires: an integration listed without the features it connects (commentsFeature()needscollab;compositeMediaFeature()needscompositesandmedia;compositeAiFeature()needscomposites,ai,media;transcriptAiFeature()needstranscript,ai) throws at composition time.bun run check:editor-compositionkeeps 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 anEditorPresetfrom the three dimensions of the table at the top plus an optionalbackend:composeEditor({ content: 'rich', interaction: 'full', ai: true, backend })returns the preset withomitted({ id, reason }[]: a feature dropped because itrequires "<id>"or becausebackend "<name>" lacks "<capability>").advancedEditor({ blockTypes?, …overrides })({ content: 'rich', interaction: 'full', ai: true }) is a one-line wrapper; the framework-freesimpleEditor()(@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) andAdvancedEditor(/vue/advanced,/react/advanced) are the matching components.@skaile/forge-editor/presets/composeis 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, thenaddin 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 (VueFeatureisEditorFeature<Component>,ReactFeatureisEditorFeature<ComponentType<{ editor: Editor }>>); onlyEditorFeature<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 throughcontentError(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,EditorSettingsModalfrom@skaile/forge-editor/react;EditorOutlinefrom/outline/react;CompositeTable,CompositeInspectorfrom/composites/react;AiSidebarfrom/ai/react;CommentsMarginfrom/comments/react;DocumentTreefrom/tree/react. The host mounts sonner'sToasteronce. EditorToolbar(Vue and React) takesoverflow:'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;componentcommands, 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 byEditorSurfacefor each mounted surface and its returned teardown when the surface unmounts (themesFeature()starts its theme runtime this way; the runtime dedupes per app and putsobsidian-theme theme-dark|theme-lighton<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 group | Entries | May 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/protocol | no UI framework, no DOM globals or feature factories |
| Server | /server, /nuxt | Nitro/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
| Goal | Use |
|---|---|
| Rich text only, no backend, smallest bundle | SimpleEditor (with the ⌘K palette) / simpleEditor() from /presets/compose (without it); features core-content, core-ui |
| Everything, Nuxt module as the backend | AdvancedEditor / 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 feature | defineEditor({ 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) | none | none |
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) | none | none |
mediaFeature() (/media/vue, /media/react; mediaFeatureCore in /media) | none | uploads (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) | none | none |
authorsFeature() (/authors/vue, /authors/react) | none | none (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 model | comments live in the Y.Doc, or in the host's CommentsModel (model: getEditor => CommentsModel) |
compositeFeature({ blockTypes? }) (/composites/vue, /composites/react) | none | none (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) | none | none (drag handle, hover actions) |
themesFeature() (/themes/vue, /themes/react) | none | adapter 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, media | uploads |
compositeAiFeature() (/integrations/composite-ai, both surfaces) | composites, ai, media | AI plus /api/ai/extract |
transcriptAiFeature() (/integrations/transcript-ai, both surfaces) | transcript, ai | AI 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.
Chapter 8: The React surface
Run the meeting-notes editor on React: EditorContextProvider, EditorSurface, EditorToolbar, SimpleEditor and the Toaster, and where the callout would go.
Media, mentions and transcript features
Add image, audio, video and file blocks, @ mentions and # tags, and speaker transcripts as independent features, with uploads, metadata lists and server schemas.