---
title: "Document tree"
description: "Show folders, documents and the open document's outline in a file drawer over your own DocumentStore, on Vue and React."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/document-tree"
---
# Document tree

> Show folders, documents and the open document's outline in a file drawer over your own DocumentStore, on Vue and React.

`DocumentTree` is the document drawer: folders and documents of *your* storage, with create, rename, move (drag and drop), delete with undo, per-item Block type filters and the Block outline of the open document. It exists for both surfaces: `@skaile/forge-editor/tree/vue` (Nuxt UI) and `@skaile/forge-editor/tree/react` (shadcn/ui; needs `EditorContextProvider` and a mounted `Toaster`). The framework-free `@skaile/forge-editor/tree` holds the `DocumentStore` contract (the tree's own interface, not the server port `DocumentContentStore`) and the view-state model (`createTreeView`, `treeView(context)`, `TreeViewModel`) that both components and their `useTreeView()` hooks sit on. It needs the settings context (`createEditor()` / `createEditorContext()`), the Typed blocks and media features' code (it reads the Block outline of the Typed blocks and imports files) and a `DocumentStore` that you implement over your storage and router; it does no HTTP or routing of its own.

The drawer shows a „Dokumente“ root with folders and documents, create/rename/move/delete, drag and drop, Block type filters per item, a title filter („Titel filtern“), and the Block outline of the open document (its headings and Typed blocks as one tree, in Flat and Structure mode) listed under it. The tree's own messages (delete with undo) are notices: Vue needs `<UApp>`, React needs `EditorContextProvider` and a mounted `Toaster`.

## Mount the tree

```vue
<script setup lang="ts">
import { DocumentTree } from '@skaile/forge-editor/tree/vue'
import type { DocumentStore } from '@skaile/forge-editor/tree'
import type { Editor } from '@tiptap/core'

defineProps<{ store: DocumentStore, editor: Editor | undefined }>()
const selectedFolder = defineModel<string>('selectedFolder', { default: '' })
</script>

<template>
  <DocumentTree v-model:selected-folder="selectedFolder" :store="store" :editor="editor">
    <template #document-actions="{ id }"><!-- optional host buttons per document --></template>
  </DocumentTree>
</template>
```

React, with the same `store` and a controlled folder:

```tsx
import type { DocumentStore } from '@skaile/forge-editor/tree'
import { DocumentTree } from '@skaile/forge-editor/tree/react'
import type { Editor } from '@tiptap/core'
import { useState } from 'react'

export function Drawer({ store, editor }: { store: DocumentStore, editor: Editor | undefined }) {
  const [selectedFolder, setSelectedFolder] = useState('')
  return (
    <DocumentTree
      store={store}
      editor={editor}
      selectedFolder={selectedFolder}
      onSelectedFolderChange={setSelectedFolder}
      documentActions={({ id }) => null /* optional host buttons per document */}
    />
  )
}
```

- `editor` is the open page's editor (`undefined` when no document is open); take it from your page through a shared composable, as `useActiveEditor()` does in the Nuxt app.
- `v-model:selectedFolder` (React: `selectedFolder` / `onSelectedFolderChange`, uncontrolled when omitted) is where new documents, new folders and your own import actions go. Bind the same state in your import button (the Nuxt app binds it in the layout and passes it to `AppDocumentTree`). The Vue `document-actions` slot is `documentActions` on React.
- Place `DocumentTree` anywhere (a sidebar, a drawer); several can coexist, sharing one filter state per app (`useTreeView()` from `/tree/vue` returns the refs `nodeFilters`, `expanded` and `collapsedOutline`, from `/tree/react` the plain values, both with the framework-free `model`).
- **Title filter.** The field „Titel filtern“ above the tree (clear button, `Esc` clears too) filters by title (for a Typed block without a title: by the label derived from its body, see the next but one point), case-insensitive substring, over folders, documents and the outline entries: only matching rows and their parents stay, the matching part is highlighted, and „Keine Treffer“ shows when nothing matches. While the filter is set the parents of matches are shown open without touching the stored open state; clearing the filter restores the rows that were open before (a row opened or closed by hand meanwhile counts only until then). The title filter belongs to one tree; the Block type filters are the shared per-app state.
- **Refused drops.** While a drag hovers a row, the tree asks whether that drop is allowed (`dropVerdict`, with a reason in the editor's language). An allowed target gets a primary outline and takes the drop; a refused one is outlined red, shows the reason under the row („Notiz nimmt keine Aufgabe auf.“, „Ein Ordner lässt sich nicht in sich selbst oder einen seiner Unterordner verschieben.“, …) and the browser shows „not allowed“. Moving a Typed block is judged by the open document's editor (`moveIntoVerdict`, so the Block types' Accepted children apply); documents and folders follow the folder rules of the `DocumentStore` contract. Both components render one framework-free model (`createTreeModel`, internal, not exported from `./tree`); the props of `DocumentTree` are unchanged.
- The outline entries under the open document are its headings and Typed blocks in both modes; in Structure mode with `blocks.treeRoot: 'file'` the open document is the *virtual root*.
- **Label of an untitled Typed block.** A Typed block without a title is labelled by its own body, so a document of many untitled Typed blocks does not read „(ohne Titel)“ in every row: the text of the first heading of its body, else the first non-empty text of its body cut at 60 characters with „…“. Text inside a nested Typed block is not used, and only a Typed block with an empty body keeps „(ohne Titel)“. The row shows such a derived label muted and italic (`TreeItem.labelFromBody`, `data-label-from-body` on the label), so it does not pass for a title; the title filter matches it like a title. A title always wins.
- The tree header has a „Ordner“ / „Datei als Wurzel“ toggle that writes `blocks.treeRoot` (the same value as the radio group in the settings tab „Bearbeiten“). It is disabled, with an explanation, while no Typed block file is open or `blocks.structure` is `'flat'` (Flat mode); the tree then shows the folders.

The host implementation of the store is [`app/composables/useDocumentStore.ts`](https://github.com/skaile-ai/forge-editor/blob/main/app/composables/useDocumentStore.ts); the `DocumentStore` contract it implements is [below](#documentstore).

## Props

Vue props: `store` (required) and `editor` (`Editor | undefined`, required). `v-model:selectedFolder` is the folder (path, `''` = root) that new documents, new folders and host import actions go to; the tree resets it to `''` when that folder is deleted or moved away. The slot `document-actions` receives `{ id, title }`. Further props: `reorder` (a `RowReorder`, see below).

**Host row reorder and Typed block slots (4.3).** The tree moves a Typed block row by the library's own drop rules (into another Typed block, or to the end of the document). A host that orders the rows among each other (e.g. the root blocks of a virtual-root document, where a drop on a row means "take its place") passes `reorder: RowReorder` (exported from `@skaile/forge-editor/tree` with `TreeItem`, `DragPayload` and `DropVerdict`):

- `verdict(source, target): DropVerdict | null` is asked first for every drag of the tree's own rows; `{ ok: true }` or `{ ok: false, reason }` decides (the row shows the reason like any refused drop), `null` leaves it to the library rules.
- `drop(source, target)` runs when the host's verdict was `ok`; the host moves the rows (the tree emits no `moveBlock`).
- `step?(item, direction)` runs on **Ctrl+↑ (-1) / Ctrl+↓ (1)** on a focused row and moves it one place among its siblings; it returns `false` when the row is not the host's (the tree does nothing else with Ctrl+arrow); after a `true` the keyboard focus follows the row to its new place, so the next press moves the same row on. Without `step` the shortcut does nothing.

Two slots decorate Typed block rows only: `block-badges` (`{ item: TreeItem }`; always visible, right after the label, e.g. a warning icon) and `block-actions` (`{ item: TreeItem }`; in the controls that show on hover or focus, after the add menu, e.g. a status menu). The row element carries `data-tree-key` (the item key). In React the props are `reorder`, `blockBadges(item)` and `blockActions(item)`. The runnable adapter is [`app/components/AppDocumentTree.vue`](https://github.com/skaile-ai/forge-editor/blob/main/app/components/AppDocumentTree.vue); the layout holds `selectedFolder` and binds it with `v-model:selected-folder`.

`store` and `editor` are required (`editor` may be `undefined`); `selectedFolder`/`onSelectedFolderChange` are optional (the tree keeps the folder itself when `selectedFolder` is omitted); `documentActions` is a render function receiving `{ id, title }`; `className` styles the root. The runnable adapter of a `DocumentStore` over the host's `/api` is [`examples/react-advanced/src/document-store.ts`](https://github.com/skaile-ai/forge-editor/blob/main/examples/react-advanced/src/document-store.ts).

## `DocumentStore`

```ts
interface DocumentStore {
  tree: ReadonlyStore<TreeFolder | null | undefined>               // undefined = loading, null = loading failed
  documents: ReadonlyStore<readonly DocumentSummary[] | undefined> // undefined = loading
  activeId: ReadonlyStore<string | undefined>
  open(id: string): Promise<void>
  create(title?: string, folder?: string): Promise<{ id: string }>
  move(id: string, folder: string): Promise<void>
  remove(id: string, folder: string): Promise<{ title: string, restore: () => Promise<{ id: string }> }>
  createFolder(path: string): Promise<void>
  moveFolder(path: string, to: string): Promise<void>   // `to` is the complete new path: moves and renames
  removeFolder(path: string): Promise<void>
  validateName(name: string, siblings: readonly string[]): string | null
}
```

The observable values are `ReadonlyStore`s (`createStore` from `@skaile/forge-editor/core`), so the same store drives both components. A Vue host that keeps refs adapts them with `storeFromRef(ref)` and `useStoreRef(storeOrGetter)` from `@skaile/forge-editor/vue`; a React host holds its data in its own state or data layer and publishes it with `createStore`.

`TreeFolder` is `{ type: 'folder', name, path, folders, documents }` (the root is named „Dokumente“, `path` is `''`), `DocumentSummary` is `{ id, title, updatedAt, folder }`; both are exported from `/tree`. The editor serves them: `GET /api/editor/tree` answers a `TreeFolder` and `GET /api/editor/documents` the `DocumentSummary[]` (from the documents port's `DocumentAdmin.listTree`). A host adapter that builds the tree imports the types from `@skaile/forge-editor/tree/types` (the `/tree` entry also carries the store and the view model, which a server's typecheck should not pull in); this repository's `server/utils/documents.ts` does.

Semantics every implementation must keep:

- **Failure ownership.** The host reports failures (its toast, its wording) and then *rejects*. `DocumentTree` shows no second message; it only stops the flow it was running (for example it keeps the folder-name input open or does not expand a folder).
- **Refresh.** Every mutation resolves only after both `tree` and `documents` show its result, so the tree can select and expand the new item right away.
- **Loading.** While `tree` is `undefined` the tree shows „Lädt…“, for `null` „Dokumente konnten nicht geladen werden“; the virtual root (below) is not affected by either.
- **create** returns the new document's ID. Omitted title and folder mean the host's default title and the root.
- **remove with undo.** The tree shows the notification „Dokument gelöscht“ (description: the title) with a „Rückgängig“ action built from the resolved value. The implementation must (1) leave the document first when it is the open one and wait for its page to be torn down, so the collaboration provider is destroyed before the server forgets the document, (2) snapshot title and content, (3) delete, and (4) return `restore`, which creates a **new** document (new ID) with the old title in `folder` and writes the JSON content back. Assets, IDs and collaboration history are not restored; the tree opens the restored document by its new ID. If `restore` rejects, the host has already reported it.
- **Folders.** Paths are `/`-joined segments. `removeFolder` must reject for a non-empty folder (the host's server says „Ordner ist nicht leer“); the tree itself refuses to drop a folder onto itself or a descendant and never calls `moveFolder` for it. A selected folder that no longer exists falls back to the root.
- **validateName(name, siblings)** returns a German error message or `null`; `siblings` are the names already taken in the target folder. The tree shows the message inline under the input (on create and rename) and only submits valid names. The host passes its server's rules (here `fileNameError` from `shared/utils/file-name.ts`) so client and server never disagree.
- **open(id)** navigates the host to the document; `activeId` follows the host's route.

## Filters, virtual root, outline

- Filters hide Block types below a tree item (per item key); they persist in `localStorage` under `tip-tree:node-filters`. The view state is a framework-free model: `createTreeView()` builds one (`nodeFilters`, `expanded`, `collapsedOutline`, `expand`, `setKindHidden`, `clearFilter`, `clearAllFilters`, `followFolderMove`), `treeView(context)` returns the one of an `EditorContext`'s app, and `useTreeView()` (`/tree/vue`: `nodeFilters`, `expanded` and `collapsedOutline` as refs; `/tree/react`: as plain values that re-render the component) reads it, so several trees of one app share it (see [§11](https://skaile-ai.github.io/forge-editor/guides/settings)).
- Outline rows are open unless their key (`outline:<block id>`) is in `collapsedOutline`; it lasts for the session, so a tree mounted again shows the rows as they were left. A host sets it to start with rows closed, e.g. `treeView(context).collapsedOutline.set(ids.map(id => `outline:${id}`))` for the top Typed blocks.
- The basket filter belongs to the same view state: `basketFilter` (the basket ids to show, `[]` = all; persists under `tip-tree:basket-filter`), `setBasketShown(id, shown)` and `clearBasketFilter()` on `TreeViewModel` ([below](#baskets-focus-and-foreign-drops)).
- The settings value `blocks.treeRoot` is `'folders'` (default: folders with their documents, the open document is an entry among them) or `'file'`. With `'file'`, an open document in Structure mode (`blocks.structure === 'structured'`) is the *virtual root* of the tree: its Typed blocks are the nested items and dragging one moves the block (`moveBlockInto`) unless the target Typed block's Block type does not accept it (`acceptsChild`, from the Block type's `accepts`): the drop is then refused, outlined red with the reason of `moveIntoVerdict` under the row. With no open document or in Flat mode the tree shows the folders instead. Switching it in the settings panel re-roots the tree immediately.
- The Block outline of the open document is read from `editor` (`documentOutline`, from `/composites`): its headings and Typed blocks as one tree in document order, the same in Flat and Structure mode. A heading nests under the nearest shallower heading before it, a Typed block under the heading it follows, and the headings in a Typed block's body start their own levels below it, so converting headings into Typed blocks („Überschriften in Blöcke“) or back keeps every entry. The Block type filter of the open document and of its rows lists the Block types plus „Überschriften“ (`HEADING_KIND` from `/tree`); hiding the headings lifts the entries below them. The basket filter hides a heading unless a shown Typed block is below it, which then keeps it dimmed. The outline is refreshed, debounced, on `update`. `DocumentTree` re-subscribes when the `editor` prop changes and removes its listener when the editor changes or the tree unmounts, so switching documents never leaves a stale listener on the previous editor.

## Baskets, focus and foreign drops

Three additions for a tree of Typed blocks; the editor side of each is in [Content tree mechanics](https://skaile-ai.github.io/forge-editor/guides/content-tree). The German labels of the basket controls still use a legacy word, so this section quotes their English catalog labels (English label).

- **Basket dot and filter.** A Typed block row shows the dot of its basket (`meta.basket`; the baskets are the ones the open editor offers, `compositeFeature({ baskets })`). With a live editor that offers baskets, a paint-bucket button (“Filter by basket”) next to the title filter opens a menu of baskets to check; the tree then shows only the Typed blocks of the checked baskets (none checked = all) plus their ancestors, which are dimmed (`TreeRowState.dimmed`). Documents, folders and headings are never hidden; “Reset basket filter” clears it. The choice is `TreeViewModel.basketFilter`, shared by the trees of one app and stored in `localStorage` under `tip-tree:basket-filter`; an id the open editor does not offer never filters.
- **Focus.** Every Typed block row has a focus button („Fokussieren“). A focused row lists only its children, under a breadcrumb whose first crumb is the document („Fokus aufheben“ leaves the focus) and whose other crumbs refocus that level; the Block type filters of the rows on the way down still apply to the children (`TreeState.focusKey`, `focusPath`). The tree and the editor stay in step: focusing a row sends the `TreeCommand` `{ type: 'focusBlock', id }`, whose executor runs `focusComposite(id)` on the `editor` prop (`id: null` leaves it), and a focus started in the editor (block action „Fokussieren“, the focus chip) is mirrored into the tree. A focused Typed block that disappears, or another editor, resets the focus. Only a Typed block with a block id can be focused.
- **Merge.** A Typed block row whose Typed block the open editor can merge with the Typed block after it shows a merge button („Mit nächstem zusammenführen“, next to the focus button). It sends the `TreeCommand` `{ type: 'mergeBlock', id }`, whose executor runs `mergeCompositeWithNext` on the `editor` prop ([Merging with the next Typed block](https://skaile-ai.github.io/forge-editor/guides/content-tree#merging-with-the-next-typed-block)); `TreeModel.canMerge(item)` says whether to show it, `merge(item)` sends it.
- **Foreign drops.** A Typed block dragged in from another editor surface or tree (`BLOCK_MIME`, [payload](https://skaile-ai.github.io/forge-editor/guides/content-tree#the-payload)) is accepted in the *virtual root* mode (`blocks.treeRoot = 'file'`) on the document root and on a Typed block row that has a block id (`DragPayload` `{ type: 'foreign-block' }`); any other row, or the folder mode, refuses it with „Hier lassen sich keine Blöcke ablegen.“. The model emits `TreeCommand` `{ type: 'transferBlock', payload, targetId, menu, x, y }`, and the executor runs `editor.commands.transferBlock` at the **end of the target Typed block** (`targetId: null` = the end of the document); with Shift held at the drop (`menu: true`) the transform menu opens at the pointer first. This needs `transferFeature` in the open editor's preset ([Block transfer](https://skaile-ai.github.io/forge-editor/guides/content-tree#block-transfer)); without it the tree refuses every foreign drop („Dieser Editor nimmt keine Blöcke von außen an.“). The rows write their own payload when dragged, so a tree row can be dropped on another surface or tree.

The `TreeCommand` union gained `transferBlock`, `focusBlock` and `mergeBlock`, and `DragPayload` gained `{ type: 'foreign-block' }`; the components run these commands through the open editor, so a host does not handle them. A host that switches exhaustively over `TreeCommand` or `DragPayload` needs the new cases.

## Not in the library

Storage, routing, authentication, toasts, and the host's own file formats. In this repository the `.cdoc` (package) export stays in the host: the document row's export link comes in through `document-actions` (`app/components/AppDocumentTree.vue`).


## Sitemap

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