强大的抽象数据共享框架
Yjs 是一种 CRDT算法实现 ,其将内部的数据结构暴露为 shared types。 Shared types 是指一些具备了如下超能力的 Map
或者 Array
的通用数据结构: 对这些数据的修改会自动同步到其他的数据使用者(以下简称“对等点”),并实现无冲突的合并。
Yjs 是 网络无关的 (p2p!), 支撑着许多现存的 富文本编辑器, 离线编辑, 版本快照, 撤销/重写 和 共享光标。 它在超大规模用户和大型文档上的表现非常棒!
- Demos: https://github.com/yjs/yjs-demos
- Discuss: https://discuss.yjs.dev
- 性能对比 Yjs vs. Automerge: https://github.com/dmonad/crdt-benchmarks
- 播客 "Yjs 深入实施协同编辑方案":
- 播客 "Google Docs-style editing in Gutenberg with the YJS framework":
👷♀️ 如果你寻求关于构建分协同软件或者分布式应用的专业建议,请通过邮箱yjs@tag1consulting.com联系我们。 或者你可以通过论坛:discussion board寻求帮助。
我们正在寻求捐赠者以使项目团队能够有更多的精力来维护和开发Yjs,以下捐赠者已经为我们提供了很多的支持:
- Serenity Notes End-to-end encrypted collaborative notes app.
- Relm A collaborative gameworld for teamwork and community. 🌟
- Input A collaborative note taking app. 🌟
- Room.sh A meeting application with integrated collaborative drawing, editing, and coding tools. ⭐
- https://coronavirustechhandbook.com/ A collaborative wiki that is edited by thousands of different people to work on a rapid and sophisticated response to the coronavirus outbreak and subsequent impacts. ⭐
- Nimbus Note A note-taking app designed by Nimbus Web.
- JoeDocs An open collaborative wiki.
- Pluxbox RadioManager A web-based app to collaboratively organize radio broadcasts.
- Alldone A next-gen project management and collaboration platform.
这个库包含了一系列可以共享操作和同步变化的数据结构。网络功能和具体编辑器技术的绑定已经在单独的模块中实现.
技术栈 | 光标 | 绑定库 | Demo |
---|---|---|---|
ProseMirror | ✔ | y-prosemirror | demo |
Quill | ✔ | y-quill | demo |
CodeMirror | ✔ | y-codemirror | demo |
Monaco | ✔ | y-monaco | demo |
Slate | ✔ | slate-yjs | demo |
valtio | valtio-yjs | demo | |
React / Vue / MobX | SyncedStore | demo |
建立客户端之间的通信、管理信息感应和离线存储共享数据是协同编辑的痛点,而 Providers为你提供了开箱即用的解决方案,是构建协作应用的完美起点。
- y-webrtc
- 使用 WebRTC 点对点传播文档更新。对等点通过信令服务器交换信令数据。可以使用公开可用的信令服务器。信令服务器上的通信可以通过提供共享秘密来加密,保持连接信息和共享文档的私密性。
- y-websocket
- 这个模块包含一个简单的 websocket 后端和连接到该后端的 websocket 客户端。后端可以进行扩展,以持久化存储数据到leveldb数据库中。
- y-indexeddb
- 高效地将文档更新保存到浏览器 indexeddb 数据库中。该文档即时可用,并且只有文档的diff部分需要通过网络Provider进行同步。
- y-libp2p
- 使用库 libp2p 通过GossipSub来广播更新。同时包含一个点对点同步机制来获取丢失的更新。
- y-dat
- [WIP] 使用multifeed高效地将文档更新写入dat网络。 每个客户端都有一个只能进行追加操作的 CRDT本地更新(hypercore) 的日志. Multifeed 管理和同步 hypercores, y-dat 则监听更新并将其应用到yjs文档中。
安装 Yjs 以及一个数据提供服务:
npm i yjs y-websocket
启动 y-websocket server:
PORT=1234 node ./node_modules/y-websocket/bin/server.js
const yarray = doc.getArray('my-array')
yarray.observe(event => {
console.log('yarray was modified')
})
// every time a local or remote client modifies yarray, the observer is called
yarray.insert(0, ['val']) // => "yarray was modified"
Remember, shared types are just plain old data types. The only limitation is that a shared type must exist only once in the shared document.
const ymap = doc.getMap('map')
const foodArray = new Y.Array()
foodArray.insert(0, ['apple', 'banana'])
ymap.set('food', foodArray)
ymap.get('food') === foodArray // => true
ymap.set('fruit', foodArray) // => Error! foodArray is already defined
现在你知道如何在共享文档中定义数据类型了。接下来你可以查看这些例子 demo repository 或者继续阅读API文档。
任何 yjs 的 provider 都可以相互结合,因此你可以通过不同的网络技术来同步数据。
大多数情况下你需要的是 network provider (比如 y-websocket or y-webrtc) 与持久化 provider 结合使用 (y-indexeddb in the browser)。 持久化允许你快速加载数据并且能够同步那些即使是在离线状态下创建的数据
下面这个例子,我们将两种不同的 Provider 结合起来。
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
// 这样可以快速获取本地缓存的数据
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
indexeddbProvider.whenSynced.then(() => {
console.log('loaded data from indexed db')
})
// 使用 y-webrtc provider 同步客户端数据
const webrtcProvider = new WebrtcProvider('count-demo', ydoc)
// 使用 y-websocket provider 同步客户端数据
const websocketProvider = new WebsocketProvider(
'wss://demos.yjs.dev', 'count-demo', ydoc
)
// 一个用来计算总和的数字列表
const yarray = ydoc.getArray('count')
// 观察求和结果的变化
yarray.observe(event => {
// 当数据更新的时候打印出来
console.log('new sum: ' + yarray.toArray().reduce((a,b) => a + b))
})
// 想列表中增加数据
yarray.push([1]) // => "new sum: 1"
import * as Y from 'yjs'
Y.Array
支持在任何位置高效地插入/删除元素的类数组数据类型,其内部是使用一个在必要时候可以进行分割的数组链表来实现的。
const yarray = new Y.Array()
-
在 index的位置插入内容。 注意插入的内容是一个元素列表。
I.e.
array.insert(0, [1])
分割数组并且在位置0插入1 - 提取特定范围的内容
- 将一个数组中的内容拷贝至一个新的数组
- 复制数组中的内容到一个新的数组中,该方法会将元素的类型通过各自的
toJSON
方法转换为JSON类型。 -
返回迭代器接口
for (let value of yarray) { .. }
- 为这个数组添加一个事件监听器,这个监听会在每次数据变化后同步触发。如果在监听器中修改了数据, 事件监听器将会在当前监听事件返回后再次触发。
- 删除一个该数据上的监听器。
- 这个与上面的方法作用类似,不过用这种方式添加的监听器,在数据的子数据发生改变时,也会触发回调。 如果在监听回调中修改了数据, 也会在当前回调完成后再次触发。 这个事件监听器会收到来自本身或者自节点数据产生的所有事件。
- 移除数据上的某个深度监听。
parent:Y.AbstractType|null
insert(index:number, content:Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type>)
push(Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>)
unshift(Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>)
delete(index:number, length:number)
get(index:number)
slice(start:number, end:number):Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>
length:number
forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type,
index:number, array: Y.Array))
map(function(T, number, YArray):M):Array<M>
toArray():Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type>
toJSON():Array<Object|boolean|Array|string|number|null>
[Symbol.Iterator]
observe(function(YArrayEvent, Transaction):void)
unobserve(function(YArrayEvent, Transaction):void)
observeDeep(function(Array<YEvent>, Transaction):void)
unobserveDeep(function(Array<YEvent>, Transaction):void)
Y.Map
A shareable Map type.
const ymap = new Y.Map()
- Total number of key/value pairs.
- Removes all elements from this YMap.
- Clone this type into a fresh Yjs type.
-
Copies the
[key,value]
pairs of this YMap to a new Object.It transforms all child types to JSON using theirtoJSON
method. - Execute the provided function once for every key-value pair.
-
Returns an Iterator of
[key, value]
pairs.for (let [key, value] of ymap) { .. }
-
Returns an Iterator of
[key, value]
pairs. - Returns an Iterator of all values.
- Returns an Iterator of all keys.
- Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
-
Removes an
observe
event listener from this type. - Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
-
Removes an
observeDeep
event listener from this type.
parent:Y.AbstractType|null
size: number
get(key:string):object|boolean|string|number|null|Uint8Array|Y.Type
set(key:string, value:object|boolean|string|number|null|Uint8Array|Y.Type)
delete(key:string)
has(key:string):boolean
get(index:number)
clear()
clone():Y.Map
toJSON():Object<string, Object|boolean|Array|string|number|null|Uint8Array>
forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type,
key:string, map: Y.Map))
[Symbol.Iterator]
entries()
values()
keys()
observe(function(YMapEvent, Transaction):void)
unobserve(function(YMapEvent, Transaction):void)
observeDeep(function(Array<YEvent>, Transaction):void)
unobserveDeep(function(Array<YEvent>, Transaction):void)
Y.Text
A shareable type that is optimized for shared editing on text. It allows to assign properties to ranges in the text. This makes it possible to implement rich-text bindings to this type.
This type can also be transformed to the delta format. Similarly the YTextEvents compute changes as deltas.
const ytext = new Y.Text()
-
Insert a string at index and assign formatting attributes to it.
ytext.insert(0, 'bold text', { bold: true })
- Assign formatting attributes to a range in the text
-
See Quill Delta
Can set options for preventing remove ending newLines, default is true.
ytext.applyDelta(delta, { sanitize: false })
- Transforms this type, without formatting options, into a string.
- See
toString
- Transforms this type to a Quill Delta
- Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
-
Removes an
observe
event listener from this type. - Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
-
Removes an
observeDeep
event listener from this type.
parent:Y.AbstractType|null
insert(index:number, content:string, [formattingAttributes:Object<string,string>])
delete(index:number, length:number)
format(index:number, length:number, formattingAttributes:Object<string,string>)
applyDelta(delta: Delta, opts:Object<string,any>)
length:number
toString():string
toJSON():string
toDelta():Delta
observe(function(YTextEvent, Transaction):void)
unobserve(function(YTextEvent, Transaction):void)
observeDeep(function(Array<YEvent>, Transaction):void)
unobserveDeep(function(Array<YEvent>, Transaction):void)
Y.XmlFragment
A container that holds an Array of Y.XmlElements.
const yxml = new Y.XmlFragment()
- Retrieve a range of content
- Clone this type into a fresh Yjs type.
- Copies the children to a new Array.
- Transforms this type and all children to new DOM elements.
- Get the XML serialization of all descendants.
- See
toString
. - Create an Iterable that walks through the children.
- Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
-
Removes an
observe
event listener from this type. - Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
-
Removes an
observeDeep
event listener from this type.
parent:Y.AbstractType|null
firstChild:Y.XmlElement|Y.XmlText|null
insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)
delete(index:number, length:number)
get(index:number)
slice(start:number, end:number):Array<Y.XmlElement|Y.XmlText>
length:number
clone():Y.XmlFragment
toArray():Array<Y.XmlElement|Y.XmlText>
toDOM():DocumentFragment
toString():string
toJSON():string
createTreeWalker(filter: function(AbstractType<any>):boolean):Iterable
observe(function(YXmlEvent, Transaction):void)
unobserve(function(YXmlEvent, Transaction):void)
observeDeep(function(Array<YEvent>, Transaction):void)
unobserveDeep(function(Array<YEvent>, Transaction):void)
Y.XmlElement
A shareable type that represents an XML Element. It has a nodeName
,
attributes, and a list of children. But it makes no effort to validate its
content and be actually XML compliant.
const yxml = new Y.XmlElement()
- Retrieve the i-th element.
- Retrieve a range of content
- Clone this type into a fresh Yjs type.
- Copies the children to a new Array.
- Transforms this type and all children to a new DOM element.
- Get the XML serialization of all descendants.
- See
toString
. - Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
-
Removes an
observe
event listener from this type. - Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
-
Removes an
observeDeep
event listener from this type.
parent:Y.AbstractType|null
firstChild:Y.XmlElement|Y.XmlText|null
nextSibling:Y.XmlElement|Y.XmlText|null
prevSibling:Y.XmlElement|Y.XmlText|null
insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)
delete(index:number, length:number)
get(index:number)
length:number
setAttribute(attributeName:string, attributeValue:string)
removeAttribute(attributeName:string)
getAttribute(attributeName:string):string
getAttributes():Object<string,string>
get(i:number):Y.XmlElement|Y.XmlText
slice(start:number, end:number):Array<Y.XmlElement|Y.XmlText>
clone():Y.XmlElement
toArray():Array<Y.XmlElement|Y.XmlText>
toDOM():Element
toString():string
toJSON():string
observe(function(YXmlEvent, Transaction):void)
unobserve(function(YXmlEvent, Transaction):void)
observeDeep(function(Array<YEvent>, Transaction):void)
unobserveDeep(function(Array<YEvent>, Transaction):void)
const doc = new Y.Doc()
- A unique id that identifies this client. (readonly)
- Whether garbage collection is enabled on this doc instance. Set `doc.gc = false` in order to disable gc and be able to restore old content. See https://github.com/yjs/yjs#yjs-crdt-algorithm for more information about gc in Yjs.
-
Every change on the shared document happens in a transaction. Observer calls and
the
update
event are called after each transaction. You should bundle changes into a single transaction to reduce the amount of event calls. I.e.doc.transact(() => { yarray.insert(..); ymap.set(..) })
triggers a single change event.
You can specify an optionalorigin
parameter that is stored ontransaction.origin
andon('update', (update, origin) => ..)
. -
Deprecated: It is recommended to call toJSON directly on the shared types.
Converts the entire document into a js object, recursively traversing each yjs
type. Doesn't log types that have not been defined (using
ydoc.getType(..)
). - Define a shared type.
- Define a shared Y.Array type. Is equivalent to
y.get(string, Y.Array)
. - Define a shared Y.Map type. Is equivalent to
y.get(string, Y.Map)
. - Define a shared Y.XmlFragment type. Is equivalent to
y.get(string, Y.XmlFragment)
. - Register an event listener on the shared type
- Unregister an event listener from the shared type
clientID
gc
transact(function(Transaction):void [, origin:any])
toJSON():any
get(string, Y.[TypeClass]):[Type]
getArray(string):Y.Array
getMap(string):Y.Map
getXmlFragment(string):Y.XmlFragment
on(string, function)
off(string, function)
- Listen to document updates. Document updates must be transmitted to all other peers. You can apply document updates in any order and multiple times.
- Emitted before each transaction.
- Emitted after each transaction.
- Transactions can be nested (e.g. when an event within a transaction calls another transaction). Emitted before the first transaction.
- Emitted after the last transaction is cleaned up.
on('update', function(updateMessage:Uint8Array, origin:any, Y.Doc):void)
on('beforeTransaction', function(Y.Transaction, Y.Doc):void)
on('afterTransaction', function(Y.Transaction, Y.Doc):void)
on('beforeAllTransactions', function(Y.Doc):void)
on('afterAllTransactions', function(Y.Doc, Array<Y.Transaction>):void)
Changes on the shared document are encoded into document updates. Document updates are commutative and idempotent. This means that they can be applied in any order and multiple times.
const doc1 = new Y.Doc()
const doc2 = new Y.Doc()
doc1.on('update', update => {
Y.applyUpdate(doc2, update)
})
doc2.on('update', update => {
Y.applyUpdate(doc1, update)
})
// All changes are also applied to the other document
doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?'])
doc2.getArray('myarray').get(0) // => 'Hello doc2, you got this?'
Yjs internally maintains a state vector that denotes the next expected clock from each client. In a different interpretation it holds the number of structs created by each client. When two clients sync, you can either exchange the complete document structure or only the differences by sending the state vector to compute the differences.
const state1 = Y.encodeStateAsUpdate(ydoc1)
const state2 = Y.encodeStateAsUpdate(ydoc2)
Y.applyUpdate(ydoc1, state2)
Y.applyUpdate(ydoc2, state1)
This example shows how to sync two clients with the minimal amount of exchanged data by computing only the differences using the state vector of the remote client. Syncing clients using the state vector requires another roundtrip, but can save a lot of bandwidth.
const stateVector1 = Y.encodeStateVector(ydoc1)
const stateVector2 = Y.encodeStateVector(ydoc2)
const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2)
const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1)
Y.applyUpdate(ydoc1, diff2)
Y.applyUpdate(ydoc2, diff1)
It is possible to sync clients and compute delta updates without loading the Yjs document to memory. Yjs exposes an API to compute the differences directly on the binary document updates.
// encode the current state as a binary buffer
let currentState1 = Y.encodeStateAsUpdate(ydoc1)
let currentState2 = Y.encodeStateAsUpdate(ydoc2)
// now we can continue syncing clients using state vectors without using the Y.Doc
ydoc1.destroy()
ydoc2.destroy()
const stateVector1 = Y.encodeStateVectorFromUpdate(currentState1)
const stateVector2 = Y.encodeStateVectorFromUpdate(currentState2)
const diff1 = Y.diffUpdate(currentState1, stateVector2)
const diff2 = Y.diffUpdate(currentState2, stateVector1)
// sync clients
currentState1 = Y.mergeUpdates([currentState1, diff2])
currentState1 = Y.mergeUpdates([currentState1, diff1])
Yjs implements two update formats. By default you are using the V1 update format.
You can opt-in into the V2 update format wich provides much better compression.
It is not yet used by all providers. However, you can already use it if
you are building your own provider. All below functions are available with the
suffix "V2". E.g. Y.applyUpdate
⇒ Y.applyUpdateV2
. We also support conversion
functions between both formats: Y.convertUpdateFormatV1ToV2
& Y.convertUpdateFormatV2ToV1
.
-
Apply a document update on the shared document. Optionally you can specify
transactionOrigin
that will be stored ontransaction.origin
andydoc.on('update', (update, origin) => ..)
. - Encode the document state as a single update message that can be applied on the remote document. Optionally specify the target state vector to only write the differences to the update message.
- Computes the state vector and encodes it into an Uint8Array.
- Merge several document updates into a single document update while removing duplicate information. The merged document update is always smaller than the separate updates because of the compressed encoding.
- Computes the state vector from a document update and encodes it into an Uint8Array.
-
Encode the missing differences to another update message. This function works
similarly to
Y.encodeStateAsUpdate(ydoc, stateVector)
but works on updates instead. - Convert V1 update format to the V2 update format.
- Convert V2 update format to the V1 update format.
Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])
Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array
Y.encodeStateVector(Y.Doc):Uint8Array
Y.mergeUpdates(Array<Uint8Array>)
Y.encodeStateVectorFromUpdate(Uint8Array): Uint8Array
Y.diffUpdate(update: Uint8Array, stateVector: Uint8Array): Uint8Array
convertUpdateFormatV1ToV2
convertUpdateFormatV2ToV1
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc)
pos.type === ytext // => true
pos.index === 2 // => true
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = JSON.stringify(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = JSON.parse(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = Y.encodeRelativePosition(relPos)
// send encodedRelPos to remote client..
const parsedRelPos = Y.decodeRelativePosition(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)
Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)
Y.encodeRelativePosition(RelativePosition):Uint8Array
Y.decodeRelativePosition(Uint8Array):RelativePosition
Yjs ships with an Undo/Redo manager for selective undo/redo of changes on a Yjs type. The changes can be optionally scoped to transaction origins.
const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext)
ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => ''
undoManager.redo()
ytext.toString() // => 'abc'
- Accepts either single type as scope or an array of types.
-
Register an event that is called when a
StackItem
is added to the undo- or the redo-stack. -
Register an event that is called when a
StackItem
is popped from the undo- or the redo-stack.
constructor(scope:Y.AbstractType|Array<Y.AbstractType>
[, {captureTimeout:number,trackedOrigins:Set<any>,deleteFilter:function(item):boolean}])
undo()
redo()
stopCapturing()
on('stack-item-added', { stackItem: { meta: Map<any,any> }, type: 'undo'
| 'redo' })
on('stack-item-popped', { stackItem: { meta: Map<any,any> }, type: 'undo'
| 'redo' })
UndoManager merges Undo-StackItems if they are created within time-gap
smaller than options.captureTimeout
. Call um.stopCapturing()
so that the next
StackItem won't be merged.
// without stopCapturing
ytext.insert(0, 'a')
ytext.insert(1, 'b')
undoManager.undo()
ytext.toString() // => '' (note that 'ab' was removed)
// with stopCapturing
ytext.insert(0, 'a')
undoManager.stopCapturing()
ytext.insert(0, 'b')
undoManager.undo()
ytext.toString() // => 'a' (note that only 'b' was removed)
Every change on the shared document has an origin. If no origin was specified,
it defaults to null
. By specifying trackedOrigins
you can
selectively specify which changes should be tracked by UndoManager
. The
UndoManager instance is always added to trackedOrigins
.
class CustomBinding {}
const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, {
trackedOrigins: new Set([42, CustomBinding])
})
ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => 'abc' (does not track because origin `null` and not part
// of `trackedTransactionOrigins`)
ytext.delete(0, 3) // revert change
doc.transact(() => {
ytext.insert(0, 'abc')
}, 42)
undoManager.undo()
ytext.toString() // => '' (tracked because origin is an instance of `trackedTransactionorigins`)
doc.transact(() => {
ytext.insert(0, 'abc')
}, 41)
undoManager.undo()
ytext.toString() // => '' (not tracked because 41 is not an instance of
// `trackedTransactionorigins`)
ytext.delete(0, 3) // revert change
doc.transact(() => {
ytext.insert(0, 'abc')
}, new CustomBinding())
undoManager.undo()
ytext.toString() // => '' (tracked because origin is a `CustomBinding` and
// `CustomBinding` is in `trackedTransactionorigins`)
When undoing or redoing a previous action, it is often expected to restore additional meta information like the cursor location or the view on the document. You can assign meta-information to Undo-/Redo-StackItems.
const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, {
trackedOrigins: new Set([42, CustomBinding])
})
undoManager.on('stack-item-added', event => {
// save the current cursor location on the stack-item
event.stackItem.meta.set('cursor-location', getRelativeCursorLocation())
})
undoManager.on('stack-item-popped', event => {
// restore the current cursor location on the stack-item
restoreCursorLocation(event.stackItem.meta.get('cursor-location'))
})
Conflict-free replicated data types (CRDT) for collaborative editing are an alternative approach to operational transformation (OT). A very simple differenciation between the two approaches is that OT attempts to transform index positions to ensure convergence (all clients end up with the same content), while CRDTs use mathematical models that usually do not involve index transformations, like linked lists. OT is currently the de-facto standard for shared editing on text. OT approaches that support shared editing without a central source of truth (a central server) require too much bookkeeping to be viable in practice. CRDTs are better suited for distributed systems, provide additional guarantees that the document can be synced with remote clients, and do not require a central source of truth.
Yjs implements a modified version of the algorithm described in this paper. This article explains a simple optimization on the CRDT model and gives more insight about the performance characteristics in Yjs. More information about the specific implementation is available in INTERNALS.md and in this walkthrough of the Yjs codebase.
CRDTs that suitable for shared text editing suffer from the fact that they only grow in size. There are CRDTs that do not grow in size, but they do not have the characteristics that are benificial for shared text editing (like intention preservation). Yjs implements many improvements to the original algorithm that diminish the trade-off that the document only grows in size. We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs. But we can 1. merge preceeding structs into a single struct to reduce the amount of meta information, 2. we can delete content from the struct if it is deleted, and 3. we can garbage collect tombstones if we don't care about the order of the structs anymore (e.g. if the parent was deleted).
Examples:
- If a user inserts elements in sequence, the struct will be merged into a
single struct. E.g.
text.insert(0, 'a'), text.insert(1, 'b');
is first represented as two structs ([{id: {client, clock: 0}, content: 'a'}, {id: {client, clock: 1}, content: 'b'}
) and then merged into a single struct:[{id: {client, clock: 0}, content: 'ab'}]
. - When a struct that contains content (e.g.
ItemString
) is deleted, the struct will be replaced with anItemDeleted
that does not contain content anymore. - When a type is deleted, all child elements are transformed to
GC
structs. AGC
struct only denotes the existence of a struct and that it is deleted.GC
structs can always be merged with otherGC
structs if the id's are adjacent.
Especially when working on structured content (e.g. shared editing on ProseMirror), these improvements yield very good results when benchmarking random document edits. In practice they show even better results, because users usually edit text in sequence, resulting in structs that can easily be merged. The benchmarks show that even in the worst case scenario that a user edits text from right to left, Yjs achieves good performance even for huge documents.
Yjs has the ability to exchange only the differences when syncing two clients.
We use lamport timestamps to identify structs and to track in which order a
client created them. Each struct has an struct.id = { client: number, clock: number}
that uniquely identifies a struct. We define the next expected clock
by each client as the state vector. This data structure is similar to the
version vectors data structure.
But we use state vectors only to describe the state of the local document, so we
can compute the missing struct of the remote client. We do not use it to track
causality.
Yjs and all related projects are MIT licensed.
Yjs is based on my research as a student at the RWTH i5. Now I am working on Yjs in my spare time.
Fund this project by donating on GitHub Sponsors or hiring me as a contractor for your collaborative app.