Live editor

Evo

A structural outliner demo. Edit the highlighted block, move items around, and try the shortcuts on the right.

evo

ClojureScript outliner with a tiny tree algebra.

Evo is a working structural text editor: nested blocks, inline markdown, page refs, images, math, multi-select, undo/redo, backlinks, and local-folder persistence. You can try it here: markusstrasser.org/evo-demo.

Before evo, I built an earlier Clojure version, now archived, and several JavaScript/Svelte prototypes. The kernel, renderer, and plugin system went through a few dozen versions in 2023/2024, before coding agents were useful for this kind of work. The repo is about 18K LoC. The structural-editing core is about 6.4K.

I did not set out to make a text editor. Evo fell out of a larger experiment: could user interfaces change directly from user events?

I now think that idea was too broad. Creative tools need stable primitives more than they need live meta-evolution. But the experiment left behind something useful: a small outliner kernel with a narrow mutation surface.

Coding agents are relevant here because Evo exposes editor behavior as data.

A user action becomes an intent map, a plugin turns that intent into ops, and the kernel validates and applies them through one transaction path. That gives coding agents a narrow surface: change an intent handler, inspect the emitted ops, and test the result without inventing DOM behavior.

I used to think of the kernel and plugin system as something like an IR for tree editing. That analogy is too heavy.

The simpler version is this: Evo compiles editor behavior down to three document operations: create-node, place, and update-node.

Quick start

Requires Node.js + npm, a JDK for shadow-cljs, and Babashka if you want the bb tasks.

macOS:

brew install node openjdk babashka

Debian/Ubuntu:

sudo apt install nodejs npm default-jdk
# install babashka separately if you want bb tasks
npm install
npm start             # clean build + watch CLJS + watch CSS
# -> http://localhost:8080/blocks.html

npm run dev:fast skips the clean step when caches are healthy. npm run build produces a release build into public/js/blocks-ui.

What it does

Area Support
Blocks nesting, indent/outdent, drag/drop, fold
Inline text **bold**, _italic_, ==highlight==, ~~strike~~
Links and refs [[Page Name]], [label](target), evo://page/<name>, evo://journal/<iso-date>
Media images with paste upload, resize handles, lightbox
Math $inline math$, $$block math$$ through MathJax
Editor state multi-select, undo/redo, autocomplete, backlinks
Persistence local folder, no server, no account

Core contract

Evo has one persistent document graph and one ephemeral session atom.

src/kernel/db.cljc stores the persistent document graph: nodes, parent-owned child vectors, and roots. The transaction pipeline attaches derived indexes after each write. src/shell/view_state.cljs owns cursor, selection, folding, autocomplete, and edit mode. That state changes too often to belong in the replayable document DB.

Canonical DB shape:

{:nodes {"a" {:type :block :props {:text "I am a node"}}}
 :children-by-parent {:doc ["a"]}
 :roots [:doc :trash]
 :derived {:parent-of {"a" :doc}
           :next-id-of {}
           :prev-id-of {}
           :index-of {"a" 0}}}

All durable document changes reduce to three ops:

{:op :create-node :id "a" :type :block :props {:text "I am a node"}}
{:op :place       :id "a" :under :doc :at :last}
{:op :update-node :id "a" :props {:text "being updated"}}

The transaction contract is:

normalize -> validate -> apply -> derive

The DB source facts are:

:nodes
:children-by-parent
:roots

The transaction pipeline derives lookup maps after each write:

:parent-of
:index-of
:prev-id-of
:next-id-of

Queries such as (q/parent-of db id) and (q/next-sibling db id) read those maps. Plugins can add derived views too; backlinks are implemented as one.

Plugins turn user intent into kernel ops.

Example intent:

{:type :indent
 :id "node-B"}

The indent plugin reads the current tree, finds node-B's previous sibling, and emits one placement:

{:op :place :id "node-B" :under "node-A" :at :last}

That is the boundary:

(db, intent) -> {:ops [...]
                 :session-updates {...}
                 :effects [...]}

Plugins do not mutate the DB. They return data. The executor sends ops through the transaction pipeline, applies session updates, and runs effects. Effects are [kind arg-map] tuples for side effects that are not document ops — system clipboard writes, file deletion. Handlers stay pure; the executor owns the I/O.

Page refs and backlinks stay out of the kernel. Plugins interpret the intent, emit normal ops, and add derived views when they need faster reads.

Structural editing means tree edits, not visual whitespace. Tab and Shift+Tab change parent-child relationships. Indent moves a block under its previous sibling; outdent moves it after its parent. In an outline, parentage carries meaning; changing one block's level should not silently reparent its siblings.

Before Shift+Tab:
Parent
  Child A
  Child B

After Shift+Tab on Child A:
Parent
  Child B
Child A

contenteditable owns live text while you type. Evo commits text back to the document graph at controlled boundaries instead of writing every keystroke into the DB. That avoids cursor and render churn. Main implementation: src/components/block.cljs and src/shell/view_state.cljs.

src/kernel/ must not import src/shell/, src/components/, or src/keymap/. Reads go through src/kernel/query.cljc.

Architecture

Runtime path:

DOM event
  -> intent map
  -> plugin handler
  -> kernel ops
  -> transaction pipeline
  -> canonical DB + derived indexes
  -> parser/render
  -> DOM

src/kernel/ owns the document machine. src/plugins/ compiles intents into ops, session updates, and effects. src/shell/ wires the browser/runtime path. src/components/ owns UI behavior. src/parser/ turns text into AST, and src/shell/render/ turns AST tags into hiccup.

The extension surface has three registries:

Registry Adds File
Intent editing/navigation behavior kernel.intent/register-intent!
Derived index materialized read views kernel.derived-registry/register!
Render AST tag rendering shell.render-registry/register-render!

Each registry is a defonce atom plus validation and dispatch. Re-registering a key replaces the old handler, which keeps hot reload and test fixtures simple. Bootstrapping goes through src/plugins/manifest.cljc, src/shell/render_manifest.cljc, and src/shell/editor.cljs.

The intent registry maps intent keywords such as :indent, :navigate-to-page, and :collapse to validated handlers that return {:ops ... :session-updates ... :effects ...}. Those ops flow through src/kernel/transaction.cljc; session updates land in src/shell/view_state.cljs. The derived-index registry owns materialized views under db[:derived]; src/plugins/backlinks_index.cljc is the canonical example. The render registry maps AST tags to pure hiccup handlers; unknown tags throw instead of silently degrading.

Where a change belongs:

Change Touch
New inline syntax parser + render handler
New editing behavior plugin intent handler
New materialized read view derived-index plugin
New document invariant kernel

Two non-registry files matter:

File Owns
src/shell/view_state.cljs cursor, selection, folds, edit mode, drag state
src/shell/log.cljs append-only transaction journal

Undo, persistence, and replay all read the same transaction history.

Multi-step edits, where one step needs the result of the previous one, run inline in the handler: it interprets ops against a scratch DB, inspects the result, and emits the final op set. The runtime still sees one atomic edit.

Backlinks show the split:

Part File
Derived index src/plugins/backlinks_index.cljc
UI panel src/components/backlinks.cljs
Navigation intent src/plugins/pages.cljc
Page-ref rendering src/shell/render/page_ref.cljs

Inline content is parsed into a uniform AST shape:

[:tag {attrs} content]

Common tags include :doc, :text, :bold, :italic, :highlight, :strikethrough, :math-inline, :math-block, :link, :page-ref, and :image. See src/parser/ast.cljc.

Where things go:

  • components render views and panels; they do not parse content or own per-tag logic
  • render handlers live per AST tag, not per block-level mode
  • session state does not go in the document DB
  • src/kernel/ must not import src/shell/, src/components/, or src/keymap/

Project layout

Most editor behavior lives in src/kernel/ and src/plugins/.

Path Approx. LoC Owns
src/components/ ~4.3k UI, especially block.cljs
src/plugins/ ~3.7k intent handlers and derived-index plugins
src/kernel/ ~3.2k pure document model, ops, transaction pipeline, queries
src/shell/ ~3.0k startup, storage, executor, view state, URL sync
src/utils/ ~1.8k DOM, text, cursor, image, helper code
src/spec/ ~0.8k FR/spec runner and registry glue
src/parser/ ~0.8k inline text parsing into AST nodes
src/keymap/ ~0.2k keybinding tables and dispatch glue
test/ - unit, property, integration, and Playwright E2E tests
resources/ - FR registry, failure modes, seed data
public/ - HTML, CSS, MathJax shim, build output
docs/ - structural editing, rendering, dispatch, testing docs

Tests

bb test                       # full unit/property suite (CLJS via shadow-cljs)
bb test:view                  # hiccup-only tier (<1s)
bb test:kernel                # kernel tests only
bb check                      # lint + arch verification + compile
npm run test:e2e:smoke        # Playwright smoke (~10s)
npm run test:e2e              # full Playwright suite (~4min)

Non-goals

  • Not a PKM product. Structured note-taking is a low-ROI trap for this project. Evo cares about the outliner mechanics, not the life system around them.
  • Not a packaged library. There is no Clojars artifact and no API stability promise. Reuse the kernel if it fits, but this repo does not pretend to be a stable dependency.
  • Not full editor parity. Evo aims for a solid structural editing spec, not every feature from rich-text editors or PKM apps. I skipped block embeds and page embeds on purpose.
  • No universal editor primitive. Evo abstracts over tree editing, not text, video, audio, CAD, or every creative domain.

These comparisons are about architecture, not product scope:

  • Logseq: Closest on outliner semantics, but its core mutations ride on Datascript transactions and app-level outliner ops. Evo makes the mutation algebra itself smaller and more explicit.
  • ProseMirror: Centers on schema, transactions, and Step transforms over a rich document model. Evo keeps a simpler tree DB and compiles plugin behavior down to three ops.
  • Slate: Also operation-based, but the mutable Editor object plus normalization/history plugins carry much of the behavior. Evo puts those semantics in a standalone kernel instead of editor-instance methods.
  • Tiptap: Mainly an extension layer over ProseMirror's transaction and plugin system. Evo owns the kernel directly instead of wrapping another editor core.
  • xi-editor: Strong core/plugin split too, but for a rope-based text engine with RPC plugins. Evo is a structural tree kernel first, not a text-buffer architecture.

Further reading

I no longer think creative tools should evolve from raw event streams. Creative work depends on stable primitives. AI can patch small parts of a tool, but the outer loop still needs a designed interface, a clear domain model, and tests.

These are the references behind that earlier interface experiment: