Migration
Import documents stored by the v13 (yjs) engine, and upgrade code written for edytor 0.0.11.
This page covers two upgrades: moving stored v13 (yjs) documents to the current engine, and updating application code written for edytor 0.0.11. Edytor is pre-1.0: no release keeps compatibility shims.
Importing v13 documents
The v13 engine and the current one cannot share a live document, a database or a room. Migration is a one-way, non-destructive import of each document’s content into a new store.
| Survives | Does not survive |
|---|---|
| The block tree, nesting and order | CRDT identity: the imported document is new |
Text, marks, inline blocks and block data |
Edit history and undo stacks |
| Block and inline block ids (they are data) | Updates still pending on an offline v13 device |
| Presence |
The migrator reads a browser’s v13 IndexedDB database named <name> and writes the import into the store createIndexeddbSync(name) opens. It never writes the v13 database, so the old build keeps working on its data and rollback stays possible.
Boot recipe
Migrate first, then open the document. Running it at every start is safe: when there is nothing to import, it only marks the store as migrated.
import * as Y from 'edytor/crdt';
import { bindCrdt } from 'edytor';
const { migration } = bindCrdt(Y);
export async function prepareDocument(name: string): Promise<void> {
const record = await migration.status(name);
if (record.status === 'none' || record.status === 'failed') {
const result = await migration.migrate(name);
if (result.status === 'failed') console.error('migration failed', result.error);
} else if (record.status === 'pending') {
await migration.waitForSettled(name); // another tab is migrating
}
// 'rolledback' is an operator decision: never migrate over it automatically.
}
<script lang="ts">
import { Edytor } from 'edytor';
import { prepareDocument } from '$lib/migrate';
let { name }: { name: string } = $props();
</script>
{#await prepareDocument(name) then}
<Edytor room={name} />
{/await}
- Tabs migrating the same document take turns through a browser lock; only one imports.
migrate(name, { wait: false })returns{ status: 'busy' }instead of waiting. migrateverifies the import against the v13 content, ids included, before committing it, and commits it atomically. An interrupted import leaves nothing behind and runs again next time.result.updateholds the migrated state;loadDocument(result.update)opens it headlessly, andresult.jsonholds the content for comparison with a v13 export.- To import into another store, such as the local copy of a WebSocket document, pass the v13 name as the source:
migrate(sync.persistName, { sourceName: 'doc-42' }). For a shared document, import once, on one client, and let the room distribute it.
| Status | Meaning |
|---|---|
none |
Never migrated. |
pending |
A tab is importing right now (reported by status, never stored). |
active |
Imported, or nothing to import. |
failed |
Refused: the source is not a v13 edytor document, or it holds updates whose dependencies never arrived (a device that went offline). Retry once that device has synced; do not force. |
rolledback |
Rolled back by an operator. migrate refuses unless { force: true }. |
Rollback and re-import
migration.rollback(name) marks the store rolled back and empties it; the v13 database is untouched, so pointing the old build at it again is the rollback. clearDocument(name) deletes the new store entirely.
migrate(name, { force: true }) re-reads the v13 database and restores it in place: every v13 block gets back its type, data, position and content, and blocks created since are deleted. It replaces rather than merges, so use it to recover edits from a v13 device that came back online after the cutover, during a quiet window.
Rooms and servers
- v13 and current clients never exchange edits: each drops the other’s frames. Ship the new build to every client before announcing the cutover, and use different room names if both versions share a relay.
- A v13 device that stays offline through the cutover keeps its edits in its own v13 database. They do not appear in the new document unless you re-import them with
force. - The migrator reads browser IndexedDB only. For v13 documents stored elsewhere (on a server), export their JSON with your v13 build and create the new documents with
createDocument({ value }).loadDocumentrefuses v13 bytes withUnsupportedDocError(reasonlegacy), andisLegacyDoc(doc)detects a v13 document.
Upgrading from 0.0.11
The current release rewrites the editor’s internals around one owner per fact. The changes most apps hit first are summarized here; the complete list below names every public change.
Stored documents
- Documents, IndexedDB stores and peers from earlier development builds of the v14 engine are refused (
GenerationMismatchError,'schema-mismatch'): re-import them from JSON. v13 documents import as described above. - Seeding a
valueis deterministic, so replicas seeding the same template converge.BOOTSTRAP_BLOCK_IDis gone, and seeded blocks have nocreatedBy.
Document API
- Every facade operation returns an
OpResult(applied,nooporrefused) instead of a boolean. Operations on deleted or missing blocks are refused. - Operations have a two-phase form:
facade.prepare.<op>(),facade.apply(plan),facade.compose(...plans). New operations:deleteRange,replaceRange,deleteBlocks,insertFlow,insertBlocks.toBlockSpec(json)turns canonical JSON into theBlockSpecthat inserts take. - New queries:
order,compare,next,previous,canPlace,canMerge,defaultChild. facade.onChangereports oneDocChangeper commit. The per-block subscription API (subscribeBlock,subscribe,blockVersion,snapshot),decorateRunsand thebind*building blocks are no longer exported: useonChangewithruns(id), a block kind’stransformTextfor decorations, andbindCrdt(Y)for engine injection.deleteBlock(id)anddeleteBlocks(ids)delete only the named blocks; children take their place. Pass{ keepChildren: false }for the old whole-subtree delete.- Undoing a block’s creation keeps the block while it holds another person’s content;
hasBlock(id)staystrue, and re-creating an undone id is refused. - A document edited without a view knows no block roles unless you pass
semantics(defaultSemanticsfor the bundled kinds). - Selection anchors are
{ b, a };toJSON()gives inline blocksdata: {}when they have none. duplicateBlock(id, freshId)andblock(id).duplicate(freshId)callfreshId(oldId, kind)for every copied block (kind'block') and inline atom ('inline'). A callback that returns nothing for an atom gets a fresh atom id; returning nothing for a block refuses the copy.
Editor and plugins
valueis the initial content and is no longer bindable:bind:valuefails type-checking. Follow edits withonChangeoredytor.value.Block,TextandInlineBlockare non-reactive handles over the document, and their constructors are gone. Snippets receive aBlockView({ id, type, data, selected, focused, handle, void }), not aBlock: commands go throughblock.handle.- Selection is a value set with
selection.select(),setAtRangeorsetAtTextOffset. The marks at the caret areselection.projection.marks(wasselection.state.currentMarks); marks for the next insertion areselection.stage(marks)/selection.pending(text.markOnNextInsertis gone). hotkeys.tsand theHotKeysclass are gone. Declare bindings in thehotKeysprop or a plugin’shotkeys(see Hotkeys);HotKeyandHotKeyCombinationcome from the package root.edytor.hotKeysis now the editor’s internal keymap; itsKeymapclass is not exported. Unknown chord tokens (cmd,esc, …) fail type-checking.- Block kinds declare
defaultChild; thedefaultBlockhook,Edytor.getDefaultBlockandEdytor.defaultTypeare gone (useedytor.defaultChild(parent)).Edytor.getBlockDefinitionis replaced byedytor.definitionOf(type), which never throws: a kind no plugin registers renders as a plain block. - Plugin hooks run on the prepared command before anything is written; a veto refuses the whole command. Several operation names changed, errors are reported as
refusedinstead of thrown, and the first definition of a kind wins. - Marks render by
tag(<strong data-edytor-mark="bold">), which changes CSS selectors.placeholderis a string or a function. HTML paste is built in. - A block selection holds exactly its members, and an emptied document shows a virtual paragraph.
See the editor and plugins sections for the current APIs.
Collaboration and providers
attachDocumentSyncis removed: usedocument.attachSync. The presence format changed (one entry per view inselections), so old and new clients do not see each other’s carets.createWebsocketSynctakes{ server, room }, the names of the<Edytor server room>props (serverUrlandroomNamestill work). It keeps a local IndexedDB copy by default (persist,persistName) and syncs tabs by default (disableBc). It no longer takesconnect,protocolsorresyncInterval; pass tokens inparams.WebsocketProviderlost theprotocolsoption, thesyncevent (usesynced) andwsconnecting(use thestatusevent). It gainedsaved,unsaved, the'saved'event, therefused,expiredandunreachableevents, and theconnectTimeoutoption.IndexeddbPersistencelostget,setanddel.- The document decides readiness itself:
syncFailed,onSyncSettledandEdytorDocSyncPendingErrorare gone; see readiness. A document keeps one provider per target. - Content with a foreign schema makes the document read-only (
document.writable) instead of throwing on every edit. edytor/cloudflareis new: the Durable Object room replaces a coordinator you wrote yourself.
Migration API
MigrationRecordno longer hasownerorleaseUntil, and is never stored aspending. TheleaseMs,owner,pollMsandwaitMsoptions are accepted and ignored.forcerestores the v13 content in place instead of overwriting, and a store of another generation makesmigratethrowGenerationMismatchError.
Complete list of changes
Every public change since 0.0.11, by area. The rows named in parentheses (del.blocks.promote, flow.*, …) are the contract rows of docs/editor-delete-contract.md in the repository.
Stored documents
- Schema generation 4 (wire word
14004). Documents, IndexedDB containers and peers of earlier v14 development generations (1–3) are refused by the generation gate (GenerationMismatchError,schema-mismatch); re-import them. Behind the bumps: per-writer delete marks (a deleted block is not read as live), the replicated block noncen(attribution records keyed by it), and text ownership as streams delimited by boundary items (theslicesattribute is gone; merge claims live onclaims). - v13 (
yjs) documents still import through the migration. - Deterministic seeds.
createDocument({ value })and<Edytor {value}>write the value under a writer id hashed from it, so two replicas seeding the same template converge and a late identical seed never erases an edit. Missing ids are derived from that hash. Seed writer ids sit below 2^26, so a seed never displaces a block a live replica wrote. Seeded blocks carry no attribution stamp (createdByis absent).BOOTSTRAP_BLOCK_IDis gone. See deterministic seeds.
Document and CRDT API (edytor, edytor/crdt/edytor)
- Operation results. Every facade op and every
document.block(id)mutator returnsOpResult{ status: 'applied' | 'noop' | 'refused', ids, reason? }instead of a boolean. An empty op (insertText(''), an empty range,moveBlocks([]), a same-value format) isnoopand writes nothing; a reused child id isrefused(id-collision); an absent target isrefused.moveBlocksanswers the moved ids in request order. - Two-phase operations.
facade.prepare.<op>(…)returns a plan (steps, effect, ids) or the refusal without writing;facade.apply(plan)writes it in one transaction;facade.compose(...plans)joins plans prepared at one version. A plan applied at another version throws. New ops:prepare.deleteRange(start, end),replaceRange,deleteBlocks(ids),insertFlow(target, flow),insertBlocks,setInlineData; positions areDocPosition { block, offset }. New helper:toBlockSpec(json, { freshIds? }). - Liveness. Deleting a block that is not live (already deleted, merged away, under a deleted parent) is refused (was
true);blockTextof a block under a deleted parent isnull. - Order and capability. New
facade.order(),compare(a, b),next(id, policy?),previous(id, policy?)(one document order;{ sealed: true }never enters an island it did not start in),canPlace(ids, parent?),canMerge(from, into),defaultChild(parentId),rendersContent(type). - Block roles as data.
createDocument({ semantics })takes block roles, content-less kinds and default children;defaultSemantics,richTextSemantics,codeSemanticsandimageSemanticshold the bundled plugins’ rows and are deeply frozen.semanticsOf(...tables)(with theKindSemanticsrow type) builds a config from kind rows; to extend a bundled config, mergeroles,rendersContentanddefaultChildfield by field, since a top-level spread drops the bundled rows (see the document API). Views still adopt roles from their plugins. - Change report.
facade.onChange(cb)delivers oneDocChangeper commit that changed the visible document:{ added, removed, moved, meta, content, order, origin, local, version }. A block that moves into a subtree added in the same commit is reported inmoved/meta/content, not folded into the subtree. - Retired exports. The per-block subscriber API (
subscribeBlock,subscribe,blockVersion,snapshoton the facade and the index),decorateRunsand its types (LocalDecoration,DecoratedRun), and the “advanced internals” tier:bindEdytorDoc,bindDocument,bindNodes,bindModel,bindRuns,bindIndexeddbProvider,bindWebsocketProvider,bindProviders,bindSync,bindAdmission,bindAttribution,bindBlockAttribution,bindMigration,bindLegacyReader,setDocRand/randOf, the rank functions,REGISTRY_KEY,SCHEMA,SCHEMA_NAME, the attribution root constants,migrationBcRoom,PREFERRED_TRIM_SIZE,GENERATION_PREFIX,generationDbName,GENERATION_KEY,writeProtocolVersion,removeAwarenessStates,outdatedTimeout,readAuthMessage, and their types. UsebindCrdt(Y)(.doc,.providers,.migration,.sync,.admission,.attribution) for engine injection,facade.onChange+facade.runs(id)for reads, and a kind’stransformTextfor local decorations. - Kept for a server coordinator (see the protocol):
bindCrdt(Y),SCHEMA_VERSION,META_KEY,GENERATION,generationWord,frame,PROTOCOL_VERSION,readProtocolVersion,GENERATION_RECORD,GenerationMismatchError, the message types, the lib0 frame helpers, the awareness codec (readAwarenessEntries,writeAwarenessEntries,applyAwarenessUpdate,encodeAwarenessUpdate,modifyAwarenessUpdate) andmessagePermissionDenied/writePermissionDenied. New:messageSaved,messageChunk,MAX_FRAME_BYTES,chunkFrame,createChunkReader. - Raw engine exports pruned (
edytor/crdt, fork patch P8). Edytor never called these; they are removed from the vendored engine: the renderers (AttributionsRenderer,createAttributionsRenderer,DiffRenderer,createDiffRenderer,SnapshotRenderer,createSnapshotRenderer;AbstractRendererand$rendererstay), snapshots (Snapshot,snapshot,createSnapshot,emptySnapshot,createDocFromSnapshot,encodeSnapshot(V2),decodeSnapshot(V2),equalSnapshots,snapshotContainsUpdate,nodeMapGetSnapshot,nodeMapGetAllSnapshot, and thesnapshotargument ofnode.getAttrs), update helpers (logUpdate(V2),obfuscateUpdate(V2),diffUpdate—diffUpdateV2stays —,encodeStateVectorFromUpdate(V2),convertUpdateFormatV1ToV2,createContentIdsFromUpdate(V2),intersectUpdateWithContentIds(V2),readUpdate,createDocFromUpdate(V2),cloneDoc,diffDocsToDelta,logNode), the delta-position helpers (createRelativePositionsFromDeltaPositions,createDeltaPositionsFromRelativePositions,createRelativePositionFromDeltaPosition,createDeltaPositionFromRelativePosition), the binary relative-position codec and comparison (encodeRelativePosition,decodeRelativePosition,compareRelativePositions; userelativePositionToJSON/createRelativePositionFromJSON), id-set/id-map algebra (gcIdSet,createInsertSetFromStructStore,encodeIdSet,decodeIdSet,mergeIdMaps,diffIdMap,intersectMaps,filterIdMap,createIdMapFromIdSet,createIdSetFromIdMap,$idMap), content-id helpers (createContentIds,createContentIdsFromContentMap,createContentIdsFromDoc,createContentIdsFromDocDiff,excludeContentIds,excludeContentMap,mergeContentIds,mergeContentMaps,createContentMapFromContentIds,intersectContentIds,intersectContentMap,filterContentMap,writeContentIds,readContentIds,encodeContentIds,decodeContentIds), andgetNodeChildren,$node,getPathTo,tryGc,undoContentIds. A document built withcreateDocFromUpdate(u)isnew Y.Doc()+Y.applyUpdate(doc, u);attribution.legacy()still returns aContentMap(encodeContentMap/decodeContentMap,encodeIdMap/decodeIdMapstay) for a renderer you supply. - Anchors are
{ b, a }: theoowner facet and thea: -2form are gone from the selection value, the presence wire and history entries. - Attribution. Undo and redo keep a block’s record (
createdBysurvives an undo/redo of its creation on every replica). A forced re-migration (force) gives a block whose stream started at a boundary a new nonce without moving its record, so itscreatedByis replaced at the next attributed write. facade.toJSON()inline atoms carrydata: {}when they have no data (the shapeedytor.valuealready had).- Block deletes promote children (
del.blocks.promote).deleteBlocks(ids)anddeleteBlock(id)delete only the named blocks: each one’s unselected children take its place (a deleted island’s children take the default child type).deleteBlock(id, { keepChildren: false })is the whole-subtree delete (was the default). Promotion is read-time: a block a peer adds, moves or splits off under a concurrently deleted block also takes that block’s slot, so nothing is hidden with a deleted subtree. - Void blocks hold no children. Retyping a block to a void kind moves its children right after it; a child that still lands under a void block (a concurrent nest) is shown in its slot.
- Undo of a creation withdraws (
hist.undo.withdraw). The history no longer deletes a block node it created: it writes a per-writer withdraw markwd.<writer>(fork patch P12, theUndoManageroptionwithdraw(item, stackItem, transaction)), and the block is shown while it holds another writer’s content.hasBlock(id)staystruefor a withdrawn block, and re-creating an undone id is refused. Old clients of the same schema generation ignore the marks and show a withdrawn block (empty, or with the other writer’s text); an old client’s own undo still deletes the node. A withdrawn block costs its node in storage (about 100 bytes) instead of a tombstone. A history created directly withnew Y.UndoManagerover the registry does not follow the rule. - Text delete marks. Every text delete writes a record naming its writer (root
textdel, in the history’s scope) and every undo that brings text back writes a restoration record (rootrestored, never removed). Old clients of the same schema generation ignore both and keep the previous undo behavior (a double delete undone by both peers can show the character twice). A text delete update is about 21 bytes larger and a stored document about 12 bytes per delete.document.historyis created byfacade.createUndoManageras before; a history created directly withnew Y.UndoManagerover the registry does not follow the marks. - Conflicting type changes. An undo returns a block to the type you replaced, never to no type.
Editor view, selection and commands
valueis not bindable.<Edytor value>is the initial content;bind:valuefails type-checking. Read edits withonChangeoredytor.value.- Component props. New:
document(anEdytorDocumentto render; several views can share one),actor(the local author),room,serverandparams(the view builds its IndexedDB copy and its websocket sync from them),onSyncExpired(the room closed with4401; refreshparamsbefore the redial) andonSyncRefused(a refusal standing at mount, then each new one),blockHandles(blockDndis its deprecated alias),defaultPlugins, and the root attributestranslate,spellcheck,autocorrect,autocomplete,autocapitalize,inputmodeandenterkeyhint.synctakes anEdytorSync(a factory fromcreateIndexeddbSync/createWebsocketSync, or your own) and overridesroom/server.hotKeysis typed (see Keymap below). See the component. - Handles.
Block,TextandInlineBlockare id-only handles over the document index: getters read the document (inside a command they see its writes); they are not reactive — templates read the snippet’s view object oredytor.cells.new Block({block}),new Text({…content})andnew InlineBlock({block})are gone: useblock.insertChildren(index, JSONBlock[] | Block[])(a handle moves) andblock.insertParts(index, (JSONText | JSONInlineBlock)[]);deletePartsis removed (usedeleteContentAtRange). ATextis theordinal-th segment of its block (text.id=t:<block>:<ordinal>) with no identity across commits; key extension state by block id and anchor. Liveness:block.isInTree,text.isInDocument,atom.isInDocument(_live,_bound,_blockId,_segOrd,_itemsare gone; a dead block’sparentisundefined).edytor.idToBlockis the handle registry;edytor.idToTexthasgetonly;edytor.idToInlineBlock,Edytor.getTextById,Block.partOffsetOf/atomOffsetOfPartIndex/projectedPartsare removed (a text’s display offset istext.segStart, a block’s partsedytor.idToBlock.parts(id)).InlineBlock.typehas no setter.Block.firstText/lastTextareundefinedfor a kind that renders no content. - Data writes are commands.
block.setData(data),block.type = …andatom.setData(data)go through the dispatcher: refused on a readonly view, shown toonBeforeOperation(setBlock, or the newsetInlineDatafor an atom), recorded indispatcher.last, and each is its own undo step.block.model.setDatais the raw write. - Definitions.
edytor.definitionOf(type)is the one lookup and never throws (getBlockDefinitionis removed). A kind no plugin registers renders as a plain block (its text and children), with one development warning per kind, instead of throwing.edytor.containeris removed: useedytor.node. - Op results inside transactions.
splitBlock,insertBlockAfter/Before,mergeBlock*,addInlineBlockandaddChildBlock(s)answer handles of what they created, also inside an outeredytor.transact. A vetoedinsertFlowanswers[null, 0]. - Selection is a value.
selection.value(none,text { anchor, focus, pending? },atom { blockId, atomId, from },blocks { ids }),selection.select(value, cause)(the only writer),projection,epoch,cause,textValue,stage(marks)/pending.selection.stateis a read-only view of the projection (assigning it fails; usesetAtRange,setAtTextOffsetorselect):startText,endText,yStart,yEnd(offsets inside the text segments),startBlock,endBlock,texts,blocks,isCollapsed,isReversed,isBlockSpanning,isVoidEditableElementandedge(new). The projection facts moved toselection.projection:content(and itslength),marks(wasstate.currentMarks),isAtStartOfText/isAtEndOfText/isAtStartOfBlock/isAtEndOfBlock,isTextSpanning,islandRoot/voidRoot(block ids;isIsland/isVoidare!== null); the caret anchors are the value’s (relativePosition/endPosition→selection.value.anchor/focus);contentParts,startNode,endNodeandyTextContentare gone.setRangeStateAtTextOffsets→setAtRange(same arguments);setCollapsedStateAtTextOffset→setAtTextOffset(the old name is removed). Removed:hasSelectedAll,focusBlocks,deadEndpointRecoveryPending,notifyTextMounted,Edytor.getTextNode,ignoreNextSelectionChange,Edytor.mirrorRevision.setAtTextOffsettakes aText(not an id) and selects in the caller’s turn.onSelectionChangefires only when the value changed (a remote edit that moves the projection does not emit). - Pending marks are
selection.pending/selection.stage(marks)(Text.markOnNextInsertis gone); a move clears them, an insertion at the caret consumes them, and they hold valued marks (links, colors) as a full set. - Default children. The plugin
defaultBlockhook,Edytor.getDefaultBlockandEdytor.defaultTypeare removed: a kind declaresdefaultChild;edytor.defaultChild(parent)answers. - Deletion and paste follow one rule each (
del.range.*,flow.*): blocks after the range end inside a dying container, and a dying tail’s children, survive; an emptied list container dies; a range from inside an island never merges out of it; multi-line plain paste and drop split the block per line; copied fragments keep their ids and paste always mints fresh ones. - Moves.
BlockMoveRequestis{ blocks } & ({ target, position } | { direction: 'up' | 'down' | 'in' | 'out' });BlockMoveDirectionand the move types come from the package root (block/blockMove.jsis deleted). A move is its own undo step. Arrow-move never nests;Mod+↑moves a selected group; the handle claims everyAlt+arrow. Outdent (Shift+Tab,unNestBlock, directionout) hands the following siblings to the outdented block, as in any outliner. - Keymap.
hotkeys.tsis deleted: the types move to the package root (HotKey,HotKeyCombination;HotKeyModifier,Single/DoubleModifierCombinationremoved);HotKeys→ the keymap behindedytor.hotKeys, whoseKeymapclass is not exported (isHotkey→handle,run(chord),offered; noinit). AHotKeypayload’seventis optional. Chords take up to three modifiers in any order, plus punctuation andf1–f12;spacematches the space bar. Unknown tokens (cmd,esc,return, …) fail type-checking and warn in development. ThehotKeysprop is typedPartial<Record<HotKeyCombination, HotKey>>. A key binding runs once per occurrence. - Navigation. Shift+Home/End/PageUp/PageDown, Mod+Shift+↑/↓ and Shift+↑/↓ move the focus and keep the anchor; word keys are visual under RTL; arrows skip a collapsed toggle’s body; an atom selection carries the side it was anchored on (
from). - Input and IME.
edytor.attempts(the input attempts) andedytor.composition(the session:live,phase,host,owns(node),ended(fn),restructured(change)) are new;edytor.isComposingis read-only. Removed:compositionState,compositionStartReplacementState,hasHandledCompositionInput,compositionText,resolveCompositionRegion,shouldIgnoreCompositionKeyDown,Text.toCompositionDomOffset, and the input-fallback flags and timers onEdytor. A composition is one undo step with its ending; no timer ends it; a commit that re-places the composing block (a peer deletes, retypes, moves or re-parents it) commits what the IME shows first.onBeforeInputno longer sees fabricated paste, drop or keydown-fallback events.preventUnsupportedDrop(event, edytor?). - History.
edytor.historyUndo()/historyRedo()restore the selection each step recorded for this view (before on undo, after on redo); the undo-snapshot queue and its helpers are gone. The dispatcher owns undo grouping: structural edits, deletions, menu, toolbar and+actions, and markdown or slash conversions are each their own step, however soon they follow typing. See history. - Block selection is exactly its members (
sel.blocks.exact). Select-all selects every block (was the root’s children); Shift+↑/↓ adds each block it passes; a block copy or cut carries the selected blocks only.Block.removeBlock()promotes the children (removeBlock({ keepChildren: false })deletes the subtree). - Virtual paragraph (
doc.empty.virtual). A view reads its document through a lens:edytor.facadeis a per-view object whose prototype isdocument.facade, andedytor.facade.virtual()names the virtual paragraph while the document shows no block (edytor.root.childrenthen holds its handle;edytor.value.childrenis empty). Every facade op the view issues on it creates it first, in the same plan. An emptied root no longer gets a replacement paragraph written by normalization.
Plugins and the extension API
- Hooks run on the prepared command before any write.
onBeforeOperationsees the command, then each planned step under its documented name (removeBlock,mergeBlockBackward,deleteContentAtRange,addChildBlocks,moveBlock(s),splitBlock,setBlock,insertText,addInlineBlock), witheffecton the payload. A veto of any step refuses the whole command (no write, no undo step);prevent(cb)or a returned command replaces it once per extension; a payload returned for a nested step is ignored with a development warning. Nested operations and normalization are not shown as operations.onAfterOperationfires once per command. Commands the document refuses are still shown (no steps), so an extension may replace them. - Operation names. New
deleteBlocks({ blocks }),insertFlow({ flow, target }, paste and drop),insertDivider,setInlineData;deleteContentWithinSelectiontakes{ replace }(was{ preserveStartBlock }); Enter’s lift issplitBlock; markdown and slash conversions aresetBlockwith adeleteContentAtRangestep; word deletes aredeleteContentAtRange; handle in/out aremoveBlock. Browser-typed text is adopted asinsertText/deleteContentAtRangeand can be vetoed (the text re-renders). - Errors. Top-level operations report
refused(edytor.dispatcher.last.status) instead of throwingPreventionError; text operations refuse in readonly; a read-only (quarantined) document refuses instead of throwingSchemaMismatchError; async handler errors are reported, not swallowed. - Normalization (
normalizeContent/normalizeChildren) runs at the end of the command’s transaction, inside it, with handles that read the command’s writes (at most 51 passes per normalizer and block); its work is part of the same update and undo step. - Definitions: first wins (was last): an extension that extends another’s definition lists itself first.
- Kind records.
BlockDefinitiongainselement,viewState,rendersContent,defaultChild,continues,container,presets,empty,html,plain,parse;snippetis optional.MarkDefinitiongainsedge,toolbar,tag,attributes,parse;InlineBlockDefinitiongainsplain.convertToKind,KindRow,KindPresetare exported;edytor.kindsis the catalogue the slash menu, markdown shortcuts and menus read. One label per kind (Text,To-do list,Toggle list). - Snippets render inner markup. The core renders, registers and marks void the block element (
use:block.attachandBlockView.attach/InlineBlockView.attachare removed;use:block.voidstill marks inner chrome). Block snippets receiveblock: BlockView={ id, type, data, selected, focused, handle, void }; inline-atom snippetsblock: InlineBlockView={ id, type, data, selected, handle }(handleisundefinedfor a suggested atom). Commands and document reads go throughblock.handle.onBlockAttachedruns once per element, and the attach hooks may return a cleanup. Identity attributes are declarative (present in server-rendered HTML). transformTextreceives declared values{ text: { stringContent, value }, block: { id, type, data }, content }, not handles.- Marks at insertion follow one rule (explicit → a replaced range’s common marks → pending → the neighbour → the mark’s
edge);insertTextwithout marks resolves them by that rule; plain paste inherits the caret’s marks; Mod+B at the start of a bold run toggles bold off. - Suggestions.
Block.suggestionsis plain JSON parts (rawSuggestionsand the readonly Proxy wrappers are removed);suggestTextstores[atom, [text runs]]groups. - HTML import is core. The HTML paste plugin (never exported) and its hand-written parser are gone; external HTML paste and drop are imported by the core through the browser’s parser and the records (
parsehooks on kind and mark records; see clipboard). A plugin’sonPastestill runs first. The code plugin highlights through TanStack Highlight, and the package declaressideEffectsso it (and its CSS) ships only in apps that importcodePlugin. - Notion behavior. Markdown
>makes a toggle and"a quote;+,a.,i.and inline markdown are new, and a prefix converts at the caret, keeping the text after it. The to-do checkbox writesdata.checked. The callout’s default icon is💡(was!). Headings areh1–h3; a missing level ish1and any other renders and copies ash3. Lists, to-dos and toggles continue on Enter, toggles, callouts and quotes are containers, and Backspace at the start of a kind other than its parent’s default child turns it into that default first: see Enter and Backspace by role. The paragraph and code block elements no longer carry Tailwind classes.KindPreset.groupandEditorCommand.hintare new;richTextOperations(edytor).removeMarkAtRange(mark)is new. - Default plugins.
<Edytor>appendsarrowMovePlugin,imagePluginandrichTextPlugin, in that order, after yourpluginsunless the list already has them, and prepends block handles;defaultPlugins={false}opts out. Your plugins therefore win over all three; to let a default win over one of yours, list it yourself before your plugin. Passingplugins={[richTextPlugin]}still works. - Image plugin.
imagePlugin,createImagePlugin({ upload })andImagePluginOptionsare exported. Theimagekind storesdata.src, has an “Image” preset in theMediagroup, and exports and imports<figure><img><figcaption>. - UI snippets.
createSlashMenuPlugin({ item, menu }),createToolbarPlugin({ toolbar }),createBlockMenuPlugin({ menu })andBlockHandlesOptions.handleare new, with the typesSlashMenuOptions,SlashMenuItem,SlashMenuController,ToolbarOptions,ToolbarController,BlockMenuController,BlockMenuAction,BlockHandleSnippetPayloadandBlockHandleController. The toolbar and block menu place the element markeddata-edytor-toolbar-bar/data-edytor-block-menu(was theirdata-testid). - Other: the slash menu opens only at the start of a text or after whitespace, matches word prefixes of labels and keywords, claims Enter only with a match, and closes when a non-empty query matches nothing or the query starts with a space; a replacing kind (divider, image, code) converts a block in place only when the block holds nothing but the slash query, and is otherwise inserted after it; a hotkey whose value is not a function is skipped with a development warning; the code plugin’s Shift+Enter runs
insertParagraphas an intent, its auto-pair is a returned payload (after-hooks see the typed character), and a typed closer steps over the same character.
Rendering, placeholder and chrome
placeholder(prop, option, plugin) is a string or(view: { type, data, focused, empty }) => string | null; the snippet form is gone. It renders asdata-placeholderon the empty text element with a shipped::beforerule (style[data-placeholder]::before);[data-edytor-text-placeholder]and its click handlers are gone.edytor.placeholderRepairis removed;edytor.placeholderAt(id)is new.- Chrome lives in
[data-edytor-overlay], a sibling after the host: block handles (in document order), the drop indicator (position: absolute, not indocument.body), menus (position: fixedinside the layer) and layer-relative remote carets.--edytor-handle-offset-yis gone.edytor.overlay(layer,add(measure),mount(…),invalidate()) is new. - Typing, formatting, Tab and block moves never remount the element under the caret; a moved block’s element is re-created where it moved.
edytor.cells,edytor.pin,edytor.surface(the DOM observer),edytor.projector,textAt,atomAt,segmentOf,deltasOfare new;refreshEditorDom/editorDomRevisionare removed. - The DOM observer restores what the editor owns and leaves an extension’s markup around the slots alone: a foreign node inside a block’s text run is removed, a node beside it stays; foreign text inside a content is adopted; attributes the core does not own (an extension’s
idon a block element,openon a toggle) are never reverted. - A “
[data-edytor-block]exists” check no longer means hydrated; wait for a registered text element. - Marks by tag. A mark record declares
tag(andattributesfrom its value); the core renders the tag itself as the mark element (<strong data-edytor-mark="bold">, was<span data-edytor-mark="bold"><b>), and the clipboard exports the same element.MarkDefinition.htmlis replaced bytag/attributes;snippetis optional (a snippet still renders inside a core<span data-edytor-mark>). Built-ins: boldstrong, italicem, underlineu, strikes, codecode, linka(sanitizedhref,target), superscriptsup, subscriptsub, color and highlightspanwith a sanitizedstyle. Selectors such as[data-edytor-mark="link"] abecomea[data-edytor-mark="link"].
Collaboration and providers
- Presence wire.
selections[viewKey] = { start, end, collapsed, reversed, t }for text (DocAnchors),{ blocks, t }for a block set,{ atom, block, t }for an atom; the legacyselectionmirror,startTextId/yStartfields and numeric fallbacks are gone. A view writes only its own key and clears it on destroy.publishPresencereplacescreateAwarenessSelection/publishAwarenessSelection/clearAwarenessSelection;attachDocumentSyncis removed (usedocument.attachSync). A lost socket clears remote carets in that tab only. createWebsocketSynctakesserver,room,params,maxBackoffTime,connectTimeout,onExpired,WebSocketPolyfill,disableBc,persistandpersistName(connect,protocolsandresyncIntervalare removed;serverUrl/roomNameare deprecated aliases ofserver/room). It persists locally by default: an IndexedDB store beside the socket (persist: falseopts out,persistNamerenames it fromedytor:<server>/<room>; the returned sync’spersistNameis the name to pass toclearDocument), skipped where there is noindexedDB, attached through the newEdytorSyncPayload.attach. While the store exists it carries the cross-tab channel;disableBcturns both off (IndexeddbPersistencegains adisableBcoption).connectTimeout(default 10 000 ms,Infinityfor no limit) closes a dial that neither opens nor fails, like a failed one.onExpired(state)runs on each4401close ({ reason, attempts, nextRetryMs }): put a fresh token inparamsbefore the redial.WebsocketProvider: removed theprotocolsoption and field (pass tokens inparams, read at every dial), thesyncevent (usesynced),wsconnecting(thestatusevent carries it) and the settle window (syncSettleMs).resyncIntervalstays a provider option only. The BroadcastChannel leg stays and is on by default (disableBcopts out); it relays other tabs’ edits to the server. New:saved,unsavedand the'saved'event (the room’smessageSavedacknowledgements),messageChunkreassembly, therefusedevent (aSyncRefusedErroron a1008close or a4xxxclose other than4401, after which it stops dialing), theexpiredevent (a4401close: expired credentials; the provider redials after the backoff with the re-readparams), theunreachableevent (the backoff cap grows to 30 s after 8 dials that never synced) and theconnectTimeoutoption. A room fault (1011) after a sync also grows the backoff, until the room acknowledges every local update again. The provider sends a textpingafter 15 s of silence, and a textpongcounts as liveness.- Readiness. The document decides readiness itself:
syncFailed,onSyncSettledandEdytorDocSyncPendingErrorare gone; a lone first client is ready afterDEFAULT_READINESS_BOUND(1 s, per factoryEdytorSync.bound;createWebsocketSyncarms it from the socket’s open or first failed dial through the newEdytorSyncPayload.armBound);historyrefuses whilepending. A refusal never seeds:document.syncRefusalanddocument.onSyncRefused(fn)are new. A refusal belongs to the provider that reported it: releasing that provider, or its next sync, lifts it.EdytorSyncPayload.holdBound()stops a provider’s bound until the nextarmBound(): a4401before the first sync holds it, so an empty document waits for a dial that gets in instead of seeding.whenSyncedexists on both providers andsyncedis the lifetime claim. - One provider per target.
EdytorSync.target(indexeddb:<name>,websocket:<server>/<room>); attaching a target already attached is a no-op returning nothing; attaching on a destroyed document throwsDocumentDestroyedError. - Admission. Unversioned content is not refused at ingress: the document is read-only and quarantined until admission accepts it (
document.writable,onWritableChange). IndexeddbPersistence:get/set/delare removed (thecustomstore holds only the generation record).edytor/cloudflare(new).DocumentRoom,attachDocumentandrouteDocumentSocket(request, namespace, documentId, authorize)replace the coordinator you wrote yourself; see the room. The room binds{ user, replica, readOnly }to each socket, strips the content of updates under another user’s client ids, and appliesdefaultSemanticsto server edits. Its close codes:4403whenauthorizereturnsnull(the provider stops),4401when it returns{ expired: true }(ExpiredCredential; the provider redials),4409for a replica id another user holds,1008for undecodable bytes or a stored document the room refuses (refused: container), and1011for a fault of the room itself (retried).- SSR.
<Edytor>releases the document it created inonDestroy, which also runs after a server render.
Migration
MigrationRecordlosesownerandleaseUntiland is never storedpending;MigrateOptions.leaseMs/owner/pollMs/waitMsandwaitForSettled’s options are accepted no-ops (a crash-releasednavigator.lockslock arbitrates;status()reportspending,wait: falsereturnsbusy).MigrationPhaselosesactivate/announce; there are no BroadcastChannel migration announcements.forcerestores legacy ids in place instead of overwriting a snapshot row;result.updateis the full migrated state; a foreign-generation container makesmigratethrowGenerationMismatchError.bindCrdt(Y).doc.restoreis new.