@kozmof/reed
v4.1.0
Published
A fast, immutable text engine for building editors, built on a piece table and usable with any UI framework.
Maintainers
Readme
reed
Reed is a fast, immutable text processor for building editors. It uses a piece table and works with any UI framework.
Installation
Install Reed with your package manager.
npm install @kozmof/reed
# or
pnpm add @kozmof/reedQuick start
Create a store, dispatch an edit, and read the result.
import { store, scan, position } from "@kozmof/reed";
// Create a document store with initial content.
const doc = store.createDocumentStore({ content: "hello world" });
// Dispatch an edit. Actions are created via store.DocumentActions.
doc.dispatch(store.DocumentActions.insert(position.byteOffset(5), ","));
// Read the full text back.
const state = doc.getSnapshot();
console.log(scan.getValue(state.pieceTable)); // "hello, world"Namespaces
Reed's runtime is organized into namespaces.
| Namespace | Use it for |
| -------------- | ----------------------------------------------------------------- |
| store.* | Store lifecycle, action creators, type guards |
| query.* | O(1) / O(log n) reads: line lookups, cursor positioning |
| scan.* | O(n) reads: full-document serialization, analysis |
| rendering.* | Viewport calculation, position ↔ line/column conversion |
| history.* | Undo/redo state queries |
| diff.* | Diff algorithm and setValue (replace whole content efficiently) |
| events.* | Event emitter and document event factories |
| position.* | Branded offset constructors (byteOffset, charOffset, …) |
| attention.* | Piece-anchored references that survive edits |
| checkpoint.* | Saving a document state as JSON-safe data and loading it back |
Import types directly from the package.
import type { DocumentState, InsertAction, ByteOffset } from "@kozmof/reed";Use query on hot paths such as keystrokes, scrolling, and rendering. Range reads also cost time proportional to the text returned. Reserve scan for occasional work such as exporting a document or running background analysis.
Creating a store
Pass initial content and optional settings to the store factory.
import { store } from "@kozmof/reed";
const doc = store.createDocumentStore({
content: "initial text", // initial document content
historyLimit: 1000, // max undo entries (default: 1000)
lineEnding: "lf", // "lf" | "crlf" | "cr" (default: "lf")
normalizeInsertedLineEndings: true, // coerce inserts to lineEnding (default: false)
undoGroupTimeout: 300, // ms window to group consecutive edits (default: 0 = off)
});The store holds the current document state and notifies subscribers on every change.
const state = doc.getSnapshot(); // current immutable DocumentState
const unsubscribe = doc.subscribe(() => {
// notified on every change
render(doc.getSnapshot());
});
unsubscribe();Editing the document
Dispatch actions from store.DocumentActions to edit a document. Construct byte offsets with position.byteOffset(...).
Store actions for edits, selections, and attentions, plus bounded public text reads, require UTF-8 code-point boundaries. Use query.isUtf8Boundary(state.pieceTable, offset) when converting raw byte positions from outside Reed. Positions produced by rendering.lineColumnToPosition and rendering.charOffsetsToSelection are already on boundaries, so the check is for offsets that arrive from elsewhere. Invalid reads and state changes throw RangeError. Low-level caller-owned attention helpers accept trusted positions, so validate those offsets first.
import { store, position } from "@kozmof/reed";
const { DocumentActions } = store;
const { byteOffset } = position;
// Insert text at a byte offset.
doc.dispatch(DocumentActions.insert(byteOffset(0), "Hello "));
// Delete the range [start, end).
doc.dispatch(DocumentActions.delete(byteOffset(0), byteOffset(6)));
// Replace a range with new text.
doc.dispatch(DocumentActions.replace(byteOffset(0), byteOffset(5), "Howdy"));
// Move the selection/cursor.
doc.dispatch(DocumentActions.setSelection([{ anchor: byteOffset(3), head: byteOffset(3) }]));dispatch returns the resulting DocumentState. Use that state for subsequent reads.
Undo / redo
Dispatch undo and redo actions, and query history to update your controls.
import { store, history } from "@kozmof/reed";
doc.dispatch(store.DocumentActions.undo());
doc.dispatch(store.DocumentActions.redo());
// Query history state (all O(1)):
history.canUndo(doc.getSnapshot()); // boolean
history.canRedo(doc.getSnapshot()); // boolean
history.getUndoCount(doc.getSnapshot());Replacing the whole document
Use store.setValue to update a live store when loading or reverting a file. The default fast strategy computes one changed range and preserves store notifications, events, history, and rollback behavior.
import { store } from "@kozmof/reed";
store.setValue(doc, "completely new content");Pass { strategy: "diff" } when fine-grained history matters. Use diff.setValue only for pure transitions that return a detached DocumentState.
store.setValue(doc, "completely new content", { strategy: "diff" });Reading the document
Fast reads: query (O(1) / O(log n))
Use selectors for line lookups and bounded text reads. Reading text also costs time proportional to the bytes returned.
import { query, position } from "@kozmof/reed";
const state = doc.getSnapshot();
query.getLineCount(state); // total lines
query.getLineCountInfo(state); // resident, unloaded, and expected line counts
query.findLineAtPosition(state, position.byteOffset(7)); // line node at a byte offset
query.findLineByNumber(state, 2); // third line node (line numbers are 0-based)
query.getLineStartOffset(state, 1); // byte offset where a line starts
query.getLength(state.pieceTable); // document length in bytes
query.isUtf8Boundary(state.pieceTable, position.byteOffset(5)); // safe text boundary
query.getText(state.pieceTable, position.byteOffset(0), position.byteOffset(5)); // substringFull reads: scan (O(n))
Read the whole document or stream it in chunks.
import { scan } from "@kozmof/reed";
const state = doc.getSnapshot();
scan.getValue(state.pieceTable); // the entire document as a string
// Stream DocumentChunk values to export large files without one large string.
for (const chunk of scan.getValueStream(state.pieceTable)) {
write(chunk.content);
}Rendering a viewport
Use rendering.getVisibleLines to read the lines your UI needs to paint. The namespace also converts cursor positions.
import { rendering } from "@kozmof/reed";
const state = doc.getSnapshot();
const visible = rendering.getVisibleLines(state, {
startLine: 0, // first visible line (0-indexed)
visibleLineCount: 30, // lines that fit in the viewport
overscan: 5, // extra lines above/below for smooth scrolling
});
for (const line of visible.lines) {
paint(line);
}For long lines, pass startColumn and maxColumns to limit the returned text to a horizontal window. Columns use UTF-16 units and both window boundaries snap forward past a split surrogate pair. Each line retains its full byte range and newline metadata. contentStartColumn gives the actual start column, and isTruncated indicates omitted content. Omit these options to return complete lines.
For a partially loaded document, visible.lines, firstLine, and lastLine use compact resident coordinates. Check visible.coordinateSpace, visible.residentLineCount, and visible.isComplete. Use visible.totalLines only for expected scroll sizing.
Reacting to changes with events
createDocumentStoreWithEvents adds typed event emission on top of the base store. Subscribe to specific change types instead of a generic notification.
import { store, position } from "@kozmof/reed";
const doc = store.createDocumentStoreWithEvents({ content: "" });
doc.addEventListener("content-change", (e) => console.log("text changed", e));
doc.addEventListener("selection-change", (e) => console.log("cursor moved", e));
doc.addEventListener("history-change", (e) => console.log("undo/redo state changed", e));
doc.addEventListener("dirty-change", (e) => console.log("dirty flag:", e));
doc.dispatch(store.DocumentActions.insert(position.byteOffset(0), "hi"));Tracking references with attention
The attention namespace anchors references to piece boundaries instead of document offsets, so a reference keeps pointing at the same text across edits elsewhere in the document. Store actions migrate DocumentState.attention automatically. For direct piece-table operations, keep a separate immutable AttentionLayerState and pass the returned state into the next operation.
import { store, scan, position, attention } from "@kozmof/reed";
const doc = store.createDocumentStore({ content: "hello world" });
let pt = doc.getSnapshot().pieceTable;
let att = attention.emptyState;
// Anchor an attention over "world".
const start = attention.createPoint(pt.root, position.byteOffset(6))!;
const end = attention.createPoint(pt.root, position.byteOffset(11))!;
let id;
[att, id] = attention.createAttention(att, start, end);
// Advance both layers when inserting earlier in the document.
const next = attention.insertWithAttention(pt, att, position.byteOffset(0), ">> ");
pt = next.pieceTableState;
att = next.attentionState;
scan.getValue(pt); // ">> hello world"
attention.getTextForAttention(pt, att, id); // "world"Use insertWithAttention / deleteWithAttention to keep the piece table and attention layer in sync. Resolution is fail-closed. A dangling reference resolves to null. Delete helpers re-anchor affected endpoints at the deletion boundary. See spec/10-attention.md for the full model.
Working with large files
Reed supports chunked, streaming loads for files that don't fit comfortably in memory. createChunkManager and createStreamingDocumentLoader are flat exports (not part of the store namespace) and take the store plus a ChunkLoader that fetches raw bytes for a chunk index.
import { store, createChunkManager, createStreamingDocumentLoader } from "@kozmof/reed";
const chunkSize = 64 * 1024;
const streamedDoc = store.createDocumentStore({
content: "",
chunkSize,
totalFileSize,
});
const loader = {
loadChunk: (chunkIndex: number): Promise<Uint8Array> => fetchChunkBytes(chunkIndex),
};
// High-level: declare chunk metadata, then load/pin/prefetch around a viewport.
const streaming = createStreamingDocumentLoader(streamedDoc, loader, metadata);
streaming.setViewport(startChunkIndex, endChunkIndex);
// Or manage chunks directly.
const manager = createChunkManager(streamedDoc, loader);
await manager.ensureLoaded(0);Chunk metadata makes query.getLineCount(state) report the expected total before every chunk is loaded. Use query.getLineCountInfo(state) to keep expected counts separate from the resident line tree used by rendering selectors.
The reconciliation scheduler repairs cached line offsets after edits. Call reconcileNow() when an operation requires an eager state immediately.
const eager = doc.reconcileNow(); // returns a fully reconciled DocumentStateSee spec/03-loading-and-history.md for the chunk lifecycle.
Saving and restoring state
Use the checkpoint namespace to persist an eager document state as JSON-safe data. Restore it without replaying the edits that produced it.
import { store, checkpoint, scan } from "@kozmof/reed";
// Capture. Checkpoints are taken from a fully reconciled state.
localStorage.setItem("draft", checkpoint.encode(doc.getEagerSnapshot()));
// Restore into a fresh store.
const saved = JSON.parse(localStorage.getItem("draft")!);
const restored = store.createDocumentStoreFromCheckpoint(saved);
scan.getValue(restored.getSnapshot().pieceTable);A checkpoint carries the whole document state, including undo history, selection, attention references, and loaded chunks. Editing continues from where it left off, so undo still walks back through edits made before the save.
Store runtime starts fresh after restore. Re-subscribe and rebuild any ChunkManager. The restored store starts with a new reconciliation scheduler and no open transaction.
Restore is fail-closed. A truncated or hand-edited payload raises CheckpointError with a
code naming the problem. Add resource limits when the payload comes from outside your
application. The JSON length is checked before parsing. Buffer and collection limits are checked
before Reed rebuilds the state trees.
const restoredState = checkpoint.decode(untrustedJson, {
maxJsonLength: 10_000_000,
maxBufferBytes: 8_000_000,
maxPieces: 100_000,
maxLines: 500_000,
maxHistoryEntries: 2_000,
maxAttentions: 10_000,
maxCollectionItems: 1_000_000,
maxStringCodeUnits: 20_000_000,
});Use store.dispatchValidated(doc, value) for actions received from JSON, plugins, workers, or the
network. Use doc.dispatch(action) for actions created by trusted TypeScript code.
For a smaller payload, capture in normalized mode. It flattens the document to its text and drops the edit-by-edit piece structure, keeping history, selection, and attention references intact.
const compact = checkpoint.encode(doc.getEagerSnapshot(), { mode: "normalized" });See spec/11-checkpoint.md for the wire format and the full validation contract.
Design Overview
- Pure reducers compute each transition from
(state, action). - Immutable snapshots share unchanged structure and support reference comparisons.
- Explicit conversion utilities distinguish UTF-8 byte offsets from UTF-16 character offsets.
- Separate
queryandscannamespaces make lookup and traversal costs visible at the call site.
Development
Install dependencies, then run the commands needed for your change.
pnpm install
pnpm build # type-check + bundle + emit declarations
pnpm test # run the test suite (vitest)
pnpm test:watch # watch mode
pnpm coverage # coverage report
pnpm test:perf # performance suite
pnpm bench # run benchmarks
pnpm lint # oxlint
pnpm fmt # oxfmtDocumentation
- SPEC.md: current implemented surface and verification status
- spec/: per-domain specifications (architecture, rendering, loading, API, testing, internals)
- docs/invariants.md: invariants the engine maintains
License
See LICENCE.
