Guides

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

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

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; the DocumentStore contract it implements is below.

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

DocumentStore

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 ReadonlyStores (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).
  • 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).
  • 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. 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); 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) 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); 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).

Copyright © 2026