Documents
Create, load, share and destroy an EdytorDocument, and understand when it is ready to edit.
An EdytorDocument is the thing views render and providers sync. Create one yourself when you want to share it between views, edit it without a view, attach a provider from code, or control its lifetime.
Create a document
import { createDocument, defaultSemantics } from 'edytor';
const document = createDocument({
value: { children: [{ type: 'paragraph', id: 'p1', content: [{ text: 'hello' }] }] },
actor: { id: 'user-42', name: 'Ada', color: '#7559ee' },
semantics: defaultSemantics
});
document.readiness; // 'local'
document.transact(() => document.facade.insertText('p1', 5, ' world'));
document.facade.blockText('p1'); // 'hello world'
document.history.undo(); // 'hello' again; the seed itself is never an undo step
| Option | Type | Description |
|---|---|---|
value |
JSONDoc |
Initial content, seeded immediately: the document is ready at once. Without it, the document stays pending until a provider or sync() decides. To seed only when a room turns out empty, pass value to attachSync instead (see the warning below). |
actor |
{ id, name?, color? } |
The local author. id is stored in attribution; name and color are published as presence. Defaults to an anonymous anon-<uuid>. |
awareness |
Awareness |
Use an existing awareness instance instead of creating one. The document does not destroy a borrowed instance. |
history |
{ captureTimeout?: number } |
Edits closer together than this many milliseconds merge into one undo step. Engine default: 500. 0 makes every commit its own step. |
semantics |
DocumentSemanticsConfig |
Block roles (void, island), kinds without content, default child types and the default block type. Views contribute these from their plugins; a document you edit without a view knows none of them until you pass them. defaultSemantics holds the bundled rich-text, code and image kinds (richTextSemantics, codeSemantics, imageSemantics each hold one plugin’s). |
lineage |
{ depth?: number } |
Keep up to depth earlier versions of each block, captured when another author, a delete or an undo replaces it. Off by default; read them with attribution.history(id). |
Without semantics, a document edited with no view attached applies every edit the tree allows: it merges text into a divider or moves a code line out of its code block, which no view can render. Pass defaultSemantics, or your plugins’ rules, whenever you edit headlessly. It is not the default because a view whose plugins redefine one of those kinds would then throw SemanticConflictError.
Load saved bytes
document.encode() returns the full replicated state as a Uint8Array. loadDocument restores it as a fresh replica with a new client id:
import { loadDocument } from 'edytor';
const bytes = document.encode(); // store or send it
const copy = loadDocument(bytes); // readiness 'hydrated'
copy.facade.toJSON(); // same JSON as the source
loadDocument checks the bytes on a scratch copy first. Corrupt bytes throw UndecodableUpdateError, a schema this build cannot own throws SchemaMismatchError, and a v13 (yjs) document throws UnsupportedDocError with reason legacy; see Migration. Your bytes are never modified.
The document object
| Member | Description |
|---|---|
facade |
The document API: reads, operations, order queries and onChange. See the document API reference. |
history |
The shared undo manager (undo(), redo(), stopCapturing()). Throws DocumentNotReadyError while the document is pending. |
clearHistory() |
Empty the undo and redo stacks. Stacks are otherwise unbounded. |
awareness |
The one presence instance for every view and provider. See Presence. |
actor |
The local author identity. |
attribution |
Per-block authorship: block(id) returns createdBy, contributors and lastChangedBy. |
transact(fn) |
Run several edits as one transaction and one undo step. |
encode() |
The full state as bytes, for loadDocument. |
attachSync(sync, { value? }) |
Attach a provider to the document. Returns its cleanup. |
ready, readiness, onReady(fn) |
Whether the content has been decided, and how. See below. |
syncRefusal, onSyncRefused(fn) |
The SyncRefusedError a provider reported when the server refused this client (close 1008, or 4xxx other than 4401). It lasts until that provider is released or syncs again. |
sync(value?) |
Decide a pending document now: seed value if it is empty, adopt its content otherwise. |
writable, onWritableChange(fn) |
false while the document holds content from a schema this build cannot own. Every write is refused and providers stop persisting and sending until it heals. |
clientID |
This replica’s CRDT client id (doc.clientID). |
doc |
The underlying CRDT document, for providers and advanced code. |
destroy() |
Release the document: providers, history, awareness and the CRDT document. |
Readiness
A document created without a value might be about to receive content from a provider, so it does not seed anything until that question is settled.
readiness |
Meaning | What works |
|---|---|---|
pending |
Undecided: a provider may still bring content. | Reads, attachSync, sync(), destroy(). history throws. |
local |
This replica seeded the content. | Everything. |
hydrated |
The content came from a provider or from loadDocument. |
Everything. |
With providers attached, the rule is settle or bound:
- A document that already holds content becomes
hydratedas soon as any provider settles, or is refused. - An empty document seeds its
valueonly once every attached provider has settled, failed, been removed, or reached its bound. - IndexedDB always settles, so it has no bound. A WebSocket alone in a new room may never hear from anyone, so it gets
DEFAULT_READINESS_BOUND(1000 ms), counted from the moment the socket opens or first fails to open: a room that is slow to accept the connection is waited for, up to the socket’sconnectTimeout(10 s by default). Expired credentials (4401) before the first sync hold the bound: an empty document waits for a dial that gets in. - A server refusal (
SyncRefusedError) never seeds: an empty document stayspendingand reports it insyncRefusalandonSyncRefused. A document that already has content keeps it and becomeshydrated, so a view mounted on it shows that content. The refusal belongs to the provider that reported it: once that provider is released (its cleanup ran) or syncs again (connect()after a refreshed token), the rule above applies to the providers still attached, and an empty room seedsvalue. Released with no provider left, the document stayspendinguntil the nextattachSyncorsync().
if (!document.ready) {
const off = document.onReady(() => console.log(document.readiness));
}
onReady fires once, at the transition; it is never called for a document that is already ready.
Attach a provider
attachSync takes the same factories as the <Edytor sync> prop. The document keeps one provider per target (one IndexedDB name, one server and room), so attaching the same target twice does nothing.
import { createDocument, createWebsocketSync } from 'edytor';
const document = createDocument({ actor: { id: 'user-42' } }); // pending
document.attachSync(
createWebsocketSync({ server: 'wss://rooms.example.com/rooms', room: 'doc-42' }),
{ value: { children: [] } } // seeded only if the room turns out empty
);
document.destroy() runs every attached provider’s cleanup. Calling the returned cleanup yourself detaches that one provider.
One document, several views
Pass the same document to several <Edytor> views. They share one document API, one undo history and one awareness; undo restores the caret in the view that issued it.
<script lang="ts">
import { onDestroy } from 'svelte';
import { Edytor, createDocument, createIndexeddbSync } from 'edytor';
const document = createDocument();
document.attachSync(createIndexeddbSync('notes/today'));
onDestroy(() => document.destroy());
</script>
<Edytor {document} />
<Edytor {document} readonly />
A view never destroys a document you passed in: you created it, you destroy it. Views contribute the structural rules of their plugins (void and island blocks, default children) to the document. Two views whose plugins disagree on a rule throw SemanticConflictError. See the Edytor component for the view’s props.
Headless use (Node and SSR)
Import from edytor/crdt/edytor in code that cannot load Svelte components. It exports the same document API as edytor, without the component:
import { defaultSemantics, loadDocument, toBlockSpec } from 'edytor/crdt/edytor';
export function appendLine(saved: Uint8Array, text: string): Uint8Array {
const document = loadDocument(saved, { actor: { id: 'server-bot' }, semantics: defaultSemantics });
const facade = document.facade;
const index = facade.childrenIds(null).length; // append at the root
facade.insertBlock({ parent: null, index }, toBlockSpec({ type: 'paragraph', content: [{ text }] }));
const bytes = document.encode();
document.destroy();
return bytes;
}
Deterministic seeds
Seeding a value is deterministic: the seed is written under a writer id hashed from the value, and blocks without an id get ids derived from the same hash. Two replicas that seed the same template write the same data, so their seeds merge into one copy instead of two, and a late identical seed never erases an edit.
const template = { children: [{ type: 'paragraph', content: [{ text: 'Agenda' }] }] };
const a = createDocument({ value: template });
const b = createDocument({ value: template });
// Merging a and b shows one "Agenda" paragraph, not two.
Seeds are not undo steps and carry no author (createdBy is absent on seeded blocks). Seeds of different values merge as a union: blocks with different ids all show, so give a template new block ids when its content changes.
When a seed shares a block id with content it meets, one version of that block wins, last-writer-wins by writer id:
| The block was written by | Result |
|---|---|
| A person editing (a live replica) | The seed loses. Seed writer ids sit below 2^26 and live ones are random 53-bit ids, so the block and every edit in it stay. |
| The same seed | Nothing changes: it is the same data. |
| A different seed | The larger writer id wins, whichever arrived first. If the loser is the room’s copy, its edits since that seed are replaced, on every replica, and no undo brings them back. |
So never pass a snapshot that changes (a copy of onChange output) as value beside a room: a client that seeds it late, because it could not reach the room before its readiness bound, puts that snapshot in the last row. Pass a fixed template, or no value at all.
Errors
| Error | When |
|---|---|
DocumentNotReadyError |
history used while the document is pending. |
DocumentDestroyedError |
The document is used after destroy(), or attachSync runs on a destroyed document. |
SemanticConflictError |
A view or option declares a block rule that contradicts one the document already adopted. |
UndecodableUpdateError |
loadDocument bytes cannot be decoded. |
UnsupportedDocError |
A foreign object, or a v13 document (reason legacy). |
SchemaMismatchError |
The content claims a schema this build cannot own. |