Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.
  • migrate verifies 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.update holds the migrated state; loadDocument(result.update) opens it headlessly, and result.json holds 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 }). loadDocument refuses v13 bytes with UnsupportedDocError (reason legacy), and isLegacyDoc(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 value is deterministic, so replicas seeding the same template converge. BOOTSTRAP_BLOCK_ID is gone, and seeded blocks have no createdBy.

Document API

  • Every facade operation returns an OpResult (applied, noop or refused) 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 the BlockSpec that inserts take.
  • New queries: order, compare, next, previous, canPlace, canMerge, defaultChild.
  • facade.onChange reports one DocChange per commit. The per-block subscription API (subscribeBlock, subscribe, blockVersion, snapshot), decorateRuns and the bind* building blocks are no longer exported: use onChange with runs(id), a block kind’s transformText for decorations, and bindCrdt(Y) for engine injection.
  • deleteBlock(id) and deleteBlocks(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) stays true, and re-creating an undone id is refused.
  • A document edited without a view knows no block roles unless you pass semantics (defaultSemantics for the bundled kinds).
  • Selection anchors are { b, a }; toJSON() gives inline blocks data: {} when they have none.
  • duplicateBlock(id, freshId) and block(id).duplicate(freshId) call freshId(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

  • value is the initial content and is no longer bindable: bind:value fails type-checking. Follow edits with onChange or edytor.value.
  • Block, Text and InlineBlock are non-reactive handles over the document, and their constructors are gone. Snippets receive a BlockView ({ id, type, data, selected, focused, handle, void }), not a Block: commands go through block.handle.
  • Selection is a value set with selection.select(), setAtRange or setAtTextOffset. The marks at the caret are selection.projection.marks (was selection.state.currentMarks); marks for the next insertion are selection.stage(marks) / selection.pending (text.markOnNextInsert is gone).
  • hotkeys.ts and the HotKeys class are gone. Declare bindings in the hotKeys prop or a plugin’s hotkeys (see Hotkeys); HotKey and HotKeyCombination come from the package root. edytor.hotKeys is now the editor’s internal keymap; its Keymap class is not exported. Unknown chord tokens (cmd, esc, …) fail type-checking.
  • Block kinds declare defaultChild; the defaultBlock hook, Edytor.getDefaultBlock and Edytor.defaultType are gone (use edytor.defaultChild(parent)). Edytor.getBlockDefinition is replaced by edytor.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 refused instead of thrown, and the first definition of a kind wins.
  • Marks render by tag (<strong data-edytor-mark="bold">), which changes CSS selectors. placeholder is 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

  • attachDocumentSync is removed: use document.attachSync. The presence format changed (one entry per view in selections), so old and new clients do not see each other’s carets.
  • createWebsocketSync takes { server, room }, the names of the <Edytor server room> props (serverUrl and roomName still work). It keeps a local IndexedDB copy by default (persist, persistName) and syncs tabs by default (disableBc). It no longer takes connect, protocols or resyncInterval; pass tokens in params.
  • WebsocketProvider lost the protocols option, the sync event (use synced) and wsconnecting (use the status event). It gained saved, unsaved, the 'saved' event, the refused, expired and unreachable events, and the connectTimeout option.
  • IndexeddbPersistence lost get, set and del.
  • The document decides readiness itself: syncFailed, onSyncSettled and EdytorDocSyncPendingError are 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/cloudflare is new: the Durable Object room replaces a coordinator you wrote yourself.

Migration API

  • MigrationRecord no longer has owner or leaseUntil, and is never stored as pending. The leaseMs, owner, pollMs and waitMs options are accepted and ignored.
  • force restores the v13 content in place instead of overwriting, and a store of another generation makes migrate throw GenerationMismatchError.

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 nonce n (attribution records keyed by it), and text ownership as streams delimited by boundary items (the slices attribute is gone; merge claims live on claims).
  • 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 (createdBy is absent). BOOTSTRAP_BLOCK_ID is gone. See deterministic seeds.

Document and CRDT API (edytor, edytor/crdt/edytor)

  • Operation results. Every facade op and every document.block(id) mutator returns OpResult { status: 'applied' | 'noop' | 'refused', ids, reason? } instead of a boolean. An empty op (insertText(''), an empty range, moveBlocks([]), a same-value format) is noop and writes nothing; a reused child id is refused (id-collision); an absent target is refused. moveBlocks answers 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 are DocPosition { 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); blockText of a block under a deleted parent is null.
  • 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, codeSemantics and imageSemantics hold the bundled plugins’ rows and are deeply frozen. semanticsOf(...tables) (with the KindSemantics row type) builds a config from kind rows; to extend a bundled config, merge roles, rendersContent and defaultChild field 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 one DocChange per 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 in moved/meta/content, not folded into the subtree.
  • Retired exports. The per-block subscriber API (subscribeBlock, subscribe, blockVersion, snapshot on the facade and the index), decorateRuns and 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. Use bindCrdt(Y) (.doc, .providers, .migration, .sync, .admission, .attribution) for engine injection, facade.onChange + facade.runs(id) for reads, and a kind’s transformText for 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) and messagePermissionDenied/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; AbstractRenderer and $renderer stay), snapshots (Snapshot, snapshot, createSnapshot, emptySnapshot, createDocFromSnapshot, encodeSnapshot(V2), decodeSnapshot(V2), equalSnapshots, snapshotContainsUpdate, nodeMapGetSnapshot, nodeMapGetAllSnapshot, and the snapshot argument of node.getAttrs), update helpers (logUpdate(V2), obfuscateUpdate(V2), diffUpdate — diffUpdateV2 stays —, 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; use relativePositionToJSON/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), and getNodeChildren, $node, getPathTo, tryGc, undoContentIds. A document built with createDocFromUpdate(u) is new Y.Doc() + Y.applyUpdate(doc, u); attribution.legacy() still returns a ContentMap (encodeContentMap/decodeContentMap, encodeIdMap/decodeIdMap stay) for a renderer you supply.
  • Anchors are { b, a }: the o owner facet and the a: -2 form are gone from the selection value, the presence wire and history entries.
  • Attribution. Undo and redo keep a block’s record (createdBy survives 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 its createdBy is replaced at the next attributed write.
  • facade.toJSON() inline atoms carry data: {} when they have no data (the shape edytor.value already had).
  • Block deletes promote children (del.blocks.promote). deleteBlocks(ids) and deleteBlock(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 mark wd.<writer> (fork patch P12, the UndoManager option withdraw(item, stackItem, transaction)), and the block is shown while it holds another writer’s content. hasBlock(id) stays true for 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 with new Y.UndoManager over 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 (root restored, 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.history is created by facade.createUndoManager as before; a history created directly with new Y.UndoManager over 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

  • value is not bindable. <Edytor value> is the initial content; bind:value fails type-checking. Read edits with onChange or edytor.value.
  • Component props. New: document (an EdytorDocument to render; several views can share one), actor (the local author), room, server and params (the view builds its IndexedDB copy and its websocket sync from them), onSyncExpired (the room closed with 4401; refresh params before the redial) and onSyncRefused (a refusal standing at mount, then each new one), blockHandles (blockDnd is its deprecated alias), defaultPlugins, and the root attributes translate, spellcheck, autocorrect, autocomplete, autocapitalize, inputmode and enterkeyhint. sync takes an EdytorSync (a factory from createIndexeddbSync/createWebsocketSync, or your own) and overrides room/server. hotKeys is typed (see Keymap below). See the component.
  • Handles. Block, Text and InlineBlock are 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 or edytor.cells. new Block({block}), new Text({…content}) and new InlineBlock({block}) are gone: use block.insertChildren(index, JSONBlock[] | Block[]) (a handle moves) and block.insertParts(index, (JSONText | JSONInlineBlock)[]); deleteParts is removed (use deleteContentAtRange). A Text is the ordinal-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, _items are gone; a dead block’s parent is undefined). edytor.idToBlock is the handle registry; edytor.idToText has get only; edytor.idToInlineBlock, Edytor.getTextById, Block.partOffsetOf/atomOffsetOfPartIndex/projectedParts are removed (a text’s display offset is text.segStart, a block’s parts edytor.idToBlock.parts(id)). InlineBlock.type has no setter. Block.firstText/lastText are undefined for a kind that renders no content.
  • Data writes are commands. block.setData(data), block.type = … and atom.setData(data) go through the dispatcher: refused on a readonly view, shown to onBeforeOperation (setBlock, or the new setInlineData for an atom), recorded in dispatcher.last, and each is its own undo step. block.model.setData is the raw write.
  • Definitions. edytor.definitionOf(type) is the one lookup and never throws (getBlockDefinition is 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.container is removed: use edytor.node.
  • Op results inside transactions. splitBlock, insertBlockAfter/Before, mergeBlock*, addInlineBlock and addChildBlock(s) answer handles of what they created, also inside an outer edytor.transact. A vetoed insertFlow answers [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.state is a read-only view of the projection (assigning it fails; use setAtRange, setAtTextOffset or select): startText, endText, yStart, yEnd (offsets inside the text segments), startBlock, endBlock, texts, blocks, isCollapsed, isReversed, isBlockSpanning, isVoidEditableElement and edge (new). The projection facts moved to selection.projection: content (and its length), marks (was state.currentMarks), isAtStartOfText/isAtEndOfText/isAtStartOfBlock/isAtEndOfBlock, isTextSpanning, islandRoot/voidRoot (block ids; isIsland/isVoid are !== null); the caret anchors are the value’s (relativePosition/endPosition → selection.value.anchor/focus); contentParts, startNode, endNode and yTextContent are gone. setRangeStateAtTextOffsets → setAtRange (same arguments); setCollapsedStateAtTextOffset → setAtTextOffset (the old name is removed). Removed: hasSelectedAll, focusBlocks, deadEndpointRecoveryPending, notifyTextMounted, Edytor.getTextNode, ignoreNextSelectionChange, Edytor.mirrorRevision. setAtTextOffset takes a Text (not an id) and selects in the caller’s turn. onSelectionChange fires only when the value changed (a remote edit that moves the projection does not emit).
  • Pending marks are selection.pending / selection.stage(marks) (Text.markOnNextInsert is 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 defaultBlock hook, Edytor.getDefaultBlock and Edytor.defaultType are removed: a kind declares defaultChild; 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. BlockMoveRequest is { blocks } & ({ target, position } | { direction: 'up' | 'down' | 'in' | 'out' }); BlockMoveDirection and the move types come from the package root (block/blockMove.js is deleted). A move is its own undo step. Arrow-move never nests; Mod+↑ moves a selected group; the handle claims every Alt+arrow. Outdent (Shift+Tab, unNestBlock, direction out) hands the following siblings to the outdented block, as in any outliner.
  • Keymap. hotkeys.ts is deleted: the types move to the package root (HotKey, HotKeyCombination; HotKeyModifier, Single/DoubleModifierCombination removed); HotKeys → the keymap behind edytor.hotKeys, whose Keymap class is not exported (isHotkey → handle, run(chord), offered; no init). A HotKey payload’s event is optional. Chords take up to three modifiers in any order, plus punctuation and f1–f12; space matches the space bar. Unknown tokens (cmd, esc, return, …) fail type-checking and warn in development. The hotKeys prop is typed Partial<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) and edytor.composition (the session: live, phase, host, owns(node), ended(fn), restructured(change)) are new; edytor.isComposing is read-only. Removed: compositionState, compositionStartReplacementState, hasHandledCompositionInput, compositionText, resolveCompositionRegion, shouldIgnoreCompositionKeyDown, Text.toCompositionDomOffset, and the input-fallback flags and timers on Edytor. 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. onBeforeInput no 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.facade is a per-view object whose prototype is document.facade, and edytor.facade.virtual() names the virtual paragraph while the document shows no block (edytor.root.children then holds its handle; edytor.value.children is 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. onBeforeOperation sees the command, then each planned step under its documented name (removeBlock, mergeBlockBackward, deleteContentAtRange, addChildBlocks, moveBlock(s), splitBlock, setBlock, insertText, addInlineBlock), with effect on 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. onAfterOperation fires 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; deleteContentWithinSelection takes { replace } (was { preserveStartBlock }); Enter’s lift is splitBlock; markdown and slash conversions are setBlock with a deleteContentAtRange step; word deletes are deleteContentAtRange; handle in/out are moveBlock. Browser-typed text is adopted as insertText/deleteContentAtRange and can be vetoed (the text re-renders).
  • Errors. Top-level operations report refused (edytor.dispatcher.last.status) instead of throwing PreventionError; text operations refuse in readonly; a read-only (quarantined) document refuses instead of throwing SchemaMismatchError; 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. BlockDefinition gains element, viewState, rendersContent, defaultChild, continues, container, presets, empty, html, plain, parse; snippet is optional. MarkDefinition gains edge, toolbar, tag, attributes, parse; InlineBlockDefinition gains plain. convertToKind, KindRow, KindPreset are exported; edytor.kinds is 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.attach and BlockView.attach/InlineBlockView.attach are removed; use:block.void still marks inner chrome). Block snippets receive block: BlockView = { id, type, data, selected, focused, handle, void }; inline-atom snippets block: InlineBlockView = { id, type, data, selected, handle } (handle is undefined for a suggested atom). Commands and document reads go through block.handle. onBlockAttached runs once per element, and the attach hooks may return a cleanup. Identity attributes are declarative (present in server-rendered HTML).
  • transformText receives 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); insertText without 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.suggestions is plain JSON parts (rawSuggestions and the readonly Proxy wrappers are removed); suggestText stores [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 (parse hooks on kind and mark records; see clipboard). A plugin’s onPaste still runs first. The code plugin highlights through TanStack Highlight, and the package declares sideEffects so it (and its CSS) ships only in apps that import codePlugin.
  • 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 writes data.checked. The callout’s default icon is 💡 (was !). Headings are h1–h3; a missing level is h1 and any other renders and copies as h3. 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.group and EditorCommand.hint are new; richTextOperations(edytor).removeMarkAtRange(mark) is new.
  • Default plugins. <Edytor> appends arrowMovePlugin, imagePlugin and richTextPlugin, in that order, after your plugins unless 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. Passing plugins={[richTextPlugin]} still works.
  • Image plugin. imagePlugin, createImagePlugin({ upload }) and ImagePluginOptions are exported. The image kind stores data.src, has an “Image” preset in the Media group, and exports and imports <figure><img><figcaption>.
  • UI snippets. createSlashMenuPlugin({ item, menu }), createToolbarPlugin({ toolbar }), createBlockMenuPlugin({ menu }) and BlockHandlesOptions.handle are new, with the types SlashMenuOptions, SlashMenuItem, SlashMenuController, ToolbarOptions, ToolbarController, BlockMenuController, BlockMenuAction, BlockHandleSnippetPayload and BlockHandleController. The toolbar and block menu place the element marked data-edytor-toolbar-bar / data-edytor-block-menu (was their data-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 insertParagraph as 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 as data-placeholder on the empty text element with a shipped ::before rule (style [data-placeholder]::before); [data-edytor-text-placeholder] and its click handlers are gone. edytor.placeholderRepair is 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 in document.body), menus (position: fixed inside the layer) and layer-relative remote carets. --edytor-handle-offset-y is 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, deltasOf are new; refreshEditorDom/editorDomRevision are 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 id on a block element, open on 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 (and attributes from 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.html is replaced by tag/attributes; snippet is optional (a snippet still renders inside a core <span data-edytor-mark>). Built-ins: bold strong, italic em, underline u, strike s, code code, link a (sanitized href, target), superscript sup, subscript sub, color and highlight span with a sanitized style. Selectors such as [data-edytor-mark="link"] a become a[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 legacy selection mirror, startTextId/yStart fields and numeric fallbacks are gone. A view writes only its own key and clears it on destroy. publishPresence replaces createAwarenessSelection/publishAwarenessSelection/clearAwarenessSelection; attachDocumentSync is removed (use document.attachSync). A lost socket clears remote carets in that tab only.
  • createWebsocketSync takes server, room, params, maxBackoffTime, connectTimeout, onExpired, WebSocketPolyfill, disableBc, persist and persistName (connect, protocols and resyncInterval are removed; serverUrl/roomName are deprecated aliases of server/room). It persists locally by default: an IndexedDB store beside the socket (persist: false opts out, persistName renames it from edytor:<server>/<room>; the returned sync’s persistName is the name to pass to clearDocument), skipped where there is no indexedDB, attached through the new EdytorSyncPayload.attach. While the store exists it carries the cross-tab channel; disableBc turns both off (IndexeddbPersistence gains a disableBc option). connectTimeout (default 10 000 ms, Infinity for no limit) closes a dial that neither opens nor fails, like a failed one. onExpired(state) runs on each 4401 close ({ reason, attempts, nextRetryMs }): put a fresh token in params before the redial.
  • WebsocketProvider: removed the protocols option and field (pass tokens in params, read at every dial), the sync event (use synced), wsconnecting (the status event carries it) and the settle window (syncSettleMs). resyncInterval stays a provider option only. The BroadcastChannel leg stays and is on by default (disableBc opts out); it relays other tabs’ edits to the server. New: saved, unsaved and the 'saved' event (the room’s messageSaved acknowledgements), messageChunk reassembly, the refused event (a SyncRefusedError on a 1008 close or a 4xxx close other than 4401, after which it stops dialing), the expired event (a 4401 close: expired credentials; the provider redials after the backoff with the re-read params), the unreachable event (the backoff cap grows to 30 s after 8 dials that never synced) and the connectTimeout option. A room fault (1011) after a sync also grows the backoff, until the room acknowledges every local update again. The provider sends a text ping after 15 s of silence, and a text pong counts as liveness.
  • Readiness. The document decides readiness itself: syncFailed, onSyncSettled and EdytorDocSyncPendingError are gone; a lone first client is ready after DEFAULT_READINESS_BOUND (1 s, per factory EdytorSync.bound; createWebsocketSync arms it from the socket’s open or first failed dial through the new EdytorSyncPayload.armBound); history refuses while pending. A refusal never seeds: document.syncRefusal and document.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 next armBound(): a 4401 before the first sync holds it, so an empty document waits for a dial that gets in instead of seeding. whenSynced exists on both providers and synced is 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 throws DocumentDestroyedError.
  • 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/del are removed (the custom store holds only the generation record).
  • edytor/cloudflare (new). DocumentRoom, attachDocument and routeDocumentSocket(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 applies defaultSemantics to server edits. Its close codes: 4403 when authorize returns null (the provider stops), 4401 when it returns { expired: true } (ExpiredCredential; the provider redials), 4409 for a replica id another user holds, 1008 for undecodable bytes or a stored document the room refuses (refused: container), and 1011 for a fault of the room itself (retried).
  • SSR. <Edytor> releases the document it created in onDestroy, which also runs after a server render.

Migration

  • MigrationRecord loses owner and leaseUntil and is never stored pending; MigrateOptions.leaseMs/owner/pollMs/waitMs and waitForSettled’s options are accepted no-ops (a crash-released navigator.locks lock arbitrates; status() reports pending, wait: false returns busy). MigrationPhase loses activate/announce; there are no BroadcastChannel migration announcements. force restores legacy ids in place instead of overwriting a snapshot row; result.update is the full migrated state; a foreign-generation container makes migrate throw GenerationMismatchError. bindCrdt(Y).doc.restore is new.

Was this page helpful?