Guides

Content tree mechanics

Work on a tree of Typed blocks: colour baskets and hidden titles, splitting by hand, by length or with AI, outline cleanup, focus and basket filter in the tree, and block transfer between two editors.

A document of Typed blocks is a tree. This guide covers the tools for reshaping that tree: sort Typed blocks into colour baskets, hide their titles, split a long Typed block into smaller ones, repair the heading outline with AI, narrow the tree and the editor to one Typed block, and move Typed blocks between two editors with a transform. Everything is client-side editor behaviour on top of the composites feature; the storage of your documents stays yours.

The runnable example is examples/vue-two-pane: two panes, each a DocumentTree over an editor, on the bare Nitro server of the vue-nitro-json example. Start it from the repository root:

AI_KEY=<OpenRouter key> bun run example:two-pane   # http://localhost:3047

AI_KEY is needed only for the AI parts („Gliederung bereinigen (KI)“, the summary and bullet transforms). Its README lists what to try: import seed/paper.md, split a long Typed block, pick baskets, filter and focus in the tree, drag a Typed block from the left pane to the right one.

You wantRead
Colour marks and title strips on Typed blocksBaskets and hidden titles
Cut one Typed block into severalSplitting a Typed block
Join a Typed block with the one after itMerging with the next Typed block
Fix skipped heading levels and numberingOutline cleanup with AI
Show only part of the tree or the editorFocus and basket filter
Copy, summarise or list a Typed block into another editorBlock transfer

Baskets and hidden titles

A Typed block can sit in one basket and its title can be hidden. Both live in the Typed block's meta, so they are stored with the document, survive Markdown round trips (::: section {"basket":"red","titleHidden":true}) and are kept by every Block type: a Block type switch, a conversion or a Markdown import never drops them, and a Block type does not declare Property definitions for them.

meta keyConstantValue
basketBASKET_KEYthe id of one of the editor's baskets; absent = no basket
titleHiddenTITLE_HIDDEN_KEYtrue while the title is hidden; absent otherwise

Baskets

A Basket is { id, label, color }; color is any CSS colour. The host decides the set:

import { compositeFeature } from '@skaile/forge-editor/composites/vue'
import type { Basket } from '@skaile/forge-editor/composites'

const BASKETS: readonly Basket[] = [
  { id: 'keep', label: 'Übernehmen', color: '#16a34a' },
  { id: 'check', label: 'Prüfen', color: '#f97316' },
  { id: 'later', label: 'Später', color: 'var(--my-later-colour)' },
]

compositeFeature({ baskets: BASKETS })
// or on the composed editor: composeEditor({ ..., compositeBaskets: BASKETS })

Without baskets the editor offers defaultBaskets(t): red, orange, yellow, green, blue (BASKET_IDS) with labels in the editor's language (composites.basket.*: „Rot“, „Orange“, …). Their colours are CSS variables, which a host overrides:

:root {
  --sk-basket-red: #dc2626;   /* also orange, yellow, green, blue */
}

The set is read once, like blockTypes. compositeBasketsOf(editor) returns the editor's own list (the built-in set for an editor without the composite node). An id on a Typed block that the set does not contain is ignored by the UI (no dot) and kept in the document.

Where baskets show:

  • In the editor. The chrome row of a Typed block has a basket button (“Basket”): it opens a list of the baskets plus “No basket” (English labels; the German catalog words both differently). A Typed block in a basket gets its colour as a left border stripe (--sk-basket-color) and a dot on the button.
  • In the tree. Every Typed block row shows the dot (the basket filter narrows the tree by it).

Commands (@skaile/forge-editor/composites, both act on the Typed block at pos, default the caret's innermost one):

editor.commands.setCompositeBasket('check', pos) // false for an id the editor does not offer
editor.commands.setCompositeBasket(null, pos)    // removes the key

basketOf(node) returns the id of a Typed block's basket (or null; it takes a CompositeRow as well), basketById(baskets, id) the Basket of an id among a set (or undefined). The defaults come in a light and a dark value (:where(.dark)); a host that overrides one sets the dark value too.

Hidden titles

setCompositeTitleHidden(hidden, pos?) hides or shows the title. A hidden title is not rendered; a thin strip keeps the chrome row (basket button and the „Titel einblenden“ eye button). When the caret enters a hidden title (keyboard navigation, click on the strip), the title shows again for the rest of the editor session (CompositeStorage.revealedTitles, a store of block ids), so nobody types into text they cannot see; the flag in meta stays set, and „Titel ausblenden“ hides it again. titleHiddenOf(node) reads the flag.

editor.commands.setCompositeTitleHidden(true, pos)  // also ends a reveal when `hidden` is false

Hidden titles are what splitting uses for generated parts: the title is only a handle for the tree and for the model, the text carries the content.

Splitting a Typed block

Three ways cut one Typed block into several. All of them write one transaction (one undo step), give every new part a title from its first sentence (generatedTitle: up to the first ., ! or ?, at most 60 characters, … appended when cut) and hide it (meta.titleHidden). New parts get no block id yet; the surface assigns one like for every new block.

Two modes decide where the parts go (SplitMode):

ModeResult
'siblings'the original keeps the first part; each further part is a following sibling of the same Block type
'children'the original keeps its title, description, tags and meta, and its body becomes one nested Typed block per part. The nested Typed blocks are of the default Block type (blocks.defaultBlockType) when the parent accepts it, else of the parent's own Block type when it accepts itself; if neither is accepted (BlockType.accepts) the split is refused

By hand

splitCompositeAtCaret({ mode }) splits the caret's innermost Typed block in front of the body block that holds the caret; the part before and the part from the caret on become the two parts. It is refused (false) when the caret is not in a body, or at the first body block (nothing before it). The caret moves into the new part.

editor.commands.splitCompositeAtCaret({ mode: 'siblings' })
editor.can().splitCompositeAtCaret({ mode: 'children' })

Block actions: „Block hier teilen“ (siblings) and „In Unterblöcke teilen“ (children), offered on the Typed block around the caret while the caret is past its first body block.

By length

splitCompositeByLength({ mode, targetChars?, pos? }) cuts the body of the Typed block at pos (default: the caret's) into parts of about targetChars characters and never cuts inside a block: a part ends before the block that would push it over the target, and a single block longer than the target forms a part of its own. It is refused with fewer than two parts. targetChars defaults to the setting blocks.splitTargetChars (default 1500, 200–20000), which has a field „Zielgröße beim Teilen (Zeichen)“ in the settings tab „Bearbeiten“; it is read on every use, so a changed setting applies without recreating the editor.

editor.commands.splitCompositeByLength({ mode: 'children', targetChars: 1200, pos })

Block actions: „Nach Länge in Unterblöcke teilen“ and „Nach Länge in Blöcke teilen“, offered while the body falls into two parts or more. Each of the four manual actions hides itself when its command would refuse, so a Block type that accepts no child Block type never shows the nested variants. They need the ability composite.meta.

The pure parts need no editor and are exported from @skaile/forge-editor/composites: splitBodyByLength(blocks, targetChars), generatedTitle(blocks), childKindFor(kinds, parentKind, defaultBlockType), splitComposite(schema, composite, groups, mode, childKind, hideTitles); the editor extension is CompositeSplit (registered by compositeFeature).

With AI

With compositeAiFeature() (see AI) a Typed block with body text gets the block actions „Mit KI in Unterblöcke teilen“ and „Mit KI in Blöcke teilen“ (ability ai.generate). The model does not cut text, it only marks the cuts: it gets the body as Markdown and the wanted section size (task split-block, generate Model role, targetChars as the prompt) and returns the same text with ## headings inserted. The Typed block is split at those headings, and each heading becomes the (hidden) title of its part.

The text must not change. bodyUnchanged(original, result) compares the answer without its heading lines to the original with whitespace collapsed (headings the original already has stay part of the comparison); if the words differ, nothing is written and the warning „Die KI hat den Text verändert, nichts wurde geteilt“ appears. Other safeguards: a body longer than AI_LIMITS.selection is refused up front, the Typed block is anchored while the model runs, and an edit to its body in the meantime discards the result („Der Block wurde währenddessen geändert …“).

import { type AiFlowContext, splitCompositeWithAi } from '@skaile/forge-editor/integrations/composite-ai'
import { modelProfiles } from '@skaile/forge-editor/ai'

const flow: AiFlowContext = { ai, modelRef: modelProfiles(app).modelRef, notify, t }
await splitCompositeWithAi(editor, pos, 'children', flow, {
  targetChars: 1500,        // the wanted section size
  defaultBlockType: 'section',   // blocks.defaultBlockType, for the `children` Block type
})

flow is the AiFlowContext: ai is backend.ai (not undefined), notify the surface's notifier and t its translator (the example takes them from useEditorContext(), useNotifier(editor) and useEditorI18n()). splitByInsertedHeadings(schema, composite, blocks, mode, kind) is the pure part: it groups the result at the lowest heading level present and returns the replacement nodes, or null for fewer than two parts.

Merging with the next Typed block

The counterpart of the split: mergeCompositeWithNext(pos?) makes the Typed block at pos (default: the caret's innermost one) absorb the Typed block that directly follows it in the same parent. The following Typed block's body, its nested Typed blocks included, is appended to the end of the first one's body in document order; the first keeps its title, description, tags, id, Block type and meta, and the following one is gone (its own title, description, tags and meta are dropped; a body that is a single empty paragraph adds nothing). It is one transaction, so one undo restores both Typed blocks, and the caret stays where it was.

editor.commands.mergeCompositeWithNext(pos)
editor.can().mergeCompositeWithNext()

It is refused (false, document untouched) when no Typed block directly follows (a paragraph or other block in between counts as "none": nothing is moved across it), when the first Typed block's Block type does not accept the Block type of a Typed block nested in the following one (BlockType.accepts), and when the editor is not editable.

Where it is offered: the block action „Mit nächstem zusammenführen“ (icon i-lucide-merge, ability compositeMeta, hidden when the command would refuse) and a button on every Typed block row of the DocumentTree (same hover place as „Fokussieren“; only on rows whose Typed block the editor can merge). The row button sends the TreeCommand { type: 'mergeBlock', id }, whose executor runs mergeCompositeWithNext at the Typed block with that block id. Both are in Vue and React. The extension is CompositeMerge (registered by compositeFeature), the pure part mergeBody(kinds, first, next) (the fragment to append, null when refused), both exported from @skaile/forge-editor/composites.

Outline cleanup with AI

„Gliederung bereinigen (KI)“ repairs the heading outline of the whole document: no skipped levels, numbering („2. Methodik“) and trailing punctuation gone, double spaces removed, one spelling, and a heading without text under it merged into the next. It is a ⌘K command (outline-sanitize, no toolbar button) and a block action on every heading, behind compositeAiFeature().

import { sanitizeOutline } from '@skaile/forge-editor/integrations/composite-ai'

await sanitizeOutline(editor, flow) // `flow` as above: { ai, modelRef, notify, t }

How it stays safe:

  1. collectHeadings(doc) lists every heading node as OutlineHeading { i, level, text, chars, pos }; chars is the text length of the blocks under it, so the model can tell an empty heading from a long one.
  2. The task outline-sanitize (generate Model role) sends only that list as JSON (i, level, text, chars: no body text) and expects a JSON list of the same length back. The list is too long for the model (more than AI_LIMITS.selection characters) → „Die Gliederung ist zu lang für die KI“.
  3. parseOutlineResponse(text, headings) accepts exactly one entry per heading (i equal to its index, level an integer 1–6, text a string; a Markdown fence around the JSON is tolerated), else null: „Die Antwort der KI war unbrauchbar, nichts wurde geändert“.
  4. If the document's headings changed while the model ran, nothing is written („Das Dokument wurde währenddessen geändert …“).
  5. applyOutline(tr, headings, outline) writes the answer into one transaction, from the last heading to the first: an empty text removes the heading, otherwise the level and, when it differs, the text are set. One undo step reverses the cleanup.

It works on heading nodes, so run it before grouping headings into Typed blocks (or on a document that has headings inside Typed blocks). The example does exactly that in its import: it sets the Markdown as plain headings, runs sanitizeOutline, then calls convertHeadingsToComposites({ recursive: true, scope: 'document' }) — see import-markdown.ts. A host can offer the same as an opt-in checkbox on its import form.

Both AI tasks (split-block, outline-sanitize) are in AI_TASK_IDS and carry AiTask.programmatic: true: only a block action or a command sends them, so they never appear in the AI sidebar.

Focus and basket filter

Focus in the editor

focusComposite(id | null) puts the editor in focus mode on the Typed block with block id id: every sibling of the Typed block and of each of its ancestors is hidden, so only the Typed block and the titles around it stay. null ends it; false is returned when id is not a Typed block of the document. The decorations carry the classes sk-focus-hidden, sk-focus-target and sk-focus-ancestor (composites.css hides the first); a caret outside the focused Typed block moves into its title, and the focus resets by itself when the Typed block is deleted.

editor.commands.focusComposite(blockId)
compositeFocusOf(editor.state)   // the focused id, or null
editor.commands.focusComposite(null)

Every Typed block has the block action „Fokussieren“, and while a focus is active a chip „Fokus: · Aufheben“ (FocusChip, mounted as an overlay of compositeFeature; CompositesUi.focusChip) ends it. Also exported from @skaile/forge-editor/composites: CompositeFocus (the extension), compositeFocusKey, focusDecorations(doc, id).

Focus in the tree

Every Typed block row of the DocumentTree has a focus button („Fokussieren“). Focusing a row lists only the children of that row under a breadcrumb: the first crumb is the document and leaves the focus („Fokus aufheben“), the others refocus that level (a Typed block without a title is named by its body there as in the rows, see Label of an untitled Typed block). The tree and the editor follow each other: focusing in the tree runs focusComposite on the open editor (TreeCommand { type: 'focusBlock', id }), and a focus started in the editor (block action, chip, a Typed block that vanished) shows in the tree. A key the tree no longer has, or another open editor, resets it. The Block type filters of the rows on the way down still apply to the children.

Basket filter

With a live editor that offers baskets, a paint-bucket button (“Filter by basket”) sits next to the title filter. Its menu checks baskets; the tree then shows only the Typed blocks of the checked baskets plus their ancestors, which are dimmed (TreeRowState.dimmed). None checked means all. Documents, folders and headings are never hidden, and “Reset basket filter” clears the choice.

The choice is part of the shared per-app view state (TreeViewModel, see Document tree) and persists in localStorage under tip-tree:basket-filter:

import { treeView } from '@skaile/forge-editor/tree'

const view = treeView(app)           // or `const { model } = useTreeView()` in a component
view.basketFilter.get()              // string[], [] = all
view.setBasketShown('check', true)
view.clearBasketFilter()

An id that the open editor does not offer never filters.

Block transfer

Block transfer moves a Typed block between two editor surfaces or from an editor into another document tree, as a copy or as the result of a transform. It has three parts: the drag payload (core), the receiving feature (@skaile/forge-editor/transfer) and the tree's drop rules.

The payload

startBlockDrag (the drag of a Typed block's Block type icon, or of a permanent drag handle) writes, next to text/plain, the block under application/x-tip-block (BLOCK_MIME). A document tree row of a Typed block writes it too. The payload is a BlockTransfer:

FieldContent
documentIdthe document the block lives in (null when its editor has none; set it with the surface's document-id, which coreContentFeature stores as editor.storage.blockTransfer.documentId)
blockIdthe block's id (null without)
blockTypethe Block type id, null for other blocks
titlethe Typed block's title, or the first 80 characters of another block's text
jsonthe block as Tiptap JSON
markdownthe block as Markdown (empty in an editor without editor.markdown)

writeBlockTransfer(dataTransfer, payload), readBlockTransfer(dataTransfer) (null for a missing, unparsable or misshapen payload) and blockTransferOf(editor, pos) are exported from @skaile/forge-editor/core, so an own drag source or drop target speaks the same format. A drag that starts and ends in the same editor stays ProseMirror's native move (Alt copies).

transferFeature

import { transferFeature } from '@skaile/forge-editor/transfer/vue'  // or '/transfer/react'

transferFeature({ transforms?, extraTransforms?, defaultTransform? })

It is a regular feature (id: 'transfer', dimension interaction), framework-free in transferFeatureCore(ui, options) of @skaile/forge-editor/transfer. It contributes a drop target and a menu overlay:

  • A plain drop of a foreign block runs the default transform (defaultTransform, default 'copy') at the drop point.
  • Shift at the drop opens the transform menu („Einfügen als …“, TransferMenu, an overlay) at the pointer, listing the available transforms.
  • A drop onto the dragged block's own position in the same document is refused („Ein Block lässt sich nicht auf sich selbst ablegen“, isSelfDrop).
  • The insert guards and the target's content rules apply: what a transform inserts must be allowed at the drop point, else the warning „Hier lässt sich der Block nicht einfügen“ shows and the document stays unchanged.

composeEditor knows it as the catalog id transfer. It is in no level list: opt in with the interaction list and pass options with transfer. With ai: true and no own transfer.transforms, composeEditor offers the copy plus summaryTransform and bulletsTransform (the layer binds them, so a composition without ai holds no AI code); transferFeature() on its own offers only the copy. The feature requires composites.

import { composeEditor } from '@skaile/forge-editor/vue/advanced'
import { INTERACTION } from '@skaile/forge-editor/presets/compose'

const preset = composeEditor({
  content: 'rich',
  interaction: [...INTERACTION.full, 'transfer'],
  ai: true,
  transfer: { defaultTransform: 'copy' },
})

extraTransforms adds transforms after the default list (or after transforms), so an add-on's transforms (slidesTransforms of @skaile/forge-editor-slides) join the built-in ones without the host listing them again; a transform whose id is already in the list is dropped. AdvancedEditor (Vue and React) takes the options as the transfer prop (and explore for the Explore feature), next to interaction: [...INTERACTION.full, 'transfer'].

For a hand-built preset, install it after composites and list the AI transforms yourself: transferFeature({ transforms: [copyTransform, summaryTransform, bulletsTransform] }) (the two AI ones from @skaile/forge-editor/integrations/composite-ai). The bundle needs core, media and composites, nothing else: no features/ai, no integration (bun run check:editor-entries, fixtures transfer, transfer-react, headless-transfer fail otherwise).

Commands: transferBlock({ payload, pos, menu, x?, y? }) puts the block of a payload into the document at pos (menu: open the transform menu at x, y instead of running the default transform); the tree's drop executor uses it. cancelTransfer() closes the menu. The state of the menu is editor.storage.blockDropTarget (pending, available, run; type BlockDropTargetStorage).

The built-in transforms

IdMenu textWhat lands in the targetNeeds
copy (copyTransform)„Kopie einfügen“the block, with fresh block ids on it and everything in itnothing
summary (summaryTransform, @skaile/forge-editor/integrations/composite-ai)„Zusammenfassung einfügen“a new Typed block „Zusammenfassung: “ with a model-written summaryAI backend, Markdown, the typedBlock node
bullets (bulletsTransform, same entry)„Stichpunkte einfügen“a new Typed block „Stichpunkte: “ with the key statements as a listsame

The AI transforms insert an empty Typed block first (Block type transferKindOf(editor): the setting blocks.defaultBlockType when the editor offers it, else its first Block type; titled after the source, with meta.derivation when the source has a block id, and a running notice while the model streams), stream the model's answer into it with the same write-back as „Ableiten (KI)“ (writeDerivedBody) and remove it again when the run fails, is aborted or returns nothing. The tasks are transfer-summary and transfer-bullets (generate Model role, programmatic, request body from transferRequest(payload, blocks, model, task): the dragged Markdown cut to AI_LIMITS.selection as the selection).

Derivation: where a copy comes from

A transferred Typed block with a block id records its source in meta.derivation (provenance), so it is a Derived block:

Transformfrommethod
copy{ block, document }'copy'
summary{ block, document }'transform:summary'
bullets{ block, document }'transform:bullets'

DerivationSource { block } may carry document, the document the source block lives in. inputHash is the hash of the source's body Markdown, outputHash of the result's, so the badge shows „bearbeitet“ once the copy is edited. For a source in another document the editor cannot look the block up, so it does not compare hashes: stale is null in the details card (DerivationDetails.foreign, isForeignSource) and the badge reads „aus anderem Dokument“ (composites.derivation.foreign, derivationIsForeign) instead of „Quelle geändert“.

Own transforms

transforms replaces the default list ([copyTransform], or with composeEditor and ai on the copy plus the AI transforms): pass the built-ins you still want next to your own. A BlockTransform:

interface BlockTransform {
  id: string
  label: string                         // menu text, shown as is unless `labelKey` is set
  labelKey?: MessageKey                 // i18n key: the menu renders it in the editor's language
  icon: string                          // Iconify name, e.g. 'i-lucide-quote'
  available?: (ctx: TransformContext) => boolean   // absent = always
  creates?: (ctx: TransformContext) => Creates | null
  run: (ctx: TransformContext) => Promise<void>
}

interface TransformContext {
  editor: Editor
  payload: BlockTransfer
  pos: number                           // a valid drop point of editor.state.doc
  ai: EditorBackend['ai'] | undefined
  app: EditorContext                    // the app context: the AI transforms read the model profiles from it
  notify: Notifier
  t: Translate
}
  • available hides the transform from the menu (and makes a plain drop that names it do nothing).
  • creates tells what run will insert ({ type, attrs? }), so the insert guards and the content rules are checked before run; a refusal shows the warning and runs nothing. Without creates, copy is checked as inserting the dragged block itself and every other transform has to check for itself.
  • run inserts at ctx.pos with its own transaction and reports its own failures through ctx.notify; runTransform never throws for a refusal.
import { copyTransform, type BlockTransform } from '@skaile/forge-editor/transfer'

const asQuote: BlockTransform = {
  id: 'quote',
  label: 'Als Zitat einfügen',
  icon: 'i-lucide-quote',
  available: ({ payload }) => payload.title.length > 0,
  creates: () => ({ type: 'blockquote' }),
  run: async ({ editor, payload, pos }) => {
    editor.view.dispatch(editor.state.tr.insert(pos, editor.schema.nodeFromJSON({
      type: 'blockquote',
      content: [{ type: 'paragraph', content: [{ type: 'text', text: payload.title }] }],
    })))
  },
}

transferFeature({ transforms: [copyTransform, asQuote] })

Exports of @skaile/forge-editor/transfer: transferFeatureCore, TransferFeatureOptions, TransferUi, BlockTransform, TransformContext, TransferEnv, copyTransform, DEFAULT_TRANSFORMS, runTransform, availableTransforms, transformContextOf, transferKindOf, transferSourceHash, createsOf, withFreshBlockIds, isSelfDrop, BlockDropTargetStorage, TransferDrop, PendingDrop. The bindings add transferFeature and TransferMenu (/transfer/vue, /transfer/react). summaryTransform, bulletsTransform and transferRequest are exported from @skaile/forge-editor/integrations/composite-ai, next to writeDerivedBody, which they use.

Drops on a tree row

A DocumentTree takes a foreign Typed block too. In the virtual root mode (blocks.treeRoot = 'file', see Document tree) the drop is accepted on the document root and on a Typed block row with a block id; every other target shows the refusal „Hier lassen sich keine Blöcke ablegen.“ and the browser's „not allowed“ cursor. An open editor without the transfer feature (no editor.storage.blockDropTarget, no transferBlock command) refuses every foreign drop with „Dieser Editor nimmt keine Blöcke von außen an.“; the executor returns false for such a command instead of throwing. The tree emits TreeCommand { type: 'transferBlock', payload, targetId, menu, x, y } and its executor runs editor.commands.transferBlock at the end of that Typed block (null = end of the document); Shift opens the transform menu at the pointer. The receiving editor is the tree's editor prop. The tree rows themselves write the payload when dragged, so a tree row can be dropped onto the other pane's surface.

Putting it together

Two panes need nothing beyond what is above: one composeEditor preset with transfer, one DocumentTree per pane over its own editor, and document-id on each surface so a transfer names where a block came from. The example does this in Pane.vue and PaneEditor.vue. What each part adds to the AI surface (tasks, Model roles, routes) is in AI.

Copyright © 2026