Document tree
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 */}
/>
)
}
editoris the open page's editor (undefinedwhen no document is open); take it from your page through a shared composable, asuseActiveEditor()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 toAppDocumentTree). The Vuedocument-actionsslot isdocumentActionson React.- Place
DocumentTreeanywhere (a sidebar, a drawer); several can coexist, sharing one filter state per app (useTreeView()from/tree/vuereturns the refsnodeFilters,expandedandcollapsedOutline, from/tree/reactthe plain values, both with the framework-freemodel). - Title filter. The field „Titel filtern“ above the tree (clear button,
Escclears 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 theDocumentStorecontract. Both components render one framework-free model (createTreeModel, internal, not exported from./tree); the props ofDocumentTreeare 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-bodyon 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 orblocks.structureis'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 | nullis 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),nullleaves it to the library rules.drop(source, target)runs when the host's verdict wasok; the host moves the rows (the tree emits nomoveBlock).step?(item, direction)runs on Ctrl+↑ (-1) / Ctrl+↓ (1) on a focused row and moves it one place among its siblings; it returnsfalsewhen the row is not the host's (the tree does nothing else with Ctrl+arrow); after atruethe keyboard focus follows the row to its new place, so the next press moves the same row on. Withoutstepthe 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.
DocumentTreeshows 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
treeanddocumentsshow its result, so the tree can select and expand the new item right away. - Loading. While
treeisundefinedthe tree shows „Lädt…“, fornull„Dokumente konnten nicht geladen werden“; the virtual root (below) is not affected by either. createreturns the new document's ID. Omitted title and folder mean the host's default title and the root.removewith 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) returnrestore, which creates a new document (new ID) with the old title infolderand writes the JSON content back. Assets, IDs and collaboration history are not restored; the tree opens the restored document by its new ID. Ifrestorerejects, the host has already reported it.- Folders. Paths are
/-joined segments.removeFoldermust 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 callsmoveFolderfor it. A selected folder that no longer exists falls back to the root. validateName(name, siblings)returns a German error message ornull;siblingsare 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 (herefileNameErrorfromshared/utils/file-name.ts) so client and server never disagree.open(id)navigates the host to the document;activeIdfollows the host's route.
Filters, virtual root, outline
- Filters hide Block types below a tree item (per item key); they persist in
localStorageundertip-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 anEditorContext's app, anduseTreeView()(/tree/vue:nodeFilters,expandedandcollapsedOutlineas 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 incollapsedOutline; 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 undertip-tree:basket-filter),setBasketShown(id, shown)andclearBasketFilter()onTreeViewModel(below). - The settings value
blocks.treeRootis'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'saccepts): the drop is then refused, outlined red with the reason ofmoveIntoVerdictunder 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_KINDfrom/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, onupdate.DocumentTreere-subscribes when theeditorprop 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 isTreeViewModel.basketFilter, shared by the trees of one app and stored inlocalStorageundertip-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 theTreeCommand{ type: 'focusBlock', id }, whose executor runsfocusComposite(id)on theeditorprop (id: nullleaves 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 runsmergeCompositeWithNexton theeditorprop (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 emitsTreeCommand{ type: 'transferBlock', payload, targetId, menu, x, y }, and the executor runseditor.commands.transferBlockat 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 needstransferFeaturein 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).
AI
Add AI to an editor: selection menu, sidebar, ghost text, backend endpoints, integrations with Typed blocks and transcripts, agent operations and fence formats.
Markdown and code
Edit Markdown files as blocks with frontmatter and unknown fences preserved, edit source files in CodeMirror, and pick a surface per file type.