---
title: "Content tree mechanics"
description: "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."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/content-tree"
---
# 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](https://skaile-ai.github.io/forge-editor/guides/composites) 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`](https://github.com/skaile-ai/forge-editor/tree/main/examples/vue-two-pane): two panes, each a [`DocumentTree`](https://skaile-ai.github.io/forge-editor/guides/document-tree) over an editor, on the bare Nitro server of the `vue-nitro-json` example. Start it from the repository root:

```sh
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.

<table>
<thead>
  <tr>
    <th>
      You want
    </th>
    
    <th>
      Read
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Colour marks and title strips on Typed blocks
    </td>
    
    <td>
      <a href="#baskets-and-hidden-titles">
        Baskets and hidden titles
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Cut one Typed block into several
    </td>
    
    <td>
      <a href="#splitting-a-typed-block">
        Splitting a Typed block
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Join a Typed block with the one after it
    </td>
    
    <td>
      <a href="#merging-with-the-next-typed-block">
        Merging with the next Typed block
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Fix skipped heading levels and numbering
    </td>
    
    <td>
      <a href="#outline-cleanup-with-ai">
        Outline cleanup with AI
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Show only part of the tree or the editor
    </td>
    
    <td>
      <a href="#focus-and-basket-filter">
        Focus and basket filter
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Copy, summarise or list a Typed block into another editor
    </td>
    
    <td>
      <a href="#block-transfer">
        Block transfer
      </a>
    </td>
  </tr>
</tbody>
</table>

## 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.

<table>
<thead>
  <tr>
    <th>
      <code>
        meta
      </code>
      
       key
    </th>
    
    <th>
      Constant
    </th>
    
    <th>
      Value
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        basket
      </code>
    </td>
    
    <td>
      <code>
        BASKET_KEY
      </code>
    </td>
    
    <td>
      the id of one of the editor's baskets; absent = no basket
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        titleHidden
      </code>
    </td>
    
    <td>
      <code>
        TITLE_HIDDEN_KEY
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
      
       while the title is hidden; absent otherwise
    </td>
  </tr>
</tbody>
</table>

### Baskets

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

```ts
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:

```css
: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](#focus-and-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):

```ts
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.

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

Hidden titles are what [splitting](#splitting-a-typed-block) 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`):

<table>
<thead>
  <tr>
    <th>
      Mode
    </th>
    
    <th>
      Result
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        'siblings'
      </code>
    </td>
    
    <td>
      the original keeps the first part; each further part is a following sibling of the same Block type
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        'children'
      </code>
    </td>
    
    <td>
      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 (<code>
        blocks.defaultBlockType
      </code>
      
      ) when the parent accepts it, else of the parent's own Block type when it accepts itself; if neither is accepted (<code>
        BlockType.accepts
      </code>
      
      ) the split is refused
    </td>
  </tr>
</tbody>
</table>

### 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.

```ts
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.

```ts
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](https://skaile-ai.github.io/forge-editor/guides/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 …“).

```ts
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.

```ts
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`](https://skaile-ai.github.io/forge-editor/guides/document-tree) (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()`.

```ts
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`](https://github.com/skaile-ai/forge-editor/blob/main/examples/vue-two-pane/src/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.

```ts
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: <titel>

· 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)`.

</titel>



### Focus in the tree

Every Typed block row of the [`DocumentTree`](https://skaile-ai.github.io/forge-editor/guides/document-tree) 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](https://skaile-ai.github.io/forge-editor/guides/document-tree)). 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](https://skaile-ai.github.io/forge-editor/guides/document-tree#filters-virtual-root-outline)) and persists in `localStorage` under `tip-tree:basket-filter`:

```ts
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`:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Content
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        documentId
      </code>
    </td>
    
    <td>
      the document the block lives in (<code>
        null
      </code>
      
       when its editor has none; set it with the surface's <code>
        document-id
      </code>
      
      , which <code>
        coreContentFeature
      </code>
      
       stores as <code>
        editor.storage.blockTransfer.documentId
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        blockId
      </code>
    </td>
    
    <td>
      the block's id (<code>
        null
      </code>
      
       without)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        blockType
      </code>
    </td>
    
    <td>
      the Block type id, <code>
        null
      </code>
      
       for other blocks
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        title
      </code>
    </td>
    
    <td>
      the Typed block's title, or the first 80 characters of another block's text
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        json
      </code>
    </td>
    
    <td>
      the block as Tiptap JSON
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        markdown
      </code>
    </td>
    
    <td>
      the block as Markdown (empty in an editor without <code>
        editor.markdown
      </code>
      
      )
    </td>
  </tr>
</tbody>
</table>

`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`

```ts
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`.

```ts
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

<table>
<thead>
  <tr>
    <th>
      Id
    </th>
    
    <th>
      Menu text
    </th>
    
    <th>
      What lands in the target
    </th>
    
    <th>
      Needs
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        copy
      </code>
      
       (<code>
        copyTransform
      </code>
      
      )
    </td>
    
    <td>
      „Kopie einfügen“
    </td>
    
    <td>
      the block, with fresh block ids on it and everything in it
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        summary
      </code>
      
       (<code>
        summaryTransform
      </code>
      
      , <code>
        @skaile/forge-editor/integrations/composite-ai
      </code>
      
      )
    </td>
    
    <td>
      „Zusammenfassung einfügen“
    </td>
    
    <td>
      a new Typed block „Zusammenfassung: <titel>
        “ with a model-written summary
      </titel>
    </td>
    
    <td>
      AI backend, Markdown, the <code>
        typedBlock
      </code>
      
       node
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bullets
      </code>
      
       (<code>
        bulletsTransform
      </code>
      
      , same entry)
    </td>
    
    <td>
      „Stichpunkte einfügen“
    </td>
    
    <td>
      a new Typed block „Stichpunkte: <titel>
        “ with the key statements as a list
      </titel>
    </td>
    
    <td>
      same
    </td>
  </tr>
</tbody>
</table>

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](https://skaile-ai.github.io/forge-editor/guides/composites#what-the-feature-contributes)), so it is a Derived block:

<table>
<thead>
  <tr>
    <th>
      Transform
    </th>
    
    <th>
      <code>
        from
      </code>
    </th>
    
    <th>
      <code>
        method
      </code>
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        copy
      </code>
    </td>
    
    <td>
      <code>
        { block, document }
      </code>
    </td>
    
    <td>
      <code>
        'copy'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        summary
      </code>
    </td>
    
    <td>
      <code>
        { block, document }
      </code>
    </td>
    
    <td>
      <code>
        'transform:summary'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bullets
      </code>
    </td>
    
    <td>
      <code>
        { block, document }
      </code>
    </td>
    
    <td>
      <code>
        'transform:bullets'
      </code>
    </td>
  </tr>
</tbody>
</table>

`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`:

```ts
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.

```ts
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](https://skaile-ai.github.io/forge-editor/guides/document-tree#filters-virtual-root-outline)) 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`](https://github.com/skaile-ai/forge-editor/blob/main/examples/vue-two-pane/src/Pane.vue) and [`PaneEditor.vue`](https://github.com/skaile-ai/forge-editor/blob/main/examples/vue-two-pane/src/PaneEditor.vue). What each part adds to the AI surface (tasks, Model roles, routes) is in [AI](https://skaile-ai.github.io/forge-editor/guides/ai#content-tree-tasks).


## Sitemap

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